diff --git a/.dockerignore b/.dockerignore index 3fbdd842..18e041a9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -# Keep Docker build contexts small: both service Dockerfiles use the repo +# Keep Docker build contexts small: every service Dockerfile uses the repo # root as context (the pnpm workspace needs the root lockfile), so anything # not excluded here uploads on every build. .git @@ -14,3 +14,16 @@ **/.env.local **/.env.*.local apps/frontend/google-service-account +# Cloudflare Worker local state and secrets (apps/mcp) +**/.dev.vars +**/.dev.vars.* +**/.wrangler +**/dist-worker + +# Credentials and private environment files; committed public examples remain available. +**/.npmrc +**/.env.* +!**/.env.ci +!**/.env.example +**/test-results +**/playwright-report diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml new file mode 100644 index 00000000..6a59b627 --- /dev/null +++ b/.github/workflows/mcp.yml @@ -0,0 +1,41 @@ +name: MCP application +on: + pull_request: + paths: &mcp-paths + - 'apps/mcp/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - 'LICENSE' + - '.dockerignore' + - '.github/workflows/mcp.yml' + - 'packages/eslint-config/**' + push: + branches: [main, develop] + paths: *mcp-paths +permissions: + contents: read +jobs: + mcp: + name: MCP application + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@10.33.0 --activate + - name: Install Chromium system dependencies + run: pnpm dlx playwright@1.64.0 install-deps chromium + - name: Install from the workspace lockfile + run: pnpm install --frozen-lockfile --strict-peer-dependencies --filter ansari-mcp... + - name: Verify the MCP application + run: | + pnpm mcp exec playwright install chromium + pnpm mcp verify + - name: Verify the root-context container + run: bash apps/mcp/scripts/verify-container.sh diff --git a/README.md b/README.md index 1410613f..e05a161e 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,7 @@ issues for vulnerabilities. ## License [MIT](LICENSE) © IASER + +## MCP application + +[`apps/mcp`](apps/mcp/README.md) exposes Ansari answers through MCP with a self-contained React card for assistant hosts. It uses public saga a2ui packages, embedded Kitab fonts and local artwork. Start it separately with `pnpm mcp build` then `pnpm mcp start`; see its README for environment variables and its Docker build, which uses the repository root as context. diff --git a/apps/frontend/assets/video/ambient-shadow-desktop-poster.jpg b/apps/frontend/assets/video/ambient-shadow-desktop-poster.jpg index 443220b2..4f2e0ea0 100644 Binary files a/apps/frontend/assets/video/ambient-shadow-desktop-poster.jpg and b/apps/frontend/assets/video/ambient-shadow-desktop-poster.jpg differ diff --git a/apps/frontend/assets/video/ambient-shadow-desktop.mp4 b/apps/frontend/assets/video/ambient-shadow-desktop.mp4 index e3c3caab..db51ca5c 100644 Binary files a/apps/frontend/assets/video/ambient-shadow-desktop.mp4 and b/apps/frontend/assets/video/ambient-shadow-desktop.mp4 differ diff --git a/apps/frontend/assets/video/ambient-shadow-desktop.webm b/apps/frontend/assets/video/ambient-shadow-desktop.webm index ccb2395c..d46df55c 100644 Binary files a/apps/frontend/assets/video/ambient-shadow-desktop.webm and b/apps/frontend/assets/video/ambient-shadow-desktop.webm differ diff --git a/apps/frontend/assets/video/ambient-shadow-poster.jpg b/apps/frontend/assets/video/ambient-shadow-poster.jpg index 94c350a7..a4be8316 100644 Binary files a/apps/frontend/assets/video/ambient-shadow-poster.jpg and b/apps/frontend/assets/video/ambient-shadow-poster.jpg differ diff --git a/apps/frontend/assets/video/ambient-shadow.mp4 b/apps/frontend/assets/video/ambient-shadow.mp4 index 6a4faf2e..8729a555 100644 Binary files a/apps/frontend/assets/video/ambient-shadow.mp4 and b/apps/frontend/assets/video/ambient-shadow.mp4 differ diff --git a/apps/frontend/assets/video/ambient-shadow.webm b/apps/frontend/assets/video/ambient-shadow.webm index e5057708..45c84394 100644 Binary files a/apps/frontend/assets/video/ambient-shadow.webm and b/apps/frontend/assets/video/ambient-shadow.webm differ diff --git a/apps/frontend/lib/ambientNight.ts b/apps/frontend/lib/ambientNight.ts index 929ba11a..97b46ae7 100644 --- a/apps/frontend/lib/ambientNight.ts +++ b/apps/frontend/lib/ambientNight.ts @@ -66,10 +66,10 @@ export type AmbientTreatment = * posters, which is what keeps the still and the video from stepping as * one fades over the other. * - * The portrait and landscape clips were graded differently — landscape - * carries barely two thirds of portrait's contrast — so they cannot - * share one grade. Anchoring each to its own numbers is what makes the - * desktop layer read like the phone one instead of a paler copy. + * The portrait and landscape clips are graded within a level of each + * other now, but anchoring each to its own measured numbers — rather + * than assuming they match — is what keeps the desktop layer reading + * like the phone one if a future re-grade pulls them apart again. * * Re-exporting the clips invalidates all of this; `ambientNight.test.ts` * pins the numbers so that goes noticed rather than shipping. @@ -78,8 +78,8 @@ export const NIGHT_SOURCE: Record< Clip, { min: number; frond: number; wall: number } > = { - portrait: { min: 0.663, frond: 0.706, wall: 0.894 }, - landscape: { min: 0.773, frond: 0.776, wall: 0.91 }, + portrait: { min: 0.631, frond: 0.671, wall: 0.898 }, + landscape: { min: 0.635, frond: 0.671, wall: 0.894 }, }; /** @@ -92,15 +92,18 @@ export const NIGHT_SOURCE: Record< * * `night.well` is 0.53–0.57 of `night.page` channel for channel. The * shadow used to stop just above it, at 0.58, and read as too faint; - * 0.517 is 15% more depth (0.42 → 0.483), which lands the frond mass at - * the well and lets the core go a little past it. That is deliberate: - * the well was a reference point, not a limit, and what actually - * protects the shadow — detail in its darkest fronds — is held by the - * test against the filter's clamp. + * pushing the frond mass to the well (and letting the core go a little + * past it) is deliberate: the well was a reference point, not a limit, + * and what actually protects the shadow — detail in its darkest fronds — + * is held by the test against the filter's clamp. * - * The darkest half-percent (`min`) continues past this. That tail is the - * shadow's core and it should be the deepest thing on screen; the test - * keeps it from running toward black or crushing. + * `min` continues past this. That tail is the shadow's core and it + * should be the deepest thing on screen; the test keeps it from running + * toward black or crushing. 0.517 is a chosen depth, not the floor of a + * search — `ambientNight.test.ts`'s crush-protection check only requires + * the graded `min` to clear 0.02, which a much deeper value than this + * still clears. Move it by eye against both clips and let the test catch + * an actual crush, rather than reading it as a limit the test found. */ export const NIGHT_DEPTH = 0.517; diff --git a/apps/mcp/.gitignore b/apps/mcp/.gitignore new file mode 100644 index 00000000..c55d70c2 --- /dev/null +++ b/apps/mcp/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +dist-worker/ +.wrangler/ +test-results/ +playwright-report/ +.env +.env.* +!.env.example +.dev.vars +.dev.vars.* diff --git a/apps/mcp/Dockerfile b/apps/mcp/Dockerfile new file mode 100644 index 00000000..e5227bee --- /dev/null +++ b/apps/mcp/Dockerfile @@ -0,0 +1,18 @@ +# Build from the repository root: docker build -f apps/mcp/Dockerfile -t ansari-mcp . +FROM node:22-slim AS build +WORKDIR /repo +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/ +RUN pnpm install --strict-peer-dependencies --frozen-lockfile --filter ansari-mcp... +COPY apps/mcp apps/mcp +RUN pnpm mcp build + +FROM node:22-slim +WORKDIR /app +ENV NODE_ENV=production PORT=3000 +COPY --from=build /repo/apps/mcp/dist ./dist +COPY --from=build /repo/apps/mcp/package.json ./package.json +EXPOSE 3000 +CMD ["node", "dist/server/index.js"] diff --git a/apps/mcp/README.md b/apps/mcp/README.md new file mode 100644 index 00000000..b983f99b --- /dev/null +++ b/apps/mcp/README.md @@ -0,0 +1,145 @@ +# 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. + +## How it works + +[Explore the interactive walkthrough](docs/show-me-ansari-mcp.html) for the complete question-to-card flow, UI states, code ownership, build process and why it builds on SAGA. Download the self-contained HTML and open it in a browser; GitHub displays its source. The walkthrough uses synthetic content and makes no live requests. + +## Screenshots + +English and Arabic answer cards rendered in ChatGPT desktop web through the Dev connection on October 23, 2026. Both rendered on first load with application revision `ee7ea4a78933544c4a96577d7ffd7276df8ae037` and public npm SAGA SDK `0.7.6-rc.1`. Arabic headings, paragraphs and lists use RTL layout and the embedded Kitab fonts. The application owner also supplied the ChatGPT and Claude mobile captures below. These captures retain their original revision identity. The application owner accepts the existing ChatGPT, Claude and earlier Alexa baseline; the [consolidated host and release evidence](https://github.com/MachineWisdomAI/saga-a2ui/pull/321) records each observation and its limits. Earlier Alexa evidence is not presented as a fresh stable-version run, and the 239 successful RC HTTP requests measure transport activity rather than rendered answers. + +### English + +![English answer card in ChatGPT Dev, showing the complete answer, floral branding, attribution and SAGA credit](docs/screenshots/chatgpt-dev-english.jpg) + +### Arabic + +![Arabic answer card in ChatGPT Dev, showing RTL text, numbered floral markers and attribution](docs/screenshots/chatgpt-dev-arabic.jpg) + +### ChatGPT mobile + +User-provided capture of the Dev answer card in ChatGPT, showing English text and floral branding. + +![Ansari MCP Dev card in ChatGPT mobile](docs/screenshots/chatgpt-dev-mobile.jpg) + +### Claude mobile + +User-provided capture of the Dev answer card in Claude, showing Arabic text, RTL layout and numbered floral markers. + +![Ansari MCP Dev card in Claude mobile with Arabic RTL content](docs/screenshots/claude-dev-mobile.jpg) + +## 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. + +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, +`useHostLink`, `readingProfile`) and its stylesheet in the view; `/content-policy` (`createContentPolicy`, `embedContentPolicy`) +in the build; `/server` (`registerPublicOperation`, `operationViewFromHtml`, `createServerBudgets`, `attachRequestLifetime`) in the +MCP server. The browser bundle holds no `/server` module. The stack's exact-version siblings come from the same release, +and its installed tree includes `@saga-a2ui/components` and `markdown-it`. SAGA owns its A2UI and Zod 3 dependency +context; the application uses Zod 4 for MCP. Do not add direct `@a2ui/react` or `@a2ui/web_core` dependencies or cast between their +schema types. The artwork owner has authorized contribution and redistribution with the application; see `assets/NOTICE.md`. + +## Development + +Requires Node 22.18 or later and pnpm 10.33. Install once from the repository root using its authoritative lockfile. Run the app explicitly; root build, test, typecheck and coverage retain their existing scope, while root lint includes this application. + +```sh +pnpm install --frozen-lockfile --strict-peer-dependencies --filter ansari-mcp... +pnpm mcp build +pnpm mcp typecheck +pnpm mcp lint +pnpm mcp test +pnpm mcp exec playwright install chromium +pnpm mcp test:browser +pnpm mcp start +``` + +`build` writes `dist/view.html` and a bundled `dist/server/index.js`. No API/auth/frontend build or SDK source checkout is needed. `dev` runs the Node entry source (`src/node/index.ts`) after `build`. The synthetic browser harness is test-only and uses no credentials or live answers. + +### Two hosting targets, one application + +The same Hono application and official MCP handler (`src/server/fetch-app.ts`) run on Node/Docker and on Cloudflare Workers. Each target is a thin entry that supplies configuration, logging and the built card explicitly: `src/node/` (`index.ts` and `http-server.ts`: environment, filesystem read at start, listener, signals) and `src/worker/index.ts` (Worker bindings, console, the card bundled at build time). Both adapters import the shared code in `src/server/`; shared modules import neither adapter and read no file, environment or process object. + +| Target | Build | Run locally | Check | +| --- | --- | --- | --- | +| Node/Docker | `pnpm build` writes `dist/` | `pnpm start` (or the Docker commands below) | `pnpm test` | +| Workers | `pnpm build:worker` writes the bundle to `dist-worker/` (Wrangler `deploy --dry-run`; nothing is published) | `pnpm dev:worker:dev` (Wrangler's local runtime, workerd; an environment is always named: `dev:worker:`) | `pnpm test` | + +```sh +# Clean frozen installation, then both artifacts and every check (Node, Workers and browser): +pnpm install --frozen-lockfile --strict-peer-dependencies --filter ansari-mcp... +pnpm mcp verify # typecheck, build, build:worker, test, test:browser + +# Run the Worker in Wrangler's local runtime under one declared environment (dev, beta or production). Its local secrets are `.dev.vars.` only (git-ignored, and excluded from the Docker build context, together with `.wrangler/` and `dist-worker/`). The script refuses a generic `.dev.vars`, which Wrangler would read for every environment, and turns off Wrangler's `.env` fallback. +pnpm dev:worker:dev --var ANSARI_RELEASE:local-1 +curl http://127.0.0.1:8787/health +``` + +The Worker takes the variables in the table below as Wrangler `vars` or secrets, not from `process.env`; `PORT` does not apply. `wrangler.jsonc` shares only the compatibility settings (`nodejs_compat`: the MCP SDK uses Node's crypto and buffer modules) and declares three named environments, `dev`, `beta` and `production`, each with its own Worker name, hostname, client label and allowlist; there is no top-level Worker, so a bare `wrangler deploy` publishes nothing. Secrets are never declared there. The `beta` and `production` hostnames are `replace-with-*` placeholders that the deploying owner must replace; do not deploy those environments while one remains. `test/parity-node.test.ts` and `test/parity-workers.test.ts` run one set of HTTP/MCP cases against the built Node server and the built Worker bundle in the local Workers runtime, each with a real synthetic upstream. They show local behaviour only; they are not a deployment, Cloudflare limit or host acceptance check. Node's signal shutdown stays in `test/lifecycle.test.ts`; the Worker's cleanup check is a caller disconnecting mid-call and the runtime stopping without leftover processes. + +| Variable | Meaning | +| --- | --- | +| `PORT` | HTTP port (default 3000). | +| `ANSARI_API_BASE_URL` | Ansari API origin (default `https://api.askansari.ai`). | +| `ANSARI_API_AUTHORIZATION` | Optional upstream Authorization header. Supply through the environment; never commit it. | +| `ANSARI_CLIENT` | Upstream client label (default `ansari-mcp`). | +| `MCP_ALLOWED_HOSTS` | Comma-separated allowed HTTP Host names, without scheme. A configured list replaces local defaults; an explicitly empty list permits no Host. Only an absent variable defaults to localhost and 127.0.0.1. | +| `MCP_ALLOWED_ORIGINS` | Comma-separated allowed browser origin hostnames. A configured list replaces defaults; an explicitly empty list permits no Origin header value. Requests without Origin remain supported. | +| `MCP_RESPONSE_MODE` | `json` for modern MCP final-answer-only responses; otherwise SSE, which supports client-requested progress notifications. Older-host compatibility responses may use SSE. | +| `ANSARI_RELEASE` | Optional safe release identifier, embedded in served HTML and health/log records. | + +`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. + +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. + +## Workspace and container + +This package lives at `apps/mcp` and is named `ansari-mcp`. The root `pnpm-lock.yaml` and `pnpm-workspace.yaml` are the only install inputs. `pnpm mcp \ No newline at end of file diff --git a/apps/mcp/eslint.config.mjs b/apps/mcp/eslint.config.mjs new file mode 100644 index 00000000..3d1a07b5 --- /dev/null +++ b/apps/mcp/eslint.config.mjs @@ -0,0 +1,16 @@ +import tseslint from 'typescript-eslint'; +import ansari from '@ansari/eslint-config/base'; + +export default [ + ...ansari, + // Test fixtures remain covered by typecheck and the test runner, as in apps/api. + {ignores: ['test/**', 'dist-worker/**', '.wrangler/**', 'test-results/**', 'playwright-report/**']}, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + parser: tseslint.parser, + parserOptions: {projectService: false, sourceType: 'module'}, + }, + }, +]; diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 00000000..3033653b --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,59 @@ +{ + "name": "ansari-mcp", + "version": "0.16.6", + "private": true, + "type": "module", + "description": "Ansari's MCP application", + "engines": { + "node": ">=22.18.0", + "pnpm": ">=10" + }, + "scripts": { + "build": "tsx scripts/build.ts", + "start": "node dist/server/index.js", + "dev": "tsx src/node/index.ts", + "typecheck": "tsc --noEmit", + "test": "tsx --test test/*.test.ts", + "test:browser": "tsx test/browser.ts", + "verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm build:worker && pnpm test && pnpm test:browser", + "check:release": "node scripts/check-release.mjs", + "build:worker": "tsx scripts/build.ts worker", + "guard:dev-vars": "test ! -e .dev.vars || { echo 'A generic .dev.vars is read by every environment: rename it to .dev.vars. for the one environment it belongs to.' >&2; exit 1; }", + "dev:worker:dev": "pnpm guard:dev-vars && pnpm build:worker && CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false wrangler dev --env dev", + "dev:worker:beta": "pnpm guard:dev-vars && pnpm build:worker && CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false wrangler dev --env beta", + "dev:worker:production": "pnpm guard:dev-vars && pnpm build:worker && CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false wrangler dev --env production", + "deploy:worker:dev": "pnpm build:worker && wrangler deploy --env dev --var ANSARI_RELEASE:$(git rev-parse HEAD)", + "deploy:worker:beta": "pnpm check:release --target beta && pnpm build:worker && wrangler deploy --env beta --var ANSARI_RELEASE:$(git rev-parse HEAD)", + "deploy:worker:production": "pnpm check:release --target production && pnpm build:worker && wrangler deploy --env production --var ANSARI_RELEASE:$(git rev-parse HEAD)", + "lint": "eslint ." + }, + "dependencies": { + "@hono/node-server": "1.19.17", + "@modelcontextprotocol/hono": "2.0.2", + "@modelcontextprotocol/server": "2.3.1", + "@saga-a2ui/react-mcp-stack": "0.7.6", + "hono": "4.13.13", + "react": "19.3.1", + "react-dom": "19.3.1", + "zod": "4.6.7" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.3.1", + "@modelcontextprotocol/ext-apps": "2.0.3", + "@playwright/test": "1.64.0", + "@types/node": "22.20.5", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "esbuild": "0.28.1", + "tsx": "4.22.0", + "typescript": "catalog:", + "vite": "6.4.1", + "vite-plugin-singlefile": "2.3.0", + "wrangler": "4.151.3", + "@ansari/eslint-config": "workspace:*", + "eslint": "catalog:", + "typescript-eslint": "catalog:" + }, + "packageManager": "pnpm@10.33.0", + "license": "MIT" +} diff --git a/apps/mcp/scripts/build.ts b/apps/mcp/scripts/build.ts new file mode 100644 index 00000000..8020f1a6 --- /dev/null +++ b/apps/mcp/scripts/build.ts @@ -0,0 +1,64 @@ +import {readFileSync, mkdirSync, writeFileSync, rmSync} from 'node:fs'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import {build as viteBuild} from 'vite'; +import {build as esbuild} from 'esbuild'; +import {viteSingleFile} from 'vite-plugin-singlefile'; +import {createContentPolicy, embedContentPolicy} from '@saga-a2ui/react-mcp-stack/content-policy'; +import {APP_VERSION} from '../src/version.js'; +import {BRANDING} from '../src/view/branding.js'; +import {thirdPartyLicenceComment} from './third-party-licences.js'; + +// `tsx scripts/build.ts [node|worker]`: both targets build the same card; each then bundles its own entry. +const target = process.argv[2] ?? 'node'; +if (target !== 'node' && target !== 'worker') throw new Error(`unknown build target: ${target} (node or worker)`); + +const root = fileURLToPath(new URL('../', import.meta.url)); +// The SDK identity (the pinned public registry release) the card document carries beside the application release, +// read by the same script the tests use. +const sdk = JSON.parse(execFileSync(process.execPath, [path.join(root, 'scripts/sdk-identity.mjs')], {encoding: 'utf8'})) as Record<'kind' | 'version' | 'source' | 'stackSha256' | 'setSha256', string>; +const SDK_FIELD = /^[A-Za-z0-9._+-]{1,80}$/; +for (const [field, value] of Object.entries(sdk)) if (!SDK_FIELD.test(value)) throw new Error(`The SDK identity ${field} is not a plain token: ${value}`); +const dist = path.join(root, 'dist'); +// The Node artifact is the whole of dist/. A Workers build adds only the card, so it leaves a Node artifact intact. +if (target === 'node') rmSync(dist, {recursive: true, force: true}); +mkdirSync(dist, {recursive: true}); +await viteBuild({ + configFile: false, + root: path.join(root, 'src/view'), + base: './', + plugins: [viteSingleFile(), { + name: 'ansari-brand', + transformIndexHtml: html => html.replaceAll('{{brand.name}}', BRANDING.name).replaceAll('{{release}}', APP_VERSION) + .replaceAll('{{sdk.kind}}', sdk.kind).replaceAll('{{sdk.version}}', sdk.version).replaceAll('{{sdk.source}}', sdk.source) + .replaceAll('{{sdk.stackSha256}}', sdk.stackSha256).replaceAll('{{sdk.setSha256}}', sdk.setSha256), + }], + esbuild: {jsx: 'automatic'}, + build: { + outDir: dist, emptyOutDir: false, target: 'es2022', cssCodeSplit: false, + assetsInlineLimit: 100_000_000, + rollupOptions: {input: path.join(root, 'src/view/mcp-app-react.html')}, + }, +}); +// The application's one policy: Standard, no external image origin, no font origin. A constant, never derived from data. +const policy = createContentPolicy({level: 'standard', imageHosts: [], fontOrigins: []}); +if (!policy.ok) throw new Error(policy.failure.message); +const built = path.join(dist, 'mcp-app-react.html'); +const document = embedContentPolicy(readFileSync(built, 'utf8'), policy.policy); +writeFileSync(path.join(dist, 'view.html'), document.replace('', `${thirdPartyLicenceComment(root)}`)); +rmSync(built); +if (target === 'node') { + await esbuild({ + absWorkingDir: root, entryPoints: ['src/node/index.ts'], outfile: 'dist/server/index.js', + bundle: true, platform: 'node', target: 'node22', format: 'esm', + banner: {js: "import {createRequire} from 'node:module'; const require = createRequire(import.meta.url);"}, + }); +} else { + // Wrangler's own bundling, without publishing: the entry imports dist/view.html as text, so the card is part of the bundle. + // wrangler.jsonc has no top-level Worker, so the dry run names an environment to find the entry point. The bundle holds no + // environment setting (vars and secrets are bindings applied at deploy time), so the same bundle serves every environment. + const outdir = path.join(root, 'dist-worker'); + rmSync(outdir, {recursive: true, force: true}); + execFileSync(path.join(root, 'node_modules/.bin/wrangler'), ['deploy', '--dry-run', '--env', 'dev', '--outdir', outdir], {cwd: root, stdio: 'inherit'}); +} diff --git a/apps/mcp/scripts/check-release.mjs b/apps/mcp/scripts/check-release.mjs new file mode 100644 index 00000000..f7d4be2e --- /dev/null +++ b/apps/mcp/scripts/check-release.mjs @@ -0,0 +1,299 @@ +// The release-input gate for one named environment. It reads files and, only when asked, the public npm +// registry. It publishes, tags and deploys nothing, and it changes no file. +// +// node scripts/check-release.mjs --target dev|beta|production [--no-registry] [--tag TAG] [--from REF [--release-files-only]] +// +// --target required; there is no default environment. +// --no-registry skip the npm registry lookups. The report then says the registry was NOT checked, so a +// structural pass is never mistaken for a verified public release. +// --tag TAG the source tag being built: it must be the annotated app tag `ansari-mcp-v`, +// point at HEAD, and the tree must be clean. +// --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 +// 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. +// +// Dev tolerates exact local SAGA tarballs under candidates/ (this repository commits none). Beta and +// Production accept only exact, public, consistent SAGA releases. Production additionally accepts no prerelease. +import {execFileSync} from 'node:child_process'; +import {existsSync, readFileSync, realpathSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const STACK = '@saga-a2ui/react-mcp-stack'; +export const TARGETS = ['dev', 'beta', 'production']; +export const TAG_PREFIX = 'ansari-mcp-v'; +/** Files a stable derivation from an accepted RC may change: version mirrors, the SDK pin and the lockfile. */ +export const RELEASE_FILES = [ + 'apps/mcp/package.json', + 'pnpm-lock.yaml', + 'apps/mcp/src/version.ts', + 'plugin/plugin.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; }; +/** + * For the small release files: the content with only the allowed fields (release version, SDK pin) blanked, so two + * revisions compare equal exactly when they differ in nothing else. The lockfile is not here: its content is not diffed. + */ +const RELEASE_CONTENT = { + 'apps/mcp/package.json': text => { + const parsed = withoutVersion(text); + if (parsed.dependencies?.[STACK] !== undefined) parsed.dependencies[STACK] = null; + return canonical(parsed); + }, + 'apps/mcp/src/version.ts': text => text.replace(/(export const APP_VERSION = )'[^']*'/, "$1''"), + 'plugin/plugin.json': text => canonical(withoutVersion(text)), +}; + +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-/saga-a2ui--.tgz`, + * 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 localVersion = ref => LOCAL_FILE.exec(ref)?.[1]; +const defaultRoot = fileURLToPath(new URL('../', import.meta.url)); +const isPrerelease = version => version.includes('-'); +const read = file => (existsSync(file) ? readFileSync(file, 'utf8') : undefined); + +/** The text of one top-level section of pnpm-lock.yaml. */ +function section(lock, name) { + const match = new RegExp(`^${name}:\\n((?:(?:[ ]+.*)?\\n)*)`, 'm').exec(lock); + return match ? match[1] : ''; +} + +/** Direct SAGA dependencies of the application as the lockfile recorded them. */ +function lockedDirect(lock, importerName) { + const found = []; + const pattern = /^ {6}'?(@saga-a2ui\/[^':]+)'?:\n {8}specifier: (.+)\n {8}version: (.+)$/gm; + const escaped = importerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const importer = new RegExp(`^ {2}['"']?${escaped}['"']?:\\n((?:(?: {4}.*)?\\n)*)`, 'm').exec(section(lock, 'importers'))?.[1] ?? ''; + for (const [, name, specifier, version] of importer.matchAll(pattern)) { + found.push({name, specifier: specifier.replace(/^'|'$/g, ''), version: version.replace(/^'|'$/g, '').replace(/\(.*$/, '')}); + } + return found; +} + +/** Every resolved `@saga-a2ui/*` package in the lockfile, with its version and where the bytes come from. */ +function lockedPackages(lock) { + const body = section(lock, 'packages'); + const result = []; + for (const [, key, block] of body.matchAll(/^ {2}'?([^'\n]+?)'?:\n((?: {4}.*\n?)*)/gm)) { + const split = /^(@saga-a2ui\/[^@]+)@(.+)$/.exec(key); + if (!split) continue; + const [, name, ref] = split; + const resolution = /resolution: \{(.*)\}/.exec(block)?.[1] ?? ''; + const tarball = /tarball: (\S+?)[,}]?(?:\s|$)/.exec(resolution)?.[1]; + const direct = /(?:directory|repo|commit):/.test(resolution); + const file = ref.startsWith('file:'); + result.push({ + name, + ref, + version: file ? localVersion(ref) : ref, + source: file ? 'file' : tarball ? 'tarball' : direct ? 'git-or-directory' : /integrity: sha512-/.test(resolution) ? 'registry' : 'unverified', + }); + } + return result; +} + +/** pnpm-workspace.yaml `overrides:` entries for SAGA packages. */ +function sagaOverrides(workspace) { + return [...(workspace ?? '').matchAll(/^ {2}'?(@saga-a2ui\/[^':]+)'?: '?([^'\n]+)'?$/gm)].map(([, name, value]) => ({name, value})); +} + +/** + * Validates the release inputs of one application folder for one environment. + * Returns {problems, packages, facts}; problems is empty when the inputs are acceptable for that environment. + */ +export function validateRelease({appRoot = defaultRoot, target} = {}) { + if (!TARGETS.includes(target)) throw new Error(`Name the environment: --target ${TARGETS.join('|')} (got ${target ?? 'none'}).`); + const problems = []; + const manifest = JSON.parse(readFileSync(path.join(appRoot, 'package.json'), 'utf8')); + const workspaceRoot = path.resolve(realpathSync(appRoot), '../..'); + const importerName = path.relative(workspaceRoot, realpathSync(appRoot)).split(path.sep).join('/'); + const lock = read(path.join(workspaceRoot, 'pnpm-lock.yaml')); + const workspace = read(path.join(workspaceRoot, 'pnpm-workspace.yaml')); + const release = target !== 'dev'; + + // 1. The direct stack dependency: present, exact, public (Beta/Production), and the only direct SAGA dependency. + const direct = Object.entries({...manifest.dependencies, ...manifest.devDependencies}).filter(([name]) => name.startsWith('@saga-a2ui/')); + const stack = manifest.dependencies?.[STACK]; + if (stack === undefined) problems.push(`${STACK} is not a direct dependency.`); + for (const [name, spec] of direct) { + if (name !== STACK) problems.push(`${name} is a redundant direct dependency: the application reaches it through ${STACK}; remove it.`); + if (!EXACT.test(spec)) problems.push(`${name}: "${spec}" is not an exact version. Floating tags (next, latest), ranges, file:, link:, workspace: and git specifiers cannot be released; resolve and pin an exact version.`); + } + if (stack !== undefined && CANDIDATE.test(stack) && release) problems.push(`${STACK}@${stack} is a private SAGA candidate installed from candidates/, not a public release. ${target} accepts only an exact public release.`); + if (stack !== undefined && EXACT.test(stack) && target === 'production' && isPrerelease(stack) && !CANDIDATE.test(stack)) problems.push(`${STACK}@${stack} is a prerelease; Production needs the stable SDK release supplied by its owner.`); + + // 2. No SAGA override may stand in for a release on Beta/Production. + const overrides = sagaOverrides(workspace); + const manifestOverrides = Object.keys({...manifest.overrides, ...manifest.pnpm?.overrides}).filter(name => name.startsWith('@saga-a2ui/')); + if (release) { + if (overrides.length > 0) problems.push(`pnpm-workspace.yaml overrides ${overrides.map(entry => entry.name).join(', ')} (${[...new Set(overrides.map(entry => entry.value.split(':')[0]))].join(', ')}): remove every SAGA override for ${target}.`); + for (const name of manifestOverrides) problems.push(`package.json overrides ${name}: remove every SAGA override for ${target}.`); + } + + // 3. The resolved graph: one version across every SAGA sibling, equal to the direct pin, from the registry. + let packages = []; + if (lock === undefined) problems.push('pnpm-lock.yaml is missing: commit the resolved graph.'); + else { + const locked = lockedDirect(lock, importerName); + const lockedStack = locked.filter(entry => entry.name === STACK); + if (lockedStack.length !== 1) problems.push(`pnpm-lock.yaml records no direct ${STACK} dependency.`); + else { + // An override rewrites the locked specifier of the direct dependency to its tarball; compare the version it names. + const resolvedFor = lockedStack[0].specifier.startsWith('file:') ? localVersion(lockedStack[0].specifier) : lockedStack[0].specifier; + if (resolvedFor !== stack) problems.push(`pnpm-lock.yaml is stale: it was resolved for "${lockedStack[0].specifier}", package.json says "${stack}".`); + } + for (const entry of locked.filter(item => item.name !== STACK)) problems.push(`pnpm-lock.yaml records a redundant direct dependency ${entry.name}.`); + packages = lockedPackages(lock); + if (!packages.some(entry => entry.name === STACK)) problems.push(`pnpm-lock.yaml resolves no ${STACK}.`); + const versions = [...new Set(packages.map(entry => entry.version ?? `unrecognised:${entry.ref}`))]; + if (versions.length > 1) problems.push(`The resolved SAGA siblings are inconsistent: ${packages.map(entry => `${entry.name}@${entry.version ?? entry.ref}`).join(', ')}. They must share one version.`); + if (versions.length === 1 && stack !== undefined && versions[0] !== stack) problems.push(`The resolved SAGA siblings are ${versions[0]}, but the direct pin is ${stack}.`); + const notRegistry = packages.filter(entry => entry.source !== 'registry'); + if (release && notRegistry.length > 0) problems.push(`${notRegistry.length} resolved SAGA package(s) are not public registry releases with an integrity hash (${[...new Set(notRegistry.map(entry => entry.source))].join(', ')}); ${target} accepts only registry releases: ${notRegistry.map(entry => entry.name).join(', ')}.`); + const localRelease = packages.filter(entry => entry.source === 'file' && entry.version !== undefined && !CANDIDATE.test(entry.version)); + if (release && localRelease.length > 0) problems.push(`${localRelease.length} resolved SAGA package(s) are unpublished local release artifacts installed from candidates/ (${[...new Set(localRelease.map(entry => entry.version))].join(', ')}), not public registry releases: ${target} accepts only a release that is on the public registry. Local files are not a registry install.`); + for (const entry of packages) { + if (entry.version !== undefined && !EXACT.test(entry.version)) problems.push(`${entry.name}@${entry.ref} is not an exact version.`); + if (!release && entry.source === 'file' && localVersion(entry.ref) === undefined) problems.push(`${entry.name}@${entry.ref}: a file: installation must be a managed tarball under candidates/ (snapshot candidate or local release artifacts).`); + if (!release && !['file', 'registry'].includes(entry.source)) problems.push(`${entry.name}@${entry.ref}: ${entry.source} installations are not releasable.`); + } + } + + // 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; + 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 (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}`))]}}; +} + +/** Looks each distinct sibling up in the public registry. `view(spec)` returns the version npm reports, or throws. */ +export function verifyRegistry(packages, view = npmView) { + const verified = []; + const failed = []; + for (const spec of [...new Set(packages.map(entry => `${entry.name}@${entry.version}`))]) { + try { + const found = view(spec); + if (found === spec.slice(spec.lastIndexOf('@') + 1)) verified.push(spec); + else failed.push(`${spec}: the registry reported ${found}`); + } catch (error) { + failed.push(`${spec}: not found in the public registry (${String(error.message).split('\n')[0]})`); + } + } + return {verified, failed}; +} + +function npmView(spec) { + const out = execFileSync('npm', ['view', spec, 'version', '--json', '--registry=https://registry.npmjs.org'], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}); + // Newer npm releases print a one-element array for an exact version; older ones print the bare string. + const parsed = JSON.parse(out); + return Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed; +} + +const git = (cwd, ...args) => execFileSync('git', args, {cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}).trim(); + +/** The source tag: app-scoped name for this version, annotated, at HEAD, with a clean tree. */ +export function validateTag({appRoot = defaultRoot, tag, appVersion}) { + const problems = []; + if (tag !== `${TAG_PREFIX}${appVersion}`) problems.push(`Tag "${tag}" must be ${TAG_PREFIX}${appVersion} for app version ${appVersion}.`); + try { + if (git(appRoot, 'cat-file', '-t', `refs/tags/${tag}`) !== 'tag') problems.push(`${tag} is not an annotated tag.`); + const [at, head] = [git(appRoot, 'rev-parse', `refs/tags/${tag}^{commit}`), git(appRoot, 'rev-parse', 'HEAD')]; + if (at !== head) problems.push(`${tag} points at ${at}, but the checked-out HEAD is ${head}: build from a checkout of the tag.`); + } catch { problems.push(`Tag ${tag} does not exist in this repository.`); } + if (git(appRoot, 'status', '--porcelain') !== '') problems.push('The working tree is not clean: build only an unmodified checkout of the tag.'); + return problems; +} + +/** What changed from REF to HEAD, whether an executable dependency input changed, and any file outside the release set. */ +export function describeChanges({appRoot = defaultRoot, from, releaseFilesOnly = false}) { + const problems = []; + // Git reports the repository root with symlinks resolved (on macOS a /var/folders checkout is /private/var/folders), + // so resolve the app directory the same way before deriving the Git-relative prefix from the two. + const top = realpathSync(git(appRoot, 'rev-parse', '--show-toplevel')); + const files = git(top, 'diff', '--name-only', from, 'HEAD').split('\n').filter(Boolean); + const appPrefix = path.relative(top, realpathSync(appRoot)).split(path.sep).join('/'); + const rel = file => `${appPrefix}/${file}`; + const dependencyInputs = ref => { + const manifest = JSON.parse(git(top, 'show', `${ref}:${rel('package.json')}`)); + return JSON.stringify([manifest.dependencies, manifest.devDependencies, manifest.overrides, manifest.pnpm]); + }; + 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. + const beyondRelease = []; + for (const file of files.filter(entry => entry in RELEASE_CONTENT)) { + try { + const [before, after] = [from, 'HEAD'].map(ref => RELEASE_CONTENT[file](git(top, 'show', `${ref}:${file}`))); + if (before !== after) beyondRelease.push(file); + } catch { beyondRelease.push(file); } + } + outside.push(...beyondRelease); + if (releaseFilesOnly) { + for (const file of files.filter(entry => !RELEASE_FILES.includes(entry))) problems.push(`${file} changed since ${from}; a stable derivation changes only the release version, the SDK pin and the lockfile.`); + for (const file of beyondRelease) problems.push(`${file} changed since ${from} beyond the release version and the SDK pin.`); + try { git(top, 'merge-base', '--is-ancestor', from, 'HEAD'); } catch { problems.push(`${from} is not an ancestor of HEAD: derive stable from the accepted RC tag.`); } + } + return {problems, files, outside, dependenciesChanged}; +} + +function parse(argv) { + const options = {registry: true, releaseFilesOnly: false}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--no-registry') options.registry = false; + else if (arg === '--release-files-only') options.releaseFilesOnly = true; + else if (['--target', '--tag', '--from'].includes(arg)) options[arg.slice(2)] = argv[i += 1]; + else throw new Error(`Unknown argument ${arg}.`); + } + return options; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const options = parse(process.argv.slice(2)); + const {problems, packages, facts} = validateRelease({target: options.target}); + const lines = [`Release inputs for ${facts.target}: app ${facts.appVersion}, ${STACK}@${facts.stack}`]; + if (options.tag !== undefined) problems.push(...validateTag({tag: options.tag, appVersion: facts.appVersion})); + if (options.from !== undefined) { + const changes = describeChanges({from: options.from, releaseFilesOnly: options.releaseFilesOnly}); + problems.push(...changes.problems); + lines.push(`Changed since ${options.from}: ${changes.files.length === 0 ? 'nothing' : changes.files.join(', ')}`); + lines.push(changes.dependenciesChanged ? 'Executable dependency inputs CHANGED: qualify the affected checks on Dev before any deployment of this release; it is a new artifact, not the earlier binary.' : 'Executable dependency inputs unchanged.'); + if (changes.outside.length > 0) lines.push(`Outside the release version and SDK pin (code changed, rerun the affected Dev checks): ${changes.outside.join(', ')}`); + if (options.releaseFilesOnly) lines.push(`Path and field screen only: the content of pnpm-lock.yaml was NOT diffed. Review the full \`git diff ${options.from} HEAD\` before qualification and tagging.`); + } + if (options.target !== 'dev' && options.registry) { + const {verified, failed} = verifyRegistry(packages); + for (const spec of verified) lines.push(`Registry verified: ${spec}`); + problems.push(...failed.map(failure => `Registry: ${failure}`)); + } else if (options.target !== 'dev') lines.push('Registry NOT checked (--no-registry): this is a structural check of the files only, not a verified public release.'); + else lines.push('Dev: the structure is checked and local SAGA tarballs under candidates/ would be tolerated; use --target beta to verify the public registry release. Registry not consulted.'); + for (const line of lines) console.log(line); + if (problems.length > 0) { + for (const problem of problems) console.error(`- ${problem}`); + console.error(`Release check FAILED for ${facts.target} (${problems.length} problem${problems.length === 1 ? '' : 's'}).`); + process.exit(1); + } + console.log(`Release check passed for ${facts.target}.`); + } catch (error) { + console.error(error.message); + process.exit(2); + } +} diff --git a/apps/mcp/scripts/docker-smoke.mjs b/apps/mcp/scripts/docker-smoke.mjs new file mode 100644 index 00000000..00d1965e --- /dev/null +++ b/apps/mcp/scripts/docker-smoke.mjs @@ -0,0 +1,60 @@ +// 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'; +let calls=0; +const upstream=createServer(async(req,res)=>{ + const chunks=[]; for await(const chunk of req) chunks.push(chunk); + const body=JSON.parse(Buffer.concat(chunks).toString()); + assert.equal(req.url,'/api/v2/mcp-complete'); + assert.equal(req.method,'POST'); + assert.equal(req.headers.authorization,undefined); + calls++; + if(body.messages[0].content==='fail') {res.writeHead(503);res.end('private upstream detail');return;} + assert.equal(body.messages[0].content,'synthetic'); + res.writeHead(200,{'content-type':'text/plain; charset=utf-8'});res.end(answer); +}); +await new Promise(resolve=>upstream.listen(4010,'127.0.0.1',resolve)); +let id=0; +async function rpc(method,params={}) { + const response=await fetch('http://127.0.0.1:3000/mcp',{ + method:'POST',signal:AbortSignal.timeout(15000), + headers:{'Content-Type':'application/json',Accept:'application/json, text/event-stream','MCP-Protocol-Version':'2026-07-28','Mcp-Method':method,...(params.name ? {'Mcp-Name':params.name} : params.uri ? {'Mcp-Name':params.uri} : {})}, + body:JSON.stringify({jsonrpc:'2.0',id:++id,method,params:{...params,_meta:{'io.modelcontextprotocol/protocolVersion':'2026-07-28','io.modelcontextprotocol/clientInfo':{name:'synthetic-container',version:'1'},'io.modelcontextprotocol/clientCapabilities':{}}}}), + }); + assert.equal(response.status,200); + assert.equal(response.headers.get('mcp-session-id'),null); + const data=await response.json();assert.equal(data.id,id);assert.equal(data.error,undefined); + return data.result; +} +try { + let health; + for(let attempt=0;attempt<40;attempt++) { + try {const r=await fetch('http://127.0.0.1:3000/health',{signal:AbortSignal.timeout(1000)});if(r.ok){health=await r.json();break;}} catch {} + await delay(250); + } + assert.equal(health?.ok,true,'container did not become healthy'); + assert.equal(health.release,'ci-public-smoke'); + const tools=await rpc('tools/list');assert.deepEqual(tools.tools.map(t=>t.name),['answer_islamic_question']); + const resources=await rpc('resources/list');assert.equal(resources.resources.length,1); + const uri=resources.resources[0].uri; + assert.equal(tools.tools[0]._meta.ui.resourceUri,uri); + const result=await rpc('tools/call',{name:'answer_islamic_question',arguments:{question:'synthetic'}}); + assert.notEqual(result.isError,true); + assert.deepEqual(result.structuredContent,{version:'0.1.0',question:'synthetic',answer}); + 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`); + 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'}}); + assert.equal(failure.isError,true); + assert.ok(!JSON.stringify(failure).includes('private upstream detail')); + const next=await rpc('tools/call',{name:'answer_islamic_question',arguments:{question:'synthetic'}}); + assert.equal(next.structuredContent.answer,answer); + assert.equal(calls,3); + console.log(JSON.stringify({health,tools:1,resources:1,answerPreserved:true,safeFailureAndRecovery:true,resourceUri:uri})); +} finally {await new Promise(resolve=>upstream.close(resolve));} diff --git a/apps/mcp/scripts/sdk-identity.mjs b/apps/mcp/scripts/sdk-identity.mjs new file mode 100644 index 00000000..41ed40ca --- /dev/null +++ b/apps/mcp/scripts/sdk-identity.mjs @@ -0,0 +1,13 @@ +// 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'; +import {fileURLToPath} from 'node:url'; + +const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + +export function sdkIdentity() { + return {kind: 'registry', version: manifest.dependencies['@saga-a2ui/react-mcp-stack'], source: 'none', stackSha256: 'none', setSha256: 'none'}; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) console.log(JSON.stringify(sdkIdentity())); diff --git a/apps/mcp/scripts/third-party-licences.ts b/apps/mcp/scripts/third-party-licences.ts new file mode 100644 index 00000000..41046e8a --- /dev/null +++ b/apps/mcp/scripts/third-party-licences.ts @@ -0,0 +1,40 @@ +import {existsSync, readFileSync, realpathSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; + +/** Keep installed package notices in the HTML that actually carries their code. */ +export function thirdPartyLicenceComment(appRoot: string): string { + const require = createRequire(path.join(appRoot, 'package.json')); + const packageRoot = (name: string, resolver = require): string => { + // Some packages expose only type exports. Locate their installed manifest through + // the dependency's normal Node search paths without importing a runtime entry. + const manifest = (resolver.resolve.paths(name) ?? []) + .map(directory => path.join(directory, name, 'package.json')) + .find(file => existsSync(file)); + if (!manifest) throw new Error(`No installed package manifest for ${name}`); + return realpathSync(path.dirname(manifest)); + }; + const stack = packageRoot('@saga-a2ui/react-mcp-stack'); + const fromStack = createRequire(path.join(stack, 'package.json')); + const licences = new Map(); + const notices: string[] = []; + for (const name of ['core', 'components', 'mcp', 'react', 'react-mcp-stack']) { + const directory = packageRoot(`@saga-a2ui/${name}`, fromStack); + const manifest = JSON.parse(readFileSync(path.join(directory, 'package.json'), 'utf8')); + const identity = `${manifest.name}@${manifest.version}`; + const licence = readFileSync(path.join(directory, 'LICENSE'), 'utf8').trim(); + licences.set(licence, [...(licences.get(licence) ?? []), identity]); + for (const file of ['NOTICE', 'NOTICE.md', 'THIRD_PARTY_NOTICES.md']) { + if (existsSync(path.join(directory, file))) notices.push(`${identity}: ${file}\n\n${readFileSync(path.join(directory, file), 'utf8').trim()}`); + } + } + const text = [ + ...[...licences].map(([licence, names]) => `${names.join('\n')}\n\n${licence}`), + ...notices, + `Ansari application (MIT)\n\n${readFileSync(path.join(appRoot, '../../LICENSE'), 'utf8').trim()}`, + ...['NOTICE.md', 'fonts/OFL.txt', 'fonts/AUTHORS.txt', 'fonts/CONTRIBUTORS.txt'].map(file => + `Ansari assets: ${file}\n\n${readFileSync(path.join(appRoot, 'assets', file), 'utf8').trim()}`), + ].join('\n\n'); + // A dependency notice must never close this comment or introduce executable HTML. + return `\n`; +} diff --git a/apps/mcp/scripts/verify-container.sh b/apps/mcp/scripts/verify-container.sh new file mode 100644 index 00000000..32302b89 --- /dev/null +++ b/apps/mcp/scripts/verify-container.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# One uncached root-lockfile image build and a synthetic, isolated runtime smoke check. +set -euo pipefail +app_root=$(cd "$(dirname "$0")/.." && pwd) +repo_root=$(cd "$app_root/../.." && pwd) +container="" +image="ansari-mcp-ci:$(date +%s)-$$" +cleanup() { + if [[ -n "$container" ]]; then docker rm -f "$container" >/dev/null 2>&1 || true; fi + docker image rm "$image" >/dev/null 2>&1 || true +} +trap cleanup EXIT +docker build --no-cache -f "$app_root/Dockerfile" -t "$image" "$repo_root" +container=$(docker run -d --network none -e ANSARI_RELEASE=ci-public-smoke -e ANSARI_API_BASE_URL=http://127.0.0.1:4010 -e MCP_RESPONSE_MODE=json "$image") +[[ $(docker inspect "$container" --format '{{len .Mounts}}') == 0 ]] +docker exec -i "$container" node --input-type=module < "$app_root/scripts/docker-smoke.mjs" +docker stop --time 10 "$container" >/dev/null +[[ $(docker inspect "$container" --format '{{.State.ExitCode}}') == 0 ]] +echo 'MCP root-context container build and smoke passed.' diff --git a/apps/mcp/src/node/http-server.ts b/apps/mcp/src/node/http-server.ts new file mode 100644 index 00000000..1367dd3c --- /dev/null +++ b/apps/mcp/src/node/http-server.ts @@ -0,0 +1,76 @@ +import {createAdaptorServer} from '@hono/node-server'; +import {readFileSync} from 'node:fs'; +import type {Server} from 'node:http'; +import {createAnsariHttpCompleter, type AnswerCompleter} from '../server/ansari-client.js'; +import {MCP_PATH, createFetchApp} from '../server/fetch-app.js'; +import type {OperationLogger, OperationRecord} from '../server/operation-log.js'; +import {settingsFromEnv} from '../server/runtime-config.js'; + +export const DEFAULT_PORT = 3000; +export {MCP_PATH}; + +export type ListenOptions = { + port?: number; + host?: string; + completer?: AnswerCompleter; + allowedHosts?: string[]; + allowedOrigins?: string[]; + html?: string; + logger?: OperationLogger; + requestMs?: number; + enableJsonResponse?: boolean; +}; + +/** The Node adapter's logger: one JSON line on standard output. */ +export function defaultOperationLogger(record: OperationRecord): void { + process.stdout.write(`${JSON.stringify(record)}\n`); +} + +/** The Node adapter reads the built card from the deployment tree, once, at start. */ +function readBuiltView(): string { + return readFileSync(new URL('../../dist/view.html', import.meta.url), 'utf8'); +} + +/** Node/Docker hosting: environment, filesystem and listener around the shared fetch application. */ +export function createHttpServer(options: ListenOptions = {}) { + const settings = settingsFromEnv(process.env, options); + const app = createFetchApp({ + completer: options.completer ?? createAnsariHttpCompleter(settings.ansari), + html: options.html ?? readBuiltView(), + release: settings.release, + allowedHosts: settings.allowedHosts, + allowedOrigins: settings.allowedOrigins, + enableJsonResponse: settings.enableJsonResponse, + logger: options.logger ?? defaultOperationLogger, + requestMs: options.requestMs, + }); + + // The adapter's default createServer is Node's HTTP/1 server. + const server = createAdaptorServer({fetch:app.fetch}) as Server; + let closing: Promise | undefined; + return { + server, + release: settings.release, + close: () => closing ??= (async () => { + // Stop admission before closing transports. The SDK tracks modern calls; + // closing Node connections also retires legacy calls and incomplete bodies. + const stopped = new Promise((resolve,reject)=>server.close(error=>error?reject(error):resolve())); + const handlersClosed = app.close(); + server.closeAllConnections(); + await Promise.all([handlersClosed,stopped]); + })(), + }; +} + +export async function listen(options: ListenOptions = {}): Promise<{port:number; close:()=>Promise}> { + const port = options.port ?? Number(process.env.PORT ?? DEFAULT_PORT); + const host = options.host ?? '0.0.0.0'; + const http = createHttpServer(options); + await new Promise((resolve,reject)=>{ + http.server.once('error',reject); + http.server.listen(port,host,()=>{http.server.off('error',reject);resolve();}); + }); + const address = http.server.address(); + const bound = typeof address === 'object' && address ? address.port : port; + return {port:bound,close:http.close}; +} diff --git a/apps/mcp/src/node/index.ts b/apps/mcp/src/node/index.ts new file mode 100644 index 00000000..be7b8b12 --- /dev/null +++ b/apps/mcp/src/node/index.ts @@ -0,0 +1,8 @@ +import {defaultOperationLogger, listen} from './http-server.js'; +import {writeOperationLog} from '../server/operation-log.js'; +import {releaseFromEnv} from '../server/runtime-config.js'; +const service = await listen(); +writeOperationLog({event: 'startup', port: service.port}, defaultOperationLogger, releaseFromEnv(process.env)); +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { void service.close().then(() => process.exit(0)); }); +} diff --git a/apps/mcp/src/server/ansari-client.ts b/apps/mcp/src/server/ansari-client.ts new file mode 100644 index 00000000..038fb0b5 --- /dev/null +++ b/apps/mcp/src/server/ansari-client.ts @@ -0,0 +1,58 @@ +export const DEFAULT_ANSARI_API_BASE = 'https://api.askansari.ai'; +export const DEFAULT_ANSARI_CLIENT = 'ansari-mcp'; + +export type AnsariCompleteRequest = { + question: string; + signal?: AbortSignal; +}; + +export type AnswerCompleter = { + complete(request: AnsariCompleteRequest): Promise; +}; + +export type AnsariHttpOptions = { + baseUrl?: string; + clientName?: string; + fetchImpl?: typeof fetch; + authorization?: string; +}; + +/** + * Direct POST /api/v2/mcp-complete. Returns the exact text/plain body, + * including Ansari attribution. No hosted-MCP forwarding. Settings are explicit + * options; the hosting adapter reads them from its own environment. + */ +export function createAnsariHttpCompleter(options: AnsariHttpOptions = {}): AnswerCompleter { + const baseUrl = (options.baseUrl ?? DEFAULT_ANSARI_API_BASE).replace( + /\/$/, + '', + ); + const clientName = options.clientName ?? DEFAULT_ANSARI_CLIENT; + const fetchImpl = options.fetchImpl ?? fetch; + const authorization = options.authorization; + + return { + async complete({ question, signal }) { + const headers: Record = { + 'content-type': 'application/json', + 'X-Ansari-Client': clientName, + }; + if (authorization) { + headers.authorization = authorization; + } + const response = await fetchImpl(`${baseUrl}/api/v2/mcp-complete`, { + method: 'POST', + headers, + signal, + body: JSON.stringify({ + messages: [{ role: 'user', content: question }], + }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Ansari mcp-complete failed: ${response.status}`); + } + return text; + }, + }; +} diff --git a/apps/mcp/src/server/answer-fingerprint.ts b/apps/mcp/src/server/answer-fingerprint.ts new file mode 100644 index 00000000..6b37e517 --- /dev/null +++ b/apps/mcp/src/server/answer-fingerprint.ts @@ -0,0 +1,9 @@ +import { createHash } from 'node:crypto'; + +export function answerFingerprint(text: string): { answerSha256: string; answerBytes: number } { + const bytes = Buffer.from(text, 'utf8'); + return { + answerSha256: createHash('sha256').update(bytes).digest('hex'), + answerBytes: bytes.byteLength, + }; +} diff --git a/apps/mcp/src/server/create-server.ts b/apps/mcp/src/server/create-server.ts new file mode 100644 index 00000000..b3aaaac5 --- /dev/null +++ b/apps/mcp/src/server/create-server.ts @@ -0,0 +1,64 @@ +import {McpServer} from '@modelcontextprotocol/server'; +import {attachRequestLifetime, createServerBudgets, registerPublicOperation, type OperationView} 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'; +import {answerFingerprint} from './answer-fingerprint.js'; +import {APP_VERSION} from '../version.js'; +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. */ +export const HANDLER_BOUND_MS=60_000; +export type CreateMcpServerOptions = { + completer: AnswerCompleter; + view: OperationView; + logger: OperationLogger; + release?: string; + requestMs?: number; +}; + +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', + description:'Answers questions about Islam including theology, jurisprudence (fiqh), history, Quranic interpretation (tafsir), Hadith sciences, Islamic law, worship practices, ethics, spirituality, and contemporary issues. Provides comprehensive answers with citations from Quran, authentic Hadith, and classical Islamic scholarship. Can handle questions about specific verses, hadiths, scholarly opinions, historical events, prophets, companions, and modern applications of Islamic principles.', + // Preserve the legacy discovery contract; read-only tools need no destructive/idempotent hints. + annotations:{readOnlyHint:true,openWorldHint:true}, + // Application handler bound, with a fresh lifetime per call. + boundMs:HANDLER_BOUND_MS, + input:z.object({question:z.string().min(1).max(4000).describe('The question about Islam to be answered. Include as much context and detail as possible. You can ask about specific Quranic verses, hadiths, Islamic rulings, historical events, theological concepts, or practical applications. The more context provided, the more comprehensive and relevant the answer will be.')}), + output:z.object({version:z.literal(ANSWER_ENVELOPE_VERSION),question:z.string(),answer:z.string()}).strict(), + // Discovery, every result's _meta.ui.resourceUri and the resource read all come from this one declaration. + view:options.view, + failures:{absent:TOOL_UNAVAILABLE_TEXT,unavailable:TOOL_UNAVAILABLE_TEXT,invalidOutput:TOOL_UNAVAILABLE_TEXT}, + async handle({question},request){ + writeOperationLog({event:'source.start',method:'tools/call'},options.logger,options.release); + await request.reportProgress({progress:0,total:1}); + // Source records do not claim terminal MCP delivery or upstream abortion. + let answer:string; + try { + answer=await options.completer.complete({question,signal:request.signal}); + } catch (error) { + // Record source failure without inspecting or serializing the exception. + writeOperationLog({event:'source.error',method:'tools/call'},options.logger,options.release); + throw error; + } + writeOperationLog({event:'source.complete',method:'tools/call',...answerFingerprint(answer)},options.logger,options.release); + 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}); + return server; +} diff --git a/apps/mcp/src/server/fetch-app.ts b/apps/mcp/src/server/fetch-app.ts new file mode 100644 index 00000000..ef746b9d --- /dev/null +++ b/apps/mcp/src/server/fetch-app.ts @@ -0,0 +1,58 @@ +import {hostHeaderValidation, originValidation} from '@modelcontextprotocol/hono'; +import {createMcpHandler} from '@modelcontextprotocol/server'; +import {Hono} from 'hono'; +import type {AnswerCompleter} from './ansari-client.js'; +import {createAnsariMcpServer} from './create-server.js'; +import {prepareAnswerView} from './view-artifact.js'; +import {type OperationLogger, writeOperationLog} from './operation-log.js'; + +export const MCP_PATH = '/mcp'; + +/** + * Everything the application needs, supplied explicitly. This module reads no process environment, + * file or global, so the same code runs under Node and Cloudflare Workers. + */ +export type FetchAppOptions = { + completer: AnswerCompleter; + /** The built card, before release stamping. */ + html: string; + release: string; + allowedHosts: string[]; + allowedOrigins: string[]; + enableJsonResponse: boolean; + logger: OperationLogger; + requestMs?: number; +}; + +export type FetchApp = { + fetch: (request: Request) => Response | Promise; + close: () => Promise; +}; + +/** One Hono application over the official stateless MCP handler. Hosting adapters only supply a listener or an entry point. */ +export function createFetchApp(options: FetchAppOptions): FetchApp { + const {completer, logger, release} = options; + const log = (record: Parameters[0]) => writeOperationLog(record, logger, release); + // The view is prepared once: release stamping first, then the resource identity from the final bytes. + const view = prepareAnswerView(options.html, release); + // The official handler owns per-request transports, protocol negotiation and cleanup. + // Both modern MCP and its older-host fallback are stateless; only the prepared view is shared. + const mcp = createMcpHandler( + () => createAnsariMcpServer({completer, view, logger, release, requestMs: options.requestMs}), + {responseMode: options.enableJsonResponse ? 'json' : 'sse', onerror: () => log({event: 'mcp.error'})}, + ); + const app = new Hono(); + app.get('/health', c => c.json({ok: true, mcp: MCP_PATH, release})); + app.use(MCP_PATH, hostHeaderValidation(options.allowedHosts)); + app.use(MCP_PATH, originValidation(options.allowedOrigins)); + app.all(MCP_PATH, c => { + log({event: 'mcp.request', method: c.req.method}); + return mcp.fetch(c.req.raw); + }); + app.notFound(c => c.json({error: 'not found'}, 404)); + app.onError((_, c) => { + log({event: 'mcp.error', method: c.req.method}); + return c.json({error: 'internal error'}, 500); + }); + return {fetch: request => app.fetch(request), close: () => mcp.close()}; +} diff --git a/apps/mcp/src/server/operation-log.ts b/apps/mcp/src/server/operation-log.ts new file mode 100644 index 00000000..03317010 --- /dev/null +++ b/apps/mcp/src/server/operation-log.ts @@ -0,0 +1,48 @@ +export type OperationRecord = { + event: string; + method?: string; + requestId?: string | number | null; + [key: string]: unknown; +}; + +export type OperationLogger = (record: OperationRecord) => void; + +const FORBIDDEN = new Set([ + 'question', + 'answer', + 'authorization', + 'session', + 'sessionId', + 'token', + 'messages', + 'params', + 'body', +]); + +export const SAFE_REVISION = /^[A-Za-z0-9._+-]{1,80}$/; + +import {APP_VERSION} from '../version.js'; +export const DEFAULT_RELEASE = APP_VERSION; + +export function stampHtmlRevision(html: string, revision: string = DEFAULT_RELEASE): string { + const safe = SAFE_REVISION.test(revision) ? revision : DEFAULT_RELEASE; + if (/data-revision=/.test(html)) { + return html.replace(/data-revision="[^"]*"/, `data-revision="${safe}"`); + } + return html.replace(/]*)>/, ``); +} + +/** Records go to an explicit logger and release: standard output under Node, the console under Workers. */ +export function writeOperationLog( + record: OperationRecord, + logger: OperationLogger, + release: string = DEFAULT_RELEASE, +): void { + const payload: OperationRecord = { release, ...record }; + for (const key of Object.keys(payload)) { + if (FORBIDDEN.has(key)) { + delete payload[key]; + } + } + logger(payload); +} diff --git a/apps/mcp/src/server/runtime-config.ts b/apps/mcp/src/server/runtime-config.ts new file mode 100644 index 00000000..1b8e4a7b --- /dev/null +++ b/apps/mcp/src/server/runtime-config.ts @@ -0,0 +1,49 @@ +import {DEFAULT_RELEASE, SAFE_REVISION} from './operation-log.js'; + +/** Plain string settings: the Node environment, or the Workers `env` bindings. */ +export type RuntimeEnv = Readonly>; + +export const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1']; +function setting(env: RuntimeEnv, name: string): string | undefined { + const value = env[name]; + return typeof value === 'string' && value !== '' ? value : undefined; +} + +function list(value: string | undefined): string[] { + return (value ?? '').split(',').map(item => item.trim()).filter(Boolean); +} + +/** The deployed revision: an explicit release, then the Railway commit, then the application version. */ +export function releaseFromEnv(env: RuntimeEnv): string { + for (const value of [setting(env, 'ANSARI_RELEASE'), setting(env, 'RAILWAY_GIT_COMMIT_SHA')]) { + if (value && SAFE_REVISION.test(value)) return value; + } + return DEFAULT_RELEASE; +} + +export type EnvSettings = { + release: string; + allowedHosts: string[]; + allowedOrigins: string[]; + enableJsonResponse: boolean; + ansari: {baseUrl?: string; clientName?: string; authorization?: string}; +}; + +/** Explicit overrides, then configured lists (including empty), then local defaults. */ +function allowedList(value: unknown, override: string[] | undefined, fallback: string[]): string[] { + const selected = override ?? (typeof value === 'string' ? list(value) : fallback); + return [...new Set(selected.map(item => item.trim()).filter(Boolean))]; +} + +/** One configuration boundary shared by the Node and Workers adapters. */ +export function settingsFromEnv(env: RuntimeEnv, overrides: {allowedHosts?: string[]; allowedOrigins?: string[]; enableJsonResponse?: boolean} = {}): EnvSettings { + const allowedHosts = allowedList(env.MCP_ALLOWED_HOSTS, overrides.allowedHosts, DEFAULT_ALLOWED_HOSTS); + const allowedOrigins = allowedList(env.MCP_ALLOWED_ORIGINS, overrides.allowedOrigins, allowedHosts); + return { + release: releaseFromEnv(env), + allowedHosts, + allowedOrigins, + enableJsonResponse: overrides.enableJsonResponse ?? setting(env, 'MCP_RESPONSE_MODE') === 'json', + ansari: {baseUrl: setting(env, 'ANSARI_API_BASE_URL'), clientName: setting(env, 'ANSARI_CLIENT'), authorization: setting(env, 'ANSARI_API_AUTHORIZATION')}, + }; +} diff --git a/apps/mcp/src/server/view-artifact.ts b/apps/mcp/src/server/view-artifact.ts new file mode 100644 index 00000000..d26b0bd0 --- /dev/null +++ b/apps/mcp/src/server/view-artifact.ts @@ -0,0 +1,12 @@ +import {operationViewFromHtml, type OperationView} from '@saga-a2ui/react-mcp-stack/server'; +import {stampHtmlRevision} from './operation-log.js'; + +/** The file name the view's content identity keeps: `ui://views/answer-card..html`. */ +export const VIEW_FILENAME = 'answer-card.html'; + +/** Prepare immutable deployment bytes once from the built card the host supplies: no file is read here. */ +export function prepareAnswerView(html: string, release?: string): OperationView { + const currentHtml = stampHtmlRevision(html, release); + // The identity is taken from the final bytes, after revision stamping. Declared permissions stay closed. + return Object.freeze(operationViewFromHtml({html: currentHtml, filename: VIEW_FILENAME, title: 'Ansari answer card'})); +} diff --git a/apps/mcp/src/version.ts b/apps/mcp/src/version.ts new file mode 100644 index 00000000..291bf61a --- /dev/null +++ b/apps/mcp/src/version.ts @@ -0,0 +1,2 @@ +// One application identity, shared by the server and built view. +export const APP_VERSION = '0.16.6'; diff --git a/apps/mcp/src/view/answer-view.tsx b/apps/mcp/src/view/answer-view.tsx new file mode 100644 index 00000000..6da22902 --- /dev/null +++ b/apps/mcp/src/view/answer-view.tsx @@ -0,0 +1,38 @@ +import type {CardChromeProps} from '@saga-a2ui/react-mcp-stack/faithful-card'; +import {useLayoutEffect, useRef} from 'react'; +import {SagaCredit} from './saga-credit.js'; +import {BRANDING} from './branding.js'; +import {watchAttribution} from './attribution.js'; + +// The application's chrome around the shared card (the stack's `chrome` option). It supplies the +// question header and the footer treatment (data-attribution -> theme.css); the answer itself is the +// card's, which the stack places here exactly once. The footer mark is taken from the paragraph the +// card rendered and marked as its closing block (attribution.ts), never from a second reading of the +// Markdown. It chooses no artwork, no policy and no text of an answer: artwork and colors are the +// app's constants (floral-artwork.ts, theme.css, both loaded by the composition root). + +export function AnswerView({card, state}: CardChromeProps) { + const article = useRef(null); + const showingAnswer = state.kind === 'answer'; + useLayoutEffect(() => (article.current === null ? undefined : watchAttribution(article.current)), [showingAnswer]); + return ( +
+ {state.kind === 'answer' ? ( +
+
+ {state.heading === undefined || state.heading === '' ? null : ( +

+ {state.heading} +

+ )} +
+ {card} + +
+ ) : null} +

+ {state.kind === 'answer' ? '' : state.text} +

+
+ ); +} diff --git a/apps/mcp/src/view/assets.d.ts b/apps/mcp/src/view/assets.d.ts new file mode 100644 index 00000000..4889978c --- /dev/null +++ b/apps/mcp/src/view/assets.d.ts @@ -0,0 +1,3 @@ +declare module "*.css"; + +declare module "*.ttf?inline" { const data: string; export default data; } diff --git a/apps/mcp/src/view/attribution.ts b/apps/mcp/src/view/attribution.ts new file mode 100644 index 00000000..04610d2d --- /dev/null +++ b/apps/mcp/src/view/attribution.ts @@ -0,0 +1,58 @@ +// Attribution seam. The answer's closing attribution paragraph is supplied text inside the answer +// Markdown ("This information came from ansari.chat." with its link): the app has no separate +// server field for it, and this slice invents none. The card renders it in place, in order, as an +// ordinary paragraph. +// +// The approved footer treatment (rule above, 14px secondary ink, the Ansari mark) is applied by CSS to +// that same paragraph, in place. The only question left to code is whether the card's last block is +// that attribution, so the treatment is not given to an unrelated closing paragraph. +// +// The answer is interpreted exactly once, by the shared card. This module therefore never touches the +// Markdown, a reader or the card's interpreted blocks: it reads the text of the paragraph the card has +// already rendered and sets one attribute. It parses nothing, copies no parser, moves no text and +// walks no tree. A card that failed or is empty renders no such paragraph, so it has no attribution. + +/** The attribution Ansari's API appends to every answer. The source identifies its public answer service by either domain. */ +const ATTRIBUTION = /^This information came from (?:ansari\.chat|askansari\.ai)\b/i; + +/** The card's documented closing seam (docs/faithful-card-styling.md): its final top-level block, when that is a paragraph. */ +const CLOSING_PARAGRAPH = '.saga-card-content > p[data-saga-closing="paragraph"]'; + +/** The attribute the theme keys the footer treatment on. */ +export const ATTRIBUTION_ATTRIBUTE = 'data-attribution'; + +/** True when the text of a closing paragraph is Ansari's attribution. */ +export function isAttributionText(text: string): boolean { + return ATTRIBUTION.test(text.trimStart()); +} + +/** + * The card's closing paragraph: the block the card itself marked `data-saga-closing="paragraph"`, + * which is its final top-level block and only when that block is a paragraph. A quotation, a list + * item or a table cell is never marked. The marker is structural; whether the marked paragraph is + * Ansari's attribution is decided here, from its words. + */ +function closingParagraph(card: Element): Element | null { + return card.querySelector(CLOSING_PARAGRAPH); +} + +/** Sets or removes the attribution attribute on `card` from the paragraph the shared card rendered at its end. */ +export function markAttribution(card: Element): boolean { + const paragraph = closingParagraph(card); + const attribution = paragraph !== null && isAttributionText(paragraph.textContent ?? ''); + if (attribution) card.setAttribute(ATTRIBUTION_ATTRIBUTE, ''); + else card.removeAttribute(ATTRIBUTION_ATTRIBUTE); + return attribution; +} + +/** + * Keeps `card` marked as the shared card renders into it (an answer is replaced inside the same + * element). Returns the function that stops watching. Only the attribute on `card` itself is written, + * which is not observed, so this cannot trigger itself. + */ +export function watchAttribution(card: Element): () => void { + markAttribution(card); + const observer = new MutationObserver(() => { markAttribution(card); }); + observer.observe(card, {childList: true, subtree: true, characterData: true}); + return () => observer.disconnect(); +} diff --git a/apps/mcp/src/view/branding.ts b/apps/mcp/src/view/branding.ts new file mode 100644 index 00000000..52b5ea6d --- /dev/null +++ b/apps/mcp/src/view/branding.ts @@ -0,0 +1,14 @@ +export const BRANDING = { + // Set false to omit the framework credit from the answer footer. + showSagaCredit: true, + name: 'Ansari MCP', + // Every sentence shown around the card. Each is the application's; the card decides none of them. + waitingText: 'Waiting for Ansari answer…', + // The definition's default sentence for a rejection that carries no wording of its own (tool-result.ts words every + // classified rejection itself). It equals the tool's failure text. + problemText: 'Ansari could not complete this request.', + connectFailureText: 'The answer card could not connect. Please retry the question.', + // The embedded Kitab faces did not load: the host connected, so this is not a connection failure. + readyFailureText: 'The answer card could not load its fonts. Please retry the question.', + policyFailureText: 'This view could not start: its content policy is not valid.', +} as const; diff --git a/apps/mcp/src/view/envelope.ts b/apps/mcp/src/view/envelope.ts new file mode 100644 index 00000000..4b034565 --- /dev/null +++ b/apps/mcp/src/view/envelope.ts @@ -0,0 +1,27 @@ +// The app's one answer envelope `{version, question, answer}`: the tool's `structuredContent`. Its +// validation is here; how a whole delivered result is read (a failed call, a host that delivers only +// text, a malformed envelope) is `tool-result.ts`, which uses this validation. Pure and DOM-free, so Node +// tests can import it. The answer is passed through unchanged. + +/** Version of the app's answer envelope `{version, question, answer}`; equal to the route's and the MCP tool's. */ +export const ANSWER_ENVELOPE_VERSION = '0.1.0'; + +/** + * Largest answer the card accepts, in UTF-16 code units (JavaScript `String.length`). This is a + * different unit from the demo route's bound, which counts UTF-8 bytes (256 KiB = 262,144 bytes). + * Every string of at most 262,144 UTF-8 bytes has at most 262,144 UTF-16 code units, so no answer + * the route returns can exceed this bound. A longer answer (for example one sent to the MCP view) + * is refused by the card with a visible message; it is never clipped. + */ +export const CARD_MAX_UTF16_UNITS = 262_144; + +export type AnswerEnvelope = {readonly question: string; readonly answer: string}; + +/** Validates the app's envelope. Anything else is refused; nothing is repaired or guessed. */ +export function readAnswerEnvelope(value: unknown): AnswerEnvelope | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const fields = value as {answer?: unknown; question?: unknown; version?: unknown}; + if (fields.version !== ANSWER_ENVELOPE_VERSION || typeof fields.question !== 'string') return undefined; + if (typeof fields.answer !== 'string' || fields.answer.length === 0) return undefined; + return {question: fields.question, answer: fields.answer}; +} diff --git a/apps/mcp/src/view/floral-artwork.ts b/apps/mcp/src/view/floral-artwork.ts new file mode 100644 index 00000000..b613048c --- /dev/null +++ b/apps/mcp/src/view/floral-artwork.ts @@ -0,0 +1,26 @@ +// The approved 8 October floral artwork, supplied to the theme stylesheet as CSS custom properties. +// The bytes are the application's own trusted assets (assets/): constants fixed at +// build time. Nothing here reads an answer, a tool result or host context, so no delivered value can +// select, replace or reference artwork. Light and dark are separate artwork, never a filter of one. +import {answerAssets} from '../../assets/answer-assets.js'; +import {ansariBrandMark} from '../../assets/brand-mark.js'; + +type Style = {setProperty(name: string, value: string): void}; + +const url = (dataUrl: string): string => `url("${dataUrl}")`; + +/** Every property the theme stylesheet reads, with the asset it carries. */ +export function artworkProperties(): Record { + const properties: Record = {'--ansari-art-brand-mark': url(ansariBrandMark)}; + for (const appearance of ['light', 'dark'] as const) { + for (const part of ['flourish', 'marker', 'separator'] as const) { + properties[`--ansari-art-${appearance}-${part}`] = url(answerAssets[appearance][part]); + } + } + return properties; +} + +/** Declares the artwork on the view's root element once, before an answer is shown. */ +export function installFloralArtwork(root: {style: Style}): void { + for (const [name, value] of Object.entries(artworkProperties())) root.style.setProperty(name, value); +} diff --git a/apps/mcp/src/view/fonts.ts b/apps/mcp/src/view/fonts.ts new file mode 100644 index 00000000..fd9b4874 --- /dev/null +++ b/apps/mcp/src/view/fonts.ts @@ -0,0 +1,23 @@ +import regular from '../../assets/fonts/Kitab-Regular.ttf?inline'; +import bold from '../../assets/fonts/Kitab-Bold.ttf?inline'; + +// A host can forbid data: font requests even when our document allows them. Binary +// FontFace sources use the bytes already in this self-contained document, without +// a URL request, external font server or change to the host's policy. +export async function loadKitab(document: Document, signal: AbortSignal): Promise { + const faces = ([[regular, '400'], [bold, '700']] as const).map(([data, weight]) => { + const bytes = Uint8Array.from(atob(data.slice(data.indexOf(',') + 1)), c => c.charCodeAt(0)); + return new FontFace('Kitab', bytes, {weight, style: 'normal'}); + }); + const remove = () => { for (const face of faces) document.fonts.delete(face); }; + signal.addEventListener('abort', remove, {once: true}); + try { + await Promise.all(faces.map(face => face.load())); + if (signal.aborted) return; + for (const face of faces) document.fonts.add(face); + } catch (error) { + signal.removeEventListener('abort', remove); + remove(); + throw error; + } +} diff --git a/apps/mcp/src/view/host-theme.ts b/apps/mcp/src/view/host-theme.ts new file mode 100644 index 00000000..72704cb2 --- /dev/null +++ b/apps/mcp/src/view/host-theme.ts @@ -0,0 +1,232 @@ +export type HostThemeAppearance = 'light' | 'dark' | 'system'; + +export type HostColorTokens = { + surface: string; + backdrop: string; + ink: string; + secondary: string; + line: string; +}; + +export type HostThemeContext = { + theme?: 'light' | 'dark'; + styles?: { variables?: Record }; + containerDimensions?: { + width?: number; + maxWidth?: number; + height?: number; + maxHeight?: number; + }; + deviceClass?: unknown; + maxWidth?: unknown; + maxHeight?: unknown; + platform?: unknown; + displayMode?: unknown; +}; + +export type ResolvedHostTheme = { + appearance: HostThemeAppearance; + tokens: HostColorTokens; + tokenSource: 'host' | 'fallback'; + viewport: 'block' | 'card' | 'unknown'; + width?: number; + height?: number; + deviceClass?: string; + hostAlreadyScales: boolean; + rootScale: 1; + missing: string[]; +}; + +export type ThemeRoot = { + setAttribute(name: string, value: string): void; + removeAttribute(name: string): void; + style: { setProperty(name: string, value: string): void; removeProperty(name: string): void }; +}; + +/** Alexa visual-foundations card colors plus the selected answer-card ink. */ +export const FALLBACK_TOKENS: Record = { + system: { + surface: 'light-dark(#ffffff, #14191c)', + backdrop: 'light-dark(#ffffff, #14191c)', + ink: 'light-dark(#172235, #eef1f4)', + secondary: 'light-dark(#556275, #b9c4cd)', + line: 'light-dark(#d9e2e5, #607c70)', + }, + light: { + surface: '#ffffff', + backdrop: '#ffffff', + ink: '#172235', + secondary: '#556275', + line: '#d9e2e5', + }, + dark: { + surface: '#14191c', + backdrop: '#14191c', + ink: '#eef1f4', + secondary: '#b9c4cd', + line: '#607c70', + }, +}; + +const HOST_TOKEN_KEYS = { + surface: '--color-background-primary', + backdrop: '--color-background-secondary', + ink: '--color-text-primary', + secondary: '--color-text-secondary', + line: '--color-border-primary', +} as const; + +/** Numeric host colors can be checked without a DOM or browser dependency. */ +function luminance(color: string): number | undefined { + const hex = /^#([a-f\d]{6})$/i.exec(color.trim()); + const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(color.trim()); + const channels = hex ? [0, 2, 4].map(i => parseInt(hex[1].slice(i, i + 2), 16)) : + rgb ? rgb.slice(1).map(Number) : undefined; + if (!channels || channels.some(c => c > 255)) return undefined; + return channels.map(c => { + const v = c / 255; + return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; + }).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0); +} + +function contrast(foreground: string, background: string): number | undefined { + const a = luminance(foreground), b = luminance(background); + return a === undefined || b === undefined ? undefined : (Math.max(a, b) + .05) / (Math.min(a, b) + .05); +} + +function asFiniteNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return undefined; +} + +function asDeviceClass(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +function pickDimension( + context: HostThemeContext | undefined, + fixed: 'width' | 'height', + max: 'maxWidth' | 'maxHeight', + extra: 'maxWidth' | 'maxHeight', +): number | undefined { + const box = context?.containerDimensions; + return asFiniteNumber(box?.[fixed]) ?? asFiniteNumber(box?.[max]) ?? asFiniteNumber(context?.[extra]); +} + +/** + * Map host context onto presentation tokens. Theme never enters answer data. + * Root scale stays 1: Alexa already CSS-scales the iframe (do not apply 1.6). + */ +export function resolveHostTheme(context?: HostThemeContext | null): ResolvedHostTheme { + const appearance: HostThemeAppearance = + context?.theme === 'light' || context?.theme === 'dark' ? context.theme : 'system'; + const variables = context?.styles?.variables; + const fallback = FALLBACK_TOKENS[appearance]; + const tokens: HostColorTokens = { ...fallback }; + let usedHostToken = false; + (Object.keys(HOST_TOKEN_KEYS) as Array).forEach((token) => { + const hostValue = variables?.[HOST_TOKEN_KEYS[token]]; + if (typeof hostValue === 'string' && hostValue.trim() !== '') { + tokens[token] = hostValue; + usedHostToken = true; + } + }); + + // Some hosts supply a muted UI gray below 4.5:1. The attribution is + // reading text, so retain the host surface while choosing legible ink. + const secondaryContrast = contrast(tokens.secondary, tokens.surface); + if (secondaryContrast !== undefined && secondaryContrast < 4.5) { + const fallbackContrast = contrast(fallback.secondary, tokens.surface); + tokens.secondary = fallbackContrast !== undefined && fallbackContrast >= 4.5 ? fallback.secondary : tokens.ink; + } + + const width = pickDimension(context ?? undefined, 'width', 'maxWidth', 'maxWidth'); + const height = pickDimension(context ?? undefined, 'height', 'maxHeight', 'maxHeight'); + const viewport: ResolvedHostTheme['viewport'] = + width !== undefined && height !== undefined ? (width >= height ? 'block' : 'card') : 'unknown'; + const deviceClass = asDeviceClass(context?.deviceClass); + const missing: string[] = []; + if (appearance === 'system') missing.push('theme'); + if (!usedHostToken) missing.push('styles.variables'); + if (viewport === 'unknown') missing.push('containerDimensions'); + if (!deviceClass) missing.push('deviceClass'); + + return { + appearance, + tokens, + tokenSource: usedHostToken ? 'host' : 'fallback', + viewport, + width, + height, + deviceClass, + hostAlreadyScales: Boolean(deviceClass), + rootScale: 1, + missing, + }; +} + +export function applyResolvedTheme(root: ThemeRoot, resolved: ResolvedHostTheme): void { + if (resolved.appearance === 'system') { + root.removeAttribute('data-theme'); + root.style.setProperty('color-scheme', 'light dark'); + } else { + root.setAttribute('data-theme', resolved.appearance); + root.style.setProperty('color-scheme', resolved.appearance); + } + + if (resolved.appearance === 'system') { + root.style.removeProperty('--ansari-light-art'); + root.style.removeProperty('--ansari-dark-art'); + } else { + root.style.setProperty('--ansari-light-art', resolved.appearance === 'light' ? 'block' : 'none'); + root.style.setProperty('--ansari-dark-art', resolved.appearance === 'dark' ? 'block' : 'none'); + } + + root.setAttribute('data-token-source', resolved.tokenSource); + root.setAttribute('data-viewport', resolved.viewport); + root.setAttribute('data-root-scale', '1'); + root.setAttribute('data-host-scale', resolved.hostAlreadyScales ? 'host' : 'none'); + if (resolved.deviceClass) { + root.setAttribute('data-device-class', resolved.deviceClass); + } else { + root.removeAttribute('data-device-class'); + } + + root.style.setProperty('--ansari-surface', resolved.tokens.surface); + root.style.setProperty('--ansari-backdrop', resolved.tokens.backdrop); + root.style.setProperty('--ansari-ink', resolved.tokens.ink); + root.style.setProperty('--ansari-secondary', resolved.tokens.secondary); + root.style.setProperty('--ansari-line', resolved.tokens.line); +} + +/** How the reader meets the host; the stack decides it (`readingProfile`), the application maps it. */ +export type ReadingProfile = 'device' | 'desktop'; + +export function applyHostTheme(root: ThemeRoot, context: HostThemeContext | null | undefined, hostName: string | undefined, profile: ReadingProfile): ResolvedHostTheme { + const resolved = resolveHostTheme(context); + applyResolvedTheme(root, resolved); + // Width alone does not establish viewing distance: Codex can open wide panels, so the stack's + // reading profile (a device host, an Echo Show device class or a mobile platform) decides. + const deviceReading = profile === 'device'; + root.style.setProperty('--ansari-wide-reading-scale', deviceReading ? '1.6' : '1'); + root.setAttribute('data-reading-profile', profile); + if (hostName) root.setAttribute('data-host-name', hostName); + else root.removeAttribute('data-host-name'); + const variables = context?.styles?.variables; + if (variables) { + for (const [name, value] of Object.entries(variables)) { + if (typeof value === 'string' && value.trim() !== '') { + root.style.setProperty(name, value); + } + } + } + return resolved; +} diff --git a/apps/mcp/src/view/main.tsx b/apps/mcp/src/view/main.tsx new file mode 100644 index 00000000..03063569 --- /dev/null +++ b/apps/mcp/src/view/main.tsx @@ -0,0 +1,46 @@ +import '@saga-a2ui/react-mcp-stack/faithful-card.css'; +import './theme.css'; +import {FaithfulCardApp, readingProfile, startFaithfulCardApp} from '@saga-a2ui/react-mcp-stack/faithful-card'; +import {APP_VERSION} from '../version.js'; +import {AnswerView} from './answer-view.js'; +import {BRANDING} from './branding.js'; +import {CARD_MAX_UTF16_UNITS} from './envelope.js'; +import {installFloralArtwork} from './floral-artwork.js'; +import {loadKitab} from './fonts.js'; +import {applyHostTheme} from './host-theme.js'; +import {classifyCardResult} from './tool-result.js'; + +// Application composition. The stack owns the handshake, the held and latest result, the content +// policy read from this document, host links, image consent, own sizing and teardown; Ansari owns +// what is its own: how it reads a whole delivered result (tool-result.ts), the sentences it shows, +// its chrome, fonts, artwork and host-theme mapping. The definition's one `.result(...)` reader +// receives the whole delivery-neutral result (a failed call, text-only delivery and a malformed +// envelope included), so nothing is intercepted before it. + +const definition = FaithfulCardApp.define({name: BRANDING.name, version: APP_VERSION}) + .reader({dialect: 'commonmark-tables-strikethrough', maxLength: CARD_MAX_UTF16_UNITS}) + .policy('document') + .result(classifyCardResult) + .texts({ + waiting: BRANDING.waitingText, + problem: BRANDING.problemText, + connectFailure: BRANDING.connectFailureText, + readyFailure: BRANDING.readyFailureText, + policyFailure: BRANDING.policyFailureText, + }) + .build(); + +const root = document.documentElement; +installFloralArtwork(root); + +const container = document.getElementById('root'); +if (container === null) throw new Error('The page has no #root element.'); + +startFaithfulCardApp(definition, { + container, + sizing: 'own', + // Do not present an answer using a substituted device font if the embedded fonts fail. + readyWhen: signal => loadKitab(document, signal), + hostContext: (context, hostName) => { applyHostTheme(root, context, hostName, readingProfile(context, hostName)); }, + chrome: AnswerView, +}); diff --git a/apps/mcp/src/view/mcp-app-react.html b/apps/mcp/src/view/mcp-app-react.html new file mode 100644 index 00000000..8a5cee35 --- /dev/null +++ b/apps/mcp/src/view/mcp-app-react.html @@ -0,0 +1,18 @@ + + + + + + + {{brand.name}} + + + + +
+ + + diff --git a/apps/mcp/src/view/saga-credit.tsx b/apps/mcp/src/view/saga-credit.tsx new file mode 100644 index 00000000..6b3e48de --- /dev/null +++ b/apps/mcp/src/view/saga-credit.tsx @@ -0,0 +1,19 @@ +import {useHostLink} from '@saga-a2ui/react-mcp-stack/faithful-card'; + +const REPOSITORY = 'https://github.com/MachineWisdomAI/saga-a2ui/tree/codex/release-0.7.6'; + +/** + * Optional application credit, separate from the unchanged upstream answer. It follows the card's + * own link rules (`useHostLink`): the enclosing view's policy decides, the host is asked once per + * activation, and a new answer drops an outcome that belonged to the previous one. + */ +export function SagaCredit({enabled}: {readonly enabled: boolean}) { + const link = useHostLink(REPOSITORY); + if (!enabled) return null; + return
+ Built with {link.allowed + ? + : saga a2ui} + {link.outcome?.message ?? ''} +
; +} diff --git a/apps/mcp/src/view/theme.css b/apps/mcp/src/view/theme.css new file mode 100644 index 00000000..e823a363 --- /dev/null +++ b/apps/mcp/src/view/theme.css @@ -0,0 +1,418 @@ +/* + * Ansari theme for the shared React FaithfulCard: the approved 8 October floral design (balanced light + * and Soft Malachite dark). This stylesheet and floral-artwork.ts are the whole branding: the card's + * component supplies structure and the documented `--saga-card-*` tokens, and nothing here is selected + * by an answer. Colors are the app's own `--ansari-*` properties, which the host-theme adapter + * (`applyHostTheme`) overwrites on the root from the host's context; the values below are the + * pre-connection fallbacks and equal branding.ts (a test asserts it). + * + * Reading profile. The adapter owns host recognition and publishes `--ansari-wide-reading-scale` on + * the root (1 for a desktop host, 1.6 for a device profile). Here the scale becomes the card's + * effective font size, line length and spacing; the document root is never transformed. + * + * Numbering. The card's `ol` and `li` keep their native list semantics (`start`, nesting, assistive + * technology announcements). The floral disk is the `li`'s `::before`, showing the browser's own + * `list-item` counter, so a list that starts at 3, or nests, displays the number its markup carries. The + * disk is generated content with an empty alternative text, so it is never announced twice. + */ +:root { + color-scheme: light dark; + --ansari-reading-scale: 1; + --ansari-font-body: 'Kitab'; + --ansari-surface: light-dark(#ffffff, #14191c); + --ansari-backdrop: light-dark(#ffffff, #14191c); + --ansari-ink: light-dark(#172235, #eef1f4); + --ansari-secondary: light-dark(#556275, #b9c4cd); + --ansari-line: light-dark(#c3ced2, #607c70); + --ansari-accent: light-dark(#0b5a46, #7fd1b5); + --ansari-code: light-dark(#eef2f3, #222b30); + --ansari-link: light-dark(#15583e, #c0d7c6); + --ansari-numeral: light-dark(#15583e, #c0d7c6); + --ansari-mark-surface: light-dark(#15583e, #1f6b4c); + --ansari-art-flourish: var(--ansari-art-light-flourish); + --ansari-art-marker: var(--ansari-art-light-marker); + --ansari-art-separator: var(--ansari-art-light-separator); +} + +/* Separate dark artwork, never a filter of the light: by the host's theme, else by the system's. */ +:root[data-theme='dark'] { + --ansari-art-flourish: var(--ansari-art-dark-flourish); + --ansari-art-marker: var(--ansari-art-dark-marker); + --ansari-art-separator: var(--ansari-art-dark-separator); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --ansari-art-flourish: var(--ansari-art-dark-flourish); + --ansari-art-marker: var(--ansari-art-dark-marker); + --ansari-art-separator: var(--ansari-art-dark-separator); + } +} + +/* Only a device reading profile (set by the adapter) enlarges the rhythm, and only on a wide view. */ +@media (min-width: 900px) { + :root { + --ansari-reading-scale: var(--ansari-wide-reading-scale, 1); + } +} + +body { + margin: 0; + -webkit-font-smoothing: antialiased; + background: var(--ansari-backdrop); + color: var(--ansari-ink); + font-family: var(--ansari-font-body); + font-synthesis: none; +} + +.answer-status { + margin: 0; + padding: 20px; + white-space: pre-wrap; + font-size: 18px; + line-height: 1.45; + color: var(--ansari-ink); +} + +.answer-status:empty { + display: none; +} + +/* ---- The card: width, typography, colors --------------------------------------------------- */ +.answer-card { + --rs: var(--ansari-reading-scale, 1); + --marker-size: calc(64px * var(--rs)); + --marker-gap: calc(18px * var(--rs)); + --marker-font: calc(25px * var(--rs)); + --card-padding-inline: calc(34px * var(--rs)); + --saga-card-font-family: var(--ansari-font-body); + font-synthesis: none; + --saga-card-font-size: calc(18px * var(--rs)); + --saga-card-line-height: 1.48; + --saga-card-reading-width: 100%; + --saga-card-text-color: var(--ansari-ink); + --saga-card-accent-color: var(--ansari-link); + --saga-card-muted-color: var(--ansari-secondary); + --saga-card-border-color: var(--ansari-line); + --saga-card-code-background: var(--ansari-code); + box-sizing: border-box; + max-width: calc(680px * var(--rs)); + margin: 0 auto; + padding: calc(12px * var(--rs)) var(--card-padding-inline) calc(24px * var(--rs)); + background: var(--ansari-surface); + border-radius: calc(12px * var(--rs)); + font-family: var(--ansari-font-body); + font-synthesis: none; + color: var(--ansari-ink); +} + +.answer-card[hidden] { + display: none; +} + +/* ---- Header: the question, with the flourish at the top right ------------------------------ */ +.answer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(24px * var(--rs)); + min-height: calc(94px * var(--rs)); +} + +.answer-question { + min-width: 0; + margin: 0; + font-family: var(--ansari-font-body); + font-synthesis: none; + font-size: calc(26px * var(--rs)); + font-weight: 700; + line-height: 1.24; + letter-spacing: calc(-0.65px * var(--rs)); + overflow-wrap: anywhere; +} + +/* Tracking drops optional Arabic ligatures, such as the name of Allah. */ +.answer-question:dir(rtl) { + letter-spacing: 0; +} + +.answer-header::after { + content: ''; + flex: none; + width: calc(94px * var(--rs)); + height: calc(94px * var(--rs)); + margin-inline-start: auto; + background: var(--ansari-art-flourish) center / contain no-repeat; +} + +/* ---- Content ------------------------------------------------------------------------------- */ +.answer-card .saga-card-content p { + margin: 0 0 calc(22px * var(--rs)); +} + +.answer-card .saga-card-content strong { + font-weight: 700; +} + +.answer-card .saga-card-content :is(h3, h4, h5, h6) { + margin: calc(22px * var(--rs)) 0 calc(12px * var(--rs)); + font-size: calc(20px * var(--rs)); + line-height: 1.35; +} + +/* Kitab has no Arabic italic, so right-to-left emphasis is weight, not a fake slant. */ +.answer-card .saga-card-content em:dir(rtl) { + font-style: normal; + font-weight: 700; +} + +.answer-card .saga-card-content :is(ul, ol) { + margin: calc(18px * var(--rs)) 0 calc(24px * var(--rs)); +} + +.answer-card .saga-card-content ul { + padding-inline-start: calc(24px * var(--rs)); +} + +.answer-card .saga-card-content ul > li { + margin: calc(12px * var(--rs)) 0; + padding-inline-start: calc(4px * var(--rs)); +} + +.answer-card .saga-card-content li :is(ul, ol) { + margin: calc(12px * var(--rs)) 0; +} + +.answer-card .saga-card-content blockquote { + margin: calc(20px * var(--rs)) 0; + padding-inline-start: calc(18px * var(--rs)); + border-inline-start: calc(2px * var(--rs)) solid var(--ansari-line); +} + +.answer-card .saga-card-content pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.answer-card .saga-card-content code { + font-size: 0.9em; +} + +.answer-card .saga-card-link { + text-underline-offset: calc(2px * var(--rs)); + text-decoration-thickness: calc(1px * var(--rs)); +} + +.answer-card .saga-card-link:hover { + text-decoration-thickness: calc(2px * var(--rs)); +} + +.answer-card .saga-card-link:focus-visible { + outline: calc(2px * var(--rs)) solid var(--ansari-link); + outline-offset: calc(2px * var(--rs)); + border-radius: 3px; +} + +/* ---- Floral numbering and botanical rules -------------------------------------------------- */ +.answer-card .saga-card-content ol { + padding: 0; + margin-block: calc(27px * var(--rs)) calc(30px * var(--rs)); +} + +.answer-card .saga-card-content ol > li { + position: relative; + min-height: var(--marker-size); + padding-inline-start: calc(var(--marker-size) + var(--marker-gap)); +} + +/* The native marker is replaced by the disk; the list keeps its list semantics. */ +.answer-card .saga-card-content ol > li::marker { + content: ''; +} + +.answer-card .saga-card-content ol > li::before { + content: counter(list-item); + content: counter(list-item) / ''; + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + display: grid; + place-items: center; + box-sizing: border-box; + width: var(--marker-size); + height: var(--marker-size); + background: var(--ansari-art-marker) center / contain no-repeat; + color: var(--ansari-numeral); + font-size: var(--marker-font); + font-weight: 700; + line-height: 1; + letter-spacing: 0; +} + +/* + * A fine rule with a small botanical ornament between numbered items. The item always holds at + * least the disk's height, and the rule sits in room reserved below that, so a short item never + * lets the disk touch the rule. + */ +.answer-card .saga-card-content ol > li:not(:last-child) { + padding-block-end: calc(51px * var(--rs)); +} + +.answer-card .saga-card-content ol > li:not(:last-child)::after { + content: ''; + position: absolute; + inset-inline: 0; + inset-block-end: calc(11px * var(--rs)); + height: calc(24px * var(--rs)); + background: + var(--ansari-art-separator) center / calc(50px * var(--rs)) calc(24px * var(--rs)) no-repeat, + linear-gradient( + to right, + var(--ansari-line) 0, + var(--ansari-line) calc(50% - 31px * var(--rs)), + transparent calc(50% - 31px * var(--rs)), + transparent calc(50% + 31px * var(--rs)), + var(--ansari-line) calc(50% + 31px * var(--rs)), + var(--ansari-line) 100% + ) + center / 100% calc(1px * var(--rs)) no-repeat; +} + +.answer-card .saga-card-content ol > li > p { + margin: calc(3px * var(--rs)) 0 0; +} + +.answer-card .saga-card-content ol > li > p + p { + margin-top: calc(12px * var(--rs)); +} + +/* A numbered heading group: the section title leads its item. */ +.answer-card .saga-card-content ol > li > :is(h3, h4, h5, h6):first-child { + margin: calc(3px * var(--rs)) 0 calc(8px * var(--rs)); + font-size: inherit; + letter-spacing: calc(-0.4px * var(--rs)); +} + +.answer-card .saga-card-content ol > li > :is(h3, h4, h5, h6):first-child:dir(rtl) { + letter-spacing: 0; +} + +/* Nested numbering: the smaller disk; the rule and its ornament are the same as the outer list's. */ +.answer-card .saga-card-content li ol > li { + --marker-size: calc(44px * var(--rs)); + --marker-gap: calc(12px * var(--rs)); + --marker-font: calc(20px * var(--rs)); +} + +/* ---- Attribution footer -------------------------------------------------------------------- + * The answer's supplied closing attribution paragraph, in place. `data-attribution` is set by the + * wrapper only when the paragraph the shared card rendered last is Ansari's attribution (attribution.ts). */ +.answer-card[data-attribution] .saga-card-content > p[data-saga-closing='paragraph'] { + --footer-mark: calc(26px * var(--rs)); + position: relative; + margin: calc(23px * var(--rs)) 0 0; + padding-block-start: calc(17px * var(--rs)); + padding-inline-start: calc(var(--footer-mark) + 10px * var(--rs)); + border-top: calc(1px * var(--rs)) solid var(--ansari-line); + color: var(--ansari-secondary); + direction: ltr; + font-size: calc(14px * var(--rs)); + line-height: 1.5; +} + +/* The supplied thematic break already separates the attribution from the answer. */ +.answer-card[data-attribution] .saga-card-content > hr + p[data-saga-closing='paragraph'] { + border-top: 0; + margin-block-start: 0; +} + +.answer-card[data-attribution] .saga-card-content > p[data-saga-closing='paragraph'] strong { + font-weight: inherit; +} + +.answer-card[data-attribution] .saga-card-content > p[data-saga-closing='paragraph']::before { + content: ''; + position: absolute; + inset-block-start: calc(18px * var(--rs)); + inset-inline-start: 0; + box-sizing: border-box; + width: var(--footer-mark); + height: var(--footer-mark); + border-radius: calc(6px * var(--rs)); + background: var(--ansari-art-brand-mark) center / calc(20px * var(--rs)) no-repeat var(--ansari-mark-surface); +} + +/* ---- Narrow views: the 375px reference ------------------------------------------------------ */ +@media (max-width: 480px) { + .answer-card { + --marker-size: 44px; + --marker-gap: 12px; + --marker-font: 20px; + --card-padding-inline: 20px; + --saga-card-font-size: 16px; + padding: 12px 20px 24px; + } + + .answer-header { + gap: 12px; + min-height: 60px; + } + + .answer-question { + font-size: 22px; + } + + .answer-header::after { + width: 60px; + height: 60px; + } +} + +/* Application credit follows the complete supplied Ansari attribution paragraph. */ +.saga-credit { display: none; } +.answer-card[data-attribution] > .saga-credit { + display: block; + margin-block-start: calc(6px * var(--rs)); + text-align: right; + color: var(--ansari-secondary); + font-size: calc(14px * var(--rs)); + line-height: 1.5; +} +.saga-credit [role="status"] { display: block; } +.saga-credit button { + font: inherit; + color: inherit; + background: none; + border: 0; + padding: 0; + text-decoration: underline; + cursor: pointer; +} + +/* Link feedback is application UI, visually separate from the supplied answer. A successful + * request has no text, so its status stays silent. */ +.answer-card .saga-card-link-status:empty, +.saga-credit [role="status"]:empty { display: none; } + +.answer-card .saga-card-link-status:not(:empty), +.saga-credit [role="status"]:not(:empty) { + display: block; + margin-block: calc(8px * var(--rs)); + padding: calc(8px * var(--rs)) calc(12px * var(--rs)); + border: 1px solid var(--ansari-line); + border-inline-start: 3px solid var(--ansari-link); + border-radius: calc(6px * var(--rs)); + background: var(--ansari-code); + color: var(--ansari-ink); + font-size: calc(14px * var(--rs)); + line-height: 1.5; + text-align: start; + overflow-wrap: anywhere; +} + +.answer-card .saga-card-link-status:not(:empty)::before, +.saga-credit [role="status"]:not(:empty)::before { + content: 'Link notice'; + display: block; + font-weight: 600; +} diff --git a/apps/mcp/src/view/tool-result.ts b/apps/mcp/src/view/tool-result.ts new file mode 100644 index 00000000..6caa4015 --- /dev/null +++ b/apps/mcp/src/view/tool-result.ts @@ -0,0 +1,55 @@ +// App-owned mapping from an MCP tool result to the answer the shared card shows. Pure and +// browser-safe. It decides what to display; it never renders Markdown and never edits the answer. +// The definition's one `.result(...)` reader is `classifyCardResult`: the stack hands it the whole +// delivered result. This is the application's one classification contract, imported from the original +// application (upstream abd256d) and kept with its original semantics. +import type {CardResultOutcome} from '@saga-a2ui/react-mcp-stack/faithful-card'; +import {readAnswerEnvelope, type AnswerEnvelope} from './envelope.js'; + +export const UNSUPPORTED_RESULT_TEXT = 'This result could not be displayed.'; + +/** The structural subset of an MCP tool result the view reads. */ +export type ToolResultLike = { + structuredContent?: unknown; + content?: ReadonlyArray<{type?: string; text?: string}>; + isError?: boolean; +}; + +export type ClassifiedResult = + | {kind: 'valid'; envelope: AnswerEnvelope} + | {kind: 'rejected'; text: string}; + +/** The FIRST text block's text, when it is a non-empty string. An empty first block is not skipped for a later one. */ +function contentText(result: ToolResultLike): string | undefined { + const text = result.content?.find(block => block.type === 'text')?.text; + return typeof text === 'string' && text.length > 0 ? text : undefined; +} + +/** + * A failed call is always a problem and shows its own (server-written, fixed) message, whatever the + * structured content says. A successful call shows the answer from `structuredContent` when the host + * delivered it: a structured envelope that is present but malformed is unsupported, even when text + * content exists. Only when it is absent (or null) does the text content stand in, exactly as sent. + */ +export function classifyToolResult(result: ToolResultLike): ClassifiedResult { + const fallback = contentText(result) ?? UNSUPPORTED_RESULT_TEXT; + if (result.isError) return {kind: 'rejected', text: fallback}; + const structured = result.structuredContent; + if (structured !== undefined && structured !== null) { + const envelope = readAnswerEnvelope(structured); + return envelope === undefined ? {kind: 'rejected', text: UNSUPPORTED_RESULT_TEXT} : {kind: 'valid', envelope}; + } + const text = contentText(result); + return text === undefined + ? {kind: 'rejected', text: UNSUPPORTED_RESULT_TEXT} + : {kind: 'valid', envelope: {question: '', answer: text}}; +} + +/** The same decision in the stack's terms: the answer as the card's Markdown, the question as its heading. */ +export function classifyCardResult(result: ToolResultLike): CardResultOutcome { + const classified = classifyToolResult(result); + return classified.kind === 'valid' + ? {kind: 'content', content: {heading: classified.envelope.question, markdown: classified.envelope.answer}} + : {kind: 'rejected', text: classified.text}; +} + diff --git a/apps/mcp/src/worker/index.ts b/apps/mcp/src/worker/index.ts new file mode 100644 index 00000000..50fa8b84 --- /dev/null +++ b/apps/mcp/src/worker/index.ts @@ -0,0 +1,28 @@ +// Cloudflare Workers hosting: the same fetch application as Node, with bindings, console logging and the +// built card bundled at build time. Nothing here touches a filesystem or a process object. +import builtView from '../../dist/view.html'; +import {createAnsariHttpCompleter} from '../server/ansari-client.js'; +import {createFetchApp, type FetchApp} from '../server/fetch-app.js'; +import {settingsFromEnv, type RuntimeEnv} from '../server/runtime-config.js'; + +// Bindings are fixed for the life of a deployed version, so the application is prepared once per isolate. +let app: FetchApp | undefined; + +function applicationFor(env: RuntimeEnv): FetchApp { + if (app) return app; + const settings = settingsFromEnv(env); + app = createFetchApp({ + completer: createAnsariHttpCompleter(settings.ansari), + html: builtView, + release: settings.release, + allowedHosts: settings.allowedHosts, + allowedOrigins: settings.allowedOrigins, + enableJsonResponse: settings.enableJsonResponse, + logger: record => console.log(JSON.stringify(record)), + }); + return app; +} + +export default { + fetch: (request: Request, env: RuntimeEnv): Response | Promise => applicationFor(env).fetch(request), +}; diff --git a/apps/mcp/src/worker/view-module.d.ts b/apps/mcp/src/worker/view-module.d.ts new file mode 100644 index 00000000..728d10ec --- /dev/null +++ b/apps/mcp/src/worker/view-module.d.ts @@ -0,0 +1,5 @@ +// Wrangler bundles `.html` imports as text. +declare module '*.html' { + const text: string; + export default text; +} diff --git a/apps/mcp/test/browser.ts b/apps/mcp/test/browser.ts new file mode 100644 index 00000000..77f0a379 --- /dev/null +++ b/apps/mcp/test/browser.ts @@ -0,0 +1,833 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import {Client, StreamableHTTPClientTransport} from '@modelcontextprotocol/client'; +import {createContentPolicy} from '@saga-a2ui/react-mcp-stack/content-policy'; +import {viewUriForHtml} from '@saga-a2ui/react-mcp-stack/server'; +import {chromium, type Frame, type Page} from '@playwright/test'; +import {build, preview} from 'vite'; +import {createHttpServer as createBaseHttpServer} from '../src/node/http-server.ts'; +import {stampHtmlRevision} from '../src/server/operation-log.ts'; +import {VIEW_FILENAME} from '../src/server/view-artifact.ts'; +import {ANSWER_A, ANSWER_B, ANSWER_C, ANSWER_HOSTILE, ATTRIBUTION_CASES, QUESTION_A, QUESTION_B} from './react-answers.ts'; + +const root = new URL('./', import.meta.url).pathname; +const out = new URL('../test-results/browser/', import.meta.url).pathname; +const sha = (text: string) => createHash('sha256').update(text).digest('hex'); +const html = fs.readFileSync(new URL('../dist/view.html', import.meta.url), 'utf8'); +fs.mkdirSync(out, {recursive: true}); + +const WAITING_TEXT = 'Waiting for Ansari answer…'; +const UNSUPPORTED_TEXT = 'This result could not be displayed.'; +const SANDBOX = 'allow-scripts'; +const ANSWERS: Record = {[QUESTION_A]: ANSWER_A, [QUESTION_B]: ANSWER_B, 'Question C': ANSWER_C, 'Question hostile': ANSWER_HOSTILE, ...Object.fromEntries(ATTRIBUTION_CASES.map(c => [c.question, c.markdown]))}; + +const checks: {name: string; pass: true}[] = []; +const errors: string[] = []; +const ports: number[] = []; +const evidence: Record = {}; +const check = (name: string, f: () => void) => { f(); checks.push({name, pass: true}); if (process.env.REACT_BROWSER_TRACE) console.error('ok', name); }; + +let browser: any, host: any, child: any, service: any, mcpClient: Client | undefined; +const served = new Map(); +const requests: {url: string; from: string}[] = []; + +async function main() { + await build({configFile: false, root: `${root}/host`, logLevel: 'error', build: {target: 'esnext', outDir: `${out}/host`, emptyOutDir: true}}); + host = await preview({configFile: false, root: `${root}/host`, logLevel: 'error', build: {outDir: `${out}/host`}, preview: {host: '127.0.0.1', port: 0}}); + ports.push(host.httpServer.address().port); + child = http.createServer((req, res) => { + const body = served.get(req.url!); + if (body === undefined) { res.writeHead(404).end(); return; } + // Hosts may disallow font URLs, including data: and blob:, independently of our embedded policy. + res.writeHead(200, {'content-type': 'text/html', 'content-security-policy': "font-src 'self'"}).end(body); + }); + await new Promise(r => child.listen(0, '127.0.0.1', r)); + ports.push(child.address().port); + + const logs: any[] = []; + service = createBaseHttpServer({ + html, allowedHosts: ['127.0.0.1'], enableJsonResponse: true, logger: r => logs.push(r), + completer: {async complete({question}) { if (question === 'FAIL') throw new Error('PRIVATE_CANARY'); return ANSWERS[question] ?? `Synthetic answer for ${question}.`; }}, + }); + await new Promise(r => service.server.listen(0, '127.0.0.1', r)); + const mcpPort = (service.server.address() as any).port; + ports.push(mcpPort); + const client = new Client({name: 'react-qualification', version: '0'}); + mcpClient = client; + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${mcpPort}/mcp`)); + await client.connect(transport); + + // ---- The served resource: exact bytes, identity, resource rules --------------------------------- + const viewUri = viewUriForHtml(stampHtmlRevision(html), VIEW_FILENAME); + const resource = await client.readResource({uri: viewUri}); + const bytes = String((resource.contents[0] as {text: string}).text); + check('resource:exact-served-bytes', () => assert.equal(bytes, stampHtmlRevision(html))); + check('resource:uri-is-sha256-fingerprint-of-served-bytes', () => assert.equal(viewUri, `ui://views/${VIEW_FILENAME.replace(/\.html$/, '')}.${sha(bytes).slice(0, 32)}.html`)); + const meta = (resource.contents[0] as any)._meta; + check('resource:closed-csp-no-origins', () => assert.deepEqual(meta.ui.csp, {connectDomains: [], resourceDomains: [], frameDomains: [], baseUriDomains: []})); + const callTool = (question: string) => client.callTool({name: 'answer_islamic_question', arguments: {question}}); + const resultA = await callTool(QUESTION_A); + const resultB = await callTool(QUESTION_B); + const resultC = await callTool('Question C'); + const resultHostile = await callTool('Question hostile'); + const failure = await callTool('FAIL'); + check('results:linked-to-served-view', () => { + for (const r of [resultA, resultB, resultC, resultHostile, failure]) assert.deepEqual(r._meta, {ui: {resourceUri: viewUri}}); + }); + check('results:structured-answers-unchanged', () => { + assert.deepEqual(resultA.structuredContent, {version: '0.1.0', question: QUESTION_A, answer: ANSWER_A}); + assert.deepEqual(resultB.structuredContent, {version: '0.1.0', question: QUESTION_B, answer: ANSWER_B}); + }); + check('results:error-is-safe', () => { assert.equal(failure.isError, true); assert.ok(!JSON.stringify([failure, logs]).includes('PRIVATE_CANARY')); }); + + browser = await chromium.launch({headless: true}); + const context = await browser.newContext(); + // tsx wraps named functions in __name(); evaluated page functions must find it in every frame. + await context.addInitScript('window.__name = (f) => f;'); + const page: Page = await context.newPage(); + // Every iframe stays inside the viewport: an off-screen cross-origin frame has throttled animation frames. + await page.setViewportSize({width: 1700, height: 9000}); + page.setDefaultTimeout(7000); + page.on('pageerror', e => errors.push(String(e))); + page.on('console', m => { if (m.type() === 'error') errors.push(`console: ${m.text()}`); }); + // Runtime networking is unavailable to the view: only the host fixture and the view document are reachable. + const allowedOrigins = () => [`http://127.0.0.1:${ports[0]}`, `http://127.0.0.1:${ports[1]}`]; + await context.route('**/*', (route: any) => { + const url = route.request().url(); + const frame = route.request().frame(); + const isView = frame !== null && frame !== page.mainFrame(); + if (url.startsWith('data:') || url.startsWith('blob:') || allowedOrigins().some(origin => url.startsWith(origin))) { route.continue(); return; } + requests.push({url, from: isView ? 'view' : 'host'}); + route.abort(); + }); + page.on('request', r => { if (!r.url().startsWith('data:')) requests.push({url: r.url(), from: r.frame() === page.mainFrame() ? 'host' : 'view'}); }); + await page.goto(host.resolvedUrls.local[0]); + await page.waitForFunction(() => (window as any).hostProbe); + + let counter = 0; + async function openView(width = 760, height = 900, document = bytes, openLinks = true): Promise<{id: string; frame: Frame}> { + const id = `view${++counter}`; + const path = `/${id}`; + served.set(path, document); + await page.evaluate(({id, url, sandbox, openLinks}) => (window as any).hostProbe.create(id, url, true, sandbox, openLinks), {id, url: `http://127.0.0.1:${ports[1]}${path}`, sandbox: SANDBOX, openLinks}); + await page.evaluate(({id, width, height}) => (window as any).hostProbe.resize(id, width, height), {id, width, height}); + const frame = (await (await page.locator(`#${id}`).elementHandle())!.contentFrame())!; + await page.waitForFunction(id => (window as any).hostProbe.state(id).initialized, id); + return {id, frame}; + } + const deliver = (id: string, result: unknown) => page.evaluate(({id, result}) => (window as any).hostProbe.result(id, result), {id, result}); + const settle = (frame: Frame) => frame.evaluate(() => new Promise(r => { const t = setTimeout(r, 600); requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(t); r(undefined); })); })); + const text = async (frame: Frame, selector: string) => (await frame.locator(selector).innerText()).trim(); + // The numbers actually laid out in the floral disks: the text of each list item's ::before in Chromium's DOM snapshot + // (computed style keeps counter() unresolved; the snapshot has the laid-out generated text, in document order). + async function displayedNumbers(id: string): Promise { + const cdp = await context.newCDPSession(page); + try { + const snap: any = await cdp.send('DOMSnapshot.captureSnapshot', {computedStyles: []}); + const doc = snap.documents.find((d: any) => snap.strings[d.documentURL].endsWith('/' + id)); + assert.ok(doc, 'the view document is in the snapshot'); + const found: string[] = []; + doc.layout.nodeIndex.forEach((node: number, i: number) => { + const textIndex = doc.layout.text[i]; + if (textIndex >= 0 && snap.strings[doc.nodes.nodeName[node]] === '::before') found.push(snap.strings[textIndex]); + }); + return found; + } finally { await cdp.detach(); } + } + + // Strict policy keeps the application credit inert, just like answer links. + const strictBytes = bytes.replace(/(]*id="saga-content-policy"[^>]*>)[\s\S]*?(<\/script>)/, '$1{"level":"strict"}$2'); + assert.notEqual(strictBytes, bytes, 'the Strict fixture replaces the embedded policy'); + const strict = await openView(760, 900, strictBytes); + await deliver(strict.id, resultA); + await strict.frame.locator('[data-testid="saga-credit"]').waitFor(); + const strictButtons = await strict.frame.locator('[data-testid="saga-credit"] button').count(); + const strictOpens = await page.evaluate(id => (window as any).hostProbe.state(id).openLinks, strict.id); + check('links:strict-credit-is-text-only', () => { + assert.equal(strictButtons, 0); + assert.deepEqual(strictOpens, []); + }); + + // ---- Start failures are worded apart: policy, connection and fonts --------------------------- + // A view that did not start says why, in the application's own sentence. The font failure is not a + // connection failure: the host connected and the handshake completed before the fonts were refused. + const START_FAILURES = { + policy: 'This view could not start: its content policy is not valid.', + connect: 'The answer card could not connect. Please retry the question.', + fonts: 'The answer card could not load its fonts. Please retry the question.', + }; + async function openStartFailure(html: string, connect: true | 'reject'): Promise<{id: string; frame: Frame; body: () => Promise}> { + const id = `view${++counter}`; + served.set(`/${id}`, html); + await page.evaluate(({id, url, sandbox, connect}) => (window as any).hostProbe.create(id, url, connect, sandbox, true), {id, url: `http://127.0.0.1:${ports[1]}/${id}`, sandbox: SANDBOX, connect}); + const frame = (await (await page.locator(`#${id}`).elementHandle())!.contentFrame())!; + return {id, frame, body: async () => (await frame.evaluate(() => document.body.innerText)).trim()}; + } + const seenBy = (id: string) => page.evaluate(id => (window as any).hostProbe.state(id), id); + const kitabRegular = fs.readFileSync(new URL('../assets/fonts/Kitab-Regular.ttf', import.meta.url)).toString('base64'); + assert.ok(bytes.includes(kitabRegular), 'the Regular face is embedded verbatim'); + const brokenFonts = bytes.replace(kitabRegular, 'A'.repeat(kitabRegular.length)); + const invalidPolicy = bytes.replace(/(]*id="saga-content-policy"[^>]*>)[\s\S]*?(<\/script>)/, '$1{not json$2'); + assert.notEqual(invalidPolicy, bytes, 'the invalid-policy fixture replaces the embedded policy'); + const policyFailed = await openStartFailure(invalidPolicy, true); + await policyFailed.frame.getByText(START_FAILURES.policy).waitFor(); + const policyState = await seenBy(policyFailed.id); + const policyCards = await policyFailed.frame.evaluate(() => document.querySelectorAll('.answer-card, .saga-card').length); + check('start-failure:invalid-policy-shows-its-text-and-never-connects', () => { + assert.equal(policyCards, 0); + assert.ok(!policyState.seen.includes('ui/initialize'), 'no handshake was started on a guessed policy'); + }); + assert.equal(await policyFailed.body(), START_FAILURES.policy); + const connectFailed = await openStartFailure(bytes, 'reject'); + await connectFailed.frame.getByText(START_FAILURES.connect).waitFor(); + const connectState = await seenBy(connectFailed.id); + check('start-failure:host-rejecting-the-handshake-shows-the-connect-text', () => { + assert.equal(connectState.rejected, 1); + assert.equal(connectState.initialized, false); + }); + assert.equal(await connectFailed.body(), START_FAILURES.connect); + const fontsFailed = await openStartFailure(brokenFonts, true); + await fontsFailed.frame.getByText(START_FAILURES.fonts).waitFor(); + const fontsState = await seenBy(fontsFailed.id); + const fontsBody = await fontsFailed.body(); + check('start-failure:font-failure-shows-the-ready-text-and-is-not-a-connect-failure', () => { + assert.ok(fontsState.seen.includes('ui/initialize'), 'the host handshake was reached'); + assert.equal(fontsBody, START_FAILURES.fonts); + assert.notEqual(START_FAILURES.fonts, START_FAILURES.connect); + assert.equal(new Set(Object.values(START_FAILURES)).size, 3, 'the three start failures read differently'); + }); + await deliver(fontsFailed.id, {content: [], structuredContent: {version: '0.1.0', question: 'q', answer: 'never shown'}}).catch(() => {}); + await settle(fontsFailed.frame); + const fontsAfter = await fontsFailed.body(); + check('start-failure:no-answer-appears-on-a-view-whose-fonts-failed', () => assert.equal(fontsAfter, START_FAILURES.fonts)); + + // ---- Neutral initial state ------------------------------------------------------------------ + const first = await openView(); + // The document the host mounted names the SDK artifacts it was built from, beside the application release. + const sdkIdentity = JSON.parse(execFileSync(process.execPath, [new URL('../scripts/sdk-identity.mjs', import.meta.url).pathname], {encoding: 'utf8'})); + const mounted = await first.frame.evaluate(() => { const data = document.body.dataset; return {release: data.release, sdkKind: data.sdkKind, sdkVersion: data.sdkVersion, sdkSource: data.sdkSource, sdkStackSha256: data.sdkStackSha256, sdkSetSha256: data.sdkSetSha256, meta: document.querySelector('meta[name="ansari-sdk"]')?.getAttribute('content')}; }); + check('document:sdk-identity-beside-release', () => { + assert.deepEqual({kind: mounted.sdkKind, version: mounted.sdkVersion, source: mounted.sdkSource, stackSha256: mounted.sdkStackSha256, setSha256: mounted.sdkSetSha256}, sdkIdentity); + assert.equal(mounted.meta, sdkIdentity.version); + assert.match(mounted.release ?? '', /^\d+\.\d+\.\d+/); + }); + const initial = await first.frame.evaluate(() => ({ + status: document.querySelector('[data-testid="answer-status"]')?.textContent, cards: document.querySelectorAll('.answer-card, .saga-card').length, + body: document.body.innerText.trim(), + })); + check('initial:neutral-waiting-no-card-no-canned-answer', () => { + assert.equal(initial.status, WAITING_TEXT); + assert.equal(initial.cards, 0); + assert.equal(initial.body, WAITING_TEXT); + }); + + // ---- Answer A then answer B in ONE mounted view ---------------------------------------------- + await deliver(first.id, resultA); + await first.frame.locator('[data-testid="answer-card"]').waitFor(); + await settle(first.frame); + await first.frame.evaluate(() => { (document.querySelector('main') as any).__mounted = 'same'; (document.getElementById('root') as any).__mounted = 'same'; }); + const shownA = await first.frame.evaluate(() => ({ + question: document.querySelector('[data-testid="question"]')?.textContent, + cardText: (document.querySelector('.saga-card-content') as HTMLElement).innerText, + attribution: document.querySelector('.answer-card')!.hasAttribute('data-attribution'), + status: document.querySelector('[data-testid="answer-status"]')?.textContent, + })); + check('answer-A:question-and-text-in-order', () => { + assert.equal(shownA.question, QUESTION_A); + assert.equal(shownA.status, ''); + const order = ['Synthetic heading A', 'Synthetic answer A. التقوى', 'Third', 'Fourth point', 'nested one', 'nested two', 'a bullet', 'a quoted line', 'This information came from ansari.chat']; + let at = -1; + for (const part of order) { const next = shownA.cardText.indexOf(part, at + 1); assert.ok(next > at, `${part} follows the previous part`); at = next; } + }); + check('answer-A:attribution-footer-selected', () => assert.equal(shownA.attribution, true)); + for (const width of [375, 760]) { + await page.evaluate(({id, width}) => (window as any).hostProbe.resize(id, width, 900), {id: first.id, width}); + await settle(first.frame); + const footer = await first.frame.evaluate(() => { + const content = document.querySelector('.saga-card-content')!; + const paragraph = content.lastElementChild!; + const credit = document.querySelector('.saga-credit')!; + return { + ruleCount: Array.from(content.querySelectorAll('hr')).filter(el => getComputedStyle(el).display !== 'none').length, + border: getComputedStyle(paragraph).borderTopWidth, + alignment: getComputedStyle(credit).textAlign, + overflow: document.documentElement.scrollWidth > window.innerWidth, + }; + }); + check(`footer:${width}:one-rule-and-right-aligned-signature`, () => { + assert.equal(footer.ruleCount + (parseFloat(footer.border) > 0 ? 1 : 0), 1); + assert.equal(footer.alignment, 'right'); + assert.equal(footer.overflow, false); + }); + } + const numbersA = await displayedNumbers(first.id); + const listsA = await first.frame.evaluate(() => { + const content = (el: Element, pseudo: string) => getComputedStyle(el, pseudo).content; + const top = document.querySelector('.saga-card-content > ol')!; + const items = Array.from(top.children) as HTMLElement[]; + const nested = Array.from(items[1]!.querySelectorAll(':scope > ol > li')) as HTMLElement[]; + const box = (el: Element) => { const s = getComputedStyle(el, '::before'); return {w: s.width, h: s.height, bg: s.backgroundImage.slice(0, 40)}; }; + return { + start: top.getAttribute('start'), tag: top.tagName, itemCount: items.length, nestedCount: nested.length, + topBox: box(items[0]!), nestedBox: box(nested[0]!), markerContent: getComputedStyle(items[0]!, '::marker').content, + listStyle: getComputedStyle(top).listStyleType, display: getComputedStyle(items[0]!).display, + }; + }); + evidence.listsA = listsA; + check('numbering:native-ol-start-3-and-displayed-3-4', () => { + assert.equal(listsA.tag, 'OL'); + assert.equal(listsA.start, '3'); + assert.equal(listsA.itemCount, 2); + assert.deepEqual(numbersA, ['3', '4', '1', '2']); + assert.equal(listsA.display, 'list-item'); + assert.notEqual(listsA.listStyle, 'none'); + }); + check('numbering:nested-list-restarts-at-1-with-smaller-disk', () => { + assert.equal(listsA.nestedCount, 2); + assert.equal(listsA.markerContent, '""', 'the native marker is replaced by the disk, the list keeps its semantics'); + assert.equal(listsA.topBox.w, '64px'); + assert.equal(listsA.nestedBox.w, '44px'); + assert.match(listsA.topBox.bg, /url\("data:image\/png;base64,/); + }); + const aria = await first.frame.locator('.saga-card-content').ariaSnapshot(); + evidence.ariaA = aria; + check('numbering:accessible-list-semantics', () => { + assert.match(aria, /- list:/); + assert.match(aria, /- listitem:/); + // The disk is generated content with empty alternative text: the number is not announced twice. + assert.ok(!/"?3"?\s*3/.test(aria)); + }); + + await first.frame.locator('.saga-card-link').click(); + await page.waitForFunction(id => (window as any).hostProbe.state(id).openLinks.length === 1, first.id); + await settle(first.frame); + const attributionNoticeVisible = await first.frame.locator('.saga-card-link-status').isVisible(); + check('links:successful-attribution-is-silent', () => assert.equal(attributionNoticeVisible, false)); + const opened = await page.evaluate(id => (window as any).hostProbe.state(id).openLinks, first.id); + check('links:attribution-link-reaches-host-only-through-openLink', () => assert.deepEqual(opened, ['https://ansari.chat/'])); + + await first.frame.getByRole('button', {name: 'saga a2ui', exact: true}).click(); + await page.waitForFunction(id => (window as any).hostProbe.state(id).openLinks.length === 2, first.id); + await settle(first.frame); + const creditNotice = await first.frame.locator('[data-testid="saga-credit"] [role="status"]').textContent(); + check('links:successful-credit-is-silent', () => assert.equal(creditNotice, '')); + const creditOpened = await page.evaluate(id => (window as any).hostProbe.state(id).openLinks, first.id); + check('links:saga-credit-follows-unchanged-answer-and-uses-host', () => { + assert.deepEqual(creditOpened, ['https://ansari.chat/', 'https://github.com/MachineWisdomAI/saga-a2ui/tree/codex/release-0.7.6']); + assert.ok(!shownA.cardText.includes('Built with')); + }); + + // Both attribution and signature use visibly separate notices when the host cannot open links. + const unavailable = await openView(375, 900, bytes, false); + await deliver(unavailable.id, resultA); + for (const [button, status, destination] of [ + ['.saga-card-link', '.saga-card-link-status', 'https://ansari.chat/'], + ['.saga-credit button', '.saga-credit [role="status"]', 'https://github.com/MachineWisdomAI/saga-a2ui/tree/codex/release-0.7.6'], + ] as const) { + await unavailable.frame.locator(button).click(); + await unavailable.frame.locator(status).filter({hasText: 'This host cannot open links.'}).waitFor(); + const notice = await unavailable.frame.locator(status).evaluate(el => ({ + text: el.textContent, border: getComputedStyle(el).borderInlineStartWidth, + label: getComputedStyle(el, '::before').content, + overflow: document.documentElement.scrollWidth > window.innerWidth, + })); + check(`links:unavailable-notice:${destination}`, () => { + assert.equal(notice.text, `This host cannot open links. Destination: ${destination}`); + assert.ok(parseFloat(notice.border) > 0); + assert.equal(notice.label, '"Link notice"'); + assert.equal(notice.overflow, false); + }); + } + for (const theme of ['light', 'dark'] as const) { + await page.evaluate(({id, theme}) => (window as any).hostProbe.theme(id, theme), {id: unavailable.id, theme}); + await unavailable.frame.waitForFunction(theme => document.documentElement.getAttribute('data-theme') === theme, theme); + const ratios = await unavailable.frame.locator('[role="status"]').evaluateAll(elements => { + const luminance = (color: string) => { + const rgb = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(c => { + const s = c / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return rgb[0]! * 0.2126 + rgb[1]! * 0.7152 + rgb[2]! * 0.0722; + }; + return elements.filter(el => el.textContent).map(el => { + const style = getComputedStyle(el); + const a = luminance(style.color), b = luminance(style.backgroundColor); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + }); + }); + check(`links:${theme}:readable-notices`, () => { + assert.equal(ratios.length, 2); + for (const ratio of ratios) assert.ok(ratio >= 4.5, `contrast ${ratio}`); + }); + await page.locator(`#${unavailable.id}`).screenshot({path: `${out}/footer-link-notices-${theme}.png`}); + } + + await deliver(first.id, resultB); + await first.frame.getByText('الصبر مفتاح الفرج', {exact: false}).waitFor(); + await settle(first.frame); + const numbersB = await displayedNumbers(first.id); + const shownB = await first.frame.evaluate(() => ({ + same: (document.querySelector('main') as any).__mounted === 'same' && (document.getElementById('root') as any).__mounted === 'same', + question: document.querySelector('[data-testid="question"]')?.textContent, + cardText: (document.querySelector('.saga-card-content') as HTMLElement).innerText, + questionDir: getComputedStyle(document.querySelector('[data-testid="question"]')!).direction, + paragraphDir: getComputedStyle(document.querySelector('.saga-card-content > p')!).direction, + itemDir: getComputedStyle(document.querySelector('.saga-card-content ol > li')!).direction, + itemPad: {left: getComputedStyle(document.querySelector('.saga-card-content ol > li')!).paddingLeft, right: getComputedStyle(document.querySelector('.saga-card-content ol > li')!).paddingRight}, + attribution: document.querySelector('.answer-card')!.hasAttribute('data-attribution'), + footerDir: getComputedStyle(document.querySelector('.saga-card-content > p:last-child')!).direction, + footerBorder: getComputedStyle(document.querySelector('.saga-card-content > p:last-child')!).borderTopWidth, + })); + check('two-answers:one-mounted-view-answer-B-replaces-A', () => { + assert.equal(shownB.same, true); + assert.equal(shownB.question, QUESTION_B); + assert.ok(!shownB.cardText.includes('Synthetic answer A')); + assert.ok(shownB.cardText.startsWith('الصبر')); + assert.deepEqual(numbersB, ['1', '2']); + }); + check('rtl:question-paragraph-and-item-directions', () => { + assert.equal(shownB.questionDir, 'rtl'); + assert.equal(shownB.paragraphDir, 'rtl'); + assert.equal(shownB.itemDir, 'rtl'); + assert.ok(parseFloat(shownB.itemPad.right) > parseFloat(shownB.itemPad.left), 'the disk sits on the inline start (right) side'); + assert.equal(shownB.attribution, true); + assert.equal(shownB.footerDir, 'ltr'); + assert.equal(shownB.footerBorder, '1px', 'an answer without a supplied rule keeps the footer border'); + }); + + // ---- Answer without attribution gets no footer treatment --------------------------------------- + await deliver(first.id, resultC); + await first.frame.getByText('Synthetic answer C.', {exact: false}).waitFor(); + const shownC = await first.frame.evaluate(() => { + const last = document.querySelector('.saga-card-content > p:last-child') as HTMLElement; + return {attribution: document.querySelector('.answer-card')!.hasAttribute('data-attribution'), border: getComputedStyle(last).borderTopWidth, size: getComputedStyle(last).fontSize, text: last.textContent}; + }); + check('attribution:ordinary-closing-paragraph-keeps-normal-style', () => { + assert.equal(shownC.attribution, false); + assert.equal(shownC.border, '0px'); + assert.equal(shownC.size, '18px'); + assert.equal(shownC.text, 'A closing paragraph that is not the attribution.'); + }); + + // ---- Lookalike closings: the footer follows the closing paragraph the card rendered -------------- + for (const lookalike of ATTRIBUTION_CASES) { + await deliver(first.id, await callTool(lookalike.question)); + await first.frame.getByText(lookalike.probe, {exact: false}).waitFor(); + await settle(first.frame); + const footer = await first.frame.evaluate(() => document.querySelector('.answer-card')!.hasAttribute('data-attribution')); + check(`attribution:${lookalike.question.toLowerCase().replaceAll(' ', '-')}-footer-is-${lookalike.footer}`, () => assert.equal(footer, lookalike.footer)); + } + + // ---- Failure clears the answer; the next valid result recovers -------------------------------- + await deliver(first.id, failure); + await first.frame.locator('[data-testid="answer-card"]').waitFor({state: 'detached'}); + const afterFailure = await first.frame.evaluate(() => ({body: document.body.innerText.trim(), cards: document.querySelectorAll('.answer-card, .saga-card').length})); + check('failure:server-error-clears-answer-and-shows-only-the-safe-message', () => { + assert.equal(afterFailure.cards, 0); + assert.equal(afterFailure.body, 'Ansari could not complete this request.'); + }); + await deliver(first.id, {content: [{type: 'text', text: 'ignored'}], structuredContent: {version: '9.9.9', question: 'q', answer: 'wrong version'}}); + await first.frame.getByText(UNSUPPORTED_TEXT).waitFor(); + const afterUnsupported = await first.frame.evaluate(() => ({body: document.body.innerText.trim(), cards: document.querySelectorAll('.answer-card, .saga-card').length})); + check('failure:unsupported-version-shows-unsupported-text-and-no-card', () => { + assert.equal(afterUnsupported.cards, 0); + assert.equal(afterUnsupported.body, UNSUPPORTED_TEXT); + }); + // A result that carries a structured envelope is read from it alone: text beside a malformed envelope never rescues it. + for (const [name, malformed] of [ + ['array', {content: [{type: 'text', text: 'Fallback text.\n'}], structuredContent: ['x']}], + ['not-an-object', {content: [{type: 'text', text: 'Fallback text.\n'}], structuredContent: 'x'}], + ['question-missing', {content: [{type: 'text', text: 'Fallback text.\n'}], structuredContent: {version: '0.1.0', answer: 'a'}}], + ['empty-answer', {content: [{type: 'text', text: 'Fallback text.\n'}], structuredContent: {version: '0.1.0', question: 'q', answer: ''}}], + ] as const) { + await deliver(first.id, resultA); + await first.frame.locator('[data-testid="answer-card"]').waitFor(); + await deliver(first.id, malformed); + await first.frame.locator('[data-testid="answer-card"]').waitFor({state: 'detached'}); + const shownMalformed = await first.frame.evaluate(() => ({body: document.body.innerText.trim(), cards: document.querySelectorAll('.answer-card, .saga-card').length})); + check(`failure:malformed-structured-${name}-is-unsupported-even-with-text`, () => { + assert.equal(shownMalformed.cards, 0); + assert.equal(shownMalformed.body, UNSUPPORTED_TEXT); + }); + } + // A failed call shows its own first usable text, whatever the structured content says; with none, the unsupported sentence. + for (const [name, failed, expected] of [ + ['server-text', {isError: true, content: [{type: 'text', text: 'Server wrote this.'}]}, 'Server wrote this.'], + ['valid-structured-does-not-save-it', {isError: true, content: [{type: 'text', text: 'Server wrote this.'}], structuredContent: {version: '0.1.0', question: 'q', answer: 'never shown'}}, 'Server wrote this.'], + ['no-text', {isError: true, content: [{type: 'image', data: 'AAAA', mimeType: 'image/png'}]}, UNSUPPORTED_TEXT], + ['empty-first-text-is-not-skipped', {isError: true, content: [{type: 'text', text: ''}, {type: 'text', text: 'Later text.'}]}, UNSUPPORTED_TEXT], + ] as const) { + await deliver(first.id, resultA); + await first.frame.locator('[data-testid="answer-card"]').waitFor(); + await deliver(first.id, failed); + await first.frame.locator('[data-testid="answer-card"]').waitFor({state: 'detached'}); + const shownFailed = await first.frame.evaluate(() => ({body: document.body.innerText.trim(), cards: document.querySelectorAll('.answer-card, .saga-card').length})); + check(`failure:error-result-${name}`, () => { + assert.equal(shownFailed.cards, 0); + assert.equal(shownFailed.body, expected); + }); + } + // A host that delivers only the text result still shows the answer, exactly as sent and without a heading. + const TEXT_ONLY = 'Text only answer.\n\nSecond paragraph.\n'; + await deliver(first.id, resultA); + await first.frame.locator('[data-testid="answer-card"]').waitFor(); + for (const [name, delivered] of [ + ['absent', {content: [{type: 'text', text: TEXT_ONLY}]}], + ['null', {content: [{type: 'text', text: TEXT_ONLY}], structuredContent: null}], + ] as const) { + await deliver(first.id, failure); + await first.frame.locator('[data-testid="answer-card"]').waitFor({state: 'detached'}); + await deliver(first.id, delivered); + await first.frame.getByText('Second paragraph.').waitFor(); + const shownText = await first.frame.evaluate(() => ({cards: document.querySelectorAll('.answer-card').length, questions: document.querySelectorAll('[data-testid="question"]').length, status: document.querySelector('[data-testid="answer-status"]')?.textContent})); + check(`fallback:text-only-${name}-structured-shows-the-text-answer-without-a-heading`, () => { + assert.equal(shownText.cards, 1); + assert.equal(shownText.questions, 0); + assert.equal(shownText.status, ''); + }); + } + // A later empty first text block does not let a second block stand in. + await deliver(first.id, {content: [{type: 'text', text: ''}, {type: 'text', text: 'Later text.'}]}); + await first.frame.locator('[data-testid="answer-card"]').waitFor({state: 'detached'}); + await first.frame.getByText(UNSUPPORTED_TEXT).waitFor(); + const emptyFirst = await first.frame.evaluate(() => ({body: document.body.innerText.trim(), cards: document.querySelectorAll('.answer-card, .saga-card').length})); + check('fallback:empty-first-text-block-is-unsupported-not-skipped', () => { + assert.equal(emptyFirst.cards, 0); + assert.equal(emptyFirst.body, UNSUPPORTED_TEXT); + }); + await deliver(first.id, resultA); + await first.frame.locator('[data-testid="answer-card"]').waitFor(); + await deliver(first.id, {isError: true, content: [{type: 'text', text: 'Ansari could not complete this request.'}]}); + await first.frame.locator('[data-testid="answer-card"]').waitFor({state: 'detached'}); + await deliver(first.id, resultB); + await first.frame.locator('[data-testid="answer-card"]').waitFor(); + const recoveredText = await text(first.frame, '.saga-card-content'); + + // ---- Complete reload input in a fresh view renders the same answer ---------------------------- + const reload = await openView(); + await deliver(reload.id, resultB); + await reload.frame.locator('[data-testid="answer-card"]').waitFor(); + const reloaded = {card: await text(reload.frame, '.saga-card-content'), question: await text(reload.frame, '[data-testid="question"]')}; + check('reload:complete-result-in-a-fresh-view-renders-the-same-answer', () => { + assert.equal(reloaded.card, recoveredText); + assert.equal(reloaded.question, QUESTION_B); + }); + + // ---- Artwork: theme-specific, embedded, decodable, selected only by the app -------------------- + async function artwork(frame: Frame) { + return frame.evaluate(async () => { + const root = document.documentElement; + const names = ['flourish', 'marker', 'separator']; + const resolved: Record = {}; + for (const name of names) resolved[name] = getComputedStyle(root).getPropertyValue(`--ansari-art-${name}`).trim(); + const decoded: Record = {}; + for (const name of names) { + const match = /url\("(data:image\/png;base64,[^"]+)"\)/.exec(resolved[name]!); + if (!match) { decoded[name] = -1; continue; } + const image = new Image(); + image.src = match[1]!; + await image.decode(); + decoded[name] = image.naturalWidth; + } + const header = getComputedStyle(document.querySelector('.answer-header')!, '::after'); + const markerItem = document.querySelector('.saga-card-content ol > li'); + const marker = markerItem === null ? undefined : getComputedStyle(markerItem, '::before'); + return {resolved, decoded, headerBg: header.backgroundImage, markerBg: marker?.backgroundImage ?? '', imgs: document.images.length, theme: root.getAttribute('data-theme')}; + }); + } + await page.evaluate(id => (window as any).hostProbe.theme(id, 'light'), first.id); + await first.frame.waitForFunction(() => document.documentElement.getAttribute('data-theme') === 'light'); + const light = await artwork(first.frame); + await page.evaluate(id => (window as any).hostProbe.theme(id, 'dark'), first.id); + await first.frame.waitForFunction(() => document.documentElement.getAttribute('data-theme') === 'dark'); + const dark = await artwork(first.frame); + check('artwork:separate-light-and-dark-embedded-decodable-images', () => { + for (const set of [light, dark]) { + for (const name of ['flourish', 'marker', 'separator']) assert.ok(set.decoded[name]! > 0, `${name} decodes`); + assert.equal(set.imgs, 0, 'artwork is CSS, not an image element'); + } + for (const name of ['flourish', 'marker', 'separator']) assert.notEqual(light.resolved[name], dark.resolved[name]); + assert.equal(light.headerBg.includes(light.resolved.flourish!.slice(5, 60)), true); + assert.equal(dark.headerBg.includes(dark.resolved.flourish!.slice(5, 60)), true); + assert.equal(dark.markerBg.includes(dark.resolved.marker!.slice(5, 60)), true); + assert.notEqual(light.markerBg, dark.markerBg); + }); + + // ---- Typography: reading profile maps to effective card typography, no root scaling ----------- + const metrics = (frame: Frame) => frame.evaluate(() => { + const card = document.querySelector('.saga-card') as HTMLElement; + const wrapper = document.querySelector('.answer-card') as HTMLElement; + const html = document.documentElement; + const p = document.querySelector('.saga-card-content p') as HTMLElement; + const question = document.querySelector('[data-testid="question"]') as HTMLElement; + const cs = getComputedStyle(card); + return { + profile: html.getAttribute('data-reading-profile'), rootTransform: getComputedStyle(html).transform, bodyTransform: getComputedStyle(document.body).transform, rootScale: html.getAttribute('data-root-scale'), + cardFont: cs.fontSize, cardLine: cs.lineHeight, cardFamily: cs.fontFamily, cardMax: cs.maxWidth, paragraph: getComputedStyle(p).fontSize, question: getComputedStyle(question).fontSize, + wrapperMax: getComputedStyle(wrapper).maxWidth, wrapperWidth: wrapper.getBoundingClientRect().width, viewport: window.innerWidth, + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + bodyFont: getComputedStyle(document.body).fontFamily, + }; + }); + await page.evaluate(id => (window as any).hostProbe.theme(id, 'light'), first.id); + await first.frame.waitForFunction(() => document.documentElement.getAttribute('data-theme') === 'light'); + const desktop = await metrics(first.frame); + check('typography:desktop-profile-effective-card-typography', () => { + assert.equal(desktop.profile, 'desktop'); + assert.equal(desktop.cardFont, '18px'); + assert.equal(desktop.paragraph, '18px'); + assert.equal(desktop.question, '26px'); + assert.equal(desktop.cardLine, '26.64px'); + assert.equal(desktop.rootTransform, 'none'); + assert.equal(desktop.rootScale, '1'); + assert.equal(desktop.cardFamily, 'Kitab'); + }); + const fonts = await first.frame.evaluate(async () => { + await document.fonts.ready; + return Array.from(document.fonts).map(face => ({family: face.family, weight: face.weight, status: face.status})); + }); + check('typography:both-embedded-Kitab-weights-loaded', () => { + assert.deepEqual(fonts.map(f => ({...f, family: f.family.replaceAll('"', '').replaceAll("'", '')})).sort((a,b) => a.weight.localeCompare(b.weight)), [ + {family:'Kitab',weight:'400',status:'loaded'}, {family:'Kitab',weight:'700',status:'loaded'}, + ]); + }); + // A device profile (Echo Show class) on a wide view: the adapter publishes the scale; the card's own typography follows it. + const trace = (s: string) => { if (process.env.REACT_BROWSER_TRACE) console.error('..', s); }; + trace('open wide'); + const wide = await openView(1000, 900); + trace('deliver'); + await deliver(wide.id, resultA); + trace('delivered'); + await wide.frame.locator('[data-testid="answer-card"]').waitFor(); + trace('context'); + await page.evaluate(id => (window as any).hostProbe.hostContext(id, {theme: 'light', deviceClass: 'echo_show_15'}), wide.id); + trace('context sent'); + await wide.frame.waitForFunction(() => document.documentElement.getAttribute('data-reading-profile') === 'device'); + await settle(wide.frame); + const device = await metrics(wide.frame); + check('typography:device-profile-scales-card-typography-and-width-without-root-transform', () => { + assert.equal(device.profile, 'device'); + assert.equal(device.cardFont, '28.8px'); + assert.equal(device.paragraph, '28.8px'); + assert.equal(device.question, '41.6px'); + assert.equal(device.wrapperMax, '1088px'); + assert.equal(device.rootTransform, 'none'); + assert.equal(device.bodyTransform, 'none'); + assert.equal(device.rootScale, '1'); + assert.equal(device.overflow, false); + }); + const listsDevice = await wide.frame.evaluate(() => { const li = document.querySelector('.saga-card-content > ol > li')!; return getComputedStyle(li, '::before').width; }); + check('typography:device-profile-scales-the-floral-disk', () => assert.ok(Math.abs(parseFloat(listsDevice) - 102.4) < 0.05, listsDevice)); + + // The stack decides the profile (`readingProfile`); the adapter only maps it. A mobile platform reads as a device; + // a desktop platform on the same wide view does not, and publishes scale 1. + const profileOf = (frame: Frame) => frame.evaluate(() => ({profile: document.documentElement.getAttribute('data-reading-profile'), scale: document.documentElement.style.getPropertyValue('--ansari-wide-reading-scale')})); + const mobile = await openView(1000, 900); + await deliver(mobile.id, resultA); + await mobile.frame.locator('[data-testid="answer-card"]').waitFor(); + await page.evaluate(id => (window as any).hostProbe.hostContext(id, {theme: 'light', platform: 'mobile'}), mobile.id); + await mobile.frame.waitForFunction(() => document.documentElement.getAttribute('data-reading-profile') === 'device'); + const desktopView = await openView(1000, 900); + await deliver(desktopView.id, resultA); + await desktopView.frame.locator('[data-testid="answer-card"]').waitFor(); + await page.evaluate(id => (window as any).hostProbe.hostContext(id, {theme: 'light', platform: 'desktop'}), desktopView.id); + await desktopView.frame.waitForFunction(() => document.documentElement.getAttribute('data-theme') === 'light'); + const [mobileProfile, desktopProfile, deviceProfile] = [await profileOf(mobile.frame), await profileOf(desktopView.frame), await profileOf(wide.frame)]; + check('typography:reading-profile-comes-from-the-stack-for-device-class-mobile-and-desktop', () => { + assert.deepEqual(deviceProfile, {profile: 'device', scale: '1.6'}); + assert.deepEqual(mobileProfile, {profile: 'device', scale: '1.6'}); + assert.deepEqual(desktopProfile, {profile: 'desktop', scale: '1'}); + }); + + // ---- Reference widths: 680px and 375px, both themes ------------------------------------------- + const shots: Record = {}; + for (const [width, label] of [[680, 'wide'], [375, 'narrow']] as const) { + const view = await openView(width, 1500); + await deliver(view.id, resultA); + await view.frame.locator('[data-testid="answer-card"]').waitFor(); + for (const theme of ['light', 'dark'] as const) { + await page.evaluate(({id, theme}) => (window as any).hostProbe.theme(id, theme), {id: view.id, theme}); + await view.frame.waitForFunction(theme => document.documentElement.getAttribute('data-theme') === theme, theme); + await settle(view.frame); + const m = await metrics(view.frame); + const geometry = await view.frame.evaluate(() => { + const rect = (el: Element | null) => { if (!el) return null; const r = el.getBoundingClientRect(); return {x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height)}; }; + const cs = (el: Element, pseudo: string) => getComputedStyle(el, pseudo); + const header = document.querySelector('.answer-header')!; + const flourish = cs(header, '::after'); + const li = document.querySelector('.saga-card-content > ol > li')!; + const footer = document.querySelector('.saga-card-content > p:last-child')!; + return { + card: rect(document.querySelector('.answer-card')), header: rect(header), flourishW: flourish.width, markerW: cs(li, '::before').width, markerFont: cs(li, '::before').fontSize, + footerFont: getComputedStyle(footer).fontSize, footerBorder: getComputedStyle(footer).borderTopWidth, surface: getComputedStyle(document.querySelector('.answer-card')!).backgroundColor, + ink: getComputedStyle(document.querySelector('.saga-card-content p')!).color, separatorH: cs(li, '::after').height, + }; + }); + shots[`${label}-${theme}`] = {width, metrics: m, geometry}; + await page.locator(`#${view.id}`).screenshot({path: `${out}/${label}-${theme}-${width}.png`}); + if (width === 375) check(`reference-${label}-${theme}:no-horizontal-overflow`, () => assert.equal(m.overflow, false)); + check(`reference-${label}-${theme}:approved-metrics`, () => { + const g: any = geometry; + if (width === 680) { + assert.equal(m.cardFont, '18px'); + assert.equal(g.flourishW, '94px'); + assert.equal(g.markerW, '64px'); + assert.equal(g.markerFont, '25px'); + assert.equal(g.footerFont, '14px'); + assert.equal(g.footerBorder, '0px'); + assert.equal(g.card.w <= 680, true); + } else { + assert.equal(m.cardFont, '16px'); + assert.equal(g.flourishW, '60px'); + assert.equal(g.markerW, '44px'); + assert.equal(g.markerFont, '20px'); + assert.equal(g.footerFont, '14px'); + assert.equal(m.question, '22px'); + } + assert.equal(g.separatorH, '24px'); + assert.equal(g.surface, theme === 'light' ? 'rgb(255, 255, 255)' : 'rgb(20, 25, 28)'); + assert.equal(g.ink, theme === 'light' ? 'rgb(23, 34, 53)' : 'rgb(238, 241, 244)'); + }); + } + } + evidence.referenceWidths = shots; + evidence.desktop = desktop; + evidence.device = device; + + // ---- Hostile Markdown: inert, no request, artwork unchanged ---------------------------------- + const guarded = await openView(); + await deliver(guarded.id, resultA); + await guarded.frame.locator('[data-testid="answer-card"]').waitFor(); + await guarded.frame.locator('.saga-card-content ol > li').first().waitFor(); + const artBefore = await artwork(guarded.frame); + const requestsBefore = requests.length; + await deliver(guarded.id, resultHostile); + await guarded.frame.getByText('Answer text containing', {exact: false}).waitFor(); + await settle(guarded.frame); + await guarded.frame.waitForTimeout(300); + const hostile = await guarded.frame.evaluate(() => ({ + images: document.images.length, pwned: (window as any).__pwned ?? null, styleCount: document.querySelectorAll('style').length, + buttons: Array.from(document.querySelectorAll('.saga-card-link')).map(b => b.textContent), + anchors: document.querySelectorAll('a[href]').length, scripts: document.querySelectorAll('.saga-card script, .saga-card style').length, + cardVisible: getComputedStyle(document.querySelector('.answer-card')!).display !== 'none', + cardText: (document.querySelector('.saga-card-content') as HTMLElement).innerText, + fontFaces: document.fonts.size, + })); + const artAfter = await artwork(guarded.frame); + check('hostile:no-image-element-no-script-no-style-no-anchor-and-card-stays', () => { + assert.equal(hostile.images, 0); + assert.equal(hostile.pwned, null); + assert.equal(hostile.anchors, 0); + assert.equal(hostile.scripts, 0); + assert.equal(hostile.cardVisible, true); + assert.equal(hostile.buttons.length, 1, 'only the https link is a control'); + assert.match(hostile.buttons[0]!, /safe link/); + }); + check('hostile:artwork-and-document-styles-unchanged-by-answer-text', () => { + assert.deepEqual(artAfter.resolved, artBefore.resolved); + assert.equal(artAfter.headerBg, artBefore.headerBg); + assert.ok(!artAfter.resolved.flourish!.includes('evil.example')); + }); + check('hostile:no-request-beyond-fixture-origins', () => { + const bad = requests.filter(r => /evil\.example/.test(r.url)); + assert.deepEqual(bad, []); + assert.equal(requests.slice(requestsBefore).filter(r => r.from === 'view').length, 0); + }); + + // ---- No runtime network from the view at all --------------------------------------------------- + check('network:view-made-no-request-except-its-own-document', () => { + const fromView = requests.filter(r => r.from === 'view' && !r.url.startsWith(`http://127.0.0.1:${ports[1]}/view`)); + assert.deepEqual(fromView, []); + }); + + // ---- An equal answer is a new selection: image consent does not carry over -------------------- + // Strict policy with one permitted image host. The document carries exactly the restrictions that + // policy derives, so the image controls are live; the fixture host answers with a 1x1 image. + const imagePolicy = createContentPolicy({level: 'strict', imageHosts: ['images.example']}); + assert.ok(imagePolicy.ok); + const PIXEL = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); + const imageRequests: string[] = []; + await context.route('https://images.example/**', (route: any) => { imageRequests.push(route.request().url()); route.fulfill({status: 200, contentType: 'image/png', body: PIXEL}); }); + const imageBytes = bytes + .replace(/()/, (_m, a, b) => `${a}${imagePolicy.policy.resource.contentSecurityPolicy}${b}`) + .replace(/(]*id="saga-content-policy"[^>]*>)[\s\S]*?(<\/script>)/, (_m, a, b) => `${a}${JSON.stringify(imagePolicy.policy.configuration).replaceAll('<', '\\u003c')}${b}`); + assert.notEqual(imageBytes, bytes, 'the image-policy fixture replaces the embedded policy and its restrictions'); + const consent = await openView(760, 900, imageBytes); + const imageAnswer = {content: [{type: 'text', text: 'ignored'}], structuredContent: {version: '0.1.0', question: 'Q image', answer: 'Image answer.\n\n![synthetic](https://images.example/a.png)\n\nThis information came from ansari.chat.'}}; + await deliver(consent.id, imageAnswer); + const toggle = consent.frame.locator('.saga-card-controls button').filter({hasText: /images$/}); + await toggle.filter({hasText: 'Show images'}).waitFor(); + const imagesBefore = await consent.frame.evaluate(() => document.images.length); + await toggle.click(); + await toggle.filter({hasText: 'Hide images'}).waitFor(); + const granted = await consent.frame.evaluate(() => ({images: document.images.length, status: document.querySelector('.saga-card-controls [role="status"]')?.textContent})); + // The same answer again, byte for byte: still a new selection, so the earlier grant ends. + await deliver(consent.id, imageAnswer); + await toggle.filter({hasText: 'Show images'}).waitFor(); + await settle(consent.frame); + const reset = await consent.frame.evaluate(() => ({images: document.images.length, status: document.querySelector('.saga-card-controls [role="status"]')?.textContent})); + check('consent:image-grant-ends-when-an-equal-answer-is-presented-again', () => { + assert.equal(imagesBefore, 0, 'nothing loads before the reader consents'); + assert.equal(granted.images, 1); + assert.equal(granted.status, 'Images shown.'); + assert.equal(reset.images, 0); + assert.equal(reset.status, ''); + assert.ok(imageRequests.length >= 1 && imageRequests.every(url => url === 'https://images.example/a.png')); + }); + + // ---- Teardown disposes the view; a late result reaches nothing ------------------------------- + const heldLink = await first.frame.locator('.saga-card-link').elementHandle(); + const opensBefore = await page.evaluate(id => (window as any).hostProbe.state(id).openLinks.length, first.id); + const ack = await page.evaluate(id => (window as any).hostProbe.teardown(id), first.id); + check('teardown:host-request-acknowledged', () => assert.deepEqual(ack, {})); + await first.frame.locator('#root >> *').first().waitFor({state: 'detached'}).catch(() => {}); + const afterTeardown = await first.frame.evaluate(() => document.getElementById('root')!.childElementCount); + check('teardown:react-tree-unmounted', () => assert.equal(afterTeardown, 0)); + const retiredFonts = await first.frame.evaluate(() => document.fonts.size); + check('teardown:owned-fonts-removed', () => assert.equal(retiredFonts, 0)); + await heldLink!.evaluate((el: HTMLElement) => el.click()).catch(() => {}); + await deliver(first.id, resultA).catch(() => {}); + await settle(first.frame); + const late = await first.frame.evaluate(() => ({children: document.getElementById('root')!.childElementCount, cards: document.querySelectorAll('.answer-card').length})); + const opensAfter = await page.evaluate(id => (window as any).hostProbe.state(id).openLinks.length, first.id); + check('teardown:late-result-and-held-link-do-nothing', () => { + assert.equal(late.children, 0); + assert.equal(late.cards, 0); + assert.equal(opensAfter, opensBefore); + }); + + const states = await page.evaluate(() => Array.from(document.querySelectorAll('iframe')).map(f => (window as any).hostProbe.state(f.id))); + check('host:no-view-initiated-tool-calls-and-no-host-errors', () => { + for (const s of states) { assert.equal(s.answerCalls, 0); assert.deepEqual(s.hostErrors, []); } + }); + check('browser:no-page-or-console-errors', () => assert.deepEqual(errors, [])); + + await page.evaluate(() => (window as any).hostProbe.close()).catch(() => {}); + await client.close(); +} + +const bounded = (work: Promise | undefined, ms = 4000) => Promise.race([work ?? Promise.resolve(), new Promise(resolve => setTimeout(resolve, ms))]).catch(() => {}); +let failure: unknown; +try { + await main(); +} catch (error) { + failure = error; + console.error(error); +} finally { + await bounded(mcpClient?.close()); + await bounded(browser?.close()); + if (service) { service.server.closeAllConnections?.(); await bounded(service.close?.()); } + if (host) await bounded(new Promise(r => (host.httpServer.closeAllConnections(), host.httpServer.close(() => r())))); + if (child) await bounded(new Promise(r => (child.closeAllConnections(), child.close(() => r())))); + fs.writeFileSync(`${out}/react-results.json`, JSON.stringify({checks, errors, ports, evidence, failed: failure === undefined ? undefined : String(failure), scope: 'built from packed SAGA packages; real MCP server and connection; sandboxed iframe in a local AppBridge fixture; local Chromium; not actual-host evidence'}, null, 2) + '\n'); +} +if (failure !== undefined) process.exit(1); +for (const port of ports) { + let refused = false; + try { await fetch(`http://127.0.0.1:${port}`, {signal: AbortSignal.timeout(1000)}); } catch { refused = true; } + assert.ok(refused, `closed port ${port}`); +} +console.log(JSON.stringify({checks: checks.length, errors, closedPorts: ports})); diff --git a/apps/mcp/test/copy-boundary.test.ts b/apps/mcp/test/copy-boundary.test.ts new file mode 100644 index 00000000..5c239bb6 --- /dev/null +++ b/apps/mcp/test/copy-boundary.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {existsSync, readFileSync, statSync} from 'node:fs'; +import {join, relative} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +// The Docker build context (the repository root) must not carry +// credentials or local runtime state. Everything here is a synthetic marker; no real secret file is read. +const root=fileURLToPath(new URL('../',import.meta.url)); +const NEVER_COPIED=['.env','.env.local','.dev.vars','.dev.vars.staging','.dev.vars.production','.wrangler/state/v3/cache.sqlite','dist-worker/index.js','node_modules/x/index.js','dist/view.html','.npmrc']; + +// A minimal Docker ignore matcher: `*` stays within a path segment, `**` crosses segments, and a +// matching parent directory excludes everything beneath it. +const toRegExp=(pattern:string)=>new RegExp('^'+pattern.replace(/[.+^${}()|[\]\\]/g,'\\$&').replace(/\*\*\/?/g,'\u0000').replace(/\*/g,'[^/]*').replace(/\u0000/g,'(?:.*/)?')+'$'); +const ignored=(patterns:string[],path:string)=>{ + const parts=path.split('/'); + let excluded=false; + for(const pattern of patterns) { + const negate=pattern.startsWith('!'); + const re=toRegExp(negate?pattern.slice(1):pattern); + if(parts.some((_,index)=>re.test(parts.slice(0,index+1).join('/')))) excluded=!negate; + } + return excluded; +}; +const patternsOf=(file:string)=>readFileSync(file,'utf8').split('\n').map(line=>line.trim()).filter(line=>line&&!line.startsWith('#')); + +test('the Docker build context excludes the Worker secret files and local state',()=>{ + const contexts:Array<[string,string[]]>=[]; + const repoIgnore=join(root,'../../.dockerignore'); + if(existsSync(repoIgnore)&&statSync(repoIgnore).isFile()) { + contexts.push([repoIgnore,[...NEVER_COPIED, ...NEVER_COPIED.map(name=>`apps/mcp/${name}`)]]); + } + for(const [file,secrets] of contexts) { + const patterns=patternsOf(file); + for(const secret of secrets) assert.ok(ignored(patterns,secret),`${relative(root,file)} excludes ${secret}`); + for(const kept of ['package.json','src/node/index.ts','wrangler.jsonc']) assert.ok(!ignored(patterns,kept),`${relative(root,file)} keeps ${kept}`); + } +}); diff --git a/apps/mcp/test/deploy-gates.test.ts b/apps/mcp/test/deploy-gates.test.ts new file mode 100644 index 00000000..84759370 --- /dev/null +++ b/apps/mcp/test/deploy-gates.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, realpathSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {test} from 'node:test'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +const gate = readFileSync(join(root, 'scripts/check-release.mjs'), 'utf8'); +const packages = ['react-mcp-stack', 'core', 'components', 'mcp', 'react']; + +// Synthetic release inputs, plus the actual deploy alias and release-check program. The fake +// pnpm bypasses registry access only; the real checker still decides whether deployment may start. +function runAlias(target: string, sdk: string) { + const repo = realpathSync(mkdtempSync(join(tmpdir(), 'ansari-deploy-gate-'))); + const app = join(repo, 'apps/mcp'); + const bin = join(repo, 'bin'); + mkdirSync(join(app, 'scripts'), {recursive: true}); + mkdirSync(join(app, 'src'), {recursive: true}); + mkdirSync(bin); + const executable = (name: string, body: string) => writeFileSync(join(bin, name), `#!/bin/sh\nset -eu\n${body}\n`, {mode: 0o755}); + try { + writeFileSync(join(app, 'package.json'), JSON.stringify({...manifest, version: '0.16.6', dependencies: {'@saga-a2ui/react-mcp-stack': sdk}})); + writeFileSync(join(app, 'src/version.ts'), "export const APP_VERSION = '0.16.6';\n"); + writeFileSync(join(app, 'scripts/check-release.mjs'), gate); + writeFileSync(join(repo, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n'); + writeFileSync(join(repo, 'pnpm-lock.yaml'), [ + "lockfileVersion: '9.0'", 'importers:', ' apps/mcp:', ' dependencies:', + " '@saga-a2ui/react-mcp-stack':", ` specifier: ${sdk}`, ` version: ${sdk}`, + 'packages:', ...packages.flatMap(name => [` '@saga-a2ui/${name}@${sdk}':`, ` resolution: {integrity: sha512-${name}fixture==}`]), '', + ].join('\n')); + executable('pnpm', `case "$1" in + check:release) shift; exec "$DEPLOY_TEST_NODE" "$DEPLOY_TEST_APP/scripts/check-release.mjs" "$@" --no-registry ;; + build:worker) printf 'build\\n' >> "$DEPLOY_TEST_LOG" ;; + *) exit 90 ;; +esac`); + executable('wrangler', `printf 'wrangler %s\\n' "$*" >> "$DEPLOY_TEST_LOG"`); + executable('git', "printf '0123456789abcdef0123456789abcdef01234567\\n'"); + const log = join(repo, 'calls.log'); + writeFileSync(log, ''); + const result = spawnSync('sh', ['-c', manifest.scripts[`deploy:worker:${target}`]], { + cwd: app, encoding: 'utf8', env: {...process.env, PATH: `${bin}:${process.env.PATH}`, + DEPLOY_TEST_NODE: process.execPath, DEPLOY_TEST_APP: app, DEPLOY_TEST_LOG: log}, + }); + return {status: result.status, output: result.stdout + result.stderr, calls: readFileSync(log, 'utf8')}; + } finally { rmSync(repo, {recursive: true, force: true}); } +} + +for (const target of ['beta', 'production']) { + test(`${target} deploy alias stops before build or Wrangler when its release gate fails`, () => { + const invalid = target === 'production' ? '0.7.6-rc.1' : '0.0.0-candidate.gdeadbeef1234'; + const result = runAlias(target, invalid); + assert.notEqual(result.status, 0); + assert.match(result.output, target === 'production' ? /prerelease/ : /private SAGA candidate/); + assert.equal(result.calls, ''); + }); + test(`${target} deploy alias checks inputs then reaches only the fake build and deploy`, () => { + const result = runAlias(target, target === 'beta' ? '0.7.6-rc.1' : '0.7.6'); + assert.equal(result.status, 0, result.output); + assert.match(result.output, new RegExp(`Release inputs for ${target}`)); + assert.match(result.calls, new RegExp(`^build\\nwrangler deploy --env ${target} --var ANSARI_RELEASE:0123456789abcdef0123456789abcdef01234567\\n$`)); + }); +} diff --git a/apps/mcp/test/envelope.test.ts b/apps/mcp/test/envelope.test.ts new file mode 100644 index 00000000..7e638f19 --- /dev/null +++ b/apps/mcp/test/envelope.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {TOOL_UNAVAILABLE_TEXT} from '../src/server/create-server.js'; +import {BRANDING} from '../src/view/branding.js'; +import {readAnswerEnvelope} from '../src/view/envelope.js'; +import {classifyCardResult, UNSUPPORTED_RESULT_TEXT} from '../src/view/tool-result.js'; + +// What the definition's one reader makes of a result that carries only this structured content. +const readCardContent = (structuredContent: unknown) => { + const outcome = classifyCardResult({structuredContent}); + return outcome.kind === 'content' ? outcome.content : undefined; +}; + +test('structured results require the current envelope, without legacy acceptance', () => { + for (const version of [undefined, '0.0.1', '9.9.9']) { + assert.equal(readCardContent({version, question: 'q', answer: 'a'}), undefined); + assert.deepEqual(classifyCardResult({structuredContent: {version, question: 'q', answer: 'a'}}), {kind: 'rejected', text: UNSUPPORTED_RESULT_TEXT}); + } + assert.deepEqual(readCardContent({version: '0.1.0', question: 'q', answer: 'complete answer\n'}), {heading: 'q', markdown: 'complete answer\n'}); + assert.deepEqual(readAnswerEnvelope({version: '0.1.0', question: '', answer: 'a'}), {question: '', answer: 'a'}); +}); + +test('anything that is not the envelope is unsupported, and nothing is repaired', () => { + for (const value of [undefined, null, 'text', 3, [], {}, {version: '0.1.0', question: 'q'}, {version: '0.1.0', question: 'q', answer: ''}, {version: '0.1.0', question: 1, answer: 'a'}, {version: '0.1.0', answer: 'a'}]) { + assert.equal(readCardContent(value), undefined, JSON.stringify(value)); + } +}); + +test('the card is shown the answer bytes exactly as sent', () => { + const answer = ' leading and trailing space \n\n3. numbered\n\nالإجابة\n'; + assert.equal(readCardContent({version: '0.1.0', question: 'q', answer})?.markdown, answer); +}); + +test('the one problem sentence is the tool\'s own safe failure text', () => { + assert.equal(BRANDING.problemText, TOOL_UNAVAILABLE_TEXT); +}); diff --git a/apps/mcp/test/environments.test.ts b/apps/mcp/test/environments.test.ts new file mode 100644 index 00000000..b2db0544 --- /dev/null +++ b/apps/mcp/test/environments.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import {spawn, spawnSync, type ChildProcess} from 'node:child_process'; +import {once} from 'node:events'; +import {mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs'; +import {request as httpRequest} from 'node:http'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, it} from 'node:test'; +import {freePort, startUpstream, waitFor} from './runtime-harness.ts'; + +// Wrangler's named environments (wrangler.jsonc) are the only declaration of the Worker targets. These tests cover the +// two ways a real defect could cross an environment boundary: selecting no environment, and a secret falling back from +// another one. They make no claim about a deployment, a Cloudflare account or a host connection. +const ENVIRONMENTS = ['dev', 'beta', 'production'] as const; +const root = fileURLToPath(new URL('../', import.meta.url)); +const wrangler = join(root, 'node_modules/.bin/wrangler'); +const scripts: Record = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts; + +type WorkerConfig = {name?: string; main?: string; workers_dev?: boolean; preview_urls?: boolean; routes?: unknown[]; vars?: Record}; +/** Wrangler's own reading of wrangler.jsonc for one environment (comments and inheritance included). */ +async function readWorker(env: string | undefined): Promise { + // Loaded by a variable name so this project's strict typecheck does not compile Wrangler's own declaration files. + const specifier = 'wrangler'; + const {unstable_readConfig: readConfig} = await import(specifier) as {unstable_readConfig: (args: {config: string; env?: string}, options: {hideWarnings: boolean}) => WorkerConfig}; + return readConfig({config: join(root, 'wrangler.jsonc'), env}, {hideWarnings: true}); +} + +describe('Worker environments', () => { + it('are three distinct named environments, and a bare deploy has no destination', async () => { + const workers = await Promise.all(ENVIRONMENTS.map(readWorker)); + for (const field of [(w: WorkerConfig) => w.name, (w: WorkerConfig) => w.vars?.ANSARI_CLIENT, (w: WorkerConfig) => w.vars?.MCP_ALLOWED_HOSTS]) { + assert.equal(new Set(workers.map(field)).size, 3, 'name, client label and allowed host all differ'); + } + // The existing Dev Worker keeps its identity. + assert.equal(workers[0]!.name, 'ansari-mcp-dev'); + assert.equal(workers[0]!.vars?.MCP_ALLOWED_HOSTS, 'ansari-mcp-dev.younes-b2d.workers.dev'); + assert.equal(workers[0]!.vars?.ANSARI_CLIENT, 'ansari-mcp-dev'); + for (const worker of workers) { + assert.ok(worker.main, 'every environment declares its entry point'); + assert.equal(worker.vars?.ANSARI_API_AUTHORIZATION, undefined, 'a secret is never a plain var'); + } + const top = await readWorker(undefined); + assert.equal(top.main, undefined); + assert.deepEqual(top.vars ?? {}, {}); + assert.deepEqual(top.routes ?? [], []); + const bare = spawnSync(wrangler, ['deploy', '--dry-run', '--outdir', mkdtempSync(join(tmpdir(), 'ansari-bare-'))], {cwd: root, encoding: 'utf8', env: {...process.env, WRANGLER_SEND_METRICS: 'false'}}); + assert.notEqual(bare.status, 0, 'wrangler deploy without --env must not succeed'); + }); + + it('keep version preview URLs off, declared explicitly in each environment', async () => { + // Wrangler 4.151.3 resolves an omitted `preview_urls` to undefined and then leaves the Worker's current setting alone, + // which switched them on at a Dev deploy. Only a literal `false` is acceptable, and `workers_dev` stays independent. + const workers = await Promise.all(ENVIRONMENTS.map(readWorker)); + ENVIRONMENTS.forEach((env, index) => assert.strictEqual(workers[index]!.preview_urls, false, `${env} resolves preview_urls to false`)); + assert.deepEqual(workers.map(worker => worker.workers_dev), [true, false, false], 'only Dev keeps its workers.dev endpoint'); + // Resolved values alone would also pass if the top level declared the setting and the blocks merely inherited it. The file + // holds only whole-line comments, so dropping them leaves plain JSON. + const declared = JSON.parse(readFileSync(join(root, 'wrangler.jsonc'), 'utf8').split('\n').filter(line => !/^\s*\/\//.test(line)).join('\n')) as {preview_urls?: unknown; env: Record}; + assert.equal(declared.preview_urls, undefined, 'there is no top-level Worker setting'); + for (const env of ENVIRONMENTS) assert.strictEqual(declared.env[env]!.preview_urls, false, `${env} declares preview_urls: false itself`); + assert.deepEqual(Object.keys(declared.env).sort(), [...ENVIRONMENTS].sort(), 'no other environment escapes the check'); + }); + + it('are selected by name in every package script, with the exact revision stamped on a deploy', () => { + assert.ok(!('deploy' in scripts) && !('dev:worker' in scripts), 'no script runs Wrangler without an environment'); + for (const env of ENVIRONMENTS) { + const deploy = scripts[`deploy:worker:${env}`]; + assert.ok(deploy, `deploy:worker:${env} exists`); + assert.match(deploy, new RegExp(`wrangler deploy --env ${env}\\b`)); + assert.match(deploy, /--var ANSARI_RELEASE:\$\(git rev-parse HEAD\)/, 'the release is the exact app revision, never the version-only default'); + assert.equal(scripts[`dev:worker:${env}`], `pnpm guard:dev-vars && pnpm build:worker && CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false wrangler dev --env ${env}`, 'the documented local launch'); + } + assert.deepEqual(Object.keys(scripts).filter(name => name.startsWith('deploy:')).sort(), ENVIRONMENTS.map(env => `deploy:worker:${env}`).sort()); + }); + + it('keep the application source free of any environment name', () => { + const walk = (dir: string): string[] => readdirSync(dir).flatMap(name => (statSync(join(dir, name)).isDirectory() ? walk(join(dir, name)) : [join(dir, name)])); + for (const file of walk(join(root, 'src')).filter(name => /\.(ts|tsx)$/.test(name))) { + assert.doesNotMatch(readFileSync(file, 'utf8'), /\b(?:beta|production)\b/i, `${file} selects no environment`); + } + }); +}); + +describe('local Workers runtime, launched as documented', () => { + it('refuses a generic .dev.vars, which Wrangler would read for every environment', () => { + const guard = (dir: string) => spawnSync('sh', ['-c', scripts['guard:dev-vars']!], {cwd: dir, encoding: 'utf8'}); + const dir = mkdtempSync(join(tmpdir(), 'ansari-guard-')); + try { + writeFileSync(join(dir, '.dev.vars.beta'), 'ANSARI_API_AUTHORIZATION=synthetic\n'); + assert.equal(guard(dir).status, 0, 'an environment-specific file is accepted'); + writeFileSync(join(dir, '.dev.vars'), 'ANSARI_API_AUTHORIZATION=synthetic\n'); + const refused = guard(dir); + assert.equal(refused.status, 1); + assert.match(refused.stderr, /generic \.dev\.vars/); + } finally { rmSync(dir, {recursive: true, force: true}); } + for (const env of ENVIRONMENTS) assert.match(scripts[`dev:worker:${env}`]!, /^pnpm guard:dev-vars && /, `dev:worker:${env} runs the guard first`); + }); + + type Started = {origin: string; close(): Promise}; + /** + * The real wrangler.jsonc (only its entry path made absolute), launched with the setting and arguments of the package + * script `dev:worker:`, beside `.env` files that no environment may inherit and the given `.dev.vars.` files. + * (A generic `.dev.vars` never reaches Wrangler: the guard that starts the same script refuses it, tested above.) + */ + async function startEnvironment(env: string, upstream: string, localSecrets: Record): Promise { + const dir = mkdtempSync(join(tmpdir(), 'ansari-env-')); + // `wrangler dev` rewrites the Host header of a request to the hostname of a configured route, which would hide the Host + // check, so the copy leaves out the `routes` lines. + const declared = readFileSync(join(root, 'wrangler.jsonc'), 'utf8').replaceAll('"src/worker/index.ts"', JSON.stringify(`${root}src/worker/index.ts`)); + writeFileSync(join(dir, 'wrangler.jsonc'), declared.split('\n').filter(line => !/^\s*"routes":/.test(line)).join('\n')); + const generic = 'ANSARI_API_AUTHORIZATION=Bearer generic-must-not-leak\n'; + for (const name of ['.env', '.env.local', `.env.${env}`]) writeFileSync(join(dir, name), generic); + for (const [name, value] of Object.entries(localSecrets)) writeFileSync(join(dir, name), `ANSARI_API_AUTHORIZATION=${value}\n`); + const launch = (scripts[`dev:worker:${env}`] ?? '').split('&&').pop()!.trim().split(/\s+/); + const settings = Object.fromEntries(launch.filter(word => /^[A-Z_]+=/.test(word)).map(word => word.split('=') as [string, string])); + launch.splice(0, Object.keys(settings).length); + assert.deepEqual(launch.slice(0, 2), ['wrangler', 'dev'], `dev:worker:${env} launches wrangler dev`); + const [port, inspectorPort] = [await freePort(), await freePort()]; + const logs: string[] = []; + const child: ChildProcess = spawn(wrangler, [...launch.slice(1), '--config', join(dir, 'wrangler.jsonc'), '--ip', '127.0.0.1', '--port', String(port), '--inspector-port', String(inspectorPort), + '--persist-to', join(dir, 'state'), '--var', `ANSARI_API_BASE_URL:${upstream}`], {cwd: dir, env: {...process.env, ...settings, WRANGLER_SEND_METRICS: 'false'}, stdio: ['ignore', 'pipe', 'pipe'], detached: true}); + await waitFor(child, logs, line => line.includes('Ready on'), `the ${env} Worker`, 90_000); + let exited = false; + child.once('exit', () => { exited = true; }); + return { + origin: `http://127.0.0.1:${port}`, + async close() { + if (!exited) { + const done = once(child, 'exit'); + try { process.kill(-child.pid!, 'SIGTERM'); } catch { child.kill('SIGTERM'); } + const timer = setTimeout(() => { try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* gone */ } }, 10_000); + await done; clearTimeout(timer); + } + rmSync(dir, {recursive: true, force: true}); + }, + }; + } + + const call = (origin: string, host: string, question: string) => new Promise<{status: number | undefined; text: string}>((resolve, reject) => { + const body = JSON.stringify({jsonrpc: '2.0', id: 1, method: 'tools/call', params: {name: 'answer_islamic_question', arguments: {question}}}); + const req = httpRequest(`${origin}/mcp`, {method: 'POST', headers: {host, 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'content-length': Buffer.byteLength(body)}}, res => { + let text = ''; res.setEncoding('utf8'); res.on('data', chunk => text += chunk); res.on('end', () => resolve({status: res.statusCode, text})); + }); + req.on('error', reject); req.end(body); + }); + + it('serves each environment under its own host and client label, and only its own .dev.vars. secret', async () => { + const workers = Object.fromEntries(await Promise.all(ENVIRONMENTS.map(async env => [env, await readWorker(env)] as const))); + const upstream = await startUpstream(); + try { + for (const env of ENVIRONMENTS) { + // Only Beta has an environment-specific secret file. Dev and Production must see neither it nor the generic + // `.env` files, which Wrangler would otherwise fall back to. + const runtime = await startEnvironment(env, upstream.url, {'.dev.vars.beta': 'Bearer beta-only-synthetic'}); + try { + const before = upstream.seen.length; + const host = workers[env]!.vars!.MCP_ALLOWED_HOSTS!; + const own = await call(runtime.origin, host, `question for ${env}`); + assert.equal(own.status, 200, `${env} answers on its own host`); + assert.match(own.text, new RegExp(`Synthetic answer to question for ${env}`)); + assert.equal(upstream.seen.length, before + 1); + const sent = upstream.seen.at(-1)!.headers; + assert.equal(sent['x-ansari-client'], workers[env]!.vars!.ANSARI_CLIENT); + assert.equal(sent.authorization, env === 'beta' ? 'Bearer beta-only-synthetic' : undefined, `${env} upstream authorization`); + for (const other of ENVIRONMENTS.filter(candidate => candidate !== env)) { + const refused = await call(runtime.origin, workers[other]!.vars!.MCP_ALLOWED_HOSTS!, 'refused'); + assert.equal(refused.status, 403, `${env} refuses the ${other} host`); + } + assert.equal(upstream.seen.length, before + 1, 'a refused host never reaches Ansari'); + assert.equal((await fetch(`${runtime.origin}/health`)).status, 200, 'localhost stays allowed'); + } finally { await runtime.close(); } + } + } finally { await upstream.close(); } + }); +}); diff --git a/apps/mcp/test/fixtures/closed-subscription.ts b/apps/mcp/test/fixtures/closed-subscription.ts new file mode 100644 index 00000000..89300139 --- /dev/null +++ b/apps/mcp/test/fixtures/closed-subscription.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import {request} from 'node:http'; +import {listen} from '../../src/node/http-server.ts'; + +// This modern path disposes its MCP server before connecting a transport. +// With no live HTTP sockets, the process must also release the request timer. +const service = await listen({ + host:'127.0.0.1', port:0, html:'synthetic', + logger:()=>{}, completer:{async complete(){throw new Error('not a tool call');}}, +}); +try { + const body = JSON.stringify({jsonrpc:'2.0',id:1,method:'subscriptions/listen',params:{ + notifications:{},_meta:{ + 'io.modelcontextprotocol/protocolVersion':'2026-07-28', + 'io.modelcontextprotocol/clientInfo':{name:'cleanup-test',version:'1'}, + 'io.modelcontextprotocol/clientCapabilities':{}, + }, + }}); + const response = await new Promise((resolve,reject)=>{ + const req = request(`http://127.0.0.1:${service.port}/mcp`,{ + method:'POST',agent:false,headers:{ + 'content-type':'application/json',accept:'application/json, text/event-stream', + 'MCP-Protocol-Version':'2026-07-28','Mcp-Method':'subscriptions/listen', + }, + },res=>{ + assert.equal(res.statusCode,200); + let text='';res.setEncoding('utf8');res.on('data',chunk=>text+=chunk); + res.on('end',()=>resolve(text));res.on('error',reject); + }); + req.on('error',reject);req.end(body); + }); + assert.match(response,/notifications\/subscriptions\/acknowledged/); + assert.match(response,/"resultType":"complete"/); +} finally {await service.close();} +console.log('exchange and listener closed'); diff --git a/apps/mcp/test/fixtures/server-close.ts b/apps/mcp/test/fixtures/server-close.ts new file mode 100644 index 00000000..4c71fb75 --- /dev/null +++ b/apps/mcp/test/fixtures/server-close.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import {createAnsariMcpServer} from '../../src/server/create-server.ts'; + +// Each factory call builds its own server and request lifetime. Whatever way a server ends, its request +// timer must be released, or this process stays alive until the 60 s request budget: the parent test +// gives it a few seconds to exit on its own. +const make = () => createAnsariMcpServer({ + completer: {async complete() { throw new Error('not a tool call'); }}, + view: {uri: 'ui://views/answer-card.00000000000000000000000000000000.html', title: 'synthetic', html: '', csp: {connectDomains: [], resourceDomains: [], frameDomains: [], baseUriDomains: []}}, + logger: () => {}, +}); +type Closing = {start(): Promise; send(): Promise; close(): Promise; onclose?: () => void}; +const transport = (closeError?: Error): Closing => { + const t: Closing = {async start() {}, async send() {}, async close() { t.onclose?.(); if (closeError) throw closeError; }}; + return t; +}; + +// 1. Disposed before any transport was connected, then closed again: the repeated close is harmless. +const unconnected = make(); +await unconnected.close(); +await unconnected.close(); + +// 2. Connected, then closed through its transport and again through the server. +const connected = make(); +await connected.connect(transport() as never); +await connected.close(); +await connected.close(); + +// 3. Closing fails: the lifetime still ends and the failure is the caller's to see. +const failing = make(); +await failing.connect(transport(new Error('transport close failed')) as never); +await assert.rejects(failing.close(), /transport close failed/); + +console.log('servers closed'); diff --git a/apps/mcp/test/host/host.js b/apps/mcp/test/host/host.js new file mode 100644 index 00000000..fa65620f --- /dev/null +++ b/apps/mcp/test/host/host.js @@ -0,0 +1,26 @@ +import {AppBridge} from '@modelcontextprotocol/ext-apps/app-bridge'; +import {PostMessageTransport} from '@modelcontextprotocol/ext-apps'; +// Local host fixture. `connect` selects when the host installs its listener relative to loading the view: +// 'before' (default, also `true`) AppBridge connected before iframe.src is set +// 'after' / false iframe.src is set first; call connectNow(id) later +// 'reject' host answers ui/initialize with a JSON-RPC error +// 'never' host never answers ui/initialize +// `stats.openLinks` records URLs the view asked the host to open; `stats.openLinkMode` ('ok' | 'deny') chooses the answer. +// `sandbox` (string) sets the iframe sandbox attribute. `seen` records only JSON-RPC method names posted by the view. +const views=new Map();window.hostProbe={ + async create(id,url,connect=true,sandbox=null,openLinks=true){const mode=connect===true?'before':connect===false?'after':connect;const iframe=document.createElement('iframe');iframe.id=id;iframe.style.cssText='width:760px;height:900px';if(sandbox!==null)iframe.setAttribute('sandbox',sandbox);document.body.append(iframe);const stats={mode,openLinks:[],openLinkMode:'ok',initialized:false,loaded:false,answerCalls:0,hostErrors:[],sizeChanges:[],seen:[],rejected:0};const bridge=new AppBridge(null,{name:'Local cross-origin host',version:'0.0.0'},{serverTools:{},...(openLinks?{openLinks:{}}:{})},{hostContext:{theme:'light'}});bridge.onsizechange=p=>stats.sizeChanges.push(p);bridge.onopenlink=async p=>{stats.openLinks.push(p.url);return stats.openLinkMode==='deny'?{isError:true}:{};};bridge.oninitialized=()=>{stats.initialized=true;};bridge.oncalltool=async()=>{stats.answerCalls++;return {content:[]};};bridge.onerror=e=>stats.hostErrors.push(String(e)); + const entry={iframe,bridge,stats,connected:false,listener:undefined};views.set(id,entry); + entry.listener=ev=>{if(ev.source!==iframe.contentWindow)return;const d=ev.data;if(d&&d.jsonrpc==='2.0'&&typeof d.method==='string'){stats.seen.push(d.method);if(mode==='reject'&&d.method==='ui/initialize'&&d.id!==undefined){stats.rejected++;iframe.contentWindow.postMessage({jsonrpc:'2.0',id:d.id,error:{code:-32603,message:'synthetic host rejection'}},'*');}}}; + window.addEventListener('message',entry.listener);iframe.addEventListener('load',()=>{stats.loaded=true;},{once:true}); + if(mode==='before')await window.hostProbe.connectNow(id);iframe.src=url;}, + async connectNow(id){const v=views.get(id);if(v.connected)return;v.connected=true;await v.bridge.connect(new PostMessageTransport(v.iframe.contentWindow,v.iframe.contentWindow));}, + async input(id){await views.get(id).bridge.sendToolInput({arguments:{question:'local probe'}});}, + async result(id,result){await views.get(id).bridge.sendToolResult(result);}, + async theme(id,theme){await views.get(id).bridge.sendHostContextChange({theme});}, + async hostContext(id,context){await views.get(id).bridge.sendHostContextChange(context);}, + resize(id,width,height){const iframe=views.get(id).iframe;iframe.style.width=width+'px';if(height!==undefined)iframe.style.height=height+'px';}, + teardown(id){return views.get(id).bridge.teardownResource({},{timeout:1200});}, + state(id){return views.get(id).stats;}, + remove(id){const v=views.get(id);window.removeEventListener('message',v.listener);v.iframe.remove();}, + async close(){for(const v of views.values()){window.removeEventListener('message',v.listener);await v.bridge.close().catch(()=>{});v.iframe.remove();}} +}; diff --git a/apps/mcp/test/host/index.html b/apps/mcp/test/host/index.html new file mode 100644 index 00000000..e007854a --- /dev/null +++ b/apps/mcp/test/host/index.html @@ -0,0 +1 @@ + diff --git a/apps/mcp/test/lifecycle.test.ts b/apps/mcp/test/lifecycle.test.ts new file mode 100644 index 00000000..ec33e0af --- /dev/null +++ b/apps/mcp/test/lifecycle.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import {execFile,spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {createServer} from 'node:http'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import {test} from 'node:test'; + +test('a completed subscription exchange releases timers even when its MCP server was never connected',async()=>{ + const fixture=fileURLToPath(new URL('./fixtures/closed-subscription.ts',import.meta.url)); + const result=await new Promise<{error:Error|null;stdout:string;stderr:string}>(resolve=>{ + execFile(process.execPath,['--import','tsx',fixture],{timeout:2500},(error,stdout,stderr)=>resolve({error,stdout,stderr})); + }); + assert.match(result.stdout,/exchange and listener closed/,'the HTTP exchange itself must finish'); + assert.equal(result.error,null,`a disposed request still keeps the process alive: ${result.error?.message}\n${result.stderr}`); +}); + +for(const modern of [true,false]) test(`SIGTERM exits with an in-flight ${modern?'modern':'older-host'} SSE tool request`,async()=>{ + let started!:()=>void; + const sourceStarted=new Promise(resolve=>{started=resolve;}); + // A real upstream HTTP request that deliberately never sends an answer. + const upstream=createServer(async(req)=>{ + for await(const _ of req) { /* consume the request before marking the work started */ } + started(); + }); + await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve)); + const address=upstream.address();assert.ok(address&&typeof address==='object'); + const child=spawn(process.execPath,[fileURLToPath(new URL('../dist/server/index.js',import.meta.url))],{ + env:{...process.env,PORT:'0',MCP_RESPONSE_MODE:'sse',ANSARI_API_BASE_URL:`http://127.0.0.1:${address.port}`}, + stdio:['ignore','pipe','pipe'], + }); + let stderr='';child.stderr.on('data',chunk=>stderr+=chunk); + const lines=createInterface({input:child.stdout}); + const controller=new AbortController(); + let pending:Promise|undefined; + try { + const startup=await Promise.race([ + (async()=>{for await(const line of lines){const log=JSON.parse(line);if(log.event==='startup')return log.port as number;}throw new Error(`server did not start: ${stderr}`);})(), + new Promise((_,reject)=>{AbortSignal.timeout(3000).addEventListener('abort',()=>reject(new Error('startup timed out')),{once:true});}), + ]); + pending=fetch(`http://127.0.0.1:${startup}/mcp`,{ + method:'POST',signal:controller.signal, + headers:{'content-type':'application/json',accept:'application/json, text/event-stream',...(modern?{'MCP-Protocol-Version':'2026-07-28','Mcp-Method':'tools/call','Mcp-Name':'answer_islamic_question'}:{})}, + body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name:'answer_islamic_question',arguments:{question:'synthetic'},_meta:{progressToken:'shutdown',...(modern?{ + 'io.modelcontextprotocol/protocolVersion':'2026-07-28','io.modelcontextprotocol/clientInfo':{name:'shutdown-test',version:'1'},'io.modelcontextprotocol/clientCapabilities':{}, + }:{})}}}), + }).then(response=>response.text()).catch(()=>undefined); + await Promise.race([ + sourceStarted, + once(child,'exit').then(()=>{throw new Error(`server exited before calling upstream: ${stderr}`);}), + new Promise((_,reject)=>{AbortSignal.timeout(3000).addEventListener('abort',()=>reject(new Error(`upstream call did not start: ${stderr}`)),{once:true});}), + ]); + const exit=once(child,'exit',{signal:AbortSignal.timeout(2000)}); + child.kill('SIGTERM'); + const [code,signal]=await exit; + assert.equal(code,0,stderr);assert.equal(signal,null); + } finally { + controller.abort();lines.close(); + if(child.exitCode===null&&child.signalCode===null){const exit=once(child,'exit');child.kill('SIGKILL');await exit;} + await pending; + const stopped=new Promise(resolve=>upstream.close(()=>resolve()));upstream.closeAllConnections();await stopped; + } +}); + +test('every way a per-request MCP server ends releases its request timer',async()=>{ + const fixture=fileURLToPath(new URL('./fixtures/server-close.ts',import.meta.url)); + const result=await new Promise<{error:Error|null;stdout:string;stderr:string}>(resolve=>{ + execFile(process.execPath,['--import','tsx',fixture],{timeout:2500},(error,stdout,stderr)=>resolve({error,stdout,stderr})); + }); + assert.match(result.stdout,/servers closed/,result.stderr); + assert.equal(result.error,null,`a closed server still keeps the process alive: ${result.error?.message}\n${result.stderr}`); +}); diff --git a/apps/mcp/test/operation-log.test.ts b/apps/mcp/test/operation-log.test.ts new file mode 100644 index 00000000..3e012c88 --- /dev/null +++ b/apps/mcp/test/operation-log.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + DEFAULT_RELEASE, + stampHtmlRevision, + writeOperationLog, +} from '../src/server/operation-log.ts'; +import { answerFingerprint } from '../src/server/answer-fingerprint.ts'; + +describe('operation log', () => { + it('stamps a data-revision on the HTML body', () => { + const html = stampHtmlRevision('x', 'abc123'); + assert.match(html, /data-revision="abc123"/); + }); + + it('omits questions, answers, and session tokens', () => { + const records: unknown[] = []; + writeOperationLog( + { + event: 'tool.start', + method: 'tools/call', + requestId: 10, + question: 'should-not-appear', + answer: 'should-not-appear', + sessionId: 'raw-session-token', + }, + (record) => records.push(record), + ); + const serialized = JSON.stringify(records[0]); + assert.equal(serialized.includes('should-not-appear'), false); + assert.equal(serialized.includes('raw-session-token'), false); + assert.match(serialized, /"event":"tool.start"/); + assert.match(serialized, new RegExp(`"release":"${DEFAULT_RELEASE}"`)); + }); + + it('fingerprints answer bytes without retaining the text', () => { + const text = 'سلام\nattribution'; + const fingerprint = answerFingerprint(text); + assert.equal(fingerprint.answerBytes, Buffer.byteLength(text, 'utf8')); + assert.equal(fingerprint.answerSha256.length, 64); + assert.equal(JSON.stringify(fingerprint).includes(text), false); + }); +}); diff --git a/apps/mcp/test/packaging.test.ts b/apps/mcp/test/packaging.test.ts new file mode 100644 index 00000000..6aa9cb12 --- /dev/null +++ b/apps/mcp/test/packaging.test.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {execFileSync} from 'node:child_process'; +import {existsSync, readFileSync, readdirSync, realpathSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const root=fileURLToPath(new URL('../',import.meta.url)); +test('packaged HTML is self-contained and assets contain bytes',()=>{ + const html=readFileSync(join(root,'dist/view.html'),'utf8'); + assert.ok(html.includes('data:image/')); + for (const name of ['Kitab-Regular.ttf', 'Kitab-Bold.ttf']) { + const bytes=readFileSync(join(root,'assets/fonts',name)); + assert.equal(bytes.readUInt32BE(0),0x00010000, 'actual TrueType bytes'); + assert.ok(html.includes(bytes.toString('base64')), `${name} is embedded verbatim`); + } + assert.ok(!html.includes('version https://git-lfs.github.com/spec/v1')); + assert.doesNotMatch(html,/]+src=/i); + assert.doesNotMatch(html,/]+href=["'][^"']+\.css/i); + assert.doesNotMatch(html,/\{\{brand\.|\{\{release\}\}|\{\{sdk\./); + assert.deepEqual(readdirSync(join(root,'dist')).sort(),['server','view.html']); + for(const name of ['answer-assets.ts','brand-mark.ts']) { + const source=readFileSync(join(root,'assets',name),'utf8'); + assert.ok(!source.startsWith('version https://git-lfs.github.com/spec/v1')); + } +}); + +// ---- The SAGA packages: one exact public npm release, never a tag, range, tarball or local file. +// The expected values below were read from the public npm registry (`npm view @saga-a2ui/@0.7.6 dist.integrity`), +// not from the files they are compared with. +const SDK_VERSION='0.7.6'; +const SDK_INTEGRITY:Record={ + core:'sha512-m+ynUS2+f36exYwqimAnnjxtVKJ1+Xm2Zscja+kWI9iFoDxVtT6C2lYxO6exSSXyi4LreJMgFhQsKh0hsaMVDg==', + components:'sha512-Gpm2uc2VbXNrxfWNy2uHfdJYdzNf5ewZKTpsaO5nsLSF0ymDjwzJ45h0BiVD77zyO4KXmDb2QipuCraTbqFApQ==', + mcp:'sha512-LBBOUhhagKZszb+Y6ooZSNDvJs3GZ6EE+Ysk8gsSVkxWrGRdcg5j3GgY/jcYF8K1NzEFQw75rm1KhM+C210ZAA==', + react:'sha512-6oz4GAWZzLjsysHSPKw6zOqYFF5D+bAcp9CtGJ5Exc/9Z+x2OM4Sn7oHMdul47iDMEJi9WNbrI5+DSGvii1a3g==', + 'react-mcp-stack':'sha512-ZYv9uL9FAjVRrfboabT6YRu6zHO8JOQKXc9D2pYskpacCRpezsVrQpNtjbh2TUDOdaY+SYEDIAl8+Js1U0oJqQ==', +}; +const SIBLINGS=Object.keys(SDK_INTEGRITY); +const pinned:string=JSON.parse(readFileSync(join(root,'package.json'),'utf8')).dependencies['@saga-a2ui/react-mcp-stack']; +const node=(...args:string[])=>execFileSync(process.execPath,args,{cwd:root,encoding:'utf8'}); + +test('the manifest pins one exact public version for every SAGA package it imports',()=>{ + const manifest=JSON.parse(readFileSync(join(root,'package.json'),'utf8')); + const saga=Object.entries({...manifest.dependencies,...manifest.devDependencies}).filter(([name])=>name.startsWith('@saga-a2ui/')); + assert.deepEqual(saga.map(([name])=>name).sort(),['@saga-a2ui/react-mcp-stack']); + for(const [name,version] of saga) { + assert.match(version,/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/,`${name} is an exact version, never a tag or range`); + assert.equal(version,SDK_VERSION,name); + } + assert.equal(manifest.devDependencies.typescript, 'catalog:'); + assert.equal(manifest.devDependencies.eslint, 'catalog:'); + assert.equal(manifest.devDependencies['@ansari/eslint-config'], 'workspace:*'); + assert.ok(!/"(?:file|link|workspace|git\+?[a-z]*|https?):/.test(JSON.stringify({...manifest.dependencies,overrides:manifest.overrides,pnpm:manifest.pnpm})),'no local, tarball or workspace dependency source'); +}); + +// The lockfile that installs this application: the workspace root's. The application has none of its own (asserted below). +const lockfiles:Array<{label:string;text:string;prefix:string}>=[]; +assert.equal(existsSync(join(root,'pnpm-lock.yaml')), false, 'only the root lockfile installs the app'); +assert.equal(existsSync(join(root,'pnpm-workspace.yaml')), false, 'only the root workspace config applies'); +const workspaceLock=join(root,'../../pnpm-lock.yaml'); +if(existsSync(workspaceLock)&&existsSync(join(root,'../../pnpm-workspace.yaml'))) lockfiles.push({label:'workspace root',text:readFileSync(workspaceLock,'utf8'),prefix:'apps/mcp'}); + +test('every lockfile resolves the five SAGA packages to the public registry with the registry integrity',()=>{ + assert.ok(lockfiles.length>0,'at least one lockfile installs this application'); + for(const {label,text} of lockfiles) { + for(const sibling of SIBLINGS) { + const key=`'@saga-a2ui/${sibling}@${SDK_VERSION}':\n resolution: {integrity: ${SDK_INTEGRITY[sibling]}}`; + assert.ok(text.includes(key),`${label} lockfile locks ${sibling} to the registry release with its integrity`); + } + // Nothing else from @saga-a2ui, no second SAGA version, and no local source of any kind for it. + const resolved=[...new Set([...text.matchAll(/^ {2}'(@saga-a2ui\/[a-z-]+)@([^':(]+)':$/gm)].map(match=>`${match[1]}@${match[2]}`))].sort(); + assert.deepEqual(resolved,SIBLINGS.map(sibling=>`@saga-a2ui/${sibling}@${SDK_VERSION}`).sort(),`${label} lockfile: exactly the five packages at one version`); + assert.doesNotMatch(text,/@saga-a2ui\/[a-z-]+@file:|tarball: file:|@saga-a2ui\/lit/,`${label} lockfile: no local file or Lit package`); + assert.doesNotMatch(text,/(?:\/tmp\/|\/opt\/|\/Users\/|\/home\/|[A-Za-z]:\\)/,`${label} lockfile records no machine path`); + } +}); + +test('no workspace file overrides a SAGA package',()=>{ + const files=[join(root,'pnpm-workspace.yaml'),join(root,'../../pnpm-workspace.yaml')].filter(file=>existsSync(file)); + assert.ok(files.length>0); + for(const file of files) { + const workspace=readFileSync(file,'utf8'); + assert.doesNotMatch(workspace,/@saga-a2ui/,`${file} does not override or mention a SAGA package`); + assert.doesNotMatch(workspace,/file:|link:/,`${file} has no local-file dependency source`); + assert.match(workspace,/['"]@a2ui\/react@0\.13\.0>@a2ui\/markdown-it['"]\s*:\s*['"]0\.2\.1['"]/,`${file} pins the A2UI Markdown adapter`); + } + assert.equal(existsSync(join(root,'candidates')),false,'no local SAGA inputs are committed'); +}); + +test('the installed tree has strict same-version siblings and one framework runtime',()=>{ + const modules=join(root,'node_modules'); + const scope=dirname(realpathSync(join(modules,'@saga-a2ui/react-mcp-stack'))); + for(const sibling of SIBLINGS) { + const installed=JSON.parse(readFileSync(join(scope,sibling,'package.json'),'utf8')); + assert.equal(installed.name,`@saga-a2ui/${sibling}`); + assert.equal(installed.version,pinned,`${sibling} is the pinned SAGA version`); + // Every SDK sibling it depends on or needs as a peer is the same version. + for(const field of ['dependencies','peerDependencies']) for(const [dependency,range] of Object.entries(installed[field]??{})) if(dependency.startsWith('@saga-a2ui/')) assert.equal(range,pinned,`${sibling} ${field} ${dependency}`); + } + // Only those five are installed. + assert.deepEqual(readdirSync(scope).sort(),SIBLINGS.slice().sort()); + assert.ok(!readdirSync(join(scope,'..','..')).some(name=>/^@saga-a2ui\+lit/.test(name)),'no Lit package in the installed store'); + // The stack declares the components package and its Markdown parser, so the installed tree has both + // (the emitted browser bundle is a separate question, answered by the card application using them). + assert.ok(existsSync(join(realpathSync(join(scope,'components')),'../../markdown-it')),'markdown-it is installed beside the components package'); + for(const runtime of ['react','react-dom']) { + const one=realpathSync(join(modules,runtime)); + assert.equal(JSON.parse(readFileSync(join(one,'package.json'),'utf8')).version,'19.3.1',`one ${runtime} 19.3.1`); + assert.equal(realpathSync(join(scope,'..',runtime)),one,`the stack uses the application's ${runtime}`); + assert.equal(realpathSync(join(dirname(realpathSync(join(scope,'react'))),'..',runtime)),one,`@saga-a2ui/react uses the same ${runtime}`); + } +}); + +// ---- One stack: production code imports only its subpaths, and the browser graph holds no server module. +test('production code imports SAGA only through the one stack, and the browser bundle holds no server module',()=>{ + const sources=(dir:string):string[]=>readdirSync(join(root,dir),{withFileTypes:true}).flatMap(entry=>entry.isDirectory()?sources(`${dir}/${entry.name}`):/\.(?:ts|tsx)$/.test(entry.name)?[`${dir}/${entry.name}`]:[]); + for(const file of [...sources('src'),'scripts/build.ts']) { + const imported=[...readFileSync(join(root,file),'utf8').matchAll(/from\s+'(@saga-a2ui\/[^']+)'/g)].map(match=>match[1]); + for(const specifier of imported) assert.match(specifier,/^@saga-a2ui\/react-mcp-stack(?:\/(?:faithful-card|faithful-card\.css|content-policy|server))?$/,`${file} imports ${specifier}`); + if(file.startsWith('src/view/')) assert.ok(!imported.some(specifier=>specifier.endsWith('/server')),`${file} (browser graph) imports no server entry`); + } + const html=readFileSync(join(root,'dist/view.html'),'utf8'); + assert.ok(!html.includes('The MCP server already ends a different request lifetime.'),'the view bundle holds no server lifetime module'); +}); + +// ---- The card document names the SDK release it was built from, beside the application release. +test('the emitted card document carries the registry SDK identity beside the application release',()=>{ + const html=readFileSync(join(root,'dist/view.html'),'utf8'); + const expected={kind:'registry',version:SDK_VERSION,source:'none','stack-sha256':'none','set-sha256':'none'}; + const body=/]*>/.exec(html)?.[0]??''; + for(const [name,value] of Object.entries(expected)) assert.equal(body.match(new RegExp(`\\sdata-sdk-${name}="([^"]*)"`))?.[1],value,`data-sdk-${name}`); + assert.equal(body.match(/\sdata-release="([^"]*)"/)?.[1],JSON.parse(readFileSync(join(root,'package.json'),'utf8')).version,'the application release stays beside the SDK identity'); + assert.ok(html.includes(` { + const html = readFileSync(join(root, 'dist/view.html'), 'utf8'); + const comment = //.exec(html)?.[1]; + assert.ok(comment, 'the distributed card carries the licence comment'); + const notices = comment.replaceAll('--', '--'); + const stack = realpathSync(join(root, 'node_modules/@saga-a2ui/react-mcp-stack')); + assert.ok(notices.includes(readFileSync(join(stack, 'LICENSE'), 'utf8').trim())); + assert.ok(notices.includes(readFileSync(join(dirname(stack), 'components/THIRD_PARTY_NOTICES.md'), 'utf8').trim())); + assert.ok(notices.includes(readFileSync(join(root, 'assets/fonts/OFL.txt'), 'utf8').trim())); + assert.ok(notices.includes(readFileSync(join(root, 'assets/NOTICE.md'), 'utf8').trim())); + for (const name of SIBLINGS) assert.ok(notices.includes(`@saga-a2ui/${name}@${SDK_VERSION}`)); +}); diff --git a/apps/mcp/test/parity-cases.ts b/apps/mcp/test/parity-cases.ts new file mode 100644 index 00000000..6f4fbd42 --- /dev/null +++ b/apps/mcp/test/parity-cases.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import {request as httpRequest} from 'node:http'; +import {after, before, describe, it} from 'node:test'; +import {Client, StreamableHTTPClientTransport} from '@modelcontextprotocol/client'; +import {CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY} from '@modelcontextprotocol/server'; +import {TOOL_UNAVAILABLE_TEXT} from '../src/server/create-server.ts'; +import {stampHtmlRevision} from '../src/server/operation-log.ts'; +import { + PRIVATE_CANARY, RELEASE, TRUSTED_ORIGIN, UPSTREAM_AUTHORIZATION, answerFor, startRuntime, startUpstream, + type Runtime, type RuntimeKind, +} from './runtime-harness.ts'; + +type Call = {isError?: boolean; content: unknown; structuredContent?: unknown; _meta?: {ui?: {resourceUri?: string}}}; + +const builtCard = readFileSync(new URL('../dist/view.html', import.meta.url), 'utf8'); +const MODERN = '2026-07-28'; + +function rpcBody(id: number, method: string, params: Record, modern: boolean, progressToken?: string) { + const meta = { + ...(progressToken ? {progressToken} : {}), + ...(modern ? {[PROTOCOL_VERSION_META_KEY]: MODERN, [CLIENT_INFO_META_KEY]: {name: 'parity', version: '1'}, [CLIENT_CAPABILITIES_META_KEY]: {}} : {}), + }; + return JSON.stringify({jsonrpc: '2.0', id, method, params: Object.keys(meta).length ? {...params, _meta: meta} : params}); +} + +function post(origin: string, method: string, params: Record, options: {modern?: boolean; headers?: Record; progressToken?: string; id?: number} = {}) { + const modern = options.modern ?? false; + return fetch(`${origin}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', accept: 'application/json, text/event-stream', + ...(modern ? {'MCP-Protocol-Version': MODERN, 'Mcp-Method': method, ...('name' in params ? {'Mcp-Name': String(params.name)} : {})} : {}), + ...options.headers, + }, + body: rpcBody(options.id ?? 1, method, params, modern, options.progressToken), + }); +} + +async function messages(response: Response): Promise { + const text = await response.text(); + if (!response.headers.get('content-type')?.includes('text/event-stream')) return [JSON.parse(text)]; + return text.split('\n').filter(line => line.startsWith('data: ')).map(line => JSON.parse(line.slice(6))); +} +const terminal = async (response: Response, id = 1) => { + const found = (await messages(response)).find(message => message.id === id); + assert.ok(found, 'expected a terminal RPC response'); + return found; +}; + +async function withClient(origin: string, use: (client: Client) => Promise): Promise { + const client = new Client({name: 'parity', version: '1'}); + await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp`))); + try { return await use(client); } finally { await client.close(); } +} +const ask = (client: Client, question: string) => client.callTool({name: 'answer_islamic_question', arguments: {question}}, {timeout: 120_000}) as Promise; + +/** + * The HTTP/MCP behaviour both hosting targets must share. Each case runs against the built artifact of one runtime, + * with one SSE and one JSON-response instance, a real upstream and no application-level mock. + */ +export function defineParityCases(kind: RuntimeKind): void { + let upstream!: Awaited>; + let sse!: Runtime; + let json!: Runtime; + before(async () => { + upstream = await startUpstream(); + const started = await Promise.allSettled([false, true].map(mode => startRuntime(kind, {json: mode, upstream: upstream.url}))); + const failed = started.find(result => result.status === 'rejected'); + if (failed) { + // Never leave the instance that did start running. + await Promise.all(started.map(result => result.status === 'fulfilled' ? result.value.close() : undefined)); + throw (failed as PromiseRejectedResult).reason; + } + [sse, json] = started.map(result => (result as PromiseFulfilledResult).value) as [Runtime, Runtime]; + }); + after(async () => { await Promise.all([sse?.close(), json?.close()]); await upstream?.close(); }); + + for (const mode of ['sse', 'json'] as const) { + const runtime = () => mode === 'sse' ? sse : json; + + describe(`${kind} ${mode}: health, complete answer and linked resource`, () => { + it('reports health with the explicit release and serves nothing else', async () => { + const health = await fetch(`${runtime().origin}/health`); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), {ok: true, mcp: '/mcp', release: RELEASE}); + for (const path of ['/demo', '/demo/answer', '/fonts/amiri.ttf']) assert.equal((await fetch(`${runtime().origin}${path}`)).status, 404); + }); + + it('validates question length before reaching the real upstream', async () => { + await withClient(runtime().origin, async client => { + const before = upstream.seen.length; + for (const question of ['', 'x'.repeat(4001)]) { + const result = await ask(client, question).then(value => ({value}), error => ({error})); + if ('value' in result) assert.equal(result.value.isError, true); + else assert.ok(result.error instanceof Error); + assert.equal(upstream.seen.length, before); + } + for (const question of ['x', 'x'.repeat(4000)]) { + assert.equal((await ask(client, question)).isError, undefined); + } + assert.equal(upstream.seen.length, before + 2); + }); + }); + + it('returns the complete answer with attribution and the linked HTML resource with its identity', async () => { + await withClient(runtime().origin, async client => { + const tools = (await client.listTools()).tools; + assert.deepEqual(tools.map(tool => tool.name), ['answer_islamic_question']); + assert.equal(tools[0].annotations?.readOnlyHint, true); + assert.equal(tools[0].annotations?.openWorldHint, true); + assert.equal(tools[0].annotations?.destructiveHint, undefined); + assert.equal(tools[0].annotations?.idempotentHint, undefined); + const before = upstream.seen.length; + const question = `complete ${kind} ${mode}`; + const result = await ask(client, question); + const answer = answerFor(question); + assert.equal(result.isError, undefined); + assert.deepEqual(result.content, [{type: 'text', text: answer}]); + assert.deepEqual(result.structuredContent, {version: '0.1.0', question, answer}); + assert.match(answer, /\*Answered by \[Ansari\]\(https:\/\/askansari\.ai\)\.\*/); + // Explicit configuration reached the upstream call. + const sent = upstream.seen.slice(before).find(request => request.question === question); + assert.ok(sent, 'the upstream received the question'); + assert.equal(sent.headers.authorization, UPSTREAM_AUTHORIZATION); + assert.equal(sent.headers['x-ansari-client'], 'parity-test'); + + const uri = result._meta?.ui?.resourceUri; + assert.ok(uri); + assert.deepEqual((await client.listResources()).resources.map(resource => resource.uri), [uri]); + const text = ((await client.readResource({uri})).contents[0] as {text: string}).text; + // Release stamping precedes hashing: the served bytes are the built card with the release stamped, and the identity is theirs. + assert.equal(text, stampHtmlRevision(builtCard, RELEASE)); + assert.equal(uri, `ui://views/answer-card.${createHash('sha256').update(text).digest('hex').slice(0, 32)}.html`); + await assert.rejects(client.readResource({uri: 'ui://obsolete.html'})); + }); + }); + }); + + describe(`${kind} ${mode}: protocol paths and request isolation`, () => { + for (const modern of [true, false]) it(`${modern ? '2026' : 'older-host'} discovery and concurrent calls need no session and do not mix`, async () => { + const origin = runtime().origin; + const listed = await post(origin, 'tools/list', {}, {modern}); + assert.equal(listed.status, 200); + assert.equal(listed.headers.get('mcp-session-id'), null); + assert.deepEqual((await terminal(listed)).result.tools.map((tool: {name: string}) => tool.name), ['answer_islamic_question']); + const questions = ['isolated one', 'slow:300', 'FAIL', 'isolated two']; + const replies = await Promise.all(questions.map(async question => { + const response = await post(origin, 'tools/call', {name: 'answer_islamic_question', arguments: {question}}, {modern}); + assert.equal(response.status, 200); + assert.equal(response.headers.get('mcp-session-id'), null); + return (await terminal(response)).result as Call; + })); + replies.forEach((reply, index) => { + if (questions[index] === 'FAIL') { + assert.equal(reply.isError, true); + assert.deepEqual(reply.content, [{type: 'text', text: TOOL_UNAVAILABLE_TEXT}]); + } else { + assert.equal((reply.structuredContent as {answer: string}).answer, answerFor(questions[index]!)); + } + }); + for (const method of ['GET', 'DELETE']) assert.equal((await fetch(`${origin}/mcp`, {method})).status, 405); + }); + + it('fails safely without leaking the upstream, then recovers on the same runtime', async () => { + await withClient(runtime().origin, async client => { + const failed = await ask(client, 'FAIL'); + assert.equal(failed.isError, true); + assert.deepEqual(failed.content, [{type: 'text', text: TOOL_UNAVAILABLE_TEXT}]); + assert.equal(failed.structuredContent, undefined); + assert.ok(!JSON.stringify(failed).includes(PRIVATE_CANARY)); + const recovered = await ask(client, 'after failure'); + assert.equal(recovered.isError, undefined); + assert.equal((recovered.structuredContent as {answer: string}).answer, answerFor('after failure')); + }); + const logs = runtime().logs.join('\n'); + assert.match(logs, /"event":"source\.error"/); + assert.ok(!logs.includes(PRIVATE_CANARY), 'logs record no upstream content'); + assert.ok(!logs.includes('after failure'), 'logs record no question'); + }); + }); + + describe(`${kind} ${mode}: Host and Origin checks, body bounds and requested progress`, () => { + it('rejects an untrusted Host or Origin and a malformed body before calling Ansari, and accepts a configured Origin', async () => { + const origin = runtime().origin; + const before = upstream.seen.length; + const params = {name: 'answer_islamic_question', arguments: {question: 'guarded'}}; + const hostStatus = await new Promise((resolve, reject) => { + const req = httpRequest(`${origin}/mcp`, {method: 'POST', headers: {host: 'other.example'}}, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }); + req.on('error', reject); req.end(); + }); + assert.equal(hostStatus, 403); + assert.equal((await post(origin, 'tools/call', params, {headers: {origin: 'https://other.example'}})).status, 403); + const malformed = await fetch(`${origin}/mcp`, {method: 'POST', headers: {'content-type': 'application/json', accept: 'application/json, text/event-stream'}, body: '{'}); + assert.equal(malformed.status, 400); + assert.equal(upstream.seen.length, before, 'no rejected request reached Ansari'); + assert.equal((await post(origin, 'tools/call', params, {headers: {origin: 'http://localhost:1234'}})).status, 403, 'configured origins do not inherit localhost'); + assert.equal(upstream.seen.length, before); + for (const trusted of [TRUSTED_ORIGIN]) { + const accepted = await post(origin, 'tools/call', params, {headers: {origin: trusted}}); + assert.equal(accepted.status, 200, trusted); + assert.equal((await terminal(accepted)).result.structuredContent.answer, answerFor('guarded')); + } + }); + + for (const modern of [true, false]) it(`${modern ? 'modern' : 'older-host'} refuses a body over the SDK limit before calling Ansari`, async () => { + const before = upstream.seen.length; + const response = await post(runtime().origin, 'tools/call', {name: 'answer_islamic_question', arguments: {question: 'x'.repeat(4 * 1024 * 1024)}}, {modern}); + assert.equal(response.status, 413); + assert.match(await response.text(), /Payload Too Large/); + assert.equal(upstream.seen.length, before); + }); + + it('delivers the answer, and sends progress only when requested and only over SSE', async () => { + const origin = runtime().origin; + const params = {name: 'answer_islamic_question', arguments: {question: 'progress'}}; + const plain = await messages(await post(origin, 'tools/call', params, {modern: true})); + assert.ok(!JSON.stringify(plain).includes('notifications/progress'), 'no progress unless requested'); + const response = await post(origin, 'tools/call', params, {modern: true, progressToken: 'progress-test', id: 2}); + assert.equal(response.status, 200); + const received = await messages(response); + assert.equal(received.find(message => message.id === 2)?.result.structuredContent.answer, answerFor('progress')); + const progress = received.find(message => message.method === 'notifications/progress'); + if (mode === 'sse') assert.deepEqual(progress?.params, {progressToken: 'progress-test', progress: 0, total: 1}); + else assert.equal(progress, undefined); + }); + }); + } + + describe(`${kind}: the 60-second handler budget`, () => { + it('keeps an answer that arrives after the SDK default 10 s request budget, over SSE and JSON', async () => { + const delayMs = 10_500; + await Promise.all([sse, json].map(runtime => withClient(runtime.origin, async client => { + const started = performance.now(); + const call = await ask(client, `slow:${delayMs}`); + assert.ok(performance.now() - started > 10_000, 'the call outlasted the SDK default budget'); + assert.equal(call.isError, undefined); + assert.equal((call.structuredContent as {answer: string}).answer, answerFor(`slow:${delayMs}`)); + }))); + }); + + it('ends a call that outlasts 60 s with the safe failure, and the next call gets its own budget', async () => { + await withClient(sse.origin, async client => { + const started = performance.now(); + const call = await ask(client, 'hang'); + const elapsed = performance.now() - started; + assert.equal(call.isError, true); + assert.deepEqual(call.content, [{type: 'text', text: TOOL_UNAVAILABLE_TEXT}]); + assert.ok(elapsed >= 58_000 && elapsed < 80_000, `the failure arrived after ${Math.round(elapsed)} ms`); + for (let waited = 0; !upstream.aborted.includes('hang') && waited < 1000; waited += 20) { + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.ok(upstream.aborted.includes('hang'), 'the request deadline cancels the actual upstream fetch'); + const next = await ask(client, 'fresh budget'); + assert.equal(next.isError, undefined); + }); + }); + }); +} diff --git a/apps/mcp/test/parity-node.test.ts b/apps/mcp/test/parity-node.test.ts new file mode 100644 index 00000000..c9f34b32 --- /dev/null +++ b/apps/mcp/test/parity-node.test.ts @@ -0,0 +1,5 @@ +import {describe} from 'node:test'; +import {defineParityCases} from './parity-cases.ts'; + +// The Node/Docker artifact (dist/server/index.js) as a child process. Its signal lifecycle stays in lifecycle.test.ts. +describe('Node runtime', () => defineParityCases('node')); diff --git a/apps/mcp/test/parity-workers.test.ts b/apps/mcp/test/parity-workers.test.ts new file mode 100644 index 00000000..3e2176fa --- /dev/null +++ b/apps/mcp/test/parity-workers.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import {defineParityCases} from './parity-cases.ts'; +import {answerFor, startRuntime, startUpstream} from './runtime-harness.ts'; + +// The Workers bundle (dist-worker/index.js) in the local Workers runtime: the same cases as Node. +describe('Workers runtime', () => defineParityCases('workers')); + +// Workers has no process signals: its cleanup is per request. A caller that disconnects mid-call must leave the +// isolate serving, and ending the runtime itself is the host's stop, not a signal the application handles. +describe('Workers lifecycle', () => { + it('keeps serving after a caller abandons an in-flight call, and stops when the runtime stops', async () => { + const upstream = await startUpstream(); + const runtime = await startRuntime('workers', {json: false, upstream: upstream.url}); + try { + const started = upstream.seen.length; + const controller = new AbortController(); + const abandoned = fetch(`${runtime.origin}/mcp`, { + method: 'POST', signal: controller.signal, + headers: {'content-type': 'application/json', accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call', 'Mcp-Name': 'answer_islamic_question'}, + body: JSON.stringify({jsonrpc: '2.0', id: 1, method: 'tools/call', params: {name: 'answer_islamic_question', arguments: {question: 'hang'}, _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientInfo': {name: 'abandon', version: '1'}, 'io.modelcontextprotocol/clientCapabilities': {}, + }}}), + }).then(response => response.text()).catch(() => undefined); + for (let waited = 0; upstream.seen.length === started; waited += 50) { + assert.ok(waited < 10_000, 'the Worker called the upstream'); + await new Promise(resolve => setTimeout(resolve, 50)); + } + controller.abort(); + await abandoned; + // A disconnected client alone does not establish upstream cancellation in workerd. + // The shared runtime suite separately verifies actual fetch cancellation at the 60 s deadline. + // A later request is served normally, and its own work is unaffected by the abandoned one. + assert.equal((await fetch(`${runtime.origin}/health`)).status, 200); + const reply = await fetch(`${runtime.origin}/mcp`, { + method: 'POST', headers: {'content-type': 'application/json', accept: 'application/json, text/event-stream'}, + body: JSON.stringify({jsonrpc: '2.0', id: 1, method: 'tools/call', params: {name: 'answer_islamic_question', arguments: {question: 'after abandon'}}}), + }); + assert.equal(reply.status, 200); + assert.match(await reply.text(), new RegExp(answerFor('after abandon').split('\n')[0]!)); + assert.ok(runtime.alive()); + } finally { + const {remaining} = await runtime.close(); + await upstream.close(); + assert.equal(remaining, false, 'no Wrangler or workerd process outlives the stop'); + } + }); +}); diff --git a/apps/mcp/test/react-answers.ts b/apps/mcp/test/react-answers.ts new file mode 100644 index 00000000..501b4cf6 --- /dev/null +++ b/apps/mcp/test/react-answers.ts @@ -0,0 +1,55 @@ +// Synthetic answers for the React view qualification. They are layout and behavior fixtures, not +// religious content and not Ansari output: wording is arbitrary except where a check needs a shape +// (non-one numbering, nesting, Arabic/RTL, the attribution paragraph). + +export const ATTRIBUTION = 'This information came from [ansari.chat](https://ansari.chat).'; + +/** English with Arabic inside, a list that starts at 3 with a nested list, bullets, a quote, and the attribution. */ +export const ANSWER_A = [ + '## Synthetic heading A', + 'Synthetic answer A. التقوى is a word inside English text.', + '3. **Third** point with نص عربي', + '4. Fourth point\n 1. nested one\n 2. nested two', + '- a bullet', + '> a quoted line', + '---', + ATTRIBUTION, +].join('\n\n'); + +/** Arabic first (right-to-left), numbering from 1, and the attribution. */ +export const ANSWER_B = [ + '## الصبر', + 'الصبر مفتاح الفرج، وهذا نص تجريبي.', + '1. الأول', + '2. الثاني', + 'This information came from [askansari.ai](https://askansari.ai).', +].join('\n\n'); + +/** Ends with an ordinary paragraph, not the attribution: no footer treatment may be given to it. */ +export const ANSWER_C = ['Synthetic answer C.', 'A closing paragraph that is not the attribution.'].join('\n\n'); + +/** Hostile Markdown: every shape that must stay inert or blocked under Standard with no image origin. */ +export const ANSWER_HOSTILE = [ + '![remote](https://evil.example/pixel.png)', + '![blob](blob:https://evil.example/x) ![data](data:image/png;base64,AAAA)', + '', + '[script link](javascript:window.__pwned=1) and [safe link](https://example.com/page)', + ' ', + 'Answer text containing --ansari-art-light-flourish: url(https://evil.example/f.png); and url(https://evil.example/s.png)', +].join('\n\n'); + +export const QUESTION_A = 'Question A: what is sincerity?'; +export const QUESTION_B = 'ما هو الصبر؟'; + +/** + * Closing shapes around the attribution text and whether the footer treatment belongs to each. Only a top-level + * closing paragraph that begins with Ansari's attribution is the footer: not a quotation, a list item, text that + * follows it, other wording, or a card that failed. + */ +export const ATTRIBUTION_CASES: readonly {readonly question: string; readonly markdown: string; readonly footer: boolean; readonly probe: string}[] = [ + {question: 'Case quote', markdown: 'Case quote opening.\n\n> This information came from ansari.chat.', footer: false, probe: 'Case quote opening.'}, + {question: 'Case list', markdown: 'Case list opening.\n\n- This information came from ansari.chat.', footer: false, probe: 'Case list opening.'}, + {question: 'Case followed', markdown: 'This information came from [ansari.chat](https://ansari.chat).\n\nCase followed by more text.', footer: false, probe: 'Case followed by more text.'}, + {question: 'Case elsewhere', markdown: 'Case elsewhere opening.\n\nThis information came from elsewhere.', footer: false, probe: 'Case elsewhere opening.'}, + {question: 'Case emphasis', markdown: 'Case emphasis opening.\n\n**This information came from [ansari.chat](https://ansari.chat)**.', footer: true, probe: 'Case emphasis opening.'}, +]; diff --git a/apps/mcp/test/release.test.ts b/apps/mcp/test/release.test.ts new file mode 100644 index 00000000..627b9be0 --- /dev/null +++ b/apps/mcp/test/release.test.ts @@ -0,0 +1,390 @@ +import assert from 'node:assert/strict'; +import {execFileSync, spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, it} from 'node:test'; + +// The release-input gate (scripts/check-release.mjs) and the tag-based release sequence it validates. +// Everything here is a FIXTURE: a disposable repository whose lockfile and workspace file +// are synthesized in this test (candidate-shaped or registry-shaped), never read from the committed tree, so the tests pass +// before and after the committed tree switches from the private candidate to a real registry SDK. No version below exists on +// any registry, nothing is pushed or deployed, and no npm tag is touched. Whether a real SDK release is on the registry is a separate +// question, answered only by `pnpm check:release --target ` against the live registry. +const root = fileURLToPath(new URL('../', import.meta.url)); +type Problems = {problems: string[]; packages: {name: string; version: string}[]; facts: {appVersion: string}}; +type Gate = { + validateRelease(options: {appRoot: string; target?: string}): Problems; + verifyRegistry(packages: {name: string; version: string}[], view: (spec: string) => string): {verified: string[]; failed: string[]}; + validateTag(options: {appRoot: string; tag: string; appVersion: string}): string[]; + describeChanges(options: {appRoot: string; from: string; releaseFilesOnly?: boolean}): {problems: string[]; files: string[]; outside: string[]; dependenciesChanged: boolean}; +}; +// Loaded by a variable name so the strict typecheck does not need a declaration file for the plain-JavaScript script. +const specifier = '../scripts/check-release.mjs'; +const gate = await import(specifier) as Gate; + +const CANDIDATE = '0.0.0-candidate.g131e5b17def5'; +// The SDK's unpublished local release artifacts (a real version, installed from candidates/ as files). Its registry spelling +// would be identical, so the fixtures name it explicitly to be shaped as files. +const LOCAL_RELEASE = '0.7.6-rc.1'; +const isLocal = (sdk: string) => sdk === LOCAL_RELEASE || sdk.startsWith('0.0.0-candidate.'); +// The committed tree installs managed local inputs while candidates/ exists; an RC or stable release tree has retired it. +const committedTreeIsLocal = () => existsSync(join(root, 'candidates')) && existsSync(join(root, 'pnpm-lock.yaml')); +const TAG_PREFIX = 'ansari-mcp-v'; +const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +type Fixture = {sdk: string; appVersion: string; stackSpec?: string; extraDirect?: Record; lockTransform?: (lock: string) => string; keepOverrides?: boolean}; +const SIBLINGS = ['core', 'components', 'mcp', 'react']; +const PEERS = '(react-dom@19.3.1(react@19.3.1))(react@19.3.1)'; +/** Where one fixture SDK package comes from: the managed candidate tarball (candidate-shaped) or the registry (registry-shaped). */ +function fixtureSource(sdk: string, name: string) { + if (!isLocal(sdk)) return {ref: sdk, resolution: `{integrity: sha512-${name}fixtureintegrity==}`}; + const tarball = `file:candidates/saga-${sdk}/saga-a2ui-${name}-${sdk}.tgz`; + return {ref: tarball, resolution: `{integrity: sha512-${name}fixtureintegrity==, tarball: ${tarball}}`}; +} +/** + * A small lockfile written from nothing but the fixture SDK version, in the shape pnpm records it: candidate tarballs for a + * `0.0.0-candidate.*` version, registry releases with integrity hashes otherwise. It is deliberately not derived from the + * committed pnpm-lock.yaml, so these tests behave the same whether the committed tree still carries the private candidate + * or has already been switched to a real registry SDK. + */ +function syntheticLock(sdk: string, specifier: string): string { + const stack = fixtureSource(sdk, 'react-mcp-stack'); + const packages = ['react-mcp-stack', ...SIBLINGS].map(name => { + const {ref, resolution} = fixtureSource(sdk, name); + return ` '@saga-a2ui/${name}@${ref}':\n resolution: ${resolution}\n`; + }).join('\n'); + const snapshots = ['react-mcp-stack', ...SIBLINGS].map(name => { + const {ref} = fixtureSource(sdk, name); + const deps = name === 'react-mcp-stack' ? SIBLINGS : name === 'core' ? [] : ['core']; + const body = deps.map(dep => ` '@saga-a2ui/${dep}': ${fixtureSource(sdk, dep).ref}\n`).join(''); + return ` '@saga-a2ui/${name}@${ref}':\n${body ? ` dependencies:\n${body}` : ' {}\n'}`; + }).join('\n'); + const specifierLine = isLocal(specifier) ? fixtureSource(specifier, 'react-mcp-stack').ref : specifier; + return [ + "lockfileVersion: '9.0'", '', + 'importers:', '', + ' apps/mcp:', ' dependencies:', + " '@saga-a2ui/react-mcp-stack':", + ` specifier: ${specifierLine}`, + ` version: ${stack.ref}${PEERS}`, '', + 'packages:', '', packages, 'snapshots:', '', snapshots, '', + ].join('\n'); +} +/** pnpm-workspace.yaml: the unrelated override always, and the managed candidate overrides only when asked. */ +function syntheticWorkspace(sdk: string, withOverrides: boolean): string { + const lines = ['packages: []', 'overrides:', " '@a2ui/markdown-it': '0.2.1'"]; + if (withOverrides) { + for (const name of [...SIBLINGS, 'react-mcp-stack']) lines.push(` '@saga-a2ui/${name}': 'file:./candidates/saga-${sdk}/saga-a2ui-${name}-${sdk}.tgz'`); + } + return `${lines.join('\n')}\n`; +} +/** Writes a repository-shaped fixture (apps/mcp + plugin) 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(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; +} +function disposableRepo(body: (repo: string) => T): T { + const repo = mkdtempSync(join(tmpdir(), 'ansari-release-')); + try { return body(repo); } finally { rmSync(repo, {recursive: true, force: true}); } +} +const validate = (fixture: Fixture, target: string) => disposableRepo(repo => gate.validateRelease({appRoot: writeApp(repo, fixture), target}).problems); +const RC: Fixture = {sdk: '0.7.0-rc.1', appVersion: '0.15.0-rc.1'}; +const STABLE: Fixture = {sdk: '0.7.0', appVersion: '0.15.0'}; + +describe('release input validation (fixture inputs, no registry)', () => { + it('rejects a stack pin belonging only to an unrelated workspace importer', () => { + const wrong = {...STABLE, lockTransform: (lock: string) => lock.replace(' apps/mcp:', ' apps/other:')}; + assert.match(validate(wrong, 'production').join('\n'), /no direct.*react-mcp-stack/); + }); + + it('requires a named environment', () => { + assert.throws(() => gate.validateRelease({appRoot: root}), /Name the environment/); + assert.throws(() => gate.validateRelease({appRoot: root, target: 'staging'}), /Name the environment/); + }); + + it('accepts exact public inputs: an RC for Beta, and a stable release for Production', () => { + assert.deepEqual(validate(RC, 'beta'), []); + assert.deepEqual(validate(STABLE, 'beta'), []); + assert.deepEqual(validate(STABLE, 'production'), []); + // The stack alone is the direct dependency; its siblings are resolved through it. + disposableRepo(repo => { + const result = gate.validateRelease({appRoot: writeApp(repo, STABLE), target: 'production'}); + assert.deepEqual([...new Set(result.packages.map(entry => entry.name))].sort(), ['@saga-a2ui/components', '@saga-a2ui/core', '@saga-a2ui/mcp', '@saga-a2ui/react', '@saga-a2ui/react-mcp-stack']); + assert.ok(result.packages.every(entry => entry.version === '0.7.0')); + }); + }); + + it('keeps Production for stable inputs: no prerelease SDK and no prerelease app version', () => { + assert.match(validate({...STABLE, sdk: '0.7.0-rc.1'}, 'production').join('\n'), /prerelease; Production needs the stable SDK/); + assert.match(validate({...STABLE, appVersion: '0.15.0-rc.1'}, 'production').join('\n'), /Production releases a stable app version/); + }); + + for (const floating of ['next', 'latest', '^0.7.0', '~0.7.0', '0.7.x', '*', 'workspace:*', 'catalog:', 'link:../saga', 'file:./saga.tgz', 'github:MachineWisdomAI/saga-a2ui']) { + it(`rejects the floating or non-registry specifier "${floating}" for Beta and Production`, () => { + for (const target of ['beta', 'production']) { + assert.match(validate({...STABLE, stackSpec: floating}, target).join('\n'), /is not an exact version/, `${target} ${floating}`); + } + }); + } + + it('rejects inconsistent SDK siblings in the resolved graph', () => { + const skewed = {...STABLE, lockTransform: (lock: string) => lock.replace("'@saga-a2ui/core@0.7.0'", "'@saga-a2ui/core@0.6.9'").replace("'@saga-a2ui/core': 0.7.0", "'@saga-a2ui/core': 0.6.9")}; + for (const target of ['beta', 'production']) assert.match(validate(skewed, target).join('\n'), /siblings are inconsistent/); + const wrongPin = {...STABLE, lockTransform: (lock: string) => lock.replaceAll('0.7.0', '0.7.1')}; + assert.match(validate({...wrongPin, stackSpec: '0.7.0'}, 'beta').join('\n'), /siblings are 0\.7\.1, but the direct pin is 0\.7\.0|is stale/); + }); + + it('rejects a lockfile that was not resolved for the committed pin', () => { + const stale = {...STABLE, lockTransform: (lock: string) => lock.replace(/(react-mcp-stack':\n {8}specifier: )0\.7\.0/, '$10.6.0')}; + assert.match(validate(stale, 'beta').join('\n'), /pnpm-lock\.yaml is stale/); + }); + + it('rejects a redundant direct SAGA dependency instead of requiring one', () => { + for (const sibling of ['components', 'mcp', 'react']) { + assert.match(validate({...STABLE, extraDirect: {[`@saga-a2ui/${sibling}`]: '0.7.0'}}, 'production').join('\n'), new RegExp(`@saga-a2ui/${sibling} is a redundant direct dependency`)); + } + }); + + it('rejects a synthetic candidate-shaped tree for Beta and Production, and keeps it for Dev', () => { + // Independent of the committed tree: a candidate lockfile with its overrides, as the Dev rehearsal installs it. + const candidate = {...RC, sdk: CANDIDATE, appVersion: '0.15.0', keepOverrides: true}; + assert.deepEqual(validate(candidate, 'dev'), []); + for (const target of ['beta', 'production']) { + const text = validate(candidate, target).join('\n'); + assert.match(text, /private SAGA candidate installed from candidates\//); + assert.match(text, /pnpm-workspace\.yaml overrides/); + assert.match(text, /not public registry releases/); + } + // Registry-shaped inputs are accepted with the same helpers: the fixtures do not depend on the candidate spelling. + assert.deepEqual(validate({...RC, keepOverrides: false}, 'beta'), []); + }); + + it('rejects a synthetic local-release-artifacts tree for Beta and Production, and keeps it for Dev', () => { + // Final SDK release artifacts at their real version, installed as files: not a registry install, so no public environment takes them. + const local = {sdk: LOCAL_RELEASE, appVersion: '0.15.0-rc.1', keepOverrides: true}; + assert.deepEqual(validate(local, 'dev'), []); + for (const target of ['beta', 'production']) { + const text = validate(local, target).join('\n'); + assert.match(text, /unpublished local release artifacts installed from candidates\//); + assert.match(text, /Local files are not a registry install/); + assert.match(text, /pnpm-workspace\.yaml overrides/); + assert.match(text, /not public registry releases/); + } + // The same version resolved from the registry is accepted: the refusal is about where the bytes come from. + assert.deepEqual(validate({sdk: LOCAL_RELEASE, appVersion: '0.15.0-rc.1', keepOverrides: false, lockTransform: lock => lock.replaceAll(/file:candidates\/saga-0\.7\.6-rc\.1\/saga-a2ui-[a-z-]+-0\.7\.6-rc\.1\.tgz/g, '0.7.6-rc.1').replaceAll(/, tarball: 0\.7\.6-rc\.1/g, '')}, 'beta'), []); + // A file tarball outside candidates/ or with mismatched directory and file versions is no managed input, even for Dev. + const stray = {sdk: LOCAL_RELEASE, appVersion: '0.15.0-rc.1', keepOverrides: true, lockTransform: (lock: string) => lock.replaceAll('file:candidates/saga-0.7.6-rc.1/', 'file:vendor/')}; + assert.match(validate(stray, 'dev').join('\n'), /must be a managed tarball under candidates\//); + }); + + it('rejects the committed local-input tree for Beta and Production while it is still installed from files', () => { + // Skipped once the committed tree carries a real registry SDK (RC preparation removes candidates/). + if (!committedTreeIsLocal()) return; + assert.deepEqual(gate.validateRelease({appRoot: root, target: 'dev'}).problems, []); + for (const target of ['beta', 'production']) { + const text = gate.validateRelease({appRoot: root, target}).problems.join('\n'); + assert.match(text, /(?:private SAGA candidate|unpublished local release artifacts) installed from candidates\//); + assert.match(text, /pnpm-workspace\.yaml overrides/); + assert.match(text, /not public registry releases/); + } + // Candidate overrides left behind on an otherwise public pin are refused as well. + assert.match(validate({...STABLE, keepOverrides: true}, 'beta').join('\n'), /pnpm-workspace\.yaml overrides/); + assert.match(validate({...STABLE, sdk: CANDIDATE, appVersion: '0.15.0'}, 'beta').join('\n'), /private SAGA candidate/); + }); + + it('keeps the documented Dev rehearsal option, and still refuses floating Dev inputs', () => { + assert.deepEqual(validate({...STABLE, sdk: CANDIDATE}, 'dev'), []); + assert.deepEqual(validate(RC, 'dev'), []); + 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', () => { + 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'})); + 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/); + }); +}); + +describe('registry validation (injected lookups; the real registry is consulted only by the CLI)', () => { + const packages = ['core', 'components', 'mcp', 'react', 'react-mcp-stack'].map(name => ({name: `@saga-a2ui/${name}`, version: '0.7.0'})); + it('verifies every distinct sibling and reports each one that is missing or different', () => { + const all = gate.verifyRegistry([...packages, ...packages], spec => spec.slice(spec.lastIndexOf('@') + 1)); + assert.equal(all.verified.length, 5); + assert.deepEqual(all.failed, []); + const partial = gate.verifyRegistry(packages, spec => { + if (spec.includes('react-mcp-stack')) throw new Error('E404 Not Found'); + return spec.includes('/mcp@') ? '0.7.1' : '0.7.0'; + }); + assert.equal(partial.verified.length, 3); + assert.equal(partial.failed.length, 2); + assert.match(partial.failed.join('\n'), /react-mcp-stack@0\.7\.0: not found in the public registry/); + assert.match(partial.failed.join('\n'), /mcp@0\.7\.0: the registry reported 0\.7\.1/); + }); +}); + +// The same checks run twice: once on the plain checkout path, and once through a symlink to the checkout. Git resolves +// the symlink, so the second layout reproduces a macOS /var/folders -> /private/var/folders temporary directory on any host. +const LAYOUTS = [{name: 'direct checkout path', symlinked: false}, {name: 'symlinked checkout path', symlinked: true}]; +for (const layout of LAYOUTS) describe(`tag-based source selection in a disposable repository (${layout.name})`, () => { + const disposable = (body: (repo: string) => T): T => { + if (!layout.symlinked) return disposableRepo(body); + const parent = mkdtempSync(join(tmpdir(), 'ansari-release-link-')); + const real = join(parent, 'real'); + const link = join(parent, 'link'); + mkdirSync(real); + symlinkSync(real, link); + try { + assert.notEqual(realpathSync(link), link, 'the checkout path is a symlink'); + return body(link); + } finally { rmSync(parent, {recursive: true, force: true}); } + }; + const env = {...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_AUTHOR_NAME: 'rehearsal', GIT_AUTHOR_EMAIL: 'rehearsal@example.invalid', GIT_COMMITTER_NAME: 'rehearsal', GIT_COMMITTER_EMAIL: 'rehearsal@example.invalid'}; + const git = (cwd: string, ...args: string[]) => execFileSync('git', args, {cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}).trim(); + const commit = (repo: string, message: string) => { git(repo, 'add', '-A'); git(repo, 'commit', '-q', '-m', message); return git(repo, 'rev-parse', 'HEAD'); }; + const RC_TAG = 'ansari-mcp-v0.15.0-rc.1'; + const STABLE_TAG = 'ansari-mcp-v0.15.0'; + + it('builds an RC from its tag, derives stable from that tag alone, and never rewrites a tag', () => { + disposable(repo => { + git(repo, 'init', '-q', '-b', 'main'); + // The qualified source: the candidate-era tree stays as it was; RC preparation is one commit on top of it. + const app = writeApp(repo, {...RC, sdk: CANDIDATE, appVersion: '0.15.0', keepOverrides: true}); + const qualified = commit(repo, 'qualified source'); + writeApp(repo, RC); + const rc = commit(repo, 'chore(release): prepare 0.15.0-rc.1'); + git(repo, 'tag', '-a', RC_TAG, '-m', 'RC'); + assert.equal(git(repo, 'rev-parse', `${RC_TAG}^{commit}`), rc); + + // What RC preparation changed: dependency inputs, so the affected checks belong on Dev before tagging. + const prep = gate.describeChanges({appRoot: app, from: qualified}); + assert.equal(prep.dependenciesChanged, true); + assert.ok(prep.files.includes('pnpm-lock.yaml')); + + // Unrelated work lands on main after the RC was tagged. + writeFileSync(join(app, 'src/unrelated.ts'), 'export const later = true;\n'); + const later = commit(repo, 'feat: unrelated later work'); + + // The checkout of the RC tag validates; the moved branch head does not claim to be the tag. + git(repo, 'checkout', '-q', '--detach', RC_TAG); + assert.deepEqual(gate.validateTag({appRoot: app, tag: RC_TAG, appVersion: '0.15.0-rc.1'}), []); + assert.deepEqual(gate.validateRelease({appRoot: app, target: 'beta'}).problems, []); + assert.equal(git(repo, 'rev-parse', 'HEAD'), rc, 'the build input is the tagged commit'); + assert.ok(!existsSync(join(app, 'src/unrelated.ts')), 'later branch work is not in the RC checkout'); + git(repo, 'checkout', '-q', 'main'); + assert.match(gate.validateTag({appRoot: app, tag: RC_TAG, appVersion: '0.15.0-rc.1'}).join('\n'), /points at .*but the checked-out HEAD/); + git(repo, 'checkout', '-q', '--detach', RC_TAG); + + // A tag is immutable: git refuses to re-create it, and a lightweight or misnamed tag does not qualify. + assert.throws(() => git(repo, 'tag', '-a', RC_TAG, '-m', 'again'), /already exists/); + git(repo, 'tag', 'lightweight-rc'); + assert.match(gate.validateTag({appRoot: app, tag: 'lightweight-rc', appVersion: '0.15.0-rc.1'}).join('\n'), /must be ansari-mcp-v0\.15\.0-rc\.1/); + git(repo, 'tag', `${TAG_PREFIX}0.15.0-rc.1-light`); + assert.match(gate.validateTag({appRoot: app, tag: `${TAG_PREFIX}0.15.0-rc.1-light`, appVersion: '0.15.0-rc.1-light'}).join('\n'), /not an annotated tag/); + writeFileSync(join(app, 'dirty.txt'), 'x'); + assert.match(gate.validateTag({appRoot: app, tag: RC_TAG, appVersion: '0.15.0-rc.1'}).join('\n'), /working tree is not clean/); + rmSync(join(app, 'dirty.txt')); + + // Stable starts from the accepted RC tag, not from main: only the release files change. + git(repo, 'switch', '-q', '-c', 'release/0.15.0', RC_TAG); + writeApp(repo, STABLE); + const stable = commit(repo, 'chore(release): promote version and pin the stable SDK'); + git(repo, 'tag', '-a', STABLE_TAG, '-m', 'stable'); + const changes = gate.describeChanges({appRoot: app, from: RC_TAG, releaseFilesOnly: true}); + 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.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'}), []); + assert.deepEqual(gate.validateRelease({appRoot: app, target: 'production'}).problems, []); + assert.notEqual(stable, rc, 'the stable release is a new commit, so a new artifact'); + assert.equal(git(repo, 'rev-parse', `${RC_TAG}^{commit}`), rc, 'the RC tag still names the RC commit'); + + // Taking main's unrelated work into the stable branch is refused. + writeFileSync(join(app, 'src/unrelated.ts'), 'export const later = true;\n'); + commit(repo, 'unrelated change on the stable branch'); + const polluted = gate.describeChanges({appRoot: app, from: RC_TAG, releaseFilesOnly: true}); + assert.match(polluted.problems.join('\n'), /apps\/mcp\/src\/unrelated\.ts changed since/); + }); + }); + + it('screens the content of the allowed release files, not just their paths', () => { + const stableFrom = (change: (app: string, repo: string) => void) => disposable(repo => { + git(repo, 'init', '-q', '-b', 'main'); + const app = writeApp(repo, {...STABLE, appVersion: '0.15.0-rc.1'}); + commit(repo, 'rc'); + git(repo, 'tag', '-a', RC_TAG, '-m', 'RC'); + writeApp(repo, STABLE); + change(app, repo); + commit(repo, 'promote'); + return gate.describeChanges({appRoot: app, from: RC_TAG, releaseFilesOnly: true}); + }); + const edit = (file: string, transform: (text: string) => string) => { writeFileSync(file, transform(readFileSync(file, 'utf8'))); }; + + // Executable code appended to src/version.ts stays inside the allowed path but is refused. + const code = stableFrom(app => edit(join(app, 'src/version.ts'), text => `${text}export const injected = () => process.env;\n`)); + assert.match(code.problems.join('\n'), /src\/version\.ts changed since .* beyond the release version and the SDK pin/); + assert.ok(code.outside.includes('apps/mcp/src/version.ts')); + + // A changed package script or an unrelated dependency inside package.json is refused; the SDK pin and version are not. + const script = stableFrom(app => edit(join(app, 'package.json'), text => { const parsed = JSON.parse(text); parsed.scripts = {...parsed.scripts, postinstall: 'node x.js'}; return JSON.stringify(parsed, null, 2); })); + assert.match(script.problems.join('\n'), /package\.json changed since .* beyond the release version and the SDK pin/); + 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/); + 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/); + + // The legitimate derivation (version mirrors and the SDK pin only) passes the same screen. + assert.deepEqual(stableFrom(() => undefined).problems, []); + }); + + it('a stable derivation whose only change is the version still reports unchanged executable dependencies', () => { + disposable(repo => { + git(repo, 'init', '-q', '-b', 'main'); + const app = writeApp(repo, {...STABLE, appVersion: '0.15.0-rc.1'}); + commit(repo, 'rc'); + git(repo, 'tag', '-a', RC_TAG, '-m', 'RC'); + writeApp(repo, STABLE); + commit(repo, 'promote'); + const changes = gate.describeChanges({appRoot: app, from: RC_TAG, releaseFilesOnly: true}); + assert.deepEqual(changes.problems, []); + assert.equal(changes.dependenciesChanged, false); + }); + }); +}); + +describe('the release CLI', () => { + const run = (...args: string[]) => spawnSync(process.execPath, [join(root, 'scripts/check-release.mjs'), ...args], {cwd: root, encoding: 'utf8'}); + it('has no default environment', () => { + const result = run(); + assert.equal(result.status, 2); + assert.match(result.stderr, /Name the environment/); + }); + it('says plainly when the registry was not consulted, and fails the local-input tree for Beta and Production', () => { + if (!committedTreeIsLocal()) return; + const beta = run('--target', 'beta', '--no-registry'); + assert.equal(beta.status, 1); + assert.match(beta.stdout, /Registry NOT checked/); + assert.match(beta.stderr, /Release check FAILED for beta/); + assert.equal(run('--target', 'dev').status, 0); + }); +}); diff --git a/apps/mcp/test/request-budget.test.ts b/apps/mcp/test/request-budget.test.ts new file mode 100644 index 00000000..2c213bce --- /dev/null +++ b/apps/mcp/test/request-budget.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {Client, StreamableHTTPClientTransport} from '@modelcontextprotocol/client'; +import type {AnswerCompleter} from '../src/server/ansari-client.ts'; +import {listen} from '../src/node/http-server.ts'; +import {TOOL_UNAVAILABLE_TEXT} from '../src/server/create-server.ts'; + +// The tool declares a 60 s handler wait. The SDK's default 10 s request budget must not clamp it, and a call +// that outlasts the declared budget must still end with the safe failure, bounded, on a server that keeps serving. + +const html = 'synthetic resourcesynthetic'; +const SDK_DEFAULT_REQUEST_MS = 10_000; + +type Call = {isError?: boolean; content: unknown; structuredContent?: unknown}; + +async function callOnce( + requestMs: number | undefined, + completer: AnswerCompleter, + json: boolean, + questions: readonly string[], +): Promise<{calls: {call: Call; elapsedMs: number}[]}> { + const service = await listen({port: 0, host: '127.0.0.1', enableJsonResponse: json, html, requestMs, completer, logger: () => {}}); + const client = new Client({name: 'budget-test', version: '0'}); + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${service.port}/mcp`)); + try { + await client.connect(transport); + const calls: {call: Call; elapsedMs: number}[] = []; + // Several independent HTTP calls: each receives a fresh request budget. + for (const question of questions) { + const started = performance.now(); + const call = await client.callTool({name: 'answer_islamic_question', arguments: {question}}, {timeout: 120_000}) as Call; + calls.push({call, elapsedMs: performance.now() - started}); + } + return {calls}; + } finally { + await client.close(); await service.close(); + } +} + +for (const json of [false, true]) test(`an answer after the SDK default request budget and within the declared bound succeeds over ${json ? 'JSON' : 'SSE'}`, async () => { + const delayMs = SDK_DEFAULT_REQUEST_MS + 500; + const completer: AnswerCompleter = {async complete({question}) { await new Promise(resolve => setTimeout(resolve, delayMs)); return `Synthetic answer to ${question}`; }}; + const {calls: [{call, elapsedMs}]} = await callOnce(undefined, completer, json, ['slow synthetic question']); + assert.ok(elapsedMs > SDK_DEFAULT_REQUEST_MS, `the call took ${elapsedMs} ms, not past the default budget`); + assert.equal(call.isError, undefined); + assert.deepEqual(call.content, [{type: 'text', text: 'Synthetic answer to slow synthetic question'}]); + assert.deepEqual(call.structuredContent, {version: '0.1.0', question: 'slow synthetic question', answer: 'Synthetic answer to slow synthetic question'}); +}); + +for (const json of [false, true]) test(`a call that outlasts the request budget still fails safely and bounded, and the next call gets its own budget over ${json ? 'JSON' : 'SSE'}`, async () => { + const requestMs = 400; + let started = 0; + const completer: AnswerCompleter = {async complete({question}) { + started += 1; + await new Promise(resolve => setTimeout(resolve, question === 'late' ? requestMs * 5 : requestMs / 4)); + return `Synthetic answer to ${question}`; + }}; + const {calls} = await callOnce(requestMs, completer, json, ['late', 'prompt', 'prompt again']); + const [late, first, second] = calls; + assert.equal(late.call.isError, true); + assert.deepEqual(late.call.content, [{type: 'text', text: TOOL_UNAVAILABLE_TEXT}]); + assert.equal(late.call.structuredContent, undefined); + assert.ok(late.elapsedMs >= requestMs * 0.9 && late.elapsedMs < requestMs * 4, `the failure arrived after ${late.elapsedMs} ms`); + // A spent budget is not carried into later calls from the same client, and each later call gets a fresh one. + for (const {call} of [first, second]) { + assert.equal(call.isError, undefined); + assert.equal((call.structuredContent as {version: string}).version, '0.1.0'); + } + assert.equal(started, 3); +}); diff --git a/apps/mcp/test/request-contract.test.ts b/apps/mcp/test/request-contract.test.ts new file mode 100644 index 00000000..76b6f195 --- /dev/null +++ b/apps/mcp/test/request-contract.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {setTimeout as delay} from 'node:timers/promises'; +import {test} from 'node:test'; +import {Client, StreamableHTTPClientTransport} from '@modelcontextprotocol/client'; +import {listen} from '../src/node/http-server.ts'; +import {createAnsariHttpCompleter} from '../src/server/ansari-client.ts'; +import {createFetchApp} from '../src/server/fetch-app.ts'; +import {settingsFromEnv} from '../src/server/runtime-config.ts'; + +const toolName = 'answer_islamic_question'; +const html = 'synthetic contract'; +const legacyDescription = 'Answers questions about Islam including theology, jurisprudence (fiqh), history, Quranic interpretation (tafsir), Hadith sciences, Islamic law, worship practices, ethics, spirituality, and contemporary issues. Provides comprehensive answers with citations from Quran, authentic Hadith, and classical Islamic scholarship. Can handle questions about specific verses, hadiths, scholarly opinions, historical events, prophets, companions, and modern applications of Islamic principles.'; + +test('legacy discovery and question bounds are enforced before upstream work', async () => { + const questions: string[] = []; + const service = await listen({host: '127.0.0.1', port: 0, html, logger: () => {}, completer: { + async complete({question}) { questions.push(question); return `Answer to ${question}`; }, + }}); + const client = new Client({name: 'contract', version: '1'}); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${service.port}/mcp`))); + const tools = (await client.listTools()).tools; + assert.deepEqual(tools.map(tool => tool.name), [toolName]); + assert.equal(tools[0].description, legacyDescription); + assert.equal(tools[0].annotations?.readOnlyHint, true); + assert.equal(tools[0].annotations?.openWorldHint, true); + assert.equal(tools[0].annotations?.destructiveHint, undefined); + assert.equal(tools[0].annotations?.idempotentHint, undefined); + for (const question of ['', 'x'.repeat(4001)]) { + const result = await client.callTool({name: toolName, arguments: {question}}).then( + value => ({value}), error => ({error}), + ); + if ('value' in result) assert.equal(result.value.isError, true); + else assert.ok(result.error instanceof Error); + assert.equal(questions.length, 0, 'invalid input never reaches the completer'); + } + for (const question of ['x', 'x'.repeat(4000)]) { + const result = await client.callTool({name: toolName, arguments: {question}}); + assert.equal(result.isError, undefined); + assert.deepEqual(result.content, [{type: 'text', text: `Answer to ${question}`}]); + } + assert.deepEqual(questions.map(question => question.length), [1, 4000]); + } finally { await client.close(); await service.close(); } +}); + +test('configured allowlists replace local defaults, including an explicitly empty list', () => { + const local = settingsFromEnv({}); + assert.deepEqual(local.allowedHosts, ['localhost', '127.0.0.1']); + assert.deepEqual(local.allowedOrigins, local.allowedHosts); + const configured = settingsFromEnv({MCP_ALLOWED_HOSTS: ' app.example, app.example, ', MCP_ALLOWED_ORIGINS: ' chat.example '}); + assert.deepEqual(configured.allowedHosts, ['app.example']); + assert.deepEqual(configured.allowedOrigins, ['chat.example']); + for (const blank of ['', ' , ']) { + const empty = settingsFromEnv({MCP_ALLOWED_HOSTS: blank, MCP_ALLOWED_ORIGINS: blank}); + assert.deepEqual(empty.allowedHosts, []); + assert.deepEqual(empty.allowedOrigins, []); + } + const explicit = settingsFromEnv({MCP_ALLOWED_HOSTS: 'env.example', MCP_ALLOWED_ORIGINS: 'env.example'}, { + allowedHosts: ['override.example', 'override.example'], allowedOrigins: [], + }); + assert.deepEqual(explicit.allowedHosts, ['override.example']); + assert.deepEqual(explicit.allowedOrigins, []); +}); + +test('the real middleware refuses localhost when a deployment allowlist replaces it', async () => { + const settings = settingsFromEnv({MCP_ALLOWED_HOSTS: 'app.example', MCP_ALLOWED_ORIGINS: 'chat.example'}); + const app = createFetchApp({...settings, html, logger: () => {}, completer: {async complete() { return 'synthetic'; }}}); + const request = (host: string, origin?: string) => app.fetch(new Request('http://app.example/mcp', { + method: 'GET', headers: {host, ...(origin ? {origin} : {})}, + })); + try { + assert.equal((await request('localhost')).status, 403); + assert.equal((await request('app.example', 'http://localhost')).status, 403); + assert.equal((await request('app.example', 'https://chat.example')).status, 405); + assert.equal((await request('app.example')).status, 405, 'a non-browser client needs no Origin header'); + } finally { await app.close(); } +}); + +for (const json of [false, true]) for (const stall of ['headers', 'body']) { + test(`${json ? 'JSON' : 'SSE'} timeout aborts upstream ${stall} consumption and a later call succeeds`, async () => { + const disconnected: string[] = []; + const upstream = createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', chunk => body += chunk); + req.on('end', () => { + const question = JSON.parse(body).messages[0].content as string; + if (question === 'next') { res.end('complete synthetic answer'); return; } + res.once('close', () => { if (!res.writableEnded) disconnected.push(question); }); + if (stall === 'body') { res.writeHead(200, {'content-type': 'text/plain'}); res.write('partial'); } + }); + }); + await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)); + const port = (upstream.address() as {port: number}).port; + const service = await listen({host: '127.0.0.1', port: 0, html, enableJsonResponse: json, requestMs: 150, + logger: () => {}, completer: createAnsariHttpCompleter({baseUrl: `http://127.0.0.1:${port}`}), + }); + const client = new Client({name: 'fetch-budget', version: '1'}); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${service.port}/mcp`))); + const timedOut = await client.callTool({name: toolName, arguments: {question: stall}}); + assert.equal(timedOut.isError, true); + assert.deepEqual(timedOut.content, [{type: 'text', text: 'Ansari could not complete this request.'}]); + for (let waited = 0; disconnected.length === 0 && waited < 500; waited += 20) await delay(20); + assert.deepEqual(disconnected, [stall], 'timeout closes the actual outstanding fetch'); + const next = await client.callTool({name: toolName, arguments: {question: 'next'}}); + assert.equal(next.isError, undefined); + assert.deepEqual(next.content, [{type: 'text', text: 'complete synthetic answer'}]); + } finally { + await client.close(); await service.close(); + const stopped = new Promise(resolve => upstream.close(() => resolve())); + upstream.closeAllConnections(); await stopped; + } + }); +} diff --git a/apps/mcp/test/runtime-boundary.test.ts b/apps/mcp/test/runtime-boundary.test.ts new file mode 100644 index 00000000..1d4b8a6b --- /dev/null +++ b/apps/mcp/test/runtime-boundary.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {existsSync, readdirSync, readFileSync} from 'node:fs'; +import {test} from 'node:test'; +import {DEFAULT_RELEASE} from '../src/server/operation-log.ts'; +import {DEFAULT_ALLOWED_HOSTS, releaseFromEnv, settingsFromEnv} from '../src/server/runtime-config.ts'; +import {prepareAnswerView} from '../src/server/view-artifact.ts'; + +const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + +test('the shared application and the Workers entry read no file, process, or Node listener', () => { + for (const file of ['fetch-app', 'runtime-config', 'view-artifact', 'ansari-client', 'create-server', 'operation-log'].map(name => `src/server/${name}.ts`).concat('src/worker/index.ts')) { + const source = read(file); + assert.doesNotMatch(source, /node:(?:fs|http|path|process)|@hono\/node-server|\bprocess\./, `${file} stays runtime-neutral`); + } +}); + +test('dependencies point inward: the Node and Workers adapters import src/server, which imports neither adapter', () => { + const shared = readdirSync(new URL('../src/server/', import.meta.url)).filter(name => name.endsWith('.ts')); + assert.ok(shared.length > 0); + for (const name of shared) { + assert.doesNotMatch(read(`src/server/${name}`), /from\s+['"](?:\.\.\/(?:node|worker)\/|\.\.\/\.\.\/src\/(?:node|worker)\/)/, `src/server/${name} imports no adapter`); + } + assert.match(read('src/node/http-server.ts'), /from '\.\.\/server\/fetch-app\.js'/); + assert.match(read('src/worker/index.ts'), /from '\.\.\/server\/fetch-app\.js'/); + assert.doesNotMatch(read('src/worker/index.ts'), /\.\.\/node\//, 'the Workers entry does not import the Node adapter'); +}); + +test('the Workers bundle holds the shared application and none of the Node hosting', () => { + assert.ok(existsSync(new URL('../dist-worker/index.js', import.meta.url)), 'run `pnpm build:worker` first'); + const bundle = read('dist-worker/index.js'); + assert.match(bundle, /answer_islamic_question/); + assert.doesNotMatch(bundle, /createAdaptorServer|SIGTERM/, 'no Node listener or signal lifecycle'); +}); + +test('release stamping precedes hashing: equal final bytes share an identity and a release change moves it', () => { + const unstamped = 'one'; + const stamped = 'one'; + const a = prepareAnswerView(unstamped, 'r1'); + const b = prepareAnswerView(stamped, 'r1'); + assert.equal(a.html, b.html, 'both end as the same final bytes'); + assert.equal(a.uri, b.uri); + assert.equal(a.uri, `ui://views/answer-card.${createHash('sha256').update(a.html).digest('hex').slice(0, 32)}.html`); + assert.match(a.html, /data-revision="r1"/); + assert.notEqual(prepareAnswerView(unstamped, 'r2').uri, a.uri, 'a legitimate release change moves the hash'); + assert.equal(prepareAnswerView(unstamped, 'r1').uri, a.uri, 'preparing again changes nothing'); +}); + +test('settings come from the supplied bindings, with the same rules under both hosts', () => { + const none = settingsFromEnv({}); + assert.deepEqual(none.allowedHosts, DEFAULT_ALLOWED_HOSTS); + assert.deepEqual(none.allowedOrigins, DEFAULT_ALLOWED_HOSTS); + assert.equal(none.release, DEFAULT_RELEASE); + assert.equal(none.enableJsonResponse, false); + assert.deepEqual(none.ansari, {baseUrl: undefined, clientName: undefined, authorization: undefined}); + + const configured = settingsFromEnv({ + ANSARI_RELEASE: 'v1.2.3-rc.1', MCP_ALLOWED_HOSTS: ' app.example , ', MCP_ALLOWED_ORIGINS: 'chat.example', MCP_RESPONSE_MODE: 'json', + ANSARI_API_BASE_URL: 'https://upstream.example', ANSARI_CLIENT: 'beta', ANSARI_API_AUTHORIZATION: 'Bearer x', + }); + assert.deepEqual(configured.allowedHosts, ['app.example']); + assert.deepEqual(configured.allowedOrigins, ['chat.example']); + assert.equal(configured.release, 'v1.2.3-rc.1'); + assert.equal(configured.enableJsonResponse, true); + assert.deepEqual(configured.ansari, {baseUrl: 'https://upstream.example', clientName: 'beta', authorization: 'Bearer x'}); + + // The release is stamped into HTML and logs: an unsafe value is never used. The Railway commit is the next source. + assert.equal(releaseFromEnv({ANSARI_RELEASE: '">