Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,15 @@ Hono runs on Node through `@hono/node-server`; the official `@modelcontextprotoc
## MCP compatibility

The tool retains the legacy server's `answer_islamic_question` name, detailed description and input help, and `readOnlyHint: true` / `openWorldHint: true` annotations. It accepts 1–4,000 characters, matching the Ansari API. The earlier experimental RC exposed `ask_ansari`; a deployment owner must refresh host discovery when adopting this tool name. The endpoint remains `/mcp`. Replacing the existing `mcp.ansari.chat` service and changing its DNS are separate rollout actions.

## ChatGPT plugin

`saga-publication.json` is the release input from which SAGA's `saga publish` tool prepares the ChatGPT plugin package. It names the listing, the Dev endpoint and the build to check. Its `assets.logo` and `assets.composerIcon` both point to `assets/ansari-plugin-icon.svg`: the floral mark from `assets/brand-mark.ts`, unchanged, on the card's `#15583e` green (see `assets/NOTICE.md`). Supplying both keeps SAGA's default artwork out of Ansari's package. The release gate requires the input's `version` and `endpoint.expectedServer.version` to match `package.json`.

The tool is not on npm yet, so it runs from a [saga-a2ui](https://github.com/MachineWisdomAI/saga-a2ui) checkout with this build in `dist/`:

```sh
npx tsx scripts/saga.ts publish prepare --target chatgpt --release <path to apps/mcp>/saga-publication.json --out ./publication
```

It always writes `findings.json`, and writes `chatgpt-plugin.zip` only when every check passes, including that the endpoint serves this exact build. It deploys, uploads and submits nothing.
1 change: 1 addition & 0 deletions apps/mcp/assets/ansari-plugin-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions apps/mcp/saga-publication.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"format": "saga-publication/1",
"name": "ansari-mcp",
"version": "0.16.6",
"description": "Answers questions about Islam and shows Ansari's complete, attributed answer in an inline Ansari answer card.",
"publisher": {
"name": "IASER",
"url": "https://iaser.ai",
"email": "feedback@ansari.chat"
},
"homepage": "https://ansari.chat",
"repository": "https://github.com/iaser-ai/ansari",
"license": "MIT",
"keywords": ["ansari", "islam", "mcp-apps"],
"listing": {
"displayName": "Ansari",
"shortDescription": "Answers questions about Islam",
"longDescription": "Ask a question about Islam, and Ansari answers in a card inside the conversation. Ansari searches the Quran, Hadith collections, the Kuwaiti Encyclopedia of Islamic Jurisprudence and Quranic exegesis, then gives a cited answer. The card shows the complete answer with its attribution.",
"category": "Education",
"capabilities": ["Answer questions about Islam"],
"websiteURL": "https://ansari.chat",
"supportURL": "https://github.com/iaser-ai/ansari/issues",
"privacyPolicyURL": "https://docs.ansari.chat/privacy/",
"termsOfServiceURL": "https://docs.ansari.chat/terms/",
"defaultPrompt": ["What are the pillars of Islam?"],
"brandColor": "#15583e"
},
"assets": {
"logo": "assets/ansari-plugin-icon.svg",
"composerIcon": "assets/ansari-plugin-icon.svg"
},
"build": { "directory": "dist", "views": ["view.html"] },
"endpoint": {
"url": "https://ansari-mcp-development.up.railway.app/mcp",
"expectedServer": { "name": "ansari-mcp", "version": "0.16.6" }
}
}
20 changes: 13 additions & 7 deletions apps/mcp/scripts/check-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// --from REF report what changed from REF (the qualified source, or the accepted RC tag) to HEAD, and whether any
// executable dependency input changed (which then needs the Dev checks before a deployment).
// --release-files-only with --from: a screen, not a proof. It fails when a file other than package.json,
// src/version.ts, plugin/plugin.json or pnpm-lock.yaml changed, when those three small files changed
// src/version.ts, saga-publication.json or pnpm-lock.yaml changed, when those three small files changed
// beyond their version fields and the SDK pin, or when REF is not an ancestor of HEAD. The CONTENT of
// pnpm-lock.yaml is not diffed (validateRelease checks only that the graph is consistent registry
// releases), so the full `git diff REF HEAD` must still be reviewed before qualification and tagging.
Expand All @@ -31,7 +31,7 @@ export const RELEASE_FILES = [
'apps/mcp/package.json',
'pnpm-lock.yaml',
'apps/mcp/src/version.ts',
'plugin/plugin.json',
'apps/mcp/saga-publication.json',
];
const canonical = value => JSON.stringify(value, (_key, item) => (item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => (a < b ? -1 : 1))) : item));
const withoutVersion = text => { const parsed = JSON.parse(text); parsed.version = null; return parsed; };
Expand All @@ -46,7 +46,11 @@ const RELEASE_CONTENT = {
return canonical(parsed);
},
'apps/mcp/src/version.ts': text => text.replace(/(export const APP_VERSION = )'[^']*'/, "$1'<version>'"),
'plugin/plugin.json': text => canonical(withoutVersion(text)),
'apps/mcp/saga-publication.json': text => {
const parsed = withoutVersion(text);
if (parsed.endpoint?.expectedServer !== undefined) parsed.endpoint.expectedServer.version = null;
return canonical(parsed);
},
};

