Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/mcp/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml LICENSE ./
COPY packages packages
COPY apps/mcp/package.json apps/mcp/
# Until SAGA 1.0.0-rc.1 is on the registry, the install resolves it from these private tarballs.
COPY apps/mcp/candidates apps/mcp/candidates
RUN pnpm install --strict-peer-dependencies --frozen-lockfile --filter ansari-mcp...
COPY apps/mcp apps/mcp
RUN pnpm mcp build
Expand Down
20 changes: 12 additions & 8 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ansari MCP

A standalone MCP application. `answer_islamic_question` calls the Ansari API and returns its complete answer and attribution unchanged. SAGA links each result to one content-addressed, self-contained React card. The application owns Ansari branding, floral artwork, theme mapping and Hono HTTP hosting; SAGA owns content interpretation, FaithfulCard rendering, content policy, consent and MCP view linkage.
A standalone MCP application. `answer_islamic_question` calls the Ansari API and returns its complete answer and attribution unchanged. SAGA links each result to one self-contained React card at a stable, versioned address. The application owns Ansari branding, floral artwork, theme mapping and Hono HTTP hosting; SAGA owns content interpretation, FaithfulCard rendering, content policy, consent and MCP view linkage.

## How it works

Expand Down Expand Up @@ -32,11 +32,15 @@ User-provided capture of the Dev answer card in Claude, showing Arabic text, RTL

## Dependencies

