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/);