const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
Expand Down Expand Up @@ -172,11 +176,13 @@ export function validateRelease({appRoot = defaultRoot, target} = {}) {
// 4. One release version in every mirror; Production accepts no prerelease app version.
const appVersion = manifest.version;
const mirror = /export const APP_VERSION = '([^']+)';/.exec(read(path.join(appRoot, 'src/version.ts')) ?? '')?.[1];
const pluginFile = path.join(appRoot, '../../plugin/plugin.json');
const plugin = read(pluginFile) === undefined ? undefined : JSON.parse(read(pluginFile)).version;
const publicationText = read(path.join(appRoot, 'saga-publication.json'));
const publication = publicationText === undefined ? undefined : JSON.parse(publicationText);
if (!EXACT.test(appVersion ?? '')) problems.push(`package.json version "${appVersion}" is not an exact version.`);
if (mirror !== appVersion) problems.push(`src/version.ts APP_VERSION (${mirror}) differs from package.json (${appVersion}).`);
if (plugin !== undefined && plugin !== appVersion) problems.push(`plugin/plugin.json version (${plugin}) differs from package.json (${appVersion}).`);
if (publication !== undefined && publication.version !== appVersion) problems.push(`saga-publication.json version (${publication.version}) differs from package.json (${appVersion}).`);
const expectedServer = publication?.endpoint?.expectedServer?.version;
if (expectedServer !== undefined && expectedServer !== appVersion) problems.push(`saga-publication.json endpoint.expectedServer.version (${expectedServer}) differs from package.json (${appVersion}).`);
if (target === 'production' && isPrerelease(appVersion ?? '')) problems.push(`Production releases a stable app version; ${appVersion} is a prerelease. Derive stable from the accepted RC.`);