SAGA is installed from the public npm registry at one exact release, `0.7.6` (stable), from the [0.7.6 release branch](https://github.com/MachineWisdomAI/saga-a2ui/tree/codex/release-0.7.6): the stack `@saga-a2ui/react-mcp-stack`
plus its exact-version siblings `@saga-a2ui/core`, `@saga-a2ui/components`, `@saga-a2ui/mcp` and `@saga-a2ui/react`. No private registry,
credential, SDK tarball, SDK workspace link or SDK-package override is involved, and the manifest pins an exact version rather than a tag or range.
`pnpm check:release --target beta` checks the pinned release against the registry and the lockfile; `--target production` additionally refuses a
prerelease. The Beta and Production deploy scripts run the corresponding gate before any build or deployment.
SAGA is the 1.0 release candidate, `0.0.0-candidate.g8b4d987dd233`: the stack `@saga-a2ui/react-mcp-stack` plus its exact-version
siblings `@saga-a2ui/core`, `@saga-a2ui/components`, `@saga-a2ui/mcp` and `@saga-a2ui/react`, packed from the SDK's source at commit
`8b4d987` and committed under `candidates/` until `1.0.0-rc.1` is on the npm registry. The manifest pins that exact version; the root
`pnpm-workspace.yaml` overrides the five siblings to those tarballs, and the lockfile records their integrity. No private registry or
credential is involved. Moving to the registry release replaces the pin and the overrides and deletes `candidates/`.
The card's address names its compatibility line, `ui://views/answer-card.v1.html` (`VIEW_MAJOR` in `src/server/view-artifact.ts`), not its
bytes: every compatible release is served at the same address, and only a change an already-loaded card cannot handle increases it.
`pnpm check:release --target beta` checks the pinned release against the registry and the lockfile, and refuses the candidate; `--target production` additionally refuses a
prerelease. The Beta and Production deploy scripts run the corresponding gate before any build or deployment, so neither deploys until the release is published. Dev accepts the candidate.

The application depends on one SAGA package, the stack `@saga-a2ui/react-mcp-stack`, plus React and React DOM, and imports
only its subpaths: `/faithful-card` (`FaithfulCardApp`, `startFaithfulCardApp`, and the definition's one `.result(...)` reader that reads the whole delivered result,
Expand Down Expand Up @@ -100,7 +104,7 @@ The Worker takes the variables in the table below as Wrangler `vars` or secrets,
| `MCP_VIEW_DIAGNOSTICS_ORIGIN` | Set only on a deployment being diagnosed: its own public origin, such as `https://ansari-mcp-development.up.railway.app`. The card may then connect to that one origin and reports its start-up milestones to `POST /diag/view`; this changes the card's bytes, address and permissions. Unset, the card, its address and its closed permissions are unchanged and the route does not exist. A value that is not a bare `https` origin (`http` only for localhost) stops startup. Implies `MCP_LOG_LEVEL=debug`. See [First-load diagnosis](#first-load-diagnosis). |
| `MCP_LOG_LEVEL` | `info` (default) or `debug`. `debug` also writes the first-load measurement records; set it only on a deployment being measured. Any other value, or `info` together with `MCP_VIEW_DIAGNOSTICS_ORIGIN`, stops startup. |

`POST /mcp` serves stateless MCP requests through Hono and the official MCP request handler. Modern requests need no initialization; older hosts can use the handler’s stateless compatibility path. No MCP session IDs, session map, expiry timer or replay store are retained. `GET /mcp` and `DELETE /mcp` return 405. `/health` exposes the release. The HTML is supplied by the hosting target (read from `dist/` at Node start, bundled into the Worker), revision-stamped and fingerprinted once before the first request is served: the release is stamped first, so equal final bytes have equal identities and a release change legitimately changes the hash. Each request creates its own lightweight MCP server using that prepared view; resource bytes and identity stay fixed until restart. No demo, font server, alternative renderer or historical resource is served. Tool calls have a fresh 60-second budget; timeout yields a safe error and leaves subsequent calls usable. The same lifetime signal aborts the upstream fetch and response-body read when the request ends or the budget expires.
`POST /mcp` serves stateless MCP requests through SAGA's fetch app (`createMcpFetchApp`), which admits by Host and Origin, bounds the body and the request budget, ends each request's lifetime, and runs the official MCP request handler. The application adds only `/health`, the Dev-only diagnostics beacon and its logs around it. Modern requests need no initialization; older hosts can use the handler’s stateless compatibility path. No MCP session IDs, session map, expiry timer or replay store are retained. `GET /mcp` and `DELETE /mcp` return 405. `/health` exposes the release. The HTML is supplied by the hosting target (read from `dist/` at Node start, bundled into the Worker), revision-stamped once before the first request is served. The release stamp changes the card's bytes, not its address, which is `ui://views/answer-card.v1.html` for every compatible release. Each request creates its own lightweight MCP server using that prepared view; resource bytes and identity stay fixed until restart. No demo, font server, alternative renderer or historical resource is served. Tool calls have a fresh 60-second budget; timeout yields a safe error and leaves subsequent calls usable. The same lifetime signal aborts the upstream fetch and response-body read when the request ends or the budget expires.

Closing a request server releases its budget even if it was never connected to a transport. On SIGINT or SIGTERM the listener stops accepting requests, closes MCP handlers and HTTP connections, and exits; unfinished calls are interrupted, including older-host SSE calls. Fetch cancellation does not guarantee that the remote backend has stopped processing. Caller-disconnect propagation depends on the host transport; the local Workers runtime does not establish immediate upstream cancellation from a client disconnect. Actual fetch cancellation at the request deadline is covered on both hosting targets.

Expand All @@ -112,7 +116,7 @@ the host gave up before the card showed anything. The aim is to locate the first
the card's start, the host handshake, tool-result arrival and the first rendered answer, where that step can be
observed at all. After the trials, this is removed or narrowed to what proved useful.

**Log levels.** Every record carries a `level`. `error`: `mcp.error` and `source.error`, by error name and code only.
**Log levels.** Every record carries a `level`. `error`: `mcp.error` (SAGA's fixed failure `kind` for `/mcp`: `server-factory`, `handler` or `route`; or, for a failure outside it, the error name and code) and `source.error`.
`warn`: rejected or rate-limited card reports. `info`: the operational records (`startup`, `mcp.request` with the HTTP
method, `source.start`, `source.complete`). `debug`: the measurement records below, written only with
`MCP_LOG_LEVEL=debug`. At `info` the server neither inspects nor wraps an exchange, and it writes the records of a
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
35 changes: 19 additions & 16 deletions apps/mcp/docs/show-me-ansari-mcp.html

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions apps/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ansari-mcp",
"version": "0.16.6",
"version": "0.17.0",
"private": true,
"type": "module",
"description": "Ansari's MCP application",
Expand Down Expand Up @@ -28,17 +28,17 @@
"lint": "eslint ."
},
"dependencies": {
"@hono/node-server": "1.19.17",
"@hono/node-server": "2.1.5",
"@modelcontextprotocol/hono": "2.0.2",
"@modelcontextprotocol/server": "2.3.1",
"@saga-a2ui/react-mcp-stack": "0.7.6",
"hono": "4.13.13",
"@modelcontextprotocol/server": "2.4.0",
"@saga-a2ui/react-mcp-stack": "0.0.0-candidate.g8b4d987dd233",
"hono": "4.13.16",
"react": "19.3.1",
"react-dom": "19.3.1",
"zod": "4.6.7"
},
"devDependencies": {
"@modelcontextprotocol/client": "2.3.1",
"@modelcontextprotocol/client": "2.4.0",
"@modelcontextprotocol/ext-apps": "2.0.3",
"@playwright/test": "1.64.0",
"@types/node": "22.20.5",
Expand Down
5 changes: 3 additions & 2 deletions apps/mcp/scripts/check-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ const RELEASE_CONTENT = {
const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const CANDIDATE = /^0\.0\.0-candidate\.g[0-9a-f]{12}$/;
/**
* A managed local SAGA tarball as the lockfile records it: `file:candidates/saga-<version>/saga-a2ui-<name>-<version>.tgz`,
* A managed local SAGA tarball as the lockfile records it: `file:candidates/saga-<version>/saga-a2ui-<name>-<version>.tgz`
* (`file:apps/mcp/candidates/...` in this monorepo, whose lockfile and overrides are at the repository root),
* the directory and file naming one version. That is a snapshot candidate (0.0.0-candidate.g...) or the SDK's unpublished
* local release artifacts (a real version such as 0.7.6-rc.1). Neither is a registry release.
*/
const LOCAL_FILE = /^file:candidates\/saga-([^/]+)\/saga-a2ui-(?:core|components|mcp|react|lit|lit-mcp-stack|react-mcp-stack)-\1\.tgz$/;
const LOCAL_FILE = /^file:(?:apps\/mcp\/)?candidates\/saga-([^/]+)\/saga-a2ui-(?:core|components|mcp|react|lit|lit-mcp-stack|react-mcp-stack)-\1\.tgz$/;
const localVersion = ref => LOCAL_FILE.exec(ref)?.[1];
const defaultRoot = fileURLToPath(new URL('../', import.meta.url));
const isPrerelease = version => version.includes('-');
Expand Down
4 changes: 2 additions & 2 deletions apps/mcp/scripts/docker-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// Run through docker exec: the container has no external network or mounted files.
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import {createServer} from 'node:http';
import {setTimeout as delay} from 'node:timers/promises';
const answer='3. Synthetic complete answer\n 1. Nested item\n\nالإجابة العربية\n\n*Answered by [Ansari](https://askansari.ai).*\n';
Expand Down Expand Up @@ -47,7 +46,8 @@ try {
assert.deepEqual(result.content,[{type:'text',text:answer}]);
assert.equal(result._meta.ui.resourceUri,uri);
const resource=await rpc('resources/read',{uri});const html=resource.contents[0].text;
assert.equal(uri,`ui://views/answer-card.${createHash('sha256').update(html).digest('hex').slice(0,32)}.html`);
// The address names the card's compatibility line; the release stamp is in the bytes.
assert.equal(uri,'ui://views/answer-card.v1.html');
assert.ok(html.includes('ci-public-smoke'));
assert.ok(!html.includes('version https://git-lfs.github.com/spec/v1'));
const failure=await rpc('tools/call',{name:'answer_islamic_question',arguments:{question:'fail'}});
Expand Down
21 changes: 17 additions & 4 deletions apps/mcp/scripts/sdk-identity.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
// Prints the SDK identity the build stamps into the card document (`data-sdk-*` on the body), as JSON.
// The application installs one exact public SAGA release from the npm registry, so the identity is that pinned stack
// version; the registry integrity of every SAGA package is recorded in the lockfile and is not repeated here.
import {readFileSync} from 'node:fs';
// A public release is identified by its pinned stack version; the registry integrity of every SAGA package is recorded
// in the lockfile and is not repeated here. A private candidate (`0.0.0-candidate.g<commit>`, installed from
// candidates/ until the release is on the registry) is identified by its source commit and the bytes of its tarballs.
import {createHash} from 'node:crypto';
import {readFileSync, readdirSync} from 'node:fs';
import {fileURLToPath} from 'node:url';

const STACK = '@saga-a2ui/react-mcp-stack';
const CANDIDATE = /^0\.0\.0-candidate\.g([0-9a-f]{12})$/;
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');

export function sdkIdentity() {
return {kind: 'registry', version: manifest.dependencies['@saga-a2ui/react-mcp-stack'], source: 'none', stackSha256: 'none', setSha256: 'none'};
const version = manifest.dependencies[STACK];
const candidate = CANDIDATE.exec(version);
if (!candidate) return {kind: 'registry', version, source: 'none', stackSha256: 'none', setSha256: 'none'};
const directory = new URL(`../candidates/saga-${version}/`, import.meta.url);
const tarballs = readdirSync(directory).filter(name => name.endsWith('.tgz')).sort();
const digests = tarballs.map(name => `${name} ${sha256(readFileSync(new URL(name, directory)))}\n`);
const stack = `saga-a2ui-react-mcp-stack-${version}.tgz`;
if (!tarballs.includes(stack)) throw new Error(`candidates/saga-${version}/ has no ${stack}.`);
return {kind: 'candidate', version, source: candidate[1], stackSha256: sha256(readFileSync(new URL(stack, directory))), setSha256: sha256(digests.join(''))};
}

if (process.argv[1] === fileURLToPath(import.meta.url)) console.log(JSON.stringify(sdkIdentity()));
16 changes: 5 additions & 11 deletions apps/mcp/src/server/create-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {McpServer} from '@modelcontextprotocol/server';
import {attachRequestLifetime, createServerBudgets, registerPublicOperation, type OperationView} from '@saga-a2ui/react-mcp-stack/server';
import {registerPublicOperation, type OperationView, type RequestLifetime} from '@saga-a2ui/react-mcp-stack/server';
import {z} from 'zod';
import type {AnswerCompleter} from './ansari-client.js';
import {writeOperationLog,type OperationLogger} from './operation-log.js';
Expand All @@ -11,25 +11,19 @@ import {ANSWER_ENVELOPE_VERSION} from '../view/envelope.js';
// Existing application identity and text, not a new release declaration.
export const SERVER_NAME='ansari-mcp';
export const TOOL_UNAVAILABLE_TEXT='Ansari could not complete this request.';
/** The handler wait the tool declares; the request budget of each call below is the same, never the SDK default. */
/** The handler wait the tool declares. The fetch app gives each request the same budget, never the SDK default. */
export const HANDLER_BOUND_MS=60_000;
export type CreateMcpServerOptions = {
completer: AnswerCompleter;
view: OperationView;
logger: OperationLogger;
release?: string;
requestMs?: number;
/** The request's lifetime, from the SDK's fetch app, which also ends it when this server closes. */
lifetime: RequestLifetime;
};

export function createAnsariMcpServer(options:CreateMcpServerOptions):McpServer{
// Without a context the SDK default 10 s request budget would clamp the declared handler bound below. The request
// budget is the declared bound itself: no new timeout target and no change to the SDK defaults. Each call gets its own.
const budgets=createServerBudgets({requestMs:options.requestMs??HANDLER_BOUND_MS});
const lifetime=budgets.startRequest();
const server=new McpServer({name:SERVER_NAME,version:APP_VERSION},{capabilities:{tools:{},resources:{}}});
// The request's timer ends when this server does: on its transport closing, and on close() with or without a transport
// (the handler also disposes a server it never connected). The SDK owns that rule; the application subclasses nothing.
attachRequestLifetime(server,lifetime);
registerPublicOperation(server,{
name:'answer_islamic_question',
title:'Answer Islamic Question',
Expand Down Expand Up @@ -59,6 +53,6 @@ export function createAnsariMcpServer(options:CreateMcpServerOptions):McpServer{
return {kind:'record' as const,record:{question,answer}};
},
project:record=>({text:record.answer,data:{version:ANSWER_ENVELOPE_VERSION,question:record.question,answer:record.answer}}),
},{lifetime});
},{lifetime:options.lifetime});
return server;
}
Loading
Loading