From b75a3cc61beebbbbc3f80621d36d5dc1f745b1f5 Mon Sep 17 00:00:00 2001 From: Younes Abouelnagah Date: Thu, 29 Oct 2026 14:59:11 -0400 Subject: [PATCH] feat(mcp): add the ChatGPT publication input with Ansari's icon Add saga-publication.json, the release input SAGA's saga publish tool uses to prepare the ChatGPT plugin package. It names Ansari's listing, the Dev endpoint and the build, and points both the logo and the composer icon at assets/ansari-plugin-icon.svg: the unchanged floral mark from brand-mark.ts on the card's green. Supplying both keeps SAGA's default artwork out of Ansari's package. The release gate's version mirror moves from a plugin/plugin.json this repository never had to the release input, and also requires its endpoint.expectedServer.version to match the app version. Co-Authored-By: Claude Opus 5.5 --- apps/mcp/README.md | 12 +++++++++ apps/mcp/assets/ansari-plugin-icon.svg | 1 + apps/mcp/saga-publication.json | 37 ++++++++++++++++++++++++++ apps/mcp/scripts/check-release.mjs | 20 +++++++++----- apps/mcp/test/release.test.ts | 22 ++++++++------- 5 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 apps/mcp/assets/ansari-plugin-icon.svg create mode 100644 apps/mcp/saga-publication.json diff --git a/apps/mcp/README.md b/apps/mcp/README.md index b983f99b..7a419ba4 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -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 /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. diff --git a/apps/mcp/assets/ansari-plugin-icon.svg b/apps/mcp/assets/ansari-plugin-icon.svg new file mode 100644 index 00000000..00532cb7 --- /dev/null +++ b/apps/mcp/assets/ansari-plugin-icon.svg @@ -0,0 +1 @@ +Ansari diff --git a/apps/mcp/saga-publication.json b/apps/mcp/saga-publication.json new file mode 100644 index 00000000..e69b43da --- /dev/null +++ b/apps/mcp/saga-publication.json @@ -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" } + } +} diff --git a/apps/mcp/scripts/check-release.mjs b/apps/mcp/scripts/check-release.mjs index f7d4be2e..1031072b 100644 --- a/apps/mcp/scripts/check-release.mjs +++ b/apps/mcp/scripts/check-release.mjs @@ -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. @@ -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; }; @@ -46,7 +46,11 @@ const RELEASE_CONTENT = { return canonical(parsed); }, 'apps/mcp/src/version.ts': text => text.replace(/(export const APP_VERSION = )'[^']*'/, "$1''"), - '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-]+)*)?$/; @@ -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}`))]}}; @@ -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 { diff --git a/apps/mcp/test/release.test.ts b/apps/mcp/test/release.test.ts index 627b9be0..1b704d8a 100644 --- a/apps/mcp/test/release.test.ts +++ b/apps/mcp/test/release.test.ts @@ -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; @@ -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/); }); }); @@ -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'}), []); @@ -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/);