return {problems, packages, facts: {target, appVersion, stack, siblings: [...new Set(packages.map(entry => `${entry.name}@${entry.version}`))]}};
Expand Down Expand Up @@ -236,7 +242,7 @@ export function describeChanges({appRoot = defaultRoot, from, releaseFilesOnly =
const dependenciesChanged = files.includes('pnpm-lock.yaml') || files.includes('pnpm-workspace.yaml') || dependencyInputs(from) !== dependencyInputs('HEAD');
const outside = files.filter(file => !RELEASE_FILES.includes(file));
// The three small release files may change only their version fields and the SDK pin. Anything else inside them
// (code in src/version.ts, package scripts, other dependencies or plugin settings) counts as changed code.
// (code in src/version.ts, package scripts, other dependencies or publication settings) counts as changed code.
const beyondRelease = [];
for (const file of files.filter(entry => entry in RELEASE_CONTENT)) {
try {
Expand Down
22 changes: 12 additions & 10 deletions apps/mcp/test/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,16 @@ function syntheticWorkspace(sdk: string, withOverrides: boolean): string {
}
return `${lines.join('\n')}\n`;
}
/** Writes a repository-shaped fixture (apps/mcp + plugin) and returns its application folder. */
/** A minimal release input whose two version fields follow the application version. */
const publication = (version: string) => ({format: 'saga-publication/1', name: 'ansari-mcp', version, endpoint: {url: 'https://mcp.example.test/mcp', expectedServer: {name: 'ansari-mcp', version}}});
/** Writes a repository-shaped fixture (apps/mcp with its release input) and returns its application folder. */
function writeApp(repo: string, fixture: Fixture): string {
const app = join(repo, 'apps/mcp');
mkdirSync(join(app, 'src'), {recursive: true});
mkdirSync(join(repo, 'plugin'), {recursive: true});
const deps = {...manifest.dependencies, '@saga-a2ui/react-mcp-stack': fixture.stackSpec ?? fixture.sdk, ...fixture.extraDirect};
writeFileSync(join(app, 'package.json'), JSON.stringify({...manifest, version: fixture.appVersion, dependencies: deps}, null, 2));
writeFileSync(join(app, 'src/version.ts'), `// One application identity, shared by the server and built view.\nexport const APP_VERSION = '${fixture.appVersion}';\n`);
writeFileSync(join(repo, 'plugin/plugin.json'), JSON.stringify({name: 'ansari-mcp-dev', version: fixture.appVersion}, null, 2));
writeFileSync(join(app, 'saga-publication.json'), JSON.stringify(publication(fixture.appVersion), null, 2));
writeFileSync(join(repo, 'pnpm-lock.yaml'), (fixture.lockTransform ?? (text => text))(syntheticLock(fixture.sdk, fixture.stackSpec ?? fixture.sdk)));
writeFileSync(join(repo, 'pnpm-workspace.yaml'), syntheticWorkspace(fixture.sdk, fixture.keepOverrides === true));
return app;
Expand Down Expand Up @@ -207,15 +208,16 @@ describe('release input validation (fixture inputs, no registry)', () => {
assert.match(validate({...STABLE, stackSpec: 'next'}, 'dev').join('\n'), /not an exact version/);
});

it('requires one release version across package.json, src/version.ts and the plugin manifest', () => {
it('requires one release version across package.json, src/version.ts and the release input', () => {
const problems = disposableRepo(repo => {
const app = writeApp(repo, STABLE);
writeFileSync(join(app, 'src/version.ts'), "export const APP_VERSION = '0.15.1';\n");
writeFileSync(join(repo, 'plugin/plugin.json'), JSON.stringify({version: '0.14.0'}));
writeFileSync(join(app, 'saga-publication.json'), JSON.stringify({...publication('0.14.0'), endpoint: {url: 'https://mcp.example.test/mcp', expectedServer: {name: 'ansari-mcp', version: '0.13.0'}}}));
return gate.validateRelease({appRoot: app, target: 'production'}).problems.join('\n');
});
assert.match(problems, /APP_VERSION \(0\.15\.1\) differs/);
assert.match(problems, /plugin\/plugin\.json version \(0\.14\.0\) differs/);
assert.match(problems, /saga-publication\.json version \(0\.14\.0\) differs/);
assert.match(problems, /saga-publication\.json endpoint\.expectedServer\.version \(0\.13\.0\) differs/);
});
});

Expand Down Expand Up @@ -307,7 +309,7 @@ for (const layout of LAYOUTS) describe(`tag-based source selection in a disposab
assert.deepEqual(changes.problems, []);
assert.deepEqual(changes.outside, []);
assert.equal(changes.dependenciesChanged, true, 'a changed SDK is an executable dependency change: qualify it on Dev');
assert.deepEqual([...changes.files].sort(), ['apps/mcp/package.json', 'pnpm-lock.yaml', 'apps/mcp/src/version.ts', 'plugin/plugin.json'].sort());
assert.deepEqual([...changes.files].sort(), ['apps/mcp/package.json', 'pnpm-lock.yaml', 'apps/mcp/src/version.ts', 'apps/mcp/saga-publication.json'].sort());
assert.ok(!existsSync(join(app, 'src/unrelated.ts')), 'the stable branch excludes later main work');
assert.notEqual(git(repo, 'merge-base', stable, later), stable, 'stable is not on main');
assert.deepEqual(gate.validateTag({appRoot: app, tag: STABLE_TAG, appVersion: '0.15.0'}), []);
Expand Down Expand Up @@ -347,9 +349,9 @@ for (const layout of LAYOUTS) describe(`tag-based source selection in a disposab
const extra = stableFrom(app => edit(join(app, 'package.json'), text => { const parsed = JSON.parse(text); parsed.dependencies.leftpad = '1.0.0'; return JSON.stringify(parsed, null, 2); }));
assert.match(extra.problems.join('\n'), /package\.json changed since .* beyond the release version and the SDK pin/);

// Plugin settings other than the version are refused; a workspace-file change is refused as a path outside the release set.
const plugin = stableFrom((_app, repo) => writeFileSync(join(repo, 'plugin/plugin.json'), JSON.stringify({name: 'other', version: '0.15.0'})));
assert.match(plugin.problems.join('\n'), /plugin\/plugin\.json changed since .* beyond the release version and the SDK pin/);
// Publication settings other than the versions are refused; a workspace-file change is refused as a path outside the release set.
const publicationChange = stableFrom(app => writeFileSync(join(app, 'saga-publication.json'), JSON.stringify({...publication('0.15.0'), name: 'other'})));
assert.match(publicationChange.problems.join('\n'), /saga-publication\.json changed since .* beyond the release version and the SDK pin/);
const workspace = stableFrom(app => edit(join(app, '../../pnpm-workspace.yaml'), text => `${text} 'left-pad': '1.0.0'\n`));
assert.match(workspace.problems.join('\n'), /pnpm-workspace\.yaml changed since/);

Expand Down
Loading