From 28815e206d26f99e89cd84807b8452f02a5450c0 Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:12:33 -0700 Subject: [PATCH 01/19] fix(design-tokens): resolve tokens.css from source inside the monorepo --- .changeset/design-tokens-css-export.md | 5 +++++ packages/design-tokens/README.md | 4 +++- packages/design-tokens/package.json | 5 ++++- scripts/package-conventions.test.ts | 27 +++++++++++++------------- 4 files changed, 26 insertions(+), 15 deletions(-) create mode 100644 .changeset/design-tokens-css-export.md diff --git a/.changeset/design-tokens-css-export.md b/.changeset/design-tokens-css-export.md new file mode 100644 index 00000000..2cf457bb --- /dev/null +++ b/.changeset/design-tokens-css-export.md @@ -0,0 +1,5 @@ +--- +'@transcend-io/design-tokens': patch +--- + +Resolve `@transcend-io/design-tokens/tokens.css` from source inside this monorepo by giving the export an `@transcend-io/source` condition, matching the package's main entry. Published consumers still read `dist/tokens.css`. Terrazzo rewrites `src/tokens.css` in place, whereas tsdown copies it into a `dist/` it has just cleaned, so a package importing the stylesheet could fail to resolve it while design-tokens happened to be rebuilding. diff --git a/packages/design-tokens/README.md b/packages/design-tokens/README.md index c99c8132..ab3739c5 100644 --- a/packages/design-tokens/README.md +++ b/packages/design-tokens/README.md @@ -24,9 +24,11 @@ CSS custom properties on `:root`: ## Development -Token source lives in `tokens/` (DTCG JSON). Terrazzo generates TypeScript and `tokens.css` into `src/` on `prebuild`: +Token source lives in `tokens/` (DTCG JSON). Terrazzo generates TypeScript and `tokens.css` into `src/` on `prebuild`, and `build` copies the stylesheet to `dist/`: ```bash pnpm --filter @transcend-io/design-tokens build pnpm --filter @transcend-io/design-tokens check:tokens ``` + +Both exports carry the `@transcend-io/source` condition, so a build inside this monorepo reads `src/` and a consumer reads `dist/`. For `./tokens.css` that is not only about skipping a build step: `build` empties `dist/` before restoring the stylesheet, so anything watching for changes — a `vite build --watch` over an MCP App view, say — sees `dist/tokens.css` briefly missing whenever this package is rebuilt, and fails to resolve the import. `src/tokens.css` is rewritten in place and never disappears. diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index 0aa62fbd..0ab2ef96 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -24,7 +24,10 @@ "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, - "./tokens.css": "./dist/tokens.css" + "./tokens.css": { + "@transcend-io/source": "./src/tokens.css", + "default": "./dist/tokens.css" + } }, "publishConfig": { "access": "public" diff --git a/scripts/package-conventions.test.ts b/scripts/package-conventions.test.ts index 5dd76d90..e213fd83 100644 --- a/scripts/package-conventions.test.ts +++ b/scripts/package-conventions.test.ts @@ -8,6 +8,12 @@ import { fileExists, readJsonFile, readRepoFile, repoRoot } from './lib/repo-fil type DependencyMap = Record; +type ExportConditions = { + '@transcend-io/source'?: string; + default?: string; + types?: string; +}; + type PackageManifest = { author?: string; dependencies?: DependencyMap; @@ -16,18 +22,8 @@ type PackageManifest = { node?: string; }; exports?: { - '.': { - '@transcend-io/source'?: string; - default?: string; - types?: string; - }; - [subpath: string]: - | string - | { - '@transcend-io/source'?: string; - default?: string; - types?: string; - }; + '.': ExportConditions; + [subpath: string]: string | ExportConditions; }; files?: string[]; homepage?: string; @@ -183,7 +179,12 @@ describe('package conventions', () => { expect(manifest.devDependencies?.typescript).toBe(requiredDevDependencies.typescript); expect(manifest.devDependencies?.vitest).toBe(requiredDevDependencies.vitest); if (isDesignTokens) { - expect(manifest.exports?.['./tokens.css']).toBe('./dist/tokens.css'); + // Conditions mirror the `.` entry above: consumers read the built + // stylesheet, builds inside this monorepo read source. + expect(manifest.exports?.['./tokens.css']).toEqual({ + '@transcend-io/source': './src/tokens.css', + default: './dist/tokens.css', + }); } }, ); From ca962ee9b73e916f48bd6ca03e0f207fd861f592 Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:18:34 -0700 Subject: [PATCH 02/19] feat(mcp-server-base): negotiate client capabilities from the handshake --- .../mcp-client-capability-negotiation.md | 11 ++ packages/mcp/README.md | 19 +++ .../src/capabilities/assume.ts | 65 ++++++++++ .../src/capabilities/client-detection.ts | 108 ++++++++++++++++ .../src/capabilities/derive.ts | 116 ++++++++++++++++++ .../mcp-server-base/src/capabilities/types.ts | 100 +++++++++++++++ packages/mcp/mcp-server-base/src/index.ts | 25 ++++ .../src/mcp-session-context.ts | 71 +++++++++++ .../src/server/build-server.ts | 89 ++++++++++++-- .../tests/assume-capabilities.test.ts | 85 +++++++++++++ .../tests/client-detection.test.ts | 114 +++++++++++++++++ .../tests/derive-capabilities.test.ts | 105 ++++++++++++++++ .../tests/mcp-session-context.test.ts | 93 ++++++++++++++ 13 files changed, 988 insertions(+), 13 deletions(-) create mode 100644 .changeset/mcp-client-capability-negotiation.md create mode 100644 packages/mcp/mcp-server-base/src/capabilities/assume.ts create mode 100644 packages/mcp/mcp-server-base/src/capabilities/client-detection.ts create mode 100644 packages/mcp/mcp-server-base/src/capabilities/derive.ts create mode 100644 packages/mcp/mcp-server-base/src/capabilities/types.ts create mode 100644 packages/mcp/mcp-server-base/src/mcp-session-context.ts create mode 100644 packages/mcp/mcp-server-base/tests/assume-capabilities.test.ts create mode 100644 packages/mcp/mcp-server-base/tests/client-detection.test.ts create mode 100644 packages/mcp/mcp-server-base/tests/derive-capabilities.test.ts create mode 100644 packages/mcp/mcp-server-base/tests/mcp-session-context.test.ts diff --git a/.changeset/mcp-client-capability-negotiation.md b/.changeset/mcp-client-capability-negotiation.md new file mode 100644 index 00000000..16747378 --- /dev/null +++ b/.changeset/mcp-client-capability-negotiation.md @@ -0,0 +1,11 @@ +--- +'@transcend-io/mcp-server-base': minor +--- + +Negotiate client capabilities from the `initialize` handshake, so a tool can adapt to what the connected host is actually able to render. + +Servers now derive the host's capabilities and identity once per connection (`deriveClientCapabilities`, `whatIsTheClient`) and expose them to handlers through an `AsyncLocalStorage` session context, reachable with `getMcpSession()` and `hasCapability()` without threading a server through every call signature. `requestElicitation` asks the host for a form and returns `undefined` when it cannot show one, rather than letting the SDK's own capability check throw and fail the tool call. + +Only elicitation and MCP Apps are detected, being the only capabilities a tool can act on differently. Sampling and roots are deliberately excluded: roots is inert for API-backed servers, our target hosts do not implement sampling, and both are deprecated as of the 2026-07-28 spec under SEP-2577. + +Nothing changes on the wire yet. Handshakes stay byte-identical, and no tool behaves differently until per-capability variants land. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index db8d9ab5..e1ad9faf 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -280,6 +280,25 @@ Each domain package (admin, consent, dsr, ...) is a self-contained MCP server wi The unified `mcp` package aggregates tools via `ToolRegistry` and composes a `TranscendGraphQLClient` that mixes in all domain GraphQL capabilities. +## Client capabilities and MCP Apps + +MCP hosts differ widely in what they can render. Claude Desktop can show interactive views and forms; a plain scripted client can only handle text. Rather than shipping the lowest common denominator or branching per host inside tool handlers, `mcp-server-base` negotiates capabilities once per connection and resolves each tool to the best variant that host supports. + +Because every server funnels its tools through `buildMcpServer`, all packages get this behavior without any per-package wiring. + +### How a session is negotiated + +During `initialize`, the host declares its capabilities and identifies itself. `deriveClientCapabilities` reduces that to the set we can act on, and `whatIsTheClient` maps `clientInfo.name` to an `McpHostClient`. The result is stored in an `AsyncLocalStorage` context for the request, so handlers can read it via `getMcpSession()` without any change to their signatures. + +Only two capabilities are detected, because they are the only ones we can act on: + +| Capability | Detected from | +| --------------------------------- | ------------------------------------------------------- | +| `McpClientCapability.Elicitation` | `capabilities.elicitation.form` | +| `McpClientCapability.McpApp` | `capabilities.extensions['io.modelcontextprotocol/ui']` | + +Sampling and roots are deliberately excluded. Roots is inert for these servers (they are API-backed, so there is no filesystem scope to negotiate), our target hosts do not implement sampling, and both are deprecated as of the 2026-07-28 spec under SEP-2577. + ## Environment variables All servers share the same environment variables: diff --git a/packages/mcp/mcp-server-base/src/capabilities/assume.ts b/packages/mcp/mcp-server-base/src/capabilities/assume.ts new file mode 100644 index 00000000..5c8f135d --- /dev/null +++ b/packages/mcp/mcp-server-base/src/capabilities/assume.ts @@ -0,0 +1,65 @@ +import { McpClientCapability } from './types.js'; + +/** + * Environment variable that forces capabilities on regardless of what the client + * declared, as a comma-separated list of {@link McpClientCapability} values. + * + * This exists for one specific reason. The MCP Apps spec has hosts advertise + * support through `capabilities.extensions["io.modelcontextprotocol/ui"]`, and + * this server correctly withholds a tool's view when that is absent. But the v1 + * MCP Inspector ships an Apps tab while declaring `capabilities: {}`, so against + * a spec-correct server its Apps tab is always empty. Rather than weaken + * negotiation for every host, `pnpm mcp:inspect` sets this variable so the + * Inspector can see views. + * + * Never set this in production: it makes the server claim a host can render a + * view when it may not, which shows up as a blank panel instead of a graceful + * text fallback. + */ +export const ASSUME_CAPABILITIES_ENV_VAR = 'TRANSCEND_MCP_ASSUME_CAPABILITIES'; + +const KNOWN_CAPABILITIES = new Set(Object.values(McpClientCapability)); + +/** Outcome of reading the override, including entries that made no sense. */ +export interface AssumedCapabilities { + /** Capabilities to force on */ + capabilities: McpClientCapability[]; + /** Entries that matched no known capability, kept so callers can warn */ + unknown: string[]; +} + +/** + * Parses a comma-separated capability list. + * + * Unknown entries are collected rather than thrown, because this is a debugging + * aid: a typo should produce a warning and a working server, not a startup + * failure. + * + * @param raw - Raw environment variable value + * @returns Recognized capabilities plus any unrecognized entries + */ +export function parseAssumedCapabilities(raw: string | undefined): AssumedCapabilities { + if (!raw) return { capabilities: [], unknown: [] }; + + const capabilities: McpClientCapability[] = []; + const unknown: string[] = []; + + for (const entry of raw.split(',')) { + const trimmed = entry.trim(); + if (trimmed === '') continue; + const normalized = trimmed.toUpperCase(); + if (KNOWN_CAPABILITIES.has(normalized)) { + const capability = normalized as McpClientCapability; + if (!capabilities.includes(capability)) capabilities.push(capability); + } else { + unknown.push(trimmed); + } + } + + return { capabilities, unknown }; +} + +/** Reads {@link ASSUME_CAPABILITIES_ENV_VAR} from the environment. */ +export function assumedCapabilitiesFromEnv(): AssumedCapabilities { + return parseAssumedCapabilities(process.env[ASSUME_CAPABILITIES_ENV_VAR]); +} diff --git a/packages/mcp/mcp-server-base/src/capabilities/client-detection.ts b/packages/mcp/mcp-server-base/src/capabilities/client-detection.ts new file mode 100644 index 00000000..cce73e8d --- /dev/null +++ b/packages/mcp/mcp-server-base/src/capabilities/client-detection.ts @@ -0,0 +1,108 @@ +import type { Implementation } from '@modelcontextprotocol/sdk/types.js'; + +import { McpHostClient } from './types.js'; + +/** Every host except the one that means "we could not tell". */ +export type DetectableHost = Exclude; + +/** + * Names each host is known to report, tested against the normalized identifier. + * + * Patterns are anchored at the start, so a client that merely mentions a known + * slug cannot borrow that host's identity; the trailing `\b` still allows the + * version and variant suffixes hosts append. No two hosts may match the same + * name, which is what lets this be keyed by host rather than ordered — with no + * overlap, iteration order cannot change the answer, and + * `client-detection.test.ts` enforces it. + */ +export const HOST_PATTERNS: Readonly> = { + // Desktop and web both send `claude-ai`, never `claude-desktop`, reported from + // RPC logs on both surfaces: + // https://github.com/anthropics/claude-ai-mcp/issues/61#issuecomment-4285045628 + // The other two are for forwarded caller headers, which partners write by hand. + [McpHostClient.Claude]: [/^claude-ai\b/, /^claude\.ai\b/, /^claude desktop\b/], + // `claude-code` from the CLI, `local-agent-mode-*` from the desktop app, per + // that same comment. + [McpHostClient.ClaudeCode]: [/^claude-code\b/, /^local-agent-mode/], + // Verified through logs + [McpHostClient.Cursor]: [/^cursor\b/], + // Neither name is a literal anywhere: VS Code sends `productService.nameLong`, + // https://github.com/microsoft/vscode/blob/278880aeb30de8d2b16d0d1ee65e5f82a3d869fe/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts#L136 + // which comes from whichever product.json the build was compiled with. The + // public repo holds only the OSS one, hence "Code - OSS": + // https://github.com/microsoft/vscode/blob/1c4f1296821a2349458e01d87910e7bf10ae1c88/product.json#L3 + // Stable builds inject Microsoft's own product.json, which is not open source, + // so "Visual Studio Code" can only be checked in an install — line 3 of + // /Applications/Visual Studio Code.app/Contents/Resources/app/product.json + [McpHostClient.VsCodeCopilot]: [/^visual studio code\b/, /^code - oss\b/], + // https://github.com/openai/codex/blob/e4e040881acab7e0059775ec58843c43ac6b882f/codex-rs/codex-mcp/src/rmcp_client.rs#L958 + [McpHostClient.Codex]: [/^codex-mcp-client\b/], + // `gemini-cli-mcp-client` today, with a rename to plain `gemini` proposed + // upstream, so match the family rather than the one name: + // https://github.com/google-gemini/gemini-cli/blob/93844dfa10f6d71edc09be40dfde205edfbcc939/packages/core/src/tools/mcp-client.ts#L1848 + [McpHostClient.Gemini]: [/^gemini\b/], + // 2.0.0 sends `mcp-inspector`, in `clients/cli/build/index.js` of the published + // package. A bare `inspector` came from an earlier build, seen off the wire + // here, and the prefix also covers the `-tui` client's own name. + [McpHostClient.McpInspector]: [/^mcp-inspector\b/, /^inspector\b/], +}; + +/** + * Per-host workarounds. + * + * Every flag here is a bug in a host, not a feature of ours, so each one needs a + * ticket and a removal condition. Keeping them in one table instead of inline + * conditionals means the set of active workarounds is greppable and auditable. + */ +export interface HostQuirks { + /** + * Host advertises MCP Apps support but cannot render a `ui://` resource whose + * HTML is served lazily, so the markup must be a literal string. + */ + requiresEagerUiHtml?: boolean; +} + +/** Known workarounds keyed by host. Absent means the host needs none. */ +export const HOST_QUIRKS: Readonly>> = { + // Intentionally empty. Add an entry with a TODO and a ticket when a host + // misbehaves, e.g.: + // // TODO(LINK-0000): drop once ships the fix in . + // [McpHostClient.SomeHost]: { requiresEagerUiHtml: true }, +}; + +/** Returns the workarounds needed for a host, or an empty object when none apply. */ +export function quirksFor( + /** Host to look up */ + host: McpHostClient, +): HostQuirks { + return HOST_QUIRKS[host] ?? {}; +} + +function matchHost(candidate: string | undefined): McpHostClient | undefined { + if (!candidate) return undefined; + const normalized = candidate.trim().toLowerCase(); + if (normalized === '') return undefined; + for (const [host, patterns] of Object.entries(HOST_PATTERNS)) { + if (patterns.some((pattern) => pattern.test(normalized))) return host as DetectableHost; + } + return undefined; +} + +/** + * Identifies the connected MCP host. + * + * Prefers `clientInfo.name` from `initialize`, which is present on both stdio + * and HTTP, and falls back to the forwarded `x-transcend-mcp-caller` header for + * HTTP callers that proxy on a user's behalf. + * + * Never throws. An unrecognized host must degrade to baseline behavior, so it + * returns {@link McpHostClient.Unknown} instead of failing the session. + */ +export function whatIsTheClient( + /** Client identity from the `initialize` handshake */ + clientInfo?: Implementation, + /** Forwarded `x-transcend-mcp-caller` header value */ + callerHeader?: string, +): McpHostClient { + return matchHost(clientInfo?.name) ?? matchHost(callerHeader) ?? McpHostClient.Unknown; +} diff --git a/packages/mcp/mcp-server-base/src/capabilities/derive.ts b/packages/mcp/mcp-server-base/src/capabilities/derive.ts new file mode 100644 index 00000000..a6ab5de7 --- /dev/null +++ b/packages/mcp/mcp-server-base/src/capabilities/derive.ts @@ -0,0 +1,116 @@ +import type { ClientCapabilities, Implementation } from '@modelcontextprotocol/sdk/types.js'; + +import { whatIsTheClient } from './client-detection.js'; +import { + MCP_APP_MIME_TYPE, + MCP_UI_EXTENSION_ID, + McpClientCapability, + type ClientCapabilityReport, +} from './types.js'; + +/** + * Where the capability report is derived from. + * + * Passed as a plain object rather than a {@link Server} so derivation stays a + * pure function that is trivial to unit test. It also keeps the signature + * stable for the 2026-07-28 protocol, which moves client info and capabilities + * out of `initialize` and into per-request `_meta`: only the call site that + * assembles this object would need to change. + */ +export interface ClientCapabilitySource { + /** Capabilities the client declared, from `initialize` */ + capabilities?: ClientCapabilities; + /** Client identity, from `initialize` */ + clientInfo?: Implementation; + /** Forwarded `x-transcend-mcp-caller` value, used to identify HTTP callers */ + callerHeader?: string; + /** + * Capabilities to treat as present no matter what the client declared. + * + * Passed in rather than read from the environment here so this stays a pure + * function. Only local debugging tooling supplies it — see + * `ASSUME_CAPABILITIES_ENV_VAR`. + */ + assumeCapabilities?: readonly McpClientCapability[]; +} + +/** + * Shape of the MCP Apps extension settings a host advertises. The spec requires + * `mimeTypes`, but hosts in the wild have shipped a bare `{}`, so treat a + * missing or empty list as "supports the default HTML profile" rather than + * refusing to render. + */ +interface McpUiExtensionSettings { + /** Content types the host can render */ + mimeTypes?: unknown; +} + +/** + * Whether the host can render a server-requested form. + * + * The SDK normalizes a bare `elicitation: {}` into `{ form: {} }` while parsing + * `initialize`, so capabilities read off a live `Server` already have `form` + * set. This function is also called with raw objects — by tests, and by whatever + * assembles capabilities once they move into per-request `_meta` — so it applies + * the same rule itself rather than assuming normalization already happened. + */ +function supportsFormElicitation(capabilities: ClientCapabilities | undefined): boolean { + const elicitation = capabilities?.elicitation; + if (!elicitation) return false; + if (elicitation.form) return true; + return Object.keys(elicitation).length === 0; +} + +function supportsMcpApps(capabilities: ClientCapabilities | undefined): boolean { + const settings = capabilities?.extensions?.[MCP_UI_EXTENSION_ID] as + | McpUiExtensionSettings + | undefined; + if (!settings) return false; + + const { mimeTypes } = settings; + if (!Array.isArray(mimeTypes) || mimeTypes.length === 0) return true; + return mimeTypes.some( + (mimeType) => typeof mimeType === 'string' && mimeType.trim() === MCP_APP_MIME_TYPE, + ); +} + +/** + * Reduces a client's declared capabilities to the set this framework can act + * on, plus a best-effort host identification. + * + * Only elicitation and MCP Apps are detected. Sampling and roots are omitted on + * purpose: roots is inert for API-backed servers, our target hosts do not + * implement sampling, and both are deprecated as of the 2026-07-28 spec. + */ +export function deriveClientCapabilities(source: ClientCapabilitySource): ClientCapabilityReport { + const { capabilities, clientInfo, callerHeader, assumeCapabilities } = source; + const detected = new Set(); + + if (supportsFormElicitation(capabilities)) { + detected.add(McpClientCapability.Elicitation); + } + if (capabilities?.elicitation?.url) { + detected.add(McpClientCapability.ElicitationUrl); + } + if (supportsMcpApps(capabilities)) { + detected.add(McpClientCapability.McpApp); + } + + for (const capability of assumeCapabilities ?? []) { + detected.add(capability); + } + + return { + capabilities: detected, + host: whatIsTheClient(clientInfo, callerHeader), + ...(clientInfo && { clientInfo }), + }; +} + +/** Renders a report's capability set as a stable, sorted list for logging. */ +export function describeCapabilities( + /** Report whose capabilities should be summarized */ + report: ClientCapabilityReport, +): string[] { + return [...report.capabilities].sort(); +} diff --git a/packages/mcp/mcp-server-base/src/capabilities/types.ts b/packages/mcp/mcp-server-base/src/capabilities/types.ts new file mode 100644 index 00000000..0003cf87 --- /dev/null +++ b/packages/mcp/mcp-server-base/src/capabilities/types.ts @@ -0,0 +1,100 @@ +import type { Implementation } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Extension identifier hosts use to advertise MCP Apps support (SEP-1865), + * found under `ClientCapabilities.extensions`. + * + * Lives with the capability layer rather than beside the view-serving code + * because the handshake is what consumes it: deriving a capability report needs + * this identifier before anything renders. `tools/ui-resource.ts` re-exports it + * for callers that think of it as part of the view surface. + */ +export const MCP_UI_EXTENSION_ID = 'io.modelcontextprotocol/ui'; + +/** + * MIME type identifying an HTML MCP App view. Hosts key off this exact string, + * including the profile parameter, so it must not be reformatted. + * + * Read during the handshake too: a host declares which MIME types it accepts, + * and a view is only offered when this one is among them. + */ +export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; + +/** + * MCP client capabilities this framework can act on when shaping tool behavior. + * + * Deliberately narrow: a member earns its place only once a tool variant can do + * something meaningfully different because of it. Sampling and roots are + * excluded — roots is inert for API-backed servers (there is no filesystem + * scope to negotiate), our target hosts do not implement sampling, and both are + * deprecated as of the 2026-07-28 spec under SEP-2577. + */ +export enum McpClientCapability { + /** Host renders server-requested forms via `elicitation/create` in `form` mode */ + Elicitation = 'ELICITATION', + /** Host opens a server-supplied URL via `elicitation/create` in `url` mode */ + ElicitationUrl = 'ELICITATION_URL', + /** Host renders `ui://` HTML resources in a sandboxed iframe (MCP Apps, SEP-1865) */ + McpApp = 'MCP_APP', +} + +/** + * MCP hosts we recognize. + * + * Values are lowercase kebab-case because they double as the outbound + * attribution value for `MCP_CALLER_HEADER`, matching the format callers + * already forward over HTTP. + * + * A host is only listed once a real `clientInfo.name` has been seen for it, so + * that {@link McpHostClient.Unknown} means "not yet observed" rather than "the + * pattern was wrong". See `HOST_PATTERNS` for the evidence behind each one. + */ +export enum McpHostClient { + /** + * Any Claude chat surface. + * + * Desktop and web are one value because both report `claude-ai`, so the + * surfaces cannot be told apart from the handshake. Split this only if a + * distinct string turns up. + */ + Claude = 'claude', + /** Claude Code, in the terminal or its desktop app */ + ClaudeCode = 'claude-code', + /** Cursor IDE */ + Cursor = 'cursor', + /** GitHub Copilot inside Visual Studio Code */ + VsCodeCopilot = 'vscode-copilot', + /** OpenAI Codex */ + Codex = 'codex', + /** Google Gemini CLI */ + Gemini = 'gemini', + /** Official MCP Inspector, used for local development via `pnpm mcp:inspect` */ + McpInspector = 'mcp-inspector', + /** Host could not be identified; behave as conservatively as possible */ + Unknown = 'unknown', +} + +/** + * Everything we know about the connected MCP host for the current session. + * + * Derived once per connection from the `initialize` handshake and read by tool + * variant resolution, outbound request attribution, and session logging. + */ +export interface ClientCapabilityReport { + /** Capabilities the host declared that we can act on */ + capabilities: ReadonlySet; + /** Best-effort identification of the connected host */ + host: McpHostClient; + /** Raw `clientInfo` from `initialize`, retained for logging and debugging */ + clientInfo?: Implementation; +} + +/** + * Report used when no `initialize` handshake has happened yet, or when the + * client declared nothing we can act on. Every capability check against it is + * false, so tools fall back to their baseline behavior. + */ +export const EMPTY_CAPABILITY_REPORT: ClientCapabilityReport = { + capabilities: new Set(), + host: McpHostClient.Unknown, +}; diff --git a/packages/mcp/mcp-server-base/src/index.ts b/packages/mcp/mcp-server-base/src/index.ts index 571ecc2b..42ba8b23 100644 --- a/packages/mcp/mcp-server-base/src/index.ts +++ b/packages/mcp/mcp-server-base/src/index.ts @@ -10,6 +10,31 @@ export { requestMcpCallerContext, } from './mcp-caller-context.js'; +export { + EMPTY_CAPABILITY_REPORT, + McpClientCapability, + McpHostClient, +} from './capabilities/types.js'; +export type { ClientCapabilityReport } from './capabilities/types.js'; +export { deriveClientCapabilities, describeCapabilities } from './capabilities/derive.js'; +export type { ClientCapabilitySource } from './capabilities/derive.js'; +export { + ASSUME_CAPABILITIES_ENV_VAR, + assumedCapabilitiesFromEnv, + parseAssumedCapabilities, +} from './capabilities/assume.js'; +export type { AssumedCapabilities } from './capabilities/assume.js'; +export { HOST_QUIRKS, quirksFor, whatIsTheClient } from './capabilities/client-detection.js'; +export type { HostQuirks } from './capabilities/client-detection.js'; + +export { + getMcpSession, + hasCapability, + mcpSessionContext, + requestElicitation, +} from './mcp-session-context.js'; +export type { McpSession } from './mcp-session-context.js'; + export { toolCallContext, getToolCallIdHeader, TOOLCALL_ID_HEADER } from './tool-call-context.js'; export type { ToolCallContext } from './tool-call-context.js'; diff --git a/packages/mcp/mcp-server-base/src/mcp-session-context.ts b/packages/mcp/mcp-server-base/src/mcp-session-context.ts new file mode 100644 index 00000000..62c22f10 --- /dev/null +++ b/packages/mcp/mcp-server-base/src/mcp-session-context.ts @@ -0,0 +1,71 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/sdk/types.js'; + +import { McpClientCapability, type ClientCapabilityReport } from './capabilities/types.js'; + +/** + * What a tool handler can learn about the host it is currently serving. + * + * Populated for the duration of a `tools/list` or `tools/call` request. The MCP + * {@link Server} is carried alongside the capability report because + * server-to-client requests such as elicitation are methods on it. + */ +export interface McpSession { + /** Capabilities and identity of the connected host */ + client: ClientCapabilityReport; + /** MCP server handling this request, for server-initiated requests */ + server: Server; +} + +/** + * Per-request MCP session context. Each inbound request stores the host's + * resolved capabilities here so tool handlers can adapt without threading the + * server through every call signature. + */ +export const mcpSessionContext = new AsyncLocalStorage(); + +/** + * Returns the session for the current async execution context, or `undefined` + * outside a request (for example in unit tests that invoke a handler directly). + */ +export function getMcpSession(): McpSession | undefined { + return mcpSessionContext.getStore(); +} + +/** + * Whether the connected host declared a capability. + * + * Returns `false` when there is no session, so a handler calling this outside a + * request takes its baseline path rather than crashing. + */ +export function hasCapability( + /** Capability to test for */ + capability: McpClientCapability, +): boolean { + return getMcpSession()?.client.capabilities.has(capability) ?? false; +} + +/** + * Asks the host to collect input from the user via a form. + * + * Returns `undefined` when the host cannot show one, so callers must handle that + * and fall back to their own behavior. Attempting the request anyway would throw + * inside the SDK, since `elicitInput` checks the declared capability itself. + * + * `requestedSchema` is restricted by the spec to a flat object of primitives — + * no nesting. {@link assertElicitFormSchema} enforces that at tool construction. + */ +export async function requestElicitation( + /** Prompt explaining to the user what is being asked and why */ + message: string, + /** Flat, primitives-only JSON Schema describing the fields to collect */ + requestedSchema: ElicitRequestFormParams['requestedSchema'], +): Promise { + const session = getMcpSession(); + if (!session || !session.client.capabilities.has(McpClientCapability.Elicitation)) { + return undefined; + } + return await session.server.elicitInput({ mode: 'form', message, requestedSchema }); +} diff --git a/packages/mcp/mcp-server-base/src/server/build-server.ts b/packages/mcp/mcp-server-base/src/server/build-server.ts index 97f5f25d..582c519d 100644 --- a/packages/mcp/mcp-server-base/src/server/build-server.ts +++ b/packages/mcp/mcp-server-base/src/server/build-server.ts @@ -5,7 +5,12 @@ import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-sc import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { getRequestAuth, requestAuthContext } from '../auth-context.js'; +import { ASSUME_CAPABILITIES_ENV_VAR, assumedCapabilitiesFromEnv } from '../capabilities/assume.js'; +import { deriveClientCapabilities, describeCapabilities } from '../capabilities/derive.js'; +import { EMPTY_CAPABILITY_REPORT, type ClientCapabilityReport } from '../capabilities/types.js'; import { SimpleLogger } from '../clients/graphql/base.js'; +import { getRequestMcpCaller } from '../mcp-caller-context.js'; +import { mcpSessionContext } from '../mcp-session-context.js'; import { ensureLazyOAuthAuth, getLazyOAuthCredentials } from '../oauth/lazy-auth.js'; import { toolCallContext } from '../tool-call-context.js'; import { createErrorResult, createToolResult } from '../tools/helpers.js'; @@ -46,6 +51,23 @@ export function buildMcpServer(options: BuildMcpServerOptions): Server { logger.info(`Registered ${toolMap.size} tools`, { toolCount: toolMap.size }); + // Read once at construction: the value cannot change for a running process, + // and warning here means it appears in startup output rather than buried in a + // per-request log. + const assumed = assumedCapabilitiesFromEnv(); + if (assumed.capabilities.length > 0) { + logger.warn( + `${ASSUME_CAPABILITIES_ENV_VAR} is forcing client capabilities on. This is a local ` + + 'debugging aid and must not be set in production.', + { assumed: assumed.capabilities }, + ); + } + if (assumed.unknown.length > 0) { + logger.warn(`${ASSUME_CAPABILITIES_ENV_VAR} contains unrecognized entries, which are ignored`, { + unknown: assumed.unknown, + }); + } + const server = new Server( { name: options.name, version: options.version }, { @@ -54,21 +76,60 @@ export function buildMcpServer(options: BuildMcpServerOptions): Server { }, ); + /** + * Capabilities are fixed for a connection's lifetime, so derive once and reuse. + * `runMcpHttp` builds a fresh Server per session and stdio has exactly one, so + * caching on this closure stays correct per client. + */ + let cachedClient: ClientCapabilityReport | undefined; + const currentClient = (): ClientCapabilityReport => { + if (!cachedClient) { + const clientInfo = server.getClientVersion(); + if (!clientInfo && !server.getClientCapabilities()) { + // Pre-handshake: do not cache, a real report is coming. + return EMPTY_CAPABILITY_REPORT; + } + cachedClient = deriveClientCapabilities({ + capabilities: server.getClientCapabilities(), + clientInfo, + callerHeader: getRequestMcpCaller(), + assumeCapabilities: assumed.capabilities, + }); + } + return cachedClient; + }; + + server.oninitialized = () => { + const client = currentClient(); + logger.info('MCP client connected', { + host: client.host, + clientName: client.clientInfo?.name, + clientVersion: client.clientInfo?.version, + capabilities: describeCapabilities(client), + }); + }; + server.setRequestHandler(ListToolsRequestSchema, async () => { - logger.debug('Listing MCP tools'); - const toolList = Array.from(toolMap.entries()).map(([name, t]) => ({ - name: t.name, - description: t.description, - inputSchema: jsonSchemaCache.get(name) || { type: 'object', properties: {} }, - annotations: t.annotations, - })); + const client = currentClient(); + logger.debug('Listing MCP tools', { host: client.host }); + + const toolList = await mcpSessionContext.run({ client, server }, async () => + Array.from(toolMap.entries()).map(([name, t]) => ({ + name: t.name, + description: t.description, + inputSchema: jsonSchemaCache.get(name) || { type: 'object', properties: {} }, + annotations: t.annotations, + })), + ); + logger.info(`Returning ${toolList.length} tools`); return { tools: toolList }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; - logger.info(`Executing tool: ${name}`, { args: Object.keys(args || {}) }); + const client = currentClient(); + logger.info(`Executing tool: ${name}`, { args: Object.keys(args || {}), host: client.host }); try { const tool = toolMap.get(name); @@ -79,7 +140,10 @@ export function buildMcpServer(options: BuildMcpServerOptions): Server { const parseResult = tool.zodSchema.safeParse(args || {}); if (!parseResult.success) { const issues = parseResult.error.issues - .map((i: any) => `${i.path.join('.') || 'input'}: ${i.message}`) + .map( + (i: { path: PropertyKey[]; message: string }) => + `${i.path.join('.') || 'input'}: ${i.message}`, + ) .join('; '); const errorResult = createToolResult(false, undefined, `Invalid input: ${issues}`, { code: 'VALIDATION_ERROR', @@ -98,15 +162,14 @@ export function buildMcpServer(options: BuildMcpServerOptions): Server { oauthCredentials = getLazyOAuthCredentials(); } - const result = await toolCallContext.run( - { toolName: name, correlationId: randomUUID() }, - () => { + const result = await mcpSessionContext.run({ client, server }, () => + toolCallContext.run({ toolName: name, correlationId: randomUUID() }, () => { const execute = () => tool.handler(parseResult.data); if (toolRequiresAuth && !getRequestAuth() && oauthCredentials) { return requestAuthContext.run(oauthCredentials, execute); } return execute(); - }, + }), ); logger.debug(`Tool ${name} completed successfully`); diff --git a/packages/mcp/mcp-server-base/tests/assume-capabilities.test.ts b/packages/mcp/mcp-server-base/tests/assume-capabilities.test.ts new file mode 100644 index 00000000..d561aade --- /dev/null +++ b/packages/mcp/mcp-server-base/tests/assume-capabilities.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + ASSUME_CAPABILITIES_ENV_VAR, + assumedCapabilitiesFromEnv, + parseAssumedCapabilities, +} from '../src/capabilities/assume.js'; +import { deriveClientCapabilities } from '../src/capabilities/derive.js'; +import { McpClientCapability } from '../src/capabilities/types.js'; + +describe('parseAssumedCapabilities', () => { + it('returns nothing for an unset or blank value', () => { + for (const value of [undefined, '', ' ', ',,']) { + expect(parseAssumedCapabilities(value)).toEqual({ capabilities: [], unknown: [] }); + } + }); + + it('parses a comma-separated list, tolerating spacing and casing', () => { + expect(parseAssumedCapabilities(' mcp_app , elicitation ')).toEqual({ + capabilities: [McpClientCapability.McpApp, McpClientCapability.Elicitation], + unknown: [], + }); + }); + + it('de-duplicates repeated entries', () => { + expect(parseAssumedCapabilities('MCP_APP,MCP_APP').capabilities).toEqual([ + McpClientCapability.McpApp, + ]); + }); + + it('collects unrecognized entries instead of throwing, since a typo should still boot', () => { + const result = parseAssumedCapabilities('MCP_APP,SAMPLING,nonsense'); + expect(result.capabilities).toEqual([McpClientCapability.McpApp]); + expect(result.unknown).toEqual(['SAMPLING', 'nonsense']); + }); +}); + +describe('assumedCapabilitiesFromEnv', () => { + const original = process.env[ASSUME_CAPABILITIES_ENV_VAR]; + + afterEach(() => { + if (original === undefined) delete process.env[ASSUME_CAPABILITIES_ENV_VAR]; + else process.env[ASSUME_CAPABILITIES_ENV_VAR] = original; + }); + + it('reads the variable, and is empty when unset so the default stays strict negotiation', () => { + process.env[ASSUME_CAPABILITIES_ENV_VAR] = 'MCP_APP'; + expect(assumedCapabilitiesFromEnv().capabilities).toEqual([McpClientCapability.McpApp]); + + delete process.env[ASSUME_CAPABILITIES_ENV_VAR]; + expect(assumedCapabilitiesFromEnv().capabilities).toEqual([]); + }); +}); + +describe('deriveClientCapabilities with assumed capabilities', () => { + // The exact shape the v1 Inspector sends: an Apps tab, but nothing declared. + const inspectorSource = { + capabilities: {}, + clientInfo: { name: 'inspector', version: '1.0.1' }, + }; + + it('forces a capability on when told to', () => { + const report = deriveClientCapabilities({ + ...inspectorSource, + assumeCapabilities: [McpClientCapability.McpApp], + }); + expect(report.capabilities.has(McpClientCapability.McpApp)).toBe(true); + }); + + it('unions with what was genuinely detected rather than replacing it', () => { + const report = deriveClientCapabilities({ + capabilities: { elicitation: { form: {} } }, + assumeCapabilities: [McpClientCapability.McpApp], + }); + expect([...report.capabilities].sort()).toEqual([ + McpClientCapability.Elicitation, + McpClientCapability.McpApp, + ]); + }); + + it('changes nothing when the list is empty', () => { + const report = deriveClientCapabilities({ ...inspectorSource, assumeCapabilities: [] }); + expect([...report.capabilities]).toEqual([]); + }); +}); diff --git a/packages/mcp/mcp-server-base/tests/client-detection.test.ts b/packages/mcp/mcp-server-base/tests/client-detection.test.ts new file mode 100644 index 00000000..19a9540e --- /dev/null +++ b/packages/mcp/mcp-server-base/tests/client-detection.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { + HOST_PATTERNS, + HOST_QUIRKS, + quirksFor, + whatIsTheClient, +} from '../src/capabilities/client-detection.js'; +import { McpHostClient } from '../src/capabilities/types.js'; + +/** + * Names hosts have actually been observed reporting, with the host each belongs + * to. Shared by the identification cases and the no-overlap check below. + */ +const OBSERVED_NAMES: readonly (readonly [name: string, host: McpHostClient])[] = [ + ['claude-ai', McpHostClient.Claude], + ['claude.ai', McpHostClient.Claude], + ['Claude Desktop', McpHostClient.Claude], + ['claude-code', McpHostClient.ClaudeCode], + ['local-agent-mode-abc123', McpHostClient.ClaudeCode], + ['Cursor', McpHostClient.Cursor], + ['cursor-vscode', McpHostClient.Cursor], + ['Visual Studio Code', McpHostClient.VsCodeCopilot], + ['Code - OSS Dev', McpHostClient.VsCodeCopilot], + ['codex-mcp-client', McpHostClient.Codex], + ['gemini-cli-mcp-client', McpHostClient.Gemini], + // The name the official Inspector actually sends, confirmed off the wire. + ['inspector', McpHostClient.McpInspector], + ['mcp-inspector', McpHostClient.McpInspector], + ['mcp-inspector-tui', McpHostClient.McpInspector], +]; + +describe('whatIsTheClient', () => { + it.each(OBSERVED_NAMES)('identifies %s', (name, expected) => { + expect(whatIsTheClient({ name, version: '1.0.0' })).toBe(expected); + }); + + it('gives every observed name exactly one host', () => { + // What makes HOST_PATTERNS safe to key by host instead of ordering: with no + // two hosts matching the same name, iteration order cannot change the answer. + // An overlap would otherwise surface as attribution quietly landing on + // whichever host happened to be declared first. + for (const [name] of OBSERVED_NAMES) { + const claimedBy = Object.entries(HOST_PATTERNS) + .filter(([, patterns]) => patterns.some((pattern) => pattern.test(name.toLowerCase()))) + .map(([host]) => host); + expect(claimedBy, `"${name}" should be claimed by exactly one host`).toHaveLength(1); + } + }); + + it('tolerates the version and variant suffixes hosts append', () => { + // Every pattern ends at a word boundary rather than the end of the string, + // because a host that renames itself `cursor-2` should not become Unknown. + expect(whatIsTheClient({ name: 'cursor-2.1', version: '1' })).toBe(McpHostClient.Cursor); + expect(whatIsTheClient({ name: 'claude-code-cli', version: '1' })).toBe( + McpHostClient.ClaudeCode, + ); + // Gemini is matched by family rather than by its one observed name, so a + // differently named Gemini surface still lands in the right bucket. + expect(whatIsTheClient({ name: 'gemini-cli', version: '1' })).toBe(McpHostClient.Gemini); + }); + + it('does not claim a client that merely mentions a known name', () => { + // Anchoring is the point: a fork or a proxy fronting another tool would + // otherwise inherit that tool's identity and quietly corrupt attribution. + for (const name of ['my-cursor-fork', 'not-claude', 'proxy-for-vscode', 'not-inspector']) { + expect(whatIsTheClient({ name, version: '1' })).toBe(McpHostClient.Unknown); + } + }); + + it('does not guess at an unseen Claude surface', () => { + // Deliberately no bare `claude` catch-all: it would also match `claude-code`, + // and resolving that overlap by declaration order is what keying this table by + // host is meant to rule out. A new surface shows up in the handshake log, and + // gets a pattern once its real name is known. + expect(whatIsTheClient({ name: 'claude', version: '1' })).toBe(McpHostClient.Unknown); + expect(whatIsTheClient({ name: 'claude-code', version: '1' })).toBe(McpHostClient.ClaudeCode); + }); + + it('returns Unknown rather than throwing for an unrecognized host', () => { + expect(whatIsTheClient({ name: 'totally-new-agent', version: '9' })).toBe( + McpHostClient.Unknown, + ); + }); + + it('returns Unknown when nothing is provided', () => { + expect(whatIsTheClient()).toBe(McpHostClient.Unknown); + }); + + it('ignores blank names and falls through to the caller header', () => { + expect(whatIsTheClient({ name: ' ', version: '1' }, 'gemini-cli')).toBe(McpHostClient.Gemini); + }); + + it('uses clientInfo in preference to the caller header', () => { + expect(whatIsTheClient({ name: 'cursor', version: '1' }, 'claude-ai')).toBe( + McpHostClient.Cursor, + ); + }); + + it('falls back to the caller header when clientInfo is absent', () => { + expect(whatIsTheClient(undefined, 'cursor')).toBe(McpHostClient.Cursor); + }); +}); + +describe('quirksFor', () => { + it('reports no quirks for a host without an entry', () => { + // The registry is empty today, so this is every host. It stays meaningful as + // entries are added: a host nobody wrote a workaround for must not inherit + // someone else's. + expect(HOST_QUIRKS[McpHostClient.Claude]).toBeUndefined(); + expect(quirksFor(McpHostClient.Claude)).toEqual({}); + expect(quirksFor(McpHostClient.Unknown)).toEqual({}); + }); +}); diff --git a/packages/mcp/mcp-server-base/tests/derive-capabilities.test.ts b/packages/mcp/mcp-server-base/tests/derive-capabilities.test.ts new file mode 100644 index 00000000..30995ede --- /dev/null +++ b/packages/mcp/mcp-server-base/tests/derive-capabilities.test.ts @@ -0,0 +1,105 @@ +import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js'; +import { describe, expect, it } from 'vitest'; + +import { deriveClientCapabilities, describeCapabilities } from '../src/capabilities/derive.js'; +import { + MCP_APP_MIME_TYPE, + MCP_UI_EXTENSION_ID, + McpClientCapability, + McpHostClient, +} from '../src/capabilities/types.js'; + +describe('deriveClientCapabilities', () => { + it('detects nothing for a client that declares nothing', () => { + const report = deriveClientCapabilities({ capabilities: {} }); + expect([...report.capabilities]).toEqual([]); + expect(report.host).toBe(McpHostClient.Unknown); + }); + + it('detects nothing when there are no capabilities at all', () => { + const report = deriveClientCapabilities({}); + expect([...report.capabilities]).toEqual([]); + }); + + it('detects form elicitation', () => { + const report = deriveClientCapabilities({ capabilities: { elicitation: { form: {} } } }); + expect(report.capabilities.has(McpClientCapability.Elicitation)).toBe(true); + expect(report.capabilities.has(McpClientCapability.ElicitationUrl)).toBe(false); + }); + + it('detects url elicitation independently of form elicitation', () => { + const report = deriveClientCapabilities({ capabilities: { elicitation: { url: {} } } }); + expect(report.capabilities.has(McpClientCapability.ElicitationUrl)).toBe(true); + expect(report.capabilities.has(McpClientCapability.Elicitation)).toBe(false); + }); + + it('treats a bare elicitation object as form support', () => { + // The SDK's schema preprocesses `elicitation: {}` into `{ form: {} }`, so a + // client declaring it that way must not be read as declaring nothing. + const capabilities = { elicitation: {} } as unknown as ClientCapabilities; + const report = deriveClientCapabilities({ capabilities }); + expect(report.capabilities.has(McpClientCapability.Elicitation)).toBe(true); + }); + + it('detects MCP Apps when the extension declares the HTML profile', () => { + const report = deriveClientCapabilities({ + capabilities: { + extensions: { [MCP_UI_EXTENSION_ID]: { mimeTypes: [MCP_APP_MIME_TYPE] } }, + }, + }); + expect(report.capabilities.has(McpClientCapability.McpApp)).toBe(true); + }); + + it('detects MCP Apps when the extension omits mimeTypes', () => { + const report = deriveClientCapabilities({ + capabilities: { extensions: { [MCP_UI_EXTENSION_ID]: {} } }, + }); + expect(report.capabilities.has(McpClientCapability.McpApp)).toBe(true); + }); + + it('does not detect MCP Apps when the host only supports other mime types', () => { + const report = deriveClientCapabilities({ + capabilities: { + extensions: { [MCP_UI_EXTENSION_ID]: { mimeTypes: ['application/vnd.future+json'] } }, + }, + }); + expect(report.capabilities.has(McpClientCapability.McpApp)).toBe(false); + }); + + it('does not detect MCP Apps from an unrelated extension', () => { + const report = deriveClientCapabilities({ + capabilities: { extensions: { 'com.example/other': {} } }, + }); + expect(report.capabilities.has(McpClientCapability.McpApp)).toBe(false); + }); + + it('combines elicitation and MCP Apps', () => { + const report = deriveClientCapabilities({ + capabilities: { + elicitation: { form: {} }, + extensions: { [MCP_UI_EXTENSION_ID]: { mimeTypes: [MCP_APP_MIME_TYPE] } }, + }, + }); + expect(describeCapabilities(report)).toEqual([ + McpClientCapability.Elicitation, + McpClientCapability.McpApp, + ]); + }); + + it('identifies the host and retains clientInfo for logging', () => { + const report = deriveClientCapabilities({ + capabilities: {}, + clientInfo: { name: 'claude-ai', version: '1.2.3' }, + }); + expect(report.host).toBe(McpHostClient.Claude); + expect(report.clientInfo).toEqual({ name: 'claude-ai', version: '1.2.3' }); + }); + + it('falls back to the caller header when clientInfo is unrecognized', () => { + const report = deriveClientCapabilities({ + clientInfo: { name: 'some-internal-proxy', version: '0.0.1' }, + callerHeader: 'cursor', + }); + expect(report.host).toBe(McpHostClient.Cursor); + }); +}); diff --git a/packages/mcp/mcp-server-base/tests/mcp-session-context.test.ts b/packages/mcp/mcp-server-base/tests/mcp-session-context.test.ts new file mode 100644 index 00000000..37b8767b --- /dev/null +++ b/packages/mcp/mcp-server-base/tests/mcp-session-context.test.ts @@ -0,0 +1,93 @@ +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/sdk/types.js'; +import { describe, expect, it, vi } from 'vitest'; + +import { McpClientCapability, McpHostClient } from '../src/capabilities/types.js'; +import { + getMcpSession, + hasCapability, + mcpSessionContext, + requestElicitation, + type McpSession, +} from '../src/mcp-session-context.js'; +// The SDK's own type rather than this package's `ElicitFormSchema` alias, so a +// test of the session layer does not reach into the tool-variant module. +const SCHEMA: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { name: { type: 'string', description: 'Name to greet in the response.' } }, +}; + +/** A session whose server records what the layer asked the host for. */ +function sessionWith( + capabilities: McpClientCapability[], + answer: ElicitResult = { action: 'accept', content: { name: 'Katherine' } }, +): { session: McpSession; elicitInput: ReturnType } { + const elicitInput = vi.fn().mockResolvedValue(answer); + return { + session: { + client: { capabilities: new Set(capabilities), host: McpHostClient.Claude }, + server: { elicitInput } as unknown as Server, + }, + elicitInput, + }; +} + +describe('getMcpSession and hasCapability', () => { + it('report no session and no capabilities outside a request', () => { + // A unit test that calls a handler directly has no session, and that has to + // mean "take the baseline path" rather than "throw". + expect(getMcpSession()).toBeUndefined(); + expect(hasCapability(McpClientCapability.McpApp)).toBe(false); + }); + + it('expose the host being served inside a request', () => { + const { session } = sessionWith([McpClientCapability.McpApp]); + + mcpSessionContext.run(session, () => { + expect(getMcpSession()?.client.host).toBe(McpHostClient.Claude); + expect(hasCapability(McpClientCapability.McpApp)).toBe(true); + expect(hasCapability(McpClientCapability.Elicitation)).toBe(false); + }); + }); +}); + +describe('requestElicitation', () => { + it('returns undefined outside a request, since there is no host to ask', async () => { + await expect(requestElicitation('Who?', SCHEMA)).resolves.toBeUndefined(); + }); + + it('does not ask a host that never declared elicitation', async () => { + // The gate matters: the SDK's elicitInput checks the declared capability and + // throws, so skipping it here is what lets a handler fall back cleanly + // instead of failing the tool call. + const { session, elicitInput } = sessionWith([McpClientCapability.McpApp]); + + await mcpSessionContext.run(session, async () => { + await expect(requestElicitation('Who?', SCHEMA)).resolves.toBeUndefined(); + }); + expect(elicitInput).not.toHaveBeenCalled(); + }); + + it('asks in form mode and returns the answer the host collected', async () => { + const { session, elicitInput } = sessionWith([McpClientCapability.Elicitation]); + + await mcpSessionContext.run(session, async () => { + const result = await requestElicitation('Who should this greeting be addressed to?', SCHEMA); + expect(result).toEqual({ action: 'accept', content: { name: 'Katherine' } }); + }); + + expect(elicitInput).toHaveBeenCalledWith({ + mode: 'form', + message: 'Who should this greeting be addressed to?', + requestedSchema: SCHEMA, + }); + }); + + it('passes a declined form back to the caller rather than treating it as an answer', async () => { + const { session } = sessionWith([McpClientCapability.Elicitation], { action: 'decline' }); + + await mcpSessionContext.run(session, async () => { + await expect(requestElicitation('Who?', SCHEMA)).resolves.toEqual({ action: 'decline' }); + }); + }); +}); From 3301163aaaec5ddd65089ac48e6b20e98824cc3c Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:20:27 -0700 Subject: [PATCH 03/19] feat(mcp-server-base): attribute stdio sessions to the detected host --- .../mcp-caller-attribution-from-host.md | 7 +++ packages/mcp/README.md | 4 ++ .../src/clients/graphql/base.ts | 4 +- .../src/clients/rest-client.ts | 6 +-- packages/mcp/mcp-server-base/src/index.ts | 1 + .../mcp-server-base/src/mcp-caller-context.ts | 19 +++++++ .../tests/mcp-caller-context.test.ts | 53 ++++++++++++++++++- 7 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 .changeset/mcp-caller-attribution-from-host.md diff --git a/.changeset/mcp-caller-attribution-from-host.md b/.changeset/mcp-caller-attribution-from-host.md new file mode 100644 index 00000000..f1e54854 --- /dev/null +++ b/.changeset/mcp-caller-attribution-from-host.md @@ -0,0 +1,7 @@ +--- +'@transcend-io/mcp-server-base': patch +--- + +Fall back to the host detected at `initialize` when setting `x-transcend-mcp-caller` on outbound Transcend requests, so stdio sessions carry usage attribution they previously had no way to send. + +An explicitly forwarded header still takes precedence, since a caller proxying on a user's behalf knows its own identity better than we can infer it. Nothing is sent when the host could not be identified, rather than guessing. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index e1ad9faf..f884b3bc 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -299,6 +299,10 @@ Only two capabilities are detected, because they are the only ones we can act on Sampling and roots are deliberately excluded. Roots is inert for these servers (they are API-backed, so there is no filesystem scope to negotiate), our target hosts do not implement sampling, and both are deprecated as of the 2026-07-28 spec under SEP-2577. +### Usage attribution + +Outbound Transcend requests carry `x-transcend-mcp-caller`. An explicitly forwarded header always wins, since a caller proxying on a user's behalf knows its own identity best. Otherwise the header falls back to the host detected at `initialize`, which gives stdio sessions attribution they previously had no way to send. The resolved host and capability set are also logged once per session. + ## Environment variables All servers share the same environment variables: diff --git a/packages/mcp/mcp-server-base/src/clients/graphql/base.ts b/packages/mcp/mcp-server-base/src/clients/graphql/base.ts index 7c1ab796..58044958 100644 --- a/packages/mcp/mcp-server-base/src/clients/graphql/base.ts +++ b/packages/mcp/mcp-server-base/src/clients/graphql/base.ts @@ -6,7 +6,7 @@ import { type AuthCredentials, authHeaders } from '../../auth.js'; import { DEFAULT_TRANSCEND_API_URL } from '../../defaults.js'; import { ToolError, ErrorCode, classifyGraphQLErrors, classifyHttpError } from '../../errors.js'; import { MCP_CALLER_HEADER, TOOLCALL_ID_HEADER } from '../../http-header-names.js'; -import { getRequestMcpCaller } from '../../mcp-caller-context.js'; +import { resolveMcpCallerAttribution } from '../../mcp-caller-context.js'; import { getToolCallIdHeader } from '../../tool-call-context.js'; import type { PaginatedResponse, RequestOptions } from '../../types/transcend.js'; import { TRANSCEND_MCP_USER_AGENT } from '../mcp-user-agent.js'; @@ -201,7 +201,7 @@ export class TranscendGraphQLBase { } const toolCallId = getToolCallIdHeader(); - const mcpCaller = getRequestMcpCaller(); + const mcpCaller = resolveMcpCallerAttribution(); const response = await fetch(url, { method: 'POST', headers: { diff --git a/packages/mcp/mcp-server-base/src/clients/rest-client.ts b/packages/mcp/mcp-server-base/src/clients/rest-client.ts index 3013f3a7..cde270b2 100644 --- a/packages/mcp/mcp-server-base/src/clients/rest-client.ts +++ b/packages/mcp/mcp-server-base/src/clients/rest-client.ts @@ -7,7 +7,7 @@ import { TRANSCEND_VERSION_HEADER, TRANSCEND_VERSION_HEADER_VALUE, } from '../http-header-names.js'; -import { getRequestMcpCaller } from '../mcp-caller-context.js'; +import { resolveMcpCallerAttribution } from '../mcp-caller-context.js'; import { getToolCallIdHeader } from '../tool-call-context.js'; import type { DSRSubmission, @@ -73,7 +73,7 @@ export class TranscendRestClient { } = options; const toolCallId = getToolCallIdHeader(); - const mcpCaller = getRequestMcpCaller(); + const mcpCaller = resolveMcpCallerAttribution(); const headers: Record = { ...authHeaders(effectiveAuth), 'Content-Type': 'application/json', @@ -203,7 +203,7 @@ export class TranscendRestClient { } const url = `${this.baseUrl}/v1/files?key=${encodeURIComponent(downloadKey)}`; const toolCallId = getToolCallIdHeader(); - const mcpCaller = getRequestMcpCaller(); + const mcpCaller = resolveMcpCallerAttribution(); const response = await fetch(url, { headers: { ...authHeaders(effectiveAuth), diff --git a/packages/mcp/mcp-server-base/src/index.ts b/packages/mcp/mcp-server-base/src/index.ts index 42ba8b23..e226f4dd 100644 --- a/packages/mcp/mcp-server-base/src/index.ts +++ b/packages/mcp/mcp-server-base/src/index.ts @@ -8,6 +8,7 @@ export { extractMcpCallerFromHeaders, getRequestMcpCaller, requestMcpCallerContext, + resolveMcpCallerAttribution, } from './mcp-caller-context.js'; export { diff --git a/packages/mcp/mcp-server-base/src/mcp-caller-context.ts b/packages/mcp/mcp-server-base/src/mcp-caller-context.ts index c9a013fd..b95c405f 100644 --- a/packages/mcp/mcp-server-base/src/mcp-caller-context.ts +++ b/packages/mcp/mcp-server-base/src/mcp-caller-context.ts @@ -1,6 +1,8 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { McpHostClient } from './capabilities/types.js'; import { MCP_CALLER_HEADER } from './http-header-names.js'; +import { getMcpSession } from './mcp-session-context.js'; export { MCP_CALLER_HEADER }; @@ -18,6 +20,23 @@ export function getRequestMcpCaller(): string | undefined { return requestMcpCallerContext.getStore(); } +/** + * Value to send as {@link MCP_CALLER_HEADER} on outbound Transcend requests. + * + * An explicitly forwarded header always wins, since a caller proxying on a + * user's behalf knows its own identity better than we can infer it. Otherwise + * falls back to the host detected from the MCP `initialize` handshake, which is + * the only attribution available on stdio — those sessions previously sent + * nothing at all. + */ +export function resolveMcpCallerAttribution(): string | undefined { + const forwarded = getRequestMcpCaller(); + if (forwarded) return forwarded; + + const host = getMcpSession()?.client.host; + return host && host !== McpHostClient.Unknown ? host : undefined; +} + /** Normalizes Node / Express header values to a list of strings (drops non-string entries). */ function headerValuesAsStrings(value: string | string[] | undefined): string[] { if (value === undefined) return []; diff --git a/packages/mcp/mcp-server-base/tests/mcp-caller-context.test.ts b/packages/mcp/mcp-server-base/tests/mcp-caller-context.test.ts index 6bc6a9b2..41a7c30e 100644 --- a/packages/mcp/mcp-server-base/tests/mcp-caller-context.test.ts +++ b/packages/mcp/mcp-server-base/tests/mcp-caller-context.test.ts @@ -1,6 +1,29 @@ +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { describe, it, expect } from 'vitest'; -import { MCP_CALLER_HEADER, extractMcpCallerFromHeaders } from '../src/mcp-caller-context.js'; +import { McpClientCapability, McpHostClient } from '../src/capabilities/types.js'; +import { + MCP_CALLER_HEADER, + extractMcpCallerFromHeaders, + requestMcpCallerContext, + resolveMcpCallerAttribution, +} from '../src/mcp-caller-context.js'; +import { mcpSessionContext } from '../src/mcp-session-context.js'; + +/** Runs `read` as if serving a host, with an optional forwarded caller header. */ +function asRequest( + host: McpHostClient, + forwarded: string | undefined, + read: () => string | undefined, +): string | undefined { + const session = { + client: { capabilities: new Set(), host }, + server: {} as Server, + }; + return mcpSessionContext.run(session, () => + forwarded === undefined ? read() : requestMcpCallerContext.run(forwarded, read), + ); +} describe('extractMcpCallerFromHeaders', () => { it('returns trimmed string values', () => { @@ -25,3 +48,31 @@ describe('extractMcpCallerFromHeaders', () => { ).toBe('zed'); }); }); + +describe('resolveMcpCallerAttribution', () => { + it('prefers a forwarded header over the host we detected', () => { + // A caller proxying on a user's behalf knows its own identity better than we + // can infer it, and it is the one being billed for the traffic. + expect( + asRequest(McpHostClient.Claude, 'partner-integration', resolveMcpCallerAttribution), + ).toBe('partner-integration'); + }); + + it('falls back to the detected host when no header was forwarded', () => { + // This is the whole point of the fallback: stdio has no headers, so those + // sessions previously reached the API with no attribution at all. + expect(asRequest(McpHostClient.Cursor, undefined, resolveMcpCallerAttribution)).toBe( + McpHostClient.Cursor, + ); + }); + + it('sends nothing for an unrecognized host, rather than attributing traffic to "UNKNOWN"', () => { + expect( + asRequest(McpHostClient.Unknown, undefined, resolveMcpCallerAttribution), + ).toBeUndefined(); + }); + + it('sends nothing outside a request, so a direct handler call attributes nothing', () => { + expect(resolveMcpCallerAttribution()).toBeUndefined(); + }); +}); From 00c49271f11060fa276c28451d2f77cbe16553b1 Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:23:05 -0700 Subject: [PATCH 04/19] feat(mcp): serve ui:// views and per-capability tool variants --- .../mcp-ui-resources-and-tool-variants.md | 10 + packages/mcp/README.md | 34 ++ packages/mcp/mcp-server-base/src/index.ts | 40 ++- .../src/server/build-server.ts | 195 +++++++++-- .../tools/define-tool-with-capabilities.ts | 280 ++++++++++++++++ .../mcp/mcp-server-base/src/tools/types.ts | 49 +++ .../mcp-server-base/src/tools/ui-resource.ts | 160 +++++++++ .../tests/build-server.test.ts | 299 ++++++++++++++++- .../define-tool-with-capabilities.test.ts | 315 ++++++++++++++++++ .../mcp-server-base/tests/ui-resource.test.ts | 133 ++++++++ packages/mcp/mcp/src/registry.ts | 54 ++- 11 files changed, 1535 insertions(+), 34 deletions(-) create mode 100644 .changeset/mcp-ui-resources-and-tool-variants.md create mode 100644 packages/mcp/mcp-server-base/src/tools/define-tool-with-capabilities.ts create mode 100644 packages/mcp/mcp-server-base/src/tools/ui-resource.ts create mode 100644 packages/mcp/mcp-server-base/tests/define-tool-with-capabilities.test.ts create mode 100644 packages/mcp/mcp-server-base/tests/ui-resource.test.ts diff --git a/.changeset/mcp-ui-resources-and-tool-variants.md b/.changeset/mcp-ui-resources-and-tool-variants.md new file mode 100644 index 00000000..8b757332 --- /dev/null +++ b/.changeset/mcp-ui-resources-and-tool-variants.md @@ -0,0 +1,10 @@ +--- +'@transcend-io/mcp-server-base': minor +'@transcend-io/mcp': minor +--- + +Serve `ui://` HTML resources and resolve tools to a per-capability variant, so one tool definition can return plain text to a scripted client, a form to a host that supports elicitation, and an interactive view to a host that supports MCP Apps (SEP-1865). + +`defineToolWithCapabilities` declares the variants; `buildMcpServer` resolves them per connection and registers `resources/list` and `resources/read` for any bound views. Tools carry a `_meta.ui.resourceUri` binding, emitted in both the canonical nested and deprecated flat forms because hosts shipped against the earlier draft still read the flat key. App-only tools stay callable through `tools/call` while being hidden from `tools/list`, so a view can reach its own helpers without cluttering the model's tool set. + +For a server with no views nothing changes on the wire: the `resources` capability is only declared when at least one `ui://` resource exists, so those handshakes stay byte-identical. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index f884b3bc..a2900697 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -299,6 +299,40 @@ Only two capabilities are detected, because they are the only ones we can act on Sampling and roots are deliberately excluded. Roots is inert for these servers (they are API-backed, so there is no filesystem scope to negotiate), our target hosts do not implement sampling, and both are deprecated as of the 2026-07-28 spec under SEP-2577. +### Adding variants to a tool + +Use `defineToolWithCapabilities` instead of `defineTool`. The inherited `handler` is the required baseline; each variant is optional: + +```typescript +defineToolWithCapabilities({ + name: 'my_tool', + // ...everything defineTool takes; this handler is the baseline + handler: async (args) => plainTextResult(args), + variants: { + [McpClientCapability.Elicitation]: { + elicitMessage: 'Which environment should this apply to?', + elicitSchema: { type: 'object', properties: { env: { type: 'string', description: '...' } } }, + handler: async (args) => withForm(args), + }, + [McpClientCapability.McpApp]: { + resource: MY_VIEW, + handler: async (args) => richPayload(args), + appOnlyTools: [refreshTool], + }, + }, +}); +``` + +Precedence is fixed at MCP App, then elicitation, then baseline, so `tools/list` and `tools/call` always agree for a given host. Note that `elicitSchema` is not a Zod schema: the spec restricts elicitation to a flat object of primitives, so it cannot reuse a tool's `zodSchema`. Both that restriction and the usual description requirements are validated at construction, so mistakes fail in CI rather than mid-conversation. + +`appOnlyTools` are emitted with `visibility: ['app']`, which keeps them callable by the view via `tools/call` but hides them from the model. + +### What this changes on the wire + +For a server with no views, nothing: the `resources` capability is only declared when at least one `ui://` resource exists, so those handshakes stay byte-identical. + +For a server with views, `resources/list` and `resources/read` are registered, tools carry a `_meta.ui.resourceUri` binding, and `resources/read` returns the HTML with the `text/html;profile=mcp-app` MIME type. The binding is emitted regardless of host support, since the spec's degradation path is that hosts without the extension ignore it and show the text result. + ### Usage attribution Outbound Transcend requests carry `x-transcend-mcp-caller`. An explicitly forwarded header always wins, since a caller proxying on a user's behalf knows its own identity best. Otherwise the header falls back to the host detected at `initialize`, which gives stdio sessions attribution they previously had no way to send. The resolved host and capability set are also logged once per session. diff --git a/packages/mcp/mcp-server-base/src/index.ts b/packages/mcp/mcp-server-base/src/index.ts index e226f4dd..768cda41 100644 --- a/packages/mcp/mcp-server-base/src/index.ts +++ b/packages/mcp/mcp-server-base/src/index.ts @@ -68,8 +68,44 @@ export { } from './validation/schemas.js'; export { collectMissingDescriptions, MIN_DESCRIPTION_LENGTH } from './validation/describe-audit.js'; -export type { ToolAnnotations, ToolDefinition, ToolClients } from './tools/types.js'; -export { defineTool } from './tools/types.js'; +export type { + ToolAnnotations, + ToolClients, + ToolDefinition, + ToolUiBinding, + ToolVisibility, +} from './tools/types.js'; +export { DEFAULT_TOOL_VISIBILITY, defineTool, isVisibleToModel } from './tools/types.js'; + +export { + MCP_APP_MIME_TYPE, + MCP_UI_EXTENSION_ID, + UI_URI_SCHEME, + assertHtmlDocument, + buildUiResourceMeta, + defineUiResource, + readUiResourceHtml, +} from './tools/ui-resource.js'; +export type { + UiResourceCsp, + UiResourceDefinition, + UiResourcePermissions, +} from './tools/ui-resource.js'; + +export { + assertElicitFormSchema, + defineToolWithCapabilities, + expandToolsForClient, + isCapabilityAwareTool, + resolveToolVariant, +} from './tools/define-tool-with-capabilities.js'; +export type { + CapabilityAwareToolDefinition, + ElicitFormSchema, + ElicitationVariant, + McpAppVariant, + ToolVariants, +} from './tools/define-tool-with-capabilities.js'; export { createToolResult, createErrorResult, createListResult, groupBy } from './tools/helpers.js'; diff --git a/packages/mcp/mcp-server-base/src/server/build-server.ts b/packages/mcp/mcp-server-base/src/server/build-server.ts index 582c519d..2df67baf 100644 --- a/packages/mcp/mcp-server-base/src/server/build-server.ts +++ b/packages/mcp/mcp-server-base/src/server/build-server.ts @@ -2,19 +2,38 @@ import { randomUUID } from 'node:crypto'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; import { getRequestAuth, requestAuthContext } from '../auth-context.js'; import { ASSUME_CAPABILITIES_ENV_VAR, assumedCapabilitiesFromEnv } from '../capabilities/assume.js'; import { deriveClientCapabilities, describeCapabilities } from '../capabilities/derive.js'; -import { EMPTY_CAPABILITY_REPORT, type ClientCapabilityReport } from '../capabilities/types.js'; +import { + EMPTY_CAPABILITY_REPORT, + McpClientCapability, + type ClientCapabilityReport, +} from '../capabilities/types.js'; import { SimpleLogger } from '../clients/graphql/base.js'; import { getRequestMcpCaller } from '../mcp-caller-context.js'; import { mcpSessionContext } from '../mcp-session-context.js'; import { ensureLazyOAuthAuth, getLazyOAuthCredentials } from '../oauth/lazy-auth.js'; import { toolCallContext } from '../tool-call-context.js'; +import { + expandToolsForClient, + isCapabilityAwareTool, +} from '../tools/define-tool-with-capabilities.js'; import { createErrorResult, createToolResult } from '../tools/helpers.js'; -import type { ToolDefinition } from '../tools/types.js'; +import { isVisibleToModel, type ToolDefinition } from '../tools/types.js'; +import { + buildUiResourceMeta, + MCP_APP_MIME_TYPE, + readUiResourceHtml, + type UiResourceDefinition, +} from '../tools/ui-resource.js'; export interface BuildMcpServerOptions { /** Server display name */ @@ -27,29 +46,93 @@ export interface BuildMcpServerOptions { instructions?: string; } +/** + * Every UI resource any tool could ever bind to, regardless of which variant a + * given host resolves to. + * + * Collected up front so the `resources` capability can be declared at + * construction time, before any client has connected. Hosts are also allowed to + * prefetch a `ui://` resource before calling the tool that references it, so + * `resources/read` has to answer for all of them, not just the active variant. + */ +function collectUiResources( + tools: readonly ToolDefinition[], + logger: SimpleLogger, +): Map { + const resources = new Map(); + + const add = (resource: UiResourceDefinition, owner: string): void => { + const existing = resources.get(resource.uri); + if (existing && existing !== resource) { + throw new Error( + `UI resource uri "${resource.uri}" is declared twice with different definitions ` + + `(most recently by "${owner}"). Share one definition or give each view its own uri.`, + ); + } + resources.set(resource.uri, resource); + }; + + for (const tool of tools) { + if (tool.ui) add(tool.ui.resource, tool.name); + if (!isCapabilityAwareTool(tool)) continue; + const mcpApp = tool.variants[McpClientCapability.McpApp]; + if (mcpApp) add(mcpApp.resource, tool.name); + } + + if (resources.size > 0) { + logger.info(`Registered ${resources.size} MCP App UI resources`, { + uris: [...resources.keys()], + }); + } + return resources; +} + +/** + * Serializes the `_meta` a host reads to find a tool's view. + * + * Emits both the canonical nested `ui.resourceUri` and the deprecated flat + * `ui/resourceUri`, because hosts shipped against the earlier draft still look + * for the flat key and the spec's own compatibility guidance is to send both. + */ +function buildToolMeta(tool: ToolDefinition): Record | undefined { + if (!tool.ui) return undefined; + const resourceUri = tool.ui.resource.uri; + return { + ui: { + resourceUri, + ...(tool.visibility && { visibility: tool.visibility }), + }, + 'ui/resourceUri': resourceUri, + }; +} + /** * Creates an MCP {@link Server} with ListTools and CallTool handlers registered * from the given tool definitions. Does not connect any transport — the caller * is responsible for creating a transport and calling `server.connect(transport)`. + * + * Tools built with `defineToolWithCapabilities` are resolved per connection, so + * the same registration serves a plain text result to one host and an MCP App + * view to another. */ export function buildMcpServer(options: BuildMcpServerOptions): Server { const logger = new SimpleLogger(); - const toolMap = new Map(); const jsonSchemaCache = new Map>(); + const registered: ToolDefinition[] = []; + const seenNames = new Set(); for (const tool of options.tools) { - if (toolMap.has(tool.name)) { + if (seenNames.has(tool.name)) { logger.warn(`Duplicate tool name "${tool.name}" — skipping`); continue; } - toolMap.set(tool.name, tool); - jsonSchemaCache.set( - tool.name, - toJsonSchemaCompat(tool.zodSchema as any) as Record, - ); + seenNames.add(tool.name); + registered.push(tool); } - logger.info(`Registered ${toolMap.size} tools`, { toolCount: toolMap.size }); + const uiResources = collectUiResources(registered, logger); + + logger.info(`Registered ${registered.length} tools`, { toolCount: registered.length }); // Read once at construction: the value cannot change for a running process, // and warning here means it appears in startup output rather than buried in a @@ -71,7 +154,12 @@ export function buildMcpServer(options: BuildMcpServerOptions): Server { const server = new Server( { name: options.name, version: options.version }, { - capabilities: { tools: {} }, + capabilities: { + tools: {}, + // Only advertise resources when there is something to serve, so servers + // with no views negotiate exactly as they did before MCP Apps existed. + ...(uiResources.size > 0 && { resources: {} }), + }, ...(options.instructions ? { instructions: options.instructions } : {}), }, ); @@ -109,30 +197,97 @@ export function buildMcpServer(options: BuildMcpServerOptions): Server { }); }; + /** Tool set for the current client, keyed by name for dispatch. */ + const toolsForClient = (client: ClientCapabilityReport): Map => { + const map = new Map(); + for (const tool of expandToolsForClient(registered, client)) { + if (!map.has(tool.name)) map.set(tool.name, tool); + } + return map; + }; + + /** + * JSON Schema derivation is the expensive part, and a variant can carry a + * different input schema than its baseline, so cache per tool name plus + * resolved handler rather than per tool name alone. + */ + const inputSchemaFor = (tool: ToolDefinition): Record => { + const cacheKey = `${tool.name}:${tool.ui?.resource.uri ?? ''}`; + const cached = jsonSchemaCache.get(cacheKey); + if (cached) return cached; + const schema = toJsonSchemaCompat(tool.zodSchema as never) as Record; + jsonSchemaCache.set(cacheKey, schema); + return schema; + }; + server.setRequestHandler(ListToolsRequestSchema, async () => { const client = currentClient(); logger.debug('Listing MCP tools', { host: client.host }); const toolList = await mcpSessionContext.run({ client, server }, async () => - Array.from(toolMap.entries()).map(([name, t]) => ({ - name: t.name, - description: t.description, - inputSchema: jsonSchemaCache.get(name) || { type: 'object', properties: {} }, - annotations: t.annotations, - })), + [...toolsForClient(client).values()] + .filter((tool) => isVisibleToModel(tool)) + .map((tool) => { + const meta = buildToolMeta(tool); + return { + name: tool.name, + description: tool.description, + inputSchema: inputSchemaFor(tool), + annotations: tool.annotations, + ...(meta && { _meta: meta }), + }; + }), ); logger.info(`Returning ${toolList.length} tools`); return { tools: toolList }; }); + if (uiResources.size > 0) { + server.setRequestHandler(ListResourcesRequestSchema, async () => { + logger.debug('Listing MCP App UI resources'); + return { + resources: [...uiResources.values()].map((resource) => ({ + uri: resource.uri, + name: resource.name, + mimeType: MCP_APP_MIME_TYPE, + ...(resource.description && { description: resource.description }), + })), + }; + }); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + const resource = uiResources.get(uri); + if (!resource) { + throw new Error( + `Unknown resource uri "${uri}". This server serves ${uiResources.size} UI ` + + `resource(s): ${[...uiResources.keys()].join(', ')}.`, + ); + } + + logger.debug(`Reading UI resource ${uri}`); + const meta = buildUiResourceMeta(resource); + return { + contents: [ + { + uri: resource.uri, + mimeType: MCP_APP_MIME_TYPE, + text: await readUiResourceHtml(resource), + ...(meta && { _meta: meta }), + }, + ], + }; + }); + } + server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const client = currentClient(); logger.info(`Executing tool: ${name}`, { args: Object.keys(args || {}), host: client.host }); try { - const tool = toolMap.get(name); + const tool = toolsForClient(client).get(name); if (!tool) { throw new Error(`Unknown tool: ${name}`); } @@ -173,8 +328,10 @@ export function buildMcpServer(options: BuildMcpServerOptions): Server { ); logger.debug(`Tool ${name} completed successfully`); + const meta = buildToolMeta(tool); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + ...(meta && { _meta: meta }), }; } catch (error) { logger.error(`Error executing tool ${name}:`, error); diff --git a/packages/mcp/mcp-server-base/src/tools/define-tool-with-capabilities.ts b/packages/mcp/mcp-server-base/src/tools/define-tool-with-capabilities.ts new file mode 100644 index 00000000..40a8c8e6 --- /dev/null +++ b/packages/mcp/mcp-server-base/src/tools/define-tool-with-capabilities.ts @@ -0,0 +1,280 @@ +import { + PrimitiveSchemaDefinitionSchema, + type ElicitRequestFormParams, +} from '@modelcontextprotocol/sdk/types.js'; +import { type z } from 'zod'; + +import { McpClientCapability, type ClientCapabilityReport } from '../capabilities/types.js'; +import { collectMissingDescriptions } from '../validation/describe-audit.js'; +import { defineTool, type ToolAnnotations, type ToolDefinition } from './types.js'; +import type { UiResourceDefinition } from './ui-resource.js'; + +/** + * Flat, primitives-only schema the host renders as a form. + * + * The MCP spec restricts `elicitation/create` to a single-level object of + * primitives, so this is deliberately not a Zod schema: it cannot express what + * a tool's `zodSchema` can, and conflating the two invites authoring a nested + * schema that the host silently refuses. + */ +export type ElicitFormSchema = ElicitRequestFormParams['requestedSchema']; + +/** Alternate behavior for hosts that can render server-requested forms. */ +export interface ElicitationVariant { + /** Fields to collect before running, as a flat primitives-only schema */ + elicitSchema: ElicitFormSchema; + /** Prompt shown above the form explaining what is being asked and why */ + elicitMessage: string; + /** Runs after the form is submitted, or when the user declines */ + handler: (args: T) => Promise; +} + +/** Alternate behavior for hosts that can render MCP App views. */ +export interface McpAppVariant { + /** View the host renders for this tool's results */ + resource: UiResourceDefinition; + /** Produces the payload the view consumes; must stay useful as plain text */ + handler: (args: T) => Promise; + /** + * Extra tools that exist only so the view can call them, for example a + * refresh action. Forced to `visibility: ['app']` so the agent never sees + * them. + */ + appOnlyTools?: ToolDefinition[]; +} + +/** Per-capability alternatives for a tool. Every entry is optional. */ +export interface ToolVariants { + /** Used when the host supports `elicitation/create` in form mode */ + [McpClientCapability.Elicitation]?: ElicitationVariant; + /** Used when the host supports MCP Apps */ + [McpClientCapability.McpApp]?: McpAppVariant; +} + +/** + * Variant map with its argument type erased, mirroring how {@link ToolDefinition} + * erases `zodSchema` and `handler`. Dispatch happens after Zod has validated the + * input, so the precise type has already done its job by then. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ErasedToolVariants = ToolVariants; + +/** + * A tool that can present itself differently depending on host capabilities. + * + * Extends {@link ToolDefinition} so that every existing code path — schema + * caching, the description audit, the unified server's registry — keeps working + * on it untouched. The inherited `handler` is the baseline that runs on hosts + * with no relevant capabilities. + */ +export interface CapabilityAwareToolDefinition extends ToolDefinition { + /** Alternate implementations keyed by the capability that unlocks them */ + variants: ErasedToolVariants; +} + +/** Whether a tool carries capability variants. */ +export function isCapabilityAwareTool( + /** Tool to test */ + tool: ToolDefinition, +): tool is CapabilityAwareToolDefinition { + return 'variants' in tool; +} + +/** + * Validates an elicitation schema against the spec's primitives-only subset. + * + * Nesting is the mistake authors actually make here, and a host's response is + * to reject the request at call time — long after the tool looked fine. Failing + * at construction keeps that in dev and CI. + */ +export function assertElicitFormSchema( + /** Tool being defined, used in the error message */ + toolName: string, + /** Schema to validate */ + schema: ElicitFormSchema, +): void { + if (schema.type !== 'object') { + throw new Error( + `Tool "${toolName}" has an elicitation schema of type "${schema.type}". ` + + 'Elicitation requires a top-level object.', + ); + } + + const properties = Object.entries(schema.properties ?? {}); + if (properties.length === 0) { + throw new Error( + `Tool "${toolName}" has an elicitation schema with no properties. Give it at ` + + 'least one field, or drop the elicitation variant.', + ); + } + + for (const [field, definition] of properties) { + const parsed = PrimitiveSchemaDefinitionSchema.safeParse(definition); + if (!parsed.success) { + throw new Error( + `Tool "${toolName}" has elicitation field "${field}" that is not a supported ` + + 'primitive. Elicitation allows only a flat object of string, number, integer, ' + + 'boolean, and enum fields — no nested objects or arrays of objects.', + ); + } + const description = (definition as { description?: unknown }).description; + if (typeof description !== 'string' || description.trim() === '') { + throw new Error( + `Tool "${toolName}" has elicitation field "${field}" with no description. The ` + + 'description is the label the user reads in the form, so it cannot be blank.', + ); + } + } + + for (const required of schema.required ?? []) { + if (!(required in (schema.properties ?? {}))) { + throw new Error( + `Tool "${toolName}" marks elicitation field "${required}" as required but never ` + + 'defines it.', + ); + } + } +} + +/** + * Type-safe factory for a tool with capability-specific implementations. + * + * Enforces the same description contract as {@link defineTool} on the baseline + * input schema, and additionally validates each variant so a malformed + * elicitation schema or UI binding fails here rather than mid-conversation. + */ +export function defineToolWithCapabilities(config: { + /** Unique tool name */ + name: string; + /** Human-readable description for LLM */ + description: string; + /** Grouping category */ + category: string; + /** Whether this tool only reads data */ + readOnly: boolean; + /** Message shown to user before execution */ + confirmationHint?: string; + /** MCP tool annotations */ + annotations: ToolAnnotations; + /** Zod schema for input validation and JSON Schema derivation */ + zodSchema: z.ZodType; + /** Baseline handler for hosts with no relevant capabilities */ + handler: (args: T) => Promise; + /** + * When false, this tool runs without lazy OAuth or request auth injection at call time. + * Use for tools that only access public resources. Default true. + */ + requireAuth?: boolean; + /** Alternate implementations keyed by the capability that unlocks them */ + variants: ToolVariants; +}): CapabilityAwareToolDefinition { + const { variants, ...base } = config; + + // Reuse defineTool so the description audit and its error message stay in one + // place rather than drifting between the two factories. + const baseline = defineTool(base); + + const elicitation = variants[McpClientCapability.Elicitation]; + if (elicitation) { + assertElicitFormSchema(config.name, elicitation.elicitSchema); + if (elicitation.elicitMessage.trim() === '') { + throw new Error( + `Tool "${config.name}" has an elicitation variant with an empty message. The ` + + 'message is what tells the user why they are being asked.', + ); + } + } + + const mcpApp = variants[McpClientCapability.McpApp]; + if (mcpApp) { + for (const appOnlyTool of mcpApp.appOnlyTools ?? []) { + const missing = collectMissingDescriptions(appOnlyTool.zodSchema); + if (missing.length > 0) { + throw new Error( + `Tool "${config.name}" has app-only tool "${appOnlyTool.name}" with input ` + + `fields missing a meaningful Zod .describe(): ${missing.join(', ')}.`, + ); + } + } + } + + return { ...baseline, variants: variants as ErasedToolVariants }; +} + +/** + * Picks the implementation to use for a host. + * + * Precedence is fixed at MCP App, then elicitation, then baseline, so + * `tools/list` and `tools/call` always agree for a given client. Returns a plain + * {@link ToolDefinition} that the rest of the server treats like any other. + */ +export function resolveToolVariant( + /** Tool to resolve */ + tool: ToolDefinition, + /** Capabilities of the connected host */ + client: ClientCapabilityReport, +): ToolDefinition { + if (!isCapabilityAwareTool(tool)) return tool; + + const { variants, ...baseline } = tool; + + const mcpApp = variants[McpClientCapability.McpApp]; + if (mcpApp && client.capabilities.has(McpClientCapability.McpApp)) { + return { ...baseline, handler: mcpApp.handler, ui: { resource: mcpApp.resource } }; + } + + const elicitation = variants[McpClientCapability.Elicitation]; + if (elicitation && client.capabilities.has(McpClientCapability.Elicitation)) { + return { ...baseline, handler: elicitation.handler }; + } + + return baseline; +} + +/** + * Expands a tool list into the concrete set for a host: one resolved variant per + * tool, plus any app-only companions that the winning MCP App variant needs. + * + * Companions are forced to `visibility: ['app']` here rather than trusting the + * author to set it, because a leaked companion tool shows the agent an + * implementation detail it cannot use sensibly. + */ +export function expandToolsForClient( + /** Tools registered with the server */ + tools: readonly ToolDefinition[], + /** Capabilities of the connected host */ + client: ClientCapabilityReport, +): ToolDefinition[] { + const expanded: ToolDefinition[] = []; + + for (const tool of tools) { + expanded.push(resolveToolVariant(tool, client)); + + if (!isCapabilityAwareTool(tool)) continue; + + const mcpApp = tool.variants[McpClientCapability.McpApp]; + const usesMcpApp = mcpApp && client.capabilities.has(McpClientCapability.McpApp); + if (!usesMcpApp) continue; + + for (const companion of mcpApp.appOnlyTools ?? []) { + expanded.push({ ...companion, visibility: ['app'] }); + } + + // An MCP App supersedes the form flow for the agent, but the view itself may + // still want to trigger one, so keep the elicitation variant reachable as an + // app-only sibling instead of dropping it. + const elicitation = tool.variants[McpClientCapability.Elicitation]; + if (elicitation && client.capabilities.has(McpClientCapability.Elicitation)) { + const { variants: _superseded, ui: _noView, ...baseline } = tool; + expanded.push({ + ...baseline, + name: `${tool.name}_form`, + description: `${tool.description} (form flow, callable by the ${tool.name} view)`, + handler: elicitation.handler, + visibility: ['app'], + }); + } + } + + return expanded; +} diff --git a/packages/mcp/mcp-server-base/src/tools/types.ts b/packages/mcp/mcp-server-base/src/tools/types.ts index d2945164..c36ee37c 100644 --- a/packages/mcp/mcp-server-base/src/tools/types.ts +++ b/packages/mcp/mcp-server-base/src/tools/types.ts @@ -3,6 +3,7 @@ import { type z } from 'zod'; import type { TranscendGraphQLBase } from '../clients/graphql/base.js'; import type { TranscendRestClient } from '../clients/rest-client.js'; import { collectMissingDescriptions } from '../validation/describe-audit.js'; +import type { UiResourceDefinition } from './ui-resource.js'; export interface ToolAnnotations { /** Whether this tool only reads data */ @@ -13,6 +14,23 @@ export interface ToolAnnotations { idempotentHint: boolean; } +/** + * Who may call a tool, per the MCP Apps spec. + * + * - `model`: the agent sees the tool in `tools/list` and may call it + * - `app`: an MCP App view served by this server may call it + */ +export type ToolVisibility = 'model' | 'app'; + +/** Default when a tool does not declare visibility: reachable by both. */ +export const DEFAULT_TOOL_VISIBILITY: readonly ToolVisibility[] = ['model', 'app']; + +/** Binds a tool's results to an MCP App view that renders them. */ +export interface ToolUiBinding { + /** UI resource the host should render for this tool's results */ + resource: UiResourceDefinition; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface ToolDefinition { /** Unique tool name */ @@ -37,6 +55,16 @@ export interface ToolDefinition { * Use for tools that only access public resources. Default true. */ requireAuth?: boolean; + /** + * MCP App view that renders this tool's results. Hosts without MCP Apps + * support ignore the metadata and show the text result instead. + */ + ui?: ToolUiBinding; + /** + * Who may call this tool. Defaults to {@link DEFAULT_TOOL_VISIBILITY}. Omit + * `model` for tools that exist only so an MCP App view can call them. + */ + visibility?: readonly ToolVisibility[]; } export interface ToolClients { @@ -80,6 +108,16 @@ export function defineTool(config: { * Use for tools that only access public resources. Default true. */ requireAuth?: boolean; + /** + * MCP App view that renders this tool's results. Hosts without MCP Apps + * support ignore the metadata and show the text result instead. + */ + ui?: ToolUiBinding; + /** + * Who may call this tool. Defaults to {@link DEFAULT_TOOL_VISIBILITY}. Omit + * `model` for tools that exist only so an MCP App view can call them. + */ + visibility?: readonly ToolVisibility[]; }): ToolDefinition { // Descriptions are the only signal an LLM caller has for what each argument // means, so refuse to construct a tool whose input schema has any field @@ -96,3 +134,14 @@ export function defineTool(config: { } return config; } + +/** + * Whether the agent should see this tool in `tools/list`. App-only tools stay + * callable via `tools/call` so an MCP App view can still reach them. + */ +export function isVisibleToModel( + /** Tool to test */ + tool: ToolDefinition, +): boolean { + return (tool.visibility ?? DEFAULT_TOOL_VISIBILITY).includes('model'); +} diff --git a/packages/mcp/mcp-server-base/src/tools/ui-resource.ts b/packages/mcp/mcp-server-base/src/tools/ui-resource.ts new file mode 100644 index 00000000..08f5ebeb --- /dev/null +++ b/packages/mcp/mcp-server-base/src/tools/ui-resource.ts @@ -0,0 +1,160 @@ +import { MCP_APP_MIME_TYPE, MCP_UI_EXTENSION_ID } from '../capabilities/types.js'; + +// Declared with the capability layer, which reads them during the handshake, and +// surfaced here too because callers reaching for them usually think of them as +// part of the view surface. +export { MCP_APP_MIME_TYPE, MCP_UI_EXTENSION_ID }; + +/** URI scheme reserved by the MCP Apps spec for UI resources. */ +export const UI_URI_SCHEME = 'ui://'; + +/** + * Content Security Policy origins a UI needs. Omitting a field means "none", + * which is the secure default the host applies. + */ +export interface UiResourceCsp { + /** Origins for network requests, mapped to CSP `connect-src` */ + connectDomains?: readonly string[]; + /** Origins for scripts, styles, images, fonts, and media */ + resourceDomains?: readonly string[]; + /** Origins for nested iframes, mapped to CSP `frame-src` */ + frameDomains?: readonly string[]; + /** Allowed document base URIs, mapped to CSP `base-uri` */ + baseUriDomains?: readonly string[]; +} + +/** + * Browser capabilities a UI requests. The wire format uses empty objects as + * presence flags; booleans are friendlier to author, so + * {@link buildUiResourceMeta} converts them. + * + * A UI must not assume a permission was granted — hosts may decline any of + * these, so feature-detect before use. + */ +export interface UiResourcePermissions { + /** Request camera access */ + camera?: boolean; + /** Request microphone access */ + microphone?: boolean; + /** Request geolocation access */ + geolocation?: boolean; + /** Request clipboard write access */ + clipboardWrite?: boolean; +} + +export interface UiResourceDefinition { + /** Resource URI; must use the `ui://` scheme */ + uri: string; + /** Human-readable name shown when hosts enumerate resources */ + name: string; + /** What the view does and when a host should render it */ + description?: string; + /** + * The view's HTML, either a literal document or a factory invoked on each + * `resources/read`. Use the factory form when the markup depends on state + * that is not known at construction time. + */ + html: string | (() => Promise); + /** External origins the view needs; omitted means no external access */ + csp?: UiResourceCsp; + /** Browser capabilities the view requests */ + permissions?: UiResourcePermissions; + /** + * Dedicated sandbox origin for the view. Useful when a view needs a stable + * origin for OAuth callbacks or API key allowlists. + */ + domain?: string; + /** Whether the host should draw a visible border; omitted lets the host decide */ + prefersBorder?: boolean; +} + +/** + * Serializes the host-facing `_meta.ui` object for a resource, converting our + * boolean permission flags into the spec's empty-object presence markers and + * dropping empty sections so the payload stays minimal. + */ +export function buildUiResourceMeta( + /** Resource whose rendering and security preferences should be serialized */ + resource: UiResourceDefinition, +): Record | undefined { + const permissionEntries = Object.entries({ + camera: resource.permissions?.camera, + microphone: resource.permissions?.microphone, + geolocation: resource.permissions?.geolocation, + clipboardWrite: resource.permissions?.clipboardWrite, + }).filter(([, enabled]) => enabled === true); + + const ui: Record = {}; + if (resource.csp && Object.keys(resource.csp).length > 0) { + ui.csp = resource.csp; + } + if (permissionEntries.length > 0) { + ui.permissions = Object.fromEntries(permissionEntries.map(([name]) => [name, {}])); + } + if (resource.domain !== undefined) { + ui.domain = resource.domain; + } + if (resource.prefersBorder !== undefined) { + ui.prefersBorder = resource.prefersBorder; + } + + return Object.keys(ui).length > 0 ? { ui } : undefined; +} + +/** Resolves a resource's HTML, invoking the factory form when present. */ +export async function readUiResourceHtml( + /** Resource whose markup should be produced */ + resource: UiResourceDefinition, +): Promise { + return typeof resource.html === 'string' ? resource.html : await resource.html(); +} + +/** + * Validating factory for MCP App UI resources. + * + * Mirrors {@link defineTool}'s fail-loud-at-construction stance: a malformed + * URI or a fragment that is not a full HTML document renders as a blank iframe + * with no error anywhere, which is painful to diagnose in a host. Throwing here + * surfaces the mistake during local dev and CI instead. + */ +export function defineUiResource(config: UiResourceDefinition): UiResourceDefinition { + if (!config.uri.startsWith(UI_URI_SCHEME)) { + throw new Error( + `UI resource "${config.name}" has uri "${config.uri}", which does not use the ` + + `${UI_URI_SCHEME} scheme. MCP Apps requires it so hosts can tell UI resources ` + + 'apart from ordinary ones.', + ); + } + if (config.uri === UI_URI_SCHEME) { + throw new Error( + `UI resource "${config.name}" has an empty path after ${UI_URI_SCHEME}. Use ` + + 'something like ui://my-server/my-view so the URI identifies the view.', + ); + } + if (config.name.trim() === '') { + throw new Error(`UI resource "${config.uri}" needs a non-empty name.`); + } + if (typeof config.html === 'string') { + assertHtmlDocument(config.uri, config.html); + } + return config; +} + +/** + * Rejects markup a host would render as a blank panel. + * + * Exported for the dev view loader, which reads documents from disk after + * construction and so cannot rely on {@link defineUiResource} having checked them. + */ +export function assertHtmlDocument(uri: string, html: string): void { + if (html.trim() === '') { + throw new Error(`UI resource "${uri}" has empty HTML; hosts would render a blank iframe.`); + } + if (!/^\s*. Hosts render the content as a standalone document, so an ' + + 'HTML fragment will not display reliably.', + ); + } +} diff --git a/packages/mcp/mcp-server-base/tests/build-server.test.ts b/packages/mcp/mcp-server-base/tests/build-server.test.ts index b13b1fe4..25121f51 100644 --- a/packages/mcp/mcp-server-base/tests/build-server.test.ts +++ b/packages/mcp/mcp-server-base/tests/build-server.test.ts @@ -1,13 +1,21 @@ import type { AddressInfo } from 'node:net'; +import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { McpClientCapability } from '../src/capabilities/types.js'; import { MCP_SESSION_ID_HEADER } from '../src/http-header-names.js'; import * as lazyAuth from '../src/oauth/lazy-auth.js'; import { buildMcpServer } from '../src/server/build-server.js'; import type { TransportConfig } from '../src/server/parse-args.js'; import { runMcpHttp, type McpHttpServer } from '../src/server/run-http.js'; -import type { ToolDefinition } from '../src/tools/types.js'; +import { defineToolWithCapabilities } from '../src/tools/define-tool-with-capabilities.js'; +import { defineTool, type ToolDefinition } from '../src/tools/types.js'; +import { + defineUiResource, + MCP_APP_MIME_TYPE, + MCP_UI_EXTENSION_ID, +} from '../src/tools/ui-resource.js'; import { z } from '../src/validation/index.js'; const MCP_HEADERS = { @@ -15,6 +23,11 @@ const MCP_HEADERS = { Accept: 'text/event-stream, application/json', }; +/** MCP Apps capability declaration a host sends to opt into `ui://` rendering. */ +const MCP_APP_CAPABILITIES: ClientCapabilities = { + extensions: { [MCP_UI_EXTENSION_ID]: { mimeTypes: [MCP_APP_MIME_TYPE] } }, +}; + function testConfig(port: number): TransportConfig { return { transport: 'http', @@ -26,7 +39,23 @@ function testConfig(port: number): TransportConfig { }; } -async function initSession(baseUrl: string): Promise { +function parseSseData(text: string): unknown[] { + return ( + text + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6).trim()) + // Keep-alive frames carry an empty payload; only decode real messages. + .filter((payload) => payload !== '') + .map((payload) => JSON.parse(payload)) + ); +} + +/** Performs the initialize handshake and returns the negotiated server capabilities. */ +async function initializeAndGetServerCapabilities( + baseUrl: string, + clientName: string, +): Promise> { const res = await fetch(`${baseUrl}/mcp`, { method: 'POST', headers: MCP_HEADERS, @@ -36,7 +65,66 @@ async function initSession(baseUrl: string): Promise { params: { protocolVersion: '2025-11-25', capabilities: {}, - clientInfo: { name: 'build-server-test', version: '0.1.0' }, + clientInfo: { name: clientName, version: '0.1.0' }, + }, + id: 1, + }), + }); + expect(res.status).toBe(200); + + const payload = parseSseData(await res.text()).find( + (entry) => (entry as { id?: number }).id === 1, + ) as { result: { capabilities: Record } } | undefined; + expect(payload, 'no initialize response').toBeDefined(); + return payload!.result.capabilities; +} + +let nextRequestId = 100; + +/** Sends a JSON-RPC request on an established session and returns its result. */ +async function rpc( + baseUrl: string, + sessionId: string, + method: string, + params?: Record, +): Promise { + const id = nextRequestId++; + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { ...MCP_HEADERS, [MCP_SESSION_ID_HEADER]: sessionId }, + body: JSON.stringify({ jsonrpc: '2.0', method, ...(params && { params }), id }), + }); + expect(res.status).toBe(200); + + const response = parseSseData(await res.text()).find( + (entry) => (entry as { id?: number }).id === id, + ) as { result?: T; error?: { message: string } } | undefined; + expect(response, `no JSON-RPC response for ${method}`).toBeDefined(); + if (response!.error) { + throw new Error(response!.error.message); + } + return response!.result as T; +} + +async function initSession( + baseUrl: string, + options: { + /** Capabilities the simulated host declares */ + capabilities?: ClientCapabilities; + /** Client name the simulated host reports */ + clientName?: string; + } = {}, +): Promise { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: MCP_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: options.capabilities ?? {}, + clientInfo: { name: options.clientName ?? 'build-server-test', version: '0.1.0' }, }, id: 1, }), @@ -206,3 +294,208 @@ describe('buildMcpServer instructions', () => { expect(text).toContain('Call docs_list before API tools.'); }); }); + +// ── MCP Apps and capability-aware tools ────────────────────────────────── + +const testView = defineUiResource({ + uri: 'ui://build-server-test/view', + name: 'Build server test view', + description: 'View used to exercise the resources handlers.', + html: 'hello', + prefersBorder: false, +}); + +function capabilityAwareTool(): ToolDefinition { + return defineToolWithCapabilities({ + name: 'greet', + description: 'Greet someone, adapting to the capabilities of the connected host.', + category: 'test', + readOnly: true, + requireAuth: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: z.object({}), + handler: async () => ({ via: 'baseline' }), + variants: { + [McpClientCapability.McpApp]: { + resource: testView, + handler: async () => ({ via: 'mcp-app' }), + appOnlyTools: [ + defineTool({ + name: 'greet_refresh', + description: 'Refresh the greeting payload for the greet view.', + category: 'test', + readOnly: true, + requireAuth: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: z.object({}), + handler: async () => ({ via: 'refresh' }), + }), + ], + }, + }, + }); +} + +interface ListedTool { + /** Tool name */ + name: string; + /** Extension metadata, including the MCP Apps view binding */ + _meta?: Record; +} + +describe('buildMcpServer without UI resources', () => { + let httpServer: McpHttpServer; + let baseUrl: string; + + beforeAll(async () => { + httpServer = await runMcpHttp( + { + name: 'no-ui-test', + version: '0.0.1', + createServer: () => + buildMcpServer({ name: 'no-ui-test', version: '0.0.1', tools: [publicTool] }), + }, + testConfig(0), + ); + const addr = httpServer.httpServer.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + }); + + afterAll(async () => { + await httpServer.shutdown(); + }); + + it('does not advertise the resources capability', async () => { + const capabilities = await initializeAndGetServerCapabilities(baseUrl, 'no-ui-test'); + expect(capabilities.tools).toBeDefined(); + expect(capabilities.resources).toBeUndefined(); + }); + + it('rejects resources/list, since the handler was never registered', async () => { + const sessionId = await initSession(baseUrl); + await expect(rpc(baseUrl, sessionId, 'resources/list')).rejects.toThrow(); + }); +}); + +describe('buildMcpServer MCP Apps', () => { + let httpServer: McpHttpServer; + let baseUrl: string; + + beforeAll(async () => { + httpServer = await runMcpHttp( + { + name: 'ui-test', + version: '0.0.1', + createServer: () => + buildMcpServer({ + name: 'ui-test', + version: '0.0.1', + tools: [publicTool, capabilityAwareTool()], + }), + }, + testConfig(0), + ); + const addr = httpServer.httpServer.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + }); + + afterAll(async () => { + await httpServer.shutdown(); + }); + + it('advertises the resources capability when a tool binds a view', async () => { + const capabilities = await initializeAndGetServerCapabilities(baseUrl, 'ui-test'); + expect(capabilities.resources).toBeDefined(); + }); + + it('lists the ui:// resource with the MCP App mime type', async () => { + const sessionId = await initSession(baseUrl, { capabilities: MCP_APP_CAPABILITIES }); + const result = await rpc<{ + resources: { uri: string; name: string; mimeType: string; description?: string }[]; + }>(baseUrl, sessionId, 'resources/list'); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0]).toMatchObject({ + uri: 'ui://build-server-test/view', + mimeType: MCP_APP_MIME_TYPE, + description: 'View used to exercise the resources handlers.', + }); + }); + + it('serves the view HTML and its rendering metadata on resources/read', async () => { + const sessionId = await initSession(baseUrl, { capabilities: MCP_APP_CAPABILITIES }); + const result = await rpc<{ + contents: { uri: string; mimeType: string; text: string; _meta?: unknown }[]; + }>(baseUrl, sessionId, 'resources/read', { uri: 'ui://build-server-test/view' }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0]!.mimeType).toBe(MCP_APP_MIME_TYPE); + expect(result.contents[0]!.text).toContain(''); + expect(result.contents[0]!._meta).toEqual({ ui: { prefersBorder: false } }); + }); + + it('reports a helpful error for an unknown resource uri', async () => { + const sessionId = await initSession(baseUrl, { capabilities: MCP_APP_CAPABILITIES }); + await expect( + rpc(baseUrl, sessionId, 'resources/read', { uri: 'ui://build-server-test/missing' }), + ).rejects.toThrow(/Unknown resource uri/); + }); + + it('serves the baseline variant and no view metadata to a host without MCP Apps', async () => { + const sessionId = await initSession(baseUrl); + const listed = await rpc<{ tools: ListedTool[] }>(baseUrl, sessionId, 'tools/list'); + + expect(listed.tools.map((tool) => tool.name).sort()).toEqual(['greet', 'public_echo']); + expect(listed.tools.find((tool) => tool.name === 'greet')!._meta).toBeUndefined(); + + const called = await rpc<{ content: { text: string }[] }>(baseUrl, sessionId, 'tools/call', { + name: 'greet', + arguments: {}, + }); + expect(JSON.parse(called.content[0]!.text)).toEqual({ via: 'baseline' }); + }); + + it('serves the MCP App variant with view metadata to a host that supports it', async () => { + const sessionId = await initSession(baseUrl, { + capabilities: MCP_APP_CAPABILITIES, + clientName: 'claude-desktop', + }); + const listed = await rpc<{ tools: ListedTool[] }>(baseUrl, sessionId, 'tools/list'); + + const greet = listed.tools.find((tool) => tool.name === 'greet')!; + expect(greet._meta).toEqual({ + ui: { resourceUri: 'ui://build-server-test/view' }, + 'ui/resourceUri': 'ui://build-server-test/view', + }); + + const called = await rpc<{ content: { text: string }[] }>(baseUrl, sessionId, 'tools/call', { + name: 'greet', + arguments: {}, + }); + expect(JSON.parse(called.content[0]!.text)).toEqual({ via: 'mcp-app' }); + }); + + it('hides app-only companions from tools/list but keeps them callable', async () => { + const sessionId = await initSession(baseUrl, { capabilities: MCP_APP_CAPABILITIES }); + const listed = await rpc<{ tools: ListedTool[] }>(baseUrl, sessionId, 'tools/list'); + expect(listed.tools.map((tool) => tool.name)).not.toContain('greet_refresh'); + + const called = await rpc<{ content: { text: string }[] }>(baseUrl, sessionId, 'tools/call', { + name: 'greet_refresh', + arguments: {}, + }); + expect(JSON.parse(called.content[0]!.text)).toEqual({ via: 'refresh' }); + }); + + it('does not expose companions to a host without MCP Apps', async () => { + const sessionId = await initSession(baseUrl); + const called = await rpc<{ content: { text: string }[]; isError?: boolean }>( + baseUrl, + sessionId, + 'tools/call', + { name: 'greet_refresh', arguments: {} }, + ); + expect(called.isError).toBe(true); + expect(called.content[0]!.text).toContain('Unknown tool'); + }); +}); diff --git a/packages/mcp/mcp-server-base/tests/define-tool-with-capabilities.test.ts b/packages/mcp/mcp-server-base/tests/define-tool-with-capabilities.test.ts new file mode 100644 index 00000000..d8a2ab66 --- /dev/null +++ b/packages/mcp/mcp-server-base/tests/define-tool-with-capabilities.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest'; + +import { + McpClientCapability, + McpHostClient, + type ClientCapabilityReport, +} from '../src/capabilities/types.js'; +import { + assertElicitFormSchema, + defineToolWithCapabilities, + expandToolsForClient, + isCapabilityAwareTool, + resolveToolVariant, + type ElicitFormSchema, +} from '../src/tools/define-tool-with-capabilities.js'; +import { defineTool, isVisibleToModel, type ToolDefinition } from '../src/tools/types.js'; +import { defineUiResource } from '../src/tools/ui-resource.js'; +import { z } from '../src/validation/index.js'; + +const VIEW = defineUiResource({ + uri: 'ui://test/view', + name: 'Test view', + html: 'hi', +}); + +const ELICIT_SCHEMA: ElicitFormSchema = { + type: 'object', + properties: { + name: { type: 'string', description: 'Name to greet in the response.' }, + }, +}; + +const SCHEMA = z.object({ + name: z.string().optional().describe('Name to greet in the response.'), +}); + +function reportFor(...capabilities: McpClientCapability[]): ClientCapabilityReport { + return { capabilities: new Set(capabilities), host: McpHostClient.Unknown }; +} + +function buildTool(): ReturnType { + return defineToolWithCapabilities({ + name: 'test_greet', + description: 'Greet someone, adapting to the host capabilities.', + category: 'test', + readOnly: true, + requireAuth: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: SCHEMA, + handler: async () => 'baseline', + variants: { + [McpClientCapability.Elicitation]: { + elicitMessage: 'Who should this greeting be addressed to?', + elicitSchema: ELICIT_SCHEMA, + handler: async () => 'elicitation', + }, + [McpClientCapability.McpApp]: { + resource: VIEW, + handler: async () => 'mcp-app', + appOnlyTools: [ + defineTool({ + name: 'test_greet_refresh', + description: 'Refresh the greeting for the test_greet view.', + category: 'test', + readOnly: true, + requireAuth: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: SCHEMA, + handler: async () => 'refresh', + }), + ], + }, + }, + }); +} + +describe('defineToolWithCapabilities', () => { + it('produces something the rest of the server treats as a plain tool', () => { + const tool = buildTool(); + expect(tool.name).toBe('test_greet'); + expect(isCapabilityAwareTool(tool)).toBe(true); + expect(isVisibleToModel(tool)).toBe(true); + }); + + it('still enforces the baseline description contract', () => { + expect(() => + defineToolWithCapabilities({ + name: 'test_undocumented', + description: 'A tool whose input field has no description.', + category: 'test', + readOnly: true, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: z.object({ name: z.string() }), + handler: async () => 'x', + variants: {}, + }), + ).toThrow(/\.describe\(\)/); + }); + + it('rejects an elicitation variant with an empty message', () => { + expect(() => + defineToolWithCapabilities({ + name: 'test_blank_message', + description: 'A tool with a blank elicitation message.', + category: 'test', + readOnly: true, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: SCHEMA, + handler: async () => 'x', + variants: { + [McpClientCapability.Elicitation]: { + elicitMessage: ' ', + elicitSchema: ELICIT_SCHEMA, + handler: async () => 'x', + }, + }, + }), + ).toThrow(/empty message/); + }); + + it('rejects an app-only tool with under-documented inputs', () => { + expect(() => + defineToolWithCapabilities({ + name: 'test_bad_companion', + description: 'A tool whose companion has an undocumented input.', + category: 'test', + readOnly: true, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: SCHEMA, + handler: async () => 'x', + variants: { + [McpClientCapability.McpApp]: { + resource: VIEW, + handler: async () => 'x', + appOnlyTools: [ + { + name: 'test_bad_companion_refresh', + description: 'Companion with an undocumented input.', + category: 'test', + readOnly: true, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: z.object({ name: z.string() }), + handler: async () => 'x', + }, + ], + }, + }, + }), + ).toThrow(/app-only tool/); + }); +}); + +describe('assertElicitFormSchema', () => { + it('accepts a flat primitives-only schema', () => { + expect(() => assertElicitFormSchema('t', ELICIT_SCHEMA)).not.toThrow(); + }); + + it('rejects a nested object, which hosts cannot render', () => { + const nested = { + type: 'object', + properties: { + address: { + type: 'object', + description: 'Postal address of the subject.', + properties: { city: { type: 'string', description: 'City name for the address.' } }, + }, + }, + } as unknown as ElicitFormSchema; + expect(() => assertElicitFormSchema('t', nested)).toThrow(/not a supported primitive/); + }); + + it('rejects a schema with no properties', () => { + const empty = { type: 'object', properties: {} } as ElicitFormSchema; + expect(() => assertElicitFormSchema('t', empty)).toThrow(/no properties/); + }); + + it('rejects a field with no description, since that is the form label', () => { + const undescribed = { + type: 'object', + properties: { name: { type: 'string' } }, + } as unknown as ElicitFormSchema; + expect(() => assertElicitFormSchema('t', undescribed)).toThrow(/no description/); + }); + + it('rejects a required field that is never defined', () => { + const dangling = { + type: 'object', + properties: { name: { type: 'string', description: 'Name to greet in the response.' } }, + required: ['nickname'], + } as ElicitFormSchema; + expect(() => assertElicitFormSchema('t', dangling)).toThrow(/required but never/); + }); +}); + +describe('resolveToolVariant', () => { + it('returns a plain tool unchanged', async () => { + const plain: ToolDefinition = { + name: 'plain', + description: 'A plain tool with no variants.', + category: 'test', + readOnly: true, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: z.object({}), + handler: async () => 'plain', + }; + expect(resolveToolVariant(plain, reportFor(McpClientCapability.McpApp))).toBe(plain); + }); + + it('falls back to the baseline when the host declares nothing', async () => { + const resolved = resolveToolVariant(buildTool(), reportFor()); + await expect(resolved.handler({})).resolves.toBe('baseline'); + expect(resolved.ui).toBeUndefined(); + }); + + it('uses the elicitation variant when only elicitation is supported', async () => { + const resolved = resolveToolVariant(buildTool(), reportFor(McpClientCapability.Elicitation)); + await expect(resolved.handler({})).resolves.toBe('elicitation'); + expect(resolved.ui).toBeUndefined(); + }); + + it('uses the MCP App variant and binds the view when MCP Apps are supported', async () => { + const resolved = resolveToolVariant(buildTool(), reportFor(McpClientCapability.McpApp)); + await expect(resolved.handler({})).resolves.toBe('mcp-app'); + expect(resolved.ui?.resource.uri).toBe('ui://test/view'); + }); + + it('prefers the MCP App variant when both are supported', async () => { + const resolved = resolveToolVariant( + buildTool(), + reportFor(McpClientCapability.Elicitation, McpClientCapability.McpApp), + ); + await expect(resolved.handler({})).resolves.toBe('mcp-app'); + }); + + it('drops the variants map so downstream code sees an ordinary tool', () => { + const resolved = resolveToolVariant(buildTool(), reportFor(McpClientCapability.McpApp)); + expect(isCapabilityAwareTool(resolved)).toBe(false); + }); +}); + +describe('expandToolsForClient', () => { + it('emits only the baseline for a host with no capabilities', () => { + const expanded = expandToolsForClient([buildTool()], reportFor()); + expect(expanded.map((tool) => tool.name)).toEqual(['test_greet']); + }); + + it('emits app-only companions alongside the MCP App variant', () => { + const expanded = expandToolsForClient([buildTool()], reportFor(McpClientCapability.McpApp)); + expect(expanded.map((tool) => tool.name)).toEqual(['test_greet', 'test_greet_refresh']); + + const companion = expanded.find((tool) => tool.name === 'test_greet_refresh')!; + expect(companion.visibility).toEqual(['app']); + expect(isVisibleToModel(companion)).toBe(false); + }); + + it('forces companion visibility to app even when the author set it wrongly', () => { + const tool = defineToolWithCapabilities({ + name: 'test_leaky', + description: 'A tool whose companion wrongly claims model visibility.', + category: 'test', + readOnly: true, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: SCHEMA, + handler: async () => 'baseline', + variants: { + [McpClientCapability.McpApp]: { + resource: VIEW, + handler: async () => 'mcp-app', + appOnlyTools: [ + { + name: 'test_leaky_refresh', + description: 'Companion that should never reach the model.', + category: 'test', + readOnly: true, + visibility: ['model', 'app'], + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: SCHEMA, + handler: async () => 'refresh', + }, + ], + }, + }, + }); + + const companion = expandToolsForClient([tool], reportFor(McpClientCapability.McpApp)).find( + (candidate) => candidate.name === 'test_leaky_refresh', + )!; + expect(companion.visibility).toEqual(['app']); + }); + + it('keeps the superseded form flow reachable by the view when both are supported', async () => { + const expanded = expandToolsForClient( + [buildTool()], + reportFor(McpClientCapability.Elicitation, McpClientCapability.McpApp), + ); + expect(expanded.map((tool) => tool.name)).toEqual([ + 'test_greet', + 'test_greet_refresh', + 'test_greet_form', + ]); + + const form = expanded.find((tool) => tool.name === 'test_greet_form')!; + expect(form.visibility).toEqual(['app']); + expect(form.ui).toBeUndefined(); + await expect(form.handler({})).resolves.toBe('elicitation'); + }); + + it('does not emit a form sibling when the host lacks MCP Apps', () => { + const expanded = expandToolsForClient( + [buildTool()], + reportFor(McpClientCapability.Elicitation), + ); + expect(expanded.map((tool) => tool.name)).toEqual(['test_greet']); + }); +}); diff --git a/packages/mcp/mcp-server-base/tests/ui-resource.test.ts b/packages/mcp/mcp-server-base/tests/ui-resource.test.ts new file mode 100644 index 00000000..dba892fc --- /dev/null +++ b/packages/mcp/mcp-server-base/tests/ui-resource.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildUiResourceMeta, + defineUiResource, + readUiResourceHtml, + type UiResourceDefinition, +} from '../src/tools/ui-resource.js'; + +const VALID_HTML = 'hi'; + +describe('defineUiResource', () => { + it('accepts a well-formed resource', () => { + const resource = defineUiResource({ + uri: 'ui://test/view', + name: 'Test view', + html: VALID_HTML, + }); + expect(resource.uri).toBe('ui://test/view'); + }); + + it('rejects a uri that does not use the ui:// scheme', () => { + expect(() => + defineUiResource({ uri: 'https://example.com/view', name: 'Test', html: VALID_HTML }), + ).toThrow(/ui:\/\//); + }); + + it('rejects a uri with an empty path', () => { + expect(() => defineUiResource({ uri: 'ui://', name: 'Test', html: VALID_HTML })).toThrow( + /empty path/, + ); + }); + + it('rejects a blank name', () => { + expect(() => defineUiResource({ uri: 'ui://test/view', name: ' ', html: VALID_HTML })).toThrow( + /non-empty name/, + ); + }); + + it('rejects empty HTML', () => { + expect(() => defineUiResource({ uri: 'ui://test/view', name: 'Test', html: '' })).toThrow( + /empty HTML/, + ); + }); + + it('rejects an HTML fragment that is not a full document', () => { + expect(() => + defineUiResource({ uri: 'ui://test/view', name: 'Test', html: '
hi
' }), + ).toThrow(/complete HTML5 document/); + }); + + it('accepts a lowercase doctype with leading whitespace', () => { + expect(() => + defineUiResource({ uri: 'ui://test/view', name: 'Test', html: '\n ' }), + ).not.toThrow(); + }); + + it('does not validate a lazily produced document, which is only known at read time', () => { + expect(() => + defineUiResource({ + uri: 'ui://test/lazy', + name: 'Lazy view', + html: async () => VALID_HTML, + }), + ).not.toThrow(); + }); +}); + +describe('readUiResourceHtml', () => { + it('returns literal HTML unchanged', async () => { + const resource: UiResourceDefinition = { uri: 'ui://t/v', name: 'v', html: VALID_HTML }; + await expect(readUiResourceHtml(resource)).resolves.toBe(VALID_HTML); + }); + + it('invokes the factory form on each read', async () => { + let calls = 0; + const resource: UiResourceDefinition = { + uri: 'ui://t/v', + name: 'v', + html: async () => { + calls += 1; + return `${VALID_HTML}`; + }, + }; + await expect(readUiResourceHtml(resource)).resolves.toContain(''); + await expect(readUiResourceHtml(resource)).resolves.toContain(''); + }); +}); + +describe('buildUiResourceMeta', () => { + it('returns undefined when there is nothing to declare', () => { + expect(buildUiResourceMeta({ uri: 'ui://t/v', name: 'v', html: VALID_HTML })).toBeUndefined(); + }); + + it('converts boolean permissions into the spec presence markers', () => { + const meta = buildUiResourceMeta({ + uri: 'ui://t/v', + name: 'v', + html: VALID_HTML, + permissions: { clipboardWrite: true, camera: false }, + }); + expect(meta).toEqual({ ui: { permissions: { clipboardWrite: {} } } }); + }); + + it('passes CSP domains through and includes rendering preferences', () => { + const meta = buildUiResourceMeta({ + uri: 'ui://t/v', + name: 'v', + html: VALID_HTML, + csp: { connectDomains: ['https://api.transcend.io'] }, + domain: 'views.transcend.io', + prefersBorder: false, + }); + expect(meta).toEqual({ + ui: { + csp: { connectDomains: ['https://api.transcend.io'] }, + domain: 'views.transcend.io', + prefersBorder: false, + }, + }); + }); + + it('omits an empty csp object rather than emitting a useless key', () => { + const meta = buildUiResourceMeta({ + uri: 'ui://t/v', + name: 'v', + html: VALID_HTML, + csp: {}, + prefersBorder: true, + }); + expect(meta).toEqual({ ui: { prefersBorder: true } }); + }); +}); diff --git a/packages/mcp/mcp/src/registry.ts b/packages/mcp/mcp/src/registry.ts index 02278480..063e2d2d 100644 --- a/packages/mcp/mcp/src/registry.ts +++ b/packages/mcp/mcp/src/registry.ts @@ -3,7 +3,12 @@ import { getAdminTools } from '@transcend-io/mcp-server-admin'; import { getAssessmentTools } from '@transcend-io/mcp-server-assessment'; import { createErrorResult, + EMPTY_CAPABILITY_REPORT, + expandToolsForClient, + isVisibleToModel, + resolveToolVariant, SimpleLogger, + type ClientCapabilityReport, type ToolDefinition, type TranscendRestClient, } from '@transcend-io/mcp-server-base'; @@ -63,7 +68,19 @@ export class ToolRegistry { } } - getToolList(): Array<{ + /** + * Tool descriptors as a host would see them. + * + * The serving path builds descriptors in `buildMcpServer` rather than here, so + * this method exists for callers embedding the registry directly. It still + * resolves capability variants and carries `_meta`, because a descriptor list + * that quietly disagreed with what the server actually serves would be worse + * than no method at all. + */ + getToolList( + /** Capabilities of the host being served; defaults to a host with none */ + client: ClientCapabilityReport = EMPTY_CAPABILITY_REPORT, + ): Array<{ name: string; description: string; inputSchema: Record; @@ -72,25 +89,42 @@ export class ToolRegistry { destructiveHint: boolean; idempotentHint: boolean; }; + _meta?: Record; }> { - return Array.from(this.tools.values()).map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: this.jsonSchemaCache.get(tool.name) || { type: 'object', properties: {} }, - annotations: tool.annotations, - })); + return expandToolsForClient(Array.from(this.tools.values()), client) + .filter((tool) => isVisibleToModel(tool)) + .map((tool) => { + const resourceUri = tool.ui?.resource.uri; + return { + name: tool.name, + description: tool.description, + inputSchema: this.jsonSchemaCache.get(tool.name) || { type: 'object', properties: {} }, + annotations: tool.annotations, + ...(resourceUri && { + _meta: { ui: { resourceUri }, 'ui/resourceUri': resourceUri }, + }), + }; + }); } getTool(name: string): ToolDefinition | undefined { return this.tools.get(name); } - async executeTool(name: string, args: Record): Promise { - const tool = this.tools.get(name); - if (!tool) { + async executeTool( + name: string, + args: Record, + /** Capabilities of the host being served; defaults to a host with none */ + client: ClientCapabilityReport = EMPTY_CAPABILITY_REPORT, + ): Promise { + const registered = this.tools.get(name); + if (!registered) { throw new Error(`Unknown tool: ${name}`); } + // Resolve so the handler that runs matches the descriptor getToolList emitted. + const tool = resolveToolVariant(registered, client); + const parseResult = tool.zodSchema.safeParse(args); if (!parseResult.success) { const issues = parseResult.error.issues From e067fb84fc445d92a1b54efd412aa0b8148ad9c9 Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:39:36 -0700 Subject: [PATCH 05/19] chore(mcp): build dependencies before the umbrella package's checks --- packages/mcp/mcp-server-base/tests/build-server.test.ts | 2 +- turbo.json | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/mcp/mcp-server-base/tests/build-server.test.ts b/packages/mcp/mcp-server-base/tests/build-server.test.ts index 25121f51..e4be8101 100644 --- a/packages/mcp/mcp-server-base/tests/build-server.test.ts +++ b/packages/mcp/mcp-server-base/tests/build-server.test.ts @@ -458,7 +458,7 @@ describe('buildMcpServer MCP Apps', () => { it('serves the MCP App variant with view metadata to a host that supports it', async () => { const sessionId = await initSession(baseUrl, { capabilities: MCP_APP_CAPABILITIES, - clientName: 'claude-desktop', + clientName: 'claude-ai', }); const listed = await rpc<{ tools: ListedTool[] }>(baseUrl, sessionId, 'tools/list'); diff --git a/turbo.json b/turbo.json index 556273d0..8774765b 100644 --- a/turbo.json +++ b/turbo.json @@ -101,6 +101,14 @@ "//#format:check:root": {}, "//#check:packages": {}, "//#check:deps:root": {}, + "@transcend-io/mcp#typecheck": { + "dependsOn": ["^build", "//#codegen"], + "outputs": [] + }, + "@transcend-io/mcp#test": { + "dependsOn": ["^build", "//#codegen"], + "outputs": [] + }, "@transcend-io/design-tokens#build": { "dependsOn": ["^build"], "inputs": ["tokens/**", "plugins/**", "terrazzo.config.ts"], From 750de3cc251823458afc2d68ae68fc7a42066516 Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:28:05 -0700 Subject: [PATCH 06/19] feat(mcp-server-base): add a browser-only ./ui subpath with useMcpApp --- .changeset/mcp-server-base-ui-subpath.md | 7 + package.json | 1 + packages/mcp/mcp-server-base/.attw.json | 3 + packages/mcp/mcp-server-base/package.json | 21 +++ packages/mcp/mcp-server-base/src/ui/index.ts | 26 +++ .../mcp/mcp-server-base/src/ui/use-mcp-app.ts | 176 ++++++++++++++++++ packages/mcp/mcp-server-base/tsconfig.json | 4 +- packages/mcp/mcp-server-base/tsconfig.ui.json | 5 + packages/mcp/mcp-server-base/tsdown.config.ts | 4 +- pnpm-lock.yaml | 58 ++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 2 +- tsconfig.ui.base.json | 27 +++ turbo.json | 6 + types/css.d.ts | 7 + 15 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 .changeset/mcp-server-base-ui-subpath.md create mode 100644 packages/mcp/mcp-server-base/.attw.json create mode 100644 packages/mcp/mcp-server-base/src/ui/index.ts create mode 100644 packages/mcp/mcp-server-base/src/ui/use-mcp-app.ts create mode 100644 packages/mcp/mcp-server-base/tsconfig.ui.json create mode 100644 tsconfig.ui.base.json create mode 100644 types/css.d.ts diff --git a/.changeset/mcp-server-base-ui-subpath.md b/.changeset/mcp-server-base-ui-subpath.md new file mode 100644 index 00000000..3febb322 --- /dev/null +++ b/.changeset/mcp-server-base-ui-subpath.md @@ -0,0 +1,7 @@ +--- +'@transcend-io/mcp-server-base': minor +--- + +Add a browser-only `@transcend-io/mcp-server-base/ui` subpath exporting `useMcpApp`, the React hook a view uses to connect to its host, read the payload the tool sent, and call tools back. + +The separate subpath is load-bearing rather than cosmetic: the package root reaches into `node:async_hooks`, GraphQL clients, and OAuth, none of which can run in a sandboxed iframe. Importing only from `/ui` in view code keeps that graph unreachable. React and `@modelcontextprotocol/ext-apps` are optional peer dependencies, so packages that ship no view install nothing new. diff --git a/package.json b/package.json index e40f4bf8..a2d6846e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "codegen:watch": "graphql-codegen --config codegen.ts --watch", "graphql:refresh-schema": "node scripts/refresh-graphql-schema.ts", "typecheck": "turbo run typecheck typecheck:root", + "typecheck:ui": "turbo run typecheck:ui", "lint": "turbo run lint lint:root", "lint:fix": "turbo run lint:fix lint:fix:root", "format": "turbo run format format:root", diff --git a/packages/mcp/mcp-server-base/.attw.json b/packages/mcp/mcp-server-base/.attw.json new file mode 100644 index 00000000..04ba2341 --- /dev/null +++ b/packages/mcp/mcp-server-base/.attw.json @@ -0,0 +1,3 @@ +{ + "excludeEntrypoints": ["ui"] +} diff --git a/packages/mcp/mcp-server-base/package.json b/packages/mcp/mcp-server-base/package.json index 43fe3ae4..a6e55581 100644 --- a/packages/mcp/mcp-server-base/package.json +++ b/packages/mcp/mcp-server-base/package.json @@ -21,6 +21,11 @@ "@transcend-io/source": "./src/index.ts", "types": "./dist/index.d.mts", "default": "./dist/index.mjs" + }, + "./ui": { + "@transcend-io/source": "./src/ui/index.ts", + "types": "./dist/ui/index.d.mts", + "default": "./dist/ui/index.mjs" } }, "publishConfig": { @@ -30,6 +35,7 @@ "build": "tsdown", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:ui": "tsc -p tsconfig.ui.json --noEmit", "check:exports": "attw --pack . --ignore-rules cjs-resolves-to-esm", "check:publint": "publint --level warning --strict --pack pnpm" }, @@ -44,14 +50,29 @@ }, "devDependencies": { "@arethetypeswrong/cli": "catalog:", + "@modelcontextprotocol/ext-apps": "catalog:", "@types/cors": "2.8.19", "@types/express": "5.0.6", "@types/node": "catalog:", + "@types/react": "catalog:", "publint": "catalog:", + "react": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, + "peerDependencies": { + "@modelcontextprotocol/ext-apps": "catalog:", + "react": "catalog:" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/ext-apps": { + "optional": true + }, + "react": { + "optional": true + } + }, "engines": { "node": ">=22.12.0" } diff --git a/packages/mcp/mcp-server-base/src/ui/index.ts b/packages/mcp/mcp-server-base/src/ui/index.ts new file mode 100644 index 00000000..d44a8646 --- /dev/null +++ b/packages/mcp/mcp-server-base/src/ui/index.ts @@ -0,0 +1,26 @@ +/** + * Browser-side helpers for building MCP App views with React. + * + * This entry point is **browser-only** and is published separately from the + * package root as `@transcend-io/mcp-server-base/ui`. Keeping it separate is + * load-bearing: the root barrel reaches into `node:async_hooks`, GraphQL + * clients, and OAuth, none of which can run in a sandboxed iframe. Import only + * from this subpath in view code so that graph stays unreachable. + * + * React and `@modelcontextprotocol/ext-apps` are optional peer dependencies — + * they are needed only by packages that actually ship a view. + * + * @example + * ```tsx + * import { useMcpApp } from '@transcend-io/mcp-server-base/ui'; + * + * export function View() { + * const { data, isConnected } = useMcpApp<{ greeting: string }>({ + * appInfo: { name: 'my-view', version: '1.0.0' }, + * }); + * return

{isConnected ? data?.greeting : 'Connecting…'}

; + * } + * ``` + */ + +export { useMcpApp, type McpAppState, type UseMcpAppOptions } from './use-mcp-app.js'; diff --git a/packages/mcp/mcp-server-base/src/ui/use-mcp-app.ts b/packages/mcp/mcp-server-base/src/ui/use-mcp-app.ts new file mode 100644 index 00000000..87c35755 --- /dev/null +++ b/packages/mcp/mcp-server-base/src/ui/use-mcp-app.ts @@ -0,0 +1,176 @@ +import { + useApp, + useDocumentTheme, + useHostStyles, + type App, + type McpUiAppCapabilities, + type McpUiTheme, +} from '@modelcontextprotocol/ext-apps/react'; +import type { CallToolResult, Implementation } from '@modelcontextprotocol/sdk/types.js'; +import { useCallback, useState } from 'react'; + +/** + * The envelope every Transcend MCP tool returns, as produced by + * `createToolResult`. Views receive it as JSON in the first text content block. + */ +interface ToolEnvelope { + /** Whether the tool call succeeded */ + success?: boolean; + /** Result payload when successful */ + data?: TData; + /** Human-readable error message when unsuccessful */ + error?: string; +} + +/** + * Pulls the payload out of a tool result. + * + * `structuredContent` is preferred because it is the spec's typed channel, but + * our servers currently serialize the envelope as JSON into the first text + * block, so that is the path taken in practice. + */ +function parseToolEnvelope(result: CallToolResult): { + data: TData | undefined; + error: string | undefined; +} { + const raw = + result.structuredContent ?? + (() => { + const firstText = result.content?.find((block) => block.type === 'text'); + if (firstText?.type !== 'text') { + return undefined; + } + try { + return JSON.parse(firstText.text) as unknown; + } catch { + // A tool that returns prose rather than JSON is still worth surfacing. + return { success: !result.isError, data: firstText.text }; + } + })(); + + if (raw === null || typeof raw !== 'object') { + return { data: undefined, error: result.isError ? 'Tool call failed' : undefined }; + } + + const envelope = raw as ToolEnvelope; + const failed = result.isError === true || envelope.success === false; + return { + data: envelope.data, + error: failed ? (envelope.error ?? 'Tool call failed') : undefined, + }; +} + +/** Options for {@link useMcpApp}. */ +export interface UseMcpAppOptions { + /** Identifies this view to the host */ + appInfo: Implementation; + /** Features this view supports; defaults to none */ + capabilities?: McpUiAppCapabilities; +} + +/** Connection state, host theme, and tool data for an MCP App view. */ +export interface McpAppState { + /** Connected app instance, or null while connecting */ + app: App | null; + /** Whether the handshake with the host completed */ + isConnected: boolean; + /** Set when the handshake itself failed */ + connectionError: Error | null; + /** Host's current color theme, kept in sync as the host changes it */ + theme: McpUiTheme; + /** Payload of the most recent tool result, if any */ + data: TData | undefined; + /** Error message from the most recent tool result, if it failed */ + toolError: string | undefined; + /** Whether a {@link McpAppState.callTool} request is in flight */ + isCallingTool: boolean; + /** + * Invokes a tool on the server this view came from and folds the response into + * `data` / `toolError`, so calling a refresh-style tool re-renders the view. + * + * Returns the parsed payload, or `undefined` when the tool reported an error. + */ + callTool: (name: string, args?: Record) => Promise; +} + +/** + * Connects a React view to its MCP host. + * + * Wraps the MCP Apps SDK with the conventions this monorepo already uses: host + * style variables and fonts are applied so the view matches the surrounding + * client, and tool results are unwrapped from the `createToolResult` envelope + * into typed `data`. Styling comes from `@transcend-io/mcp-server-base/ui/theme.css`, + * which a view imports; this hook only feeds it the host's values. + * + * The initial payload arrives as a `ui/notifications/tool-result` notification + * rather than in the handshake response, so the handler is registered in + * `onAppCreated` — before `connect()` — to avoid dropping a result that lands + * immediately. + * + * @param options - View identity and declared capabilities + * @returns Connection state, host theme, tool data, and a tool caller + * + * @example + * ```tsx + * const { data, theme, callTool } = useMcpApp<{ greeting: string }>({ + * appInfo: { name: 'docs-hello', version: '1.0.0' }, + * }); + * ``` + */ +export function useMcpApp({ + appInfo, + capabilities = {}, +}: UseMcpAppOptions): McpAppState { + const [data, setData] = useState(undefined); + const [toolError, setToolError] = useState(undefined); + const [isCallingTool, setIsCallingTool] = useState(false); + + const { app, isConnected, error } = useApp({ + appInfo, + capabilities, + autoResize: true, + onAppCreated: (created: App) => { + created.ontoolresult = (params) => { + const parsed = parseToolEnvelope(params); + setData(parsed.data); + setToolError(parsed.error); + }; + created.ontoolcancelled = (params) => { + setToolError(params.reason ?? 'Tool call cancelled'); + }; + }, + }); + + useHostStyles(app, app?.getHostContext()); + const theme = useDocumentTheme(); + + const callTool = useCallback( + async (name: string, args?: Record): Promise => { + if (!app) { + throw new Error(`Cannot call "${name}" before the app is connected to its host`); + } + setIsCallingTool(true); + try { + const result = await app.callServerTool({ name, arguments: args ?? {} }); + const parsed = parseToolEnvelope(result); + setData(parsed.data); + setToolError(parsed.error); + return parsed.error === undefined ? parsed.data : undefined; + } finally { + setIsCallingTool(false); + } + }, + [app], + ); + + return { + app, + isConnected, + connectionError: error, + theme, + data, + toolError, + isCallingTool, + callTool, + }; +} diff --git a/packages/mcp/mcp-server-base/tsconfig.json b/packages/mcp/mcp-server-base/tsconfig.json index 4969cfcd..2d52b4f7 100644 --- a/packages/mcp/mcp-server-base/tsconfig.json +++ b/packages/mcp/mcp-server-base/tsconfig.json @@ -2,10 +2,12 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "../../../tsconfig.base.json", "compilerOptions": { + "lib": ["ES2022", "DOM"], "outDir": "dist", "rootDir": "src", "isolatedDeclarations": false, "types": ["node", "vitest/globals"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/ui/**"] } diff --git a/packages/mcp/mcp-server-base/tsconfig.ui.json b/packages/mcp/mcp-server-base/tsconfig.ui.json new file mode 100644 index 00000000..56c23e85 --- /dev/null +++ b/packages/mcp/mcp-server-base/tsconfig.ui.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.ui.base.json", + "include": ["src/ui/**/*.ts", "src/ui/**/*.tsx"] +} diff --git a/packages/mcp/mcp-server-base/tsdown.config.ts b/packages/mcp/mcp-server-base/tsdown.config.ts index c92e19a4..ae7d3b0d 100644 --- a/packages/mcp/mcp-server-base/tsdown.config.ts +++ b/packages/mcp/mcp-server-base/tsdown.config.ts @@ -4,5 +4,7 @@ import sharedConfig from '../../../tsdown.config.base.ts'; export default defineConfig({ ...sharedConfig, - entry: ['src/index.ts'], + // `src/ui/index.ts` is a browser-only entry, published as the `./ui` subpath so + // view code never resolves through the Node-only root barrel. + entry: ['src/index.ts', 'src/ui/index.ts'], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e70c2c7..d809236e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: '@graphql-typed-document-node/core': specifier: ^3.2.0 version: 3.2.0 + '@modelcontextprotocol/ext-apps': + specifier: ^1.7.5 + version: 1.7.5 '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0 @@ -39,6 +42,9 @@ catalogs: '@types/node': specifier: ^22.19.15 version: 22.19.21 + '@types/react': + specifier: ^19.2.17 + version: 19.2.18 '@types/semver': specifier: ^7.7.1 version: 7.7.1 @@ -66,6 +72,9 @@ catalogs: publint: specifier: ^0.3.18 version: 0.3.21 + react: + specifier: ^19.2.8 + version: 19.2.8 semver: specifier: ^7.7.4 version: 7.8.4 @@ -617,6 +626,9 @@ importers: '@arethetypeswrong/cli': specifier: 'catalog:' version: 0.18.3 + '@modelcontextprotocol/ext-apps': + specifier: 'catalog:' + version: 1.7.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react@19.2.8)(zod@4.4.3) '@types/cors': specifier: 2.8.19 version: 2.8.19 @@ -626,9 +638,15 @@ importers: '@types/node': specifier: 'catalog:' version: 22.19.21 + '@types/react': + specifier: 'catalog:' + version: 19.2.18 publint: specifier: 'catalog:' version: 0.3.21 + react: + specifier: 'catalog:' + version: 19.2.8 tsdown: specifier: 'catalog:' version: 0.21.10(@arethetypeswrong/core@0.18.3)(publint@0.3.21)(typescript@6.0.3) @@ -1894,6 +1912,20 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@modelcontextprotocol/ext-apps@1.7.5': + resolution: {integrity: sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.29.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -2812,6 +2844,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -3251,6 +3286,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + csv-parse@5.6.0: resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==} @@ -4729,6 +4767,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -6681,6 +6723,14 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react@19.2.8)(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + optionalDependencies: + react: 19.2.8 + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.25) @@ -7334,6 +7384,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + '@types/semver@7.7.1': {} '@types/send@1.2.1': @@ -7781,6 +7835,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + csv-parse@5.6.0: {} data-uri-to-buffer@4.0.1: {} @@ -9251,6 +9307,8 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react@19.2.8: {} + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 434ea2db..6508ac08 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,12 +8,14 @@ catalog: '@graphql-codegen/cli': ^7.1.0 '@graphql-codegen/client-preset': ^6.0.1 '@graphql-typed-document-node/core': ^3.2.0 + '@modelcontextprotocol/ext-apps': ^1.7.5 '@modelcontextprotocol/sdk': ^1.29.0 '@terrazzo/cli': 2.0.3 '@terrazzo/plugin-css': 2.0.3 '@types/json-schema': ^7.0.15 '@types/lodash': 4.14.202 '@types/node': ^22.19.15 + '@types/react': ^19.2.17 '@types/semver': ^7.7.1 colorjs.io: 0.6.1 graphql: ^16.14.0 @@ -23,6 +25,7 @@ catalog: oxfmt: ^0.54.0 oxlint: ^1.53.0 publint: ^0.3.18 + react: ^19.2.8 semver: ^7.7.4 syncpack: ^14.2.0 tsdown: ^0.21.2 diff --git a/tsconfig.base.json b/tsconfig.base.json index 5cb871e4..4ed7e290 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "files": ["./types/svg.d.ts"], + "files": ["./types/svg.d.ts", "./types/css.d.ts"], "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], diff --git a/tsconfig.ui.base.json b/tsconfig.ui.base.json new file mode 100644 index 00000000..ce22bbc5 --- /dev/null +++ b/tsconfig.ui.base.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", + "compilerOptions": { + // MCP App views are browser code bundled by Vite, so they need a different + // baseline than the Node libraries this repo otherwise ships. + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + // Bundler resolution is required, not just convenient: + // `@modelcontextprotocol/ext-apps` re-exports its React entry with + // extensionless specifiers, which NodeNext refuses to resolve. + "moduleResolution": "Bundler", + "jsx": "react-jsx", + // Vite resolves `./Component.tsx` literally, so view code says what it means + // instead of pointing at a `.js` file that never exists. + "allowImportingTsExtensions": true, + // No ambient Node or Vitest globals; a view runs in a sandboxed iframe. + "types": [], + // Vite emits the bundle, so tsc is only ever a checker here. Declarations + // and project references are therefore off. + "noEmit": true, + "composite": false, + "declaration": false, + "declarationMap": false, + "isolatedDeclarations": false + } +} diff --git a/turbo.json b/turbo.json index 8774765b..40f3ea79 100644 --- a/turbo.json +++ b/turbo.json @@ -5,6 +5,7 @@ "mise.toml", "mise.lock", "tsconfig.base.json", + "tsconfig.ui.base.json", "tsdown.config.base.ts", "vitest.config.ts", "types/**", @@ -35,6 +36,7 @@ "quality:checks": { "dependsOn": [ "typecheck", + "typecheck:ui", "//#typecheck:root", "//#check:packages", "check:deps", @@ -50,6 +52,10 @@ "dependsOn": ["transit", "//#codegen"], "outputs": [] }, + "typecheck:ui": { + "dependsOn": ["transit"], + "outputs": [] + }, "//#codegen": { "inputs": [ "codegen.ts", diff --git a/types/css.d.ts b/types/css.d.ts new file mode 100644 index 00000000..57a1f441 --- /dev/null +++ b/types/css.d.ts @@ -0,0 +1,7 @@ +/** + * Ambient module declaration for stylesheets imported for their side effects. + * + * Used by MCP App views, where Vite collects each imported stylesheet and the + * shared build inlines the result into the view's single HTML document. + */ +declare module '*.css'; From 69c8f4a6dda7aa16797334d683e9bcf38dc90430 Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:31:29 -0700 Subject: [PATCH 07/19] feat(mcp-server-base): publish the Tailwind view theme --- .changeset/mcp-server-base-view-theme.md | 9 + packages/mcp/README.md | 26 +++ packages/mcp/mcp-server-base/.attw.json | 2 +- packages/mcp/mcp-server-base/package.json | 16 +- packages/mcp/mcp-server-base/src/ui/index.ts | 8 +- packages/mcp/mcp-server-base/src/ui/theme.css | 213 ++++++++++++++++++ packages/mcp/mcp-server-base/tsdown.config.ts | 3 + pnpm-lock.yaml | 14 ++ pnpm-workspace.yaml | 1 + 9 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 .changeset/mcp-server-base-view-theme.md create mode 100644 packages/mcp/mcp-server-base/src/ui/theme.css diff --git a/.changeset/mcp-server-base-view-theme.md b/.changeset/mcp-server-base-view-theme.md new file mode 100644 index 00000000..ed7d425d --- /dev/null +++ b/.changeset/mcp-server-base-view-theme.md @@ -0,0 +1,9 @@ +--- +'@transcend-io/mcp-server-base': minor +--- + +Publish `@transcend-io/mcp-server-base/ui/theme.css`, the Tailwind theme MCP App views are styled with. + +Stock Tailwind is deliberately absent — the default theme is never imported, so `bg-red-500` does not exist and every utility resolves to a host value, a Transcend design token, or a literal fallback. Surfaces, typography, radii, and shadows follow the style variables the host sends at handshake time, so a view looks native in light or dark Claude; brand and status colors come from `@transcend-io/design-tokens` so it still reads as ours; spacing stays on Tailwind's scale, which the MCP Apps spec omits on purpose because layouts break when it shifts underneath them. + +The theme also replaces Tailwind's Preflight, because a view lives in an iframe the host measures: the body has to stay transparent and nothing may trap content in its own scroller. `tailwindcss` and `@transcend-io/design-tokens` are optional peer dependencies, so packages that ship no view install nothing new. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index a2900697..5d653df6 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -359,6 +359,32 @@ All servers share the same environment variables: **Monorepo:** store these in root **`secret.env`** (from [`secret.env.example`](../../secret.env.example)); load with `source` or [`scripts/mcp-run.sh`](../../scripts/mcp-run.sh). See [CONTRIBUTING.md](../../CONTRIBUTING.md#mcp-servers). +## Building MCP App views + +A view is a React component that runs inside the host's sandboxed iframe. It reaches the host through `useMcpApp` from `@transcend-io/mcp-server-base/ui`, and it is styled with the Tailwind theme published alongside that entry point. + +### Styling and design tokens + +Views are styled with Tailwind utilities, but not stock Tailwind: the default theme is never imported, so `bg-red-500` does not exist. Every utility resolves through `@transcend-io/mcp-server-base/ui/theme.css`, which deliberately splits where a value comes from: + +- **Surfaces, typography, radii, and shadows follow the host**, via the style variables it sends during the handshake. A view then looks native in light or dark Claude, and this covers what `@transcend-io/design-tokens` does not yet define. +- **Brand and status colors come from Transcend tokens**, so a view still reads as ours. +- **Spacing is Tailwind's own scale.** The MCP Apps spec omits spacing on purpose, since layouts break when it shifts underneath them. + +The namespaces available, all of which are host-aware: + +| Utilities | Values | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `bg-*`, `text-*`, `border-*` | `surface`, `surface-raised`, `surface-sunken`, `content`, `content-muted`, `content-subtle`, `content-inverse`, `line`, `line-subtle`, `focus` | +| `bg-brand*`, `text-brand-text` | `brand`, `brand-hovered`, `brand-pressed`, `brand-text` | +| `text-success`, `text-warning`, `text-danger` | Transcend status colors | +| `text-sm`, `text-md`, `text-heading-sm`, `text-heading-md` | Font sizes, each carrying its line height | +| `rounded-*`, `shadow-sm`, `font-*` | `sm`, `md`, `lg`, `full` | + +Two rules follow from this. **Never write an arbitrary color or length** — `bg-[#fff]`, `p-[20px]`, `bg-[var(--color-surface)]` — because each one opts a view out of the host. Snap to the scale, or add a token to the theme if the scale is genuinely missing something. Arbitrary values that are _structural_ are fine, since they have no namespace to live in: `grid-cols-[max-content_1fr]` is the intended way to write that. + +The theme replaces Tailwind's Preflight rather than layering on top of it, because a view lives in an iframe the host measures: the body has to be transparent and nothing may trap content in its own scroller. It is ordered as `@layer theme, tokens, base, components, utilities`, which is also how a view ends up dark inside a dark host — `tokens.css` declares `color-scheme: light`, and the later `base` layer overrides it. + ## Contributing See the [MCP Servers section of CONTRIBUTING.md](../../CONTRIBUTING.md#mcp-servers) for how to add tools, run tests, and publish packages. diff --git a/packages/mcp/mcp-server-base/.attw.json b/packages/mcp/mcp-server-base/.attw.json index 04ba2341..046202d5 100644 --- a/packages/mcp/mcp-server-base/.attw.json +++ b/packages/mcp/mcp-server-base/.attw.json @@ -1,3 +1,3 @@ { - "excludeEntrypoints": ["ui"] + "excludeEntrypoints": ["ui", "ui/theme.css"] } diff --git a/packages/mcp/mcp-server-base/package.json b/packages/mcp/mcp-server-base/package.json index a6e55581..6ff26ada 100644 --- a/packages/mcp/mcp-server-base/package.json +++ b/packages/mcp/mcp-server-base/package.json @@ -26,6 +26,10 @@ "@transcend-io/source": "./src/ui/index.ts", "types": "./dist/ui/index.d.mts", "default": "./dist/ui/index.mjs" + }, + "./ui/theme.css": { + "@transcend-io/source": "./src/ui/theme.css", + "default": "./dist/ui/theme.css" } }, "publishConfig": { @@ -51,26 +55,36 @@ "devDependencies": { "@arethetypeswrong/cli": "catalog:", "@modelcontextprotocol/ext-apps": "catalog:", + "@transcend-io/design-tokens": "workspace:*", "@types/cors": "2.8.19", "@types/express": "5.0.6", "@types/node": "catalog:", "@types/react": "catalog:", "publint": "catalog:", "react": "catalog:", + "tailwindcss": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "catalog:", - "react": "catalog:" + "@transcend-io/design-tokens": "workspace:*", + "react": "catalog:", + "tailwindcss": "catalog:" }, "peerDependenciesMeta": { "@modelcontextprotocol/ext-apps": { "optional": true }, + "@transcend-io/design-tokens": { + "optional": true + }, "react": { "optional": true + }, + "tailwindcss": { + "optional": true } }, "engines": { diff --git a/packages/mcp/mcp-server-base/src/ui/index.ts b/packages/mcp/mcp-server-base/src/ui/index.ts index d44a8646..558db9ca 100644 --- a/packages/mcp/mcp-server-base/src/ui/index.ts +++ b/packages/mcp/mcp-server-base/src/ui/index.ts @@ -7,8 +7,12 @@ * clients, and OAuth, none of which can run in a sandboxed iframe. Import only * from this subpath in view code so that graph stays unreachable. * - * React and `@modelcontextprotocol/ext-apps` are optional peer dependencies — - * they are needed only by packages that actually ship a view. + * React, `@modelcontextprotocol/ext-apps`, and `tailwindcss` are optional peer + * dependencies — they are needed only by packages that actually ship a view. + * + * Styling lives alongside this entry as `./theme.css`, imported from a view's + * own stylesheet rather than re-exported here, because Tailwind has to see it at + * build time. * * @example * ```tsx diff --git a/packages/mcp/mcp-server-base/src/ui/theme.css b/packages/mcp/mcp-server-base/src/ui/theme.css new file mode 100644 index 00000000..092ae5cc --- /dev/null +++ b/packages/mcp/mcp-server-base/src/ui/theme.css @@ -0,0 +1,213 @@ +/* + * Shared Tailwind theme for MCP App views. + * + * A view imports this and gets its whole styling vocabulary: utilities named + * after semantic roles, a reset tuned for a sandboxed iframe, and nothing else. + * There is deliberately no default Tailwind theme underneath, so `bg-red-500` + * and the rest of the stock palette do not exist — every utility here resolves + * to a host value, a Transcend token, or a literal fallback, in that order. + */ + +/* + * `tokens` sits below `base` so the `color-scheme: light` that the design + * tokens stylesheet declares cannot pin a view to light mode inside a dark + * host, and `utilities` sits last so a utility always beats the reset. + */ +@layer theme, tokens, base, components, utilities; + +@import '@transcend-io/design-tokens/tokens.css' layer(tokens); + +/* + * Only `utilities` is imported from Tailwind. Skipping `theme.css` is what + * removes the stock palette and type scale, and skipping `preflight.css` leaves + * the `base` layer below as the single reset — ours is transparent-bodied and + * never traps content in a scroller, neither of which Preflight knows about. + * + * `source(none)` because automatic class detection starts at the working + * directory, which differs between a package build and the dev harness. Each + * view registers its own `@source` instead, so the same classes are generated + * either way. + */ +@import 'tailwindcss/utilities.css' layer(utilities) source(none); + +@theme { + /* + * Surfaces, text, and lines follow the host so a view reads as part of the + * conversation rather than an embedded box. Each chain is: host style + * variable, then Transcend token, then a literal for hosts that send neither. + * + * The token names in the middle position have no `--color-` prefix and are + * colors despite living in what Tailwind calls the `--text-*` namespace here + * (`--text-subtle` is a token; `--text-sm` below is a font size). They are + * distinct variables, so nothing collides. + */ + --color-surface: var(--color-background-primary, var(--background-default, #ffffff)); + --color-surface-raised: var( + --color-background-secondary, + var(--background-default-hover, #f4f4f6) + ); + --color-surface-sunken: var(--color-background-tertiary, var(--background-neutral, #ebebef)); + --color-content: var(--color-text-primary, var(--text, #1e1d28)); + --color-content-muted: var(--color-text-secondary, var(--text-subtle, #55535f)); + --color-content-subtle: var(--color-text-tertiary, var(--text-subtlest, #85838f)); + --color-content-inverse: var(--color-text-inverse, var(--text-inverse, #ffffff)); + --color-line: var(--color-border-primary, var(--border-default, #d6d5db)); + --color-line-subtle: var(--color-border-secondary, var(--border-subtle, #ebebef)); + --color-focus: var(--color-ring-primary, var(--border-focused, #3e2ebc)); + + /* + * Brand and status are Transcend's identity, so tokens lead here and the host + * is only the fallback. + */ + --color-brand: var(--background-brand-bold, var(--color-background-info, #5f5bf7)); + --color-brand-hovered: var(--background-brand-bold-hovered, var(--color-brand)); + --color-brand-pressed: var(--background-brand-bold-pressed, var(--color-brand)); + --color-brand-text: var(--text-brand-bold, var(--color-text-info, #3e2ebc)); + --color-success: var(--text-success-bold, var(--color-text-success, #1a7f4b)); + --color-warning: var(--text-warning-bold, var(--color-text-warning, #8a5a00)); + --color-danger: var(--text-danger-bold, var(--color-text-danger, #b42318)); + + /* + * These four namespaces are named exactly what the host names them, so a + * `var()` chain would reference itself and be invalid. It is also unnecessary: + * the host applies its variables as inline styles on the document element, + * which outranks the `:root` rule Tailwind emits from this block. So these are + * the fallback values, and a host that sends its own silently wins. + */ + --font-sans: ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.08); + + /* Type scale. Sizes and their line heights travel together as one utility. */ + --text-sm: var(--font-text-sm-size, 0.8125rem); + --text-sm--line-height: var(--font-text-sm-line-height, 1.25rem); + --text-md: var(--font-text-md-size, 0.875rem); + --text-md--line-height: var(--font-text-md-line-height, 1.375rem); + --text-heading-sm: var(--font-heading-sm-size, 1rem); + --text-heading-sm--line-height: var(--font-heading-sm-line-height, 1.5rem); + --text-heading-md: var(--font-heading-md-size, 1.125rem); + --text-heading-md--line-height: var(--font-heading-md-line-height, 1.625rem); + + --radius-sm: var(--border-radius-sm, 6px); + --radius-md: var(--border-radius-md, 8px); + --radius-lg: var(--border-radius-lg, 12px); + --radius-full: var(--border-radius-full, 9999px); + + /* + * Ours, not the host's: the MCP Apps spec excludes spacing on purpose, on the + * grounds that layouts break when spacing shifts underneath them. Breakpoints + * are likewise ours, since none survive skipping Tailwind's default theme, and + * a view has to work from 320px up. + */ + --spacing: 0.25rem; + --breakpoint-sm: 24rem; + --breakpoint-md: 32rem; +} + +@layer base { + :root { + /* + * Let the OS decide until the host reports its theme. The Apps SDK sets + * `color-scheme` inline once connected, which outranks this. + */ + color-scheme: light dark; + } + + *, + *::before, + *::after { + box-sizing: border-box; + /* + * Preflight's rule, kept because the `border` utility only sets a width and + * would otherwise render nothing. + */ + border: 0 solid; + } + + body { + margin: 0; + /* + * Transparent so the host's own surface shows through; a view that paints + * its own page background reads as a foreign box inside the conversation. + */ + background: transparent; + color: var(--color-content); + font-family: var(--font-sans); + font-size: var(--text-md); + line-height: var(--text-md--line-height); + -webkit-font-smoothing: antialiased; + } + + /* + * Views are measured and resized by the host, so never trap content in a + * scroller of our own. + */ + html, + body { + overflow: visible; + } + + /* The rest of what Preflight would have normalized, minus its opinions we do + not need. Headings and lists are unstyled so type comes from the scale + above rather than from the user-agent stylesheet. */ + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + font-size: inherit; + font-weight: inherit; + } + + p, + dl, + dd, + figure, + blockquote { + margin: 0; + } + + ol, + ul, + menu { + margin: 0; + padding: 0; + list-style: none; + } + + img, + svg, + video, + canvas { + display: block; + max-width: 100%; + height: auto; + } + + button, + input, + select, + textarea { + font: inherit; + color: inherit; + } + + :focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + } +} diff --git a/packages/mcp/mcp-server-base/tsdown.config.ts b/packages/mcp/mcp-server-base/tsdown.config.ts index ae7d3b0d..322e95dc 100644 --- a/packages/mcp/mcp-server-base/tsdown.config.ts +++ b/packages/mcp/mcp-server-base/tsdown.config.ts @@ -7,4 +7,7 @@ export default defineConfig({ // `src/ui/index.ts` is a browser-only entry, published as the `./ui` subpath so // view code never resolves through the Node-only root barrel. entry: ['src/index.ts', 'src/ui/index.ts'], + // Copied rather than bundled: a consuming view imports this from its own + // stylesheet, so Tailwind reads it as CSS and never as a module. + copy: [{ from: 'src/ui/theme.css', to: 'dist/ui' }], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d809236e..6ecb50d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ catalogs: syncpack: specifier: ^14.2.0 version: 14.3.1 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 tsdown: specifier: ^0.21.2 version: 0.21.10 @@ -629,6 +632,9 @@ importers: '@modelcontextprotocol/ext-apps': specifier: 'catalog:' version: 1.7.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react@19.2.8)(zod@4.4.3) + '@transcend-io/design-tokens': + specifier: workspace:* + version: link:../../design-tokens '@types/cors': specifier: 2.8.19 version: 2.8.19 @@ -647,6 +653,9 @@ importers: react: specifier: 'catalog:' version: 19.2.8 + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 tsdown: specifier: 'catalog:' version: 0.21.10(@arethetypeswrong/core@0.18.3)(publint@0.3.21)(typescript@6.0.3) @@ -5159,6 +5168,9 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -9773,6 +9785,8 @@ snapshots: tagged-tag@1.0.0: {} + tailwindcss@4.3.3: {} + term-size@2.2.1: {} thenify-all@1.6.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6508ac08..ae79af21 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,6 +28,7 @@ catalog: react: ^19.2.8 semver: ^7.7.4 syncpack: ^14.2.0 + tailwindcss: ^4.3.3 tsdown: ^0.21.2 turbo: ^2.9.14 typescript: ^6.0.0 From de730b5cff36595ed8457a6f1719dca3b15bec88 Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:34:11 -0700 Subject: [PATCH 08/19] feat(mcp): build MCP App views with React and Vite --- package.json | 4 + packages/mcp/README.md | 75 +++++++ pnpm-lock.yaml | 195 +++++++++++++++++ pnpm-workspace.yaml | 4 + scripts/build-mcp-views.ts | 65 ++++++ scripts/tsconfig.json | 5 +- turbo.json | 1 + vite.config.base.ts | 435 +++++++++++++++++++++++++++++++++++++ 8 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 scripts/build-mcp-views.ts create mode 100644 vite.config.base.ts diff --git a/package.json b/package.json index a2d6846e..bab8095a 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@changesets/cli": "catalog:", "@graphql-codegen/cli": "catalog:", "@graphql-codegen/client-preset": "catalog:", + "@tailwindcss/vite": "catalog:", "@transcend-io/mcp-server-admin": "workspace:*", "@transcend-io/mcp-server-assessment": "workspace:*", "@transcend-io/mcp-server-base": "workspace:*", @@ -54,8 +55,11 @@ "oxfmt": "catalog:", "oxlint": "catalog:", "syncpack": "catalog:", + "tailwindcss": "catalog:", + "tsdown": "catalog:", "turbo": "catalog:", "typescript": "catalog:", + "vite": "catalog:", "vitest": "catalog:", "zod": "catalog:" }, diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5d653df6..58bc283c 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -363,6 +363,70 @@ All servers share the same environment variables: A view is a React component that runs inside the host's sandboxed iframe. It reaches the host through `useMcpApp` from `@transcend-io/mcp-server-base/ui`, and it is styled with the Tailwind theme published alongside that entry point. +### Building a view with React + +Views are React apps built by Vite into a single self-contained HTML document. That single-file constraint is not a style preference: `resources/read` returns one string, and the host renders it in a sandboxed iframe with no same-origin server, so anything left as a separate file or CDN URL cannot be fetched. Inlining everything also means a view needs no CSP `resourceDomains` at all. + +A view is discovered by convention rather than declared: **a directory under `src/ui/` holding exactly one `*View.tsx`, which exports the name its filename promises.** `HelloView.tsx` must export `HelloView`. Zero or several matching files is an error naming the directory. Prefix a directory with `_` to hold shared code that is not a view. + +Two files that a view needs are **synthesized during the build and exist nowhere on disk**, served by `synthesizeMcpAppViews` in the repo-root `vite.config.base.ts` from ids inside the view's own directory: + +- `mcp-app-entry.tsx` — imports the component, mounts it into `#root` under `StrictMode`, and imports the stylesheet below. +- `mcp-app-theme.css` — imports the shared theme and registers the view's directory as a Tailwind source, i.e. exactly the two statements under [Styling and design tokens](#styling-and-design-tokens). + +The tradeoff is deliberate: a view directory no longer shows how it boots, which is why that is spelled out here. If a view needs CSS that utilities cannot express — keyframes, or an `@layer components` rule — add `src/ui//.css` and the synthesized entry imports it automatically. + +#### Splitting a view into components + +A view is one _entry_ component, not one file. Break it up freely: + +``` +src/ui/ + _shared/ shared across views; `_` means "not a view" + Badge.tsx + cookie-triage/ + CookieTriageView.tsx the entry — the only *View.tsx here + ActionBar.tsx sibling components + ConfirmModal.tsx + useTriageSelection.ts + table/ + TriageTable.tsx nested as deep as you like + TableRow.tsx + cookie-triage.css optional, imported automatically +``` + +Two rules, both enforced rather than trusted: + +- **Only the entry may end in `View.tsx`.** Discovery looks for exactly one match at the top level of the view directory, so a sibling named `ConfirmModalView.tsx` fails the build by name. Nesting is unaffected — `table/TriageTableView.tsx` would be fine, since the search is not recursive — but the simplest habit is to reserve the suffix for the entry. +- **Shared components go in a `_`-prefixed directory under `src/ui/`.** Tailwind generates utilities per document by scanning files, so the synthesized stylesheet sources every `_` directory in addition to the view's own. Putting a shared component anywhere else outside the view directory bundles it correctly and then renders it unstyled, which reads as a CSS bug rather than a missing `@source`. The cost of sourcing them is that shared components' utilities appear in every view's document, which is why `_shared` is for genuinely shared UI rather than a dumping ground. + +```tsx +// src/ui/hello/HelloView.tsx +import { useMcpApp } from '@transcend-io/mcp-server-base/ui'; + +export function HelloView() { + const { data, theme, callTool, isCallingTool } = useMcpApp<{ greeting: string }>({ + appInfo: { name: 'my-view', version: '1.0.0' }, + }); + + return ( + + ); +} +``` + +`useMcpApp` wraps `@modelcontextprotocol/ext-apps` with this repo's conventions: it connects to the host, applies the host's style variables to the document so the shared theme can pick them up, keeps `theme` in sync, and unwraps the `createToolResult` envelope into typed `data`. `callTool` invokes a tool on the originating server and folds the response back into `data`, so a refresh re-renders the view. + +Import view code only from `@transcend-io/mcp-server-base/ui`, never the package root. That subpath exists so browser code cannot reach the root barrel, which pulls in `node:async_hooks`, GraphQL clients, and OAuth. + +A few constraints worth knowing before adding a view: + +- **A package has no Vite config of its own.** `scripts/build-mcp-views.ts` discovers the package's views and runs one Vite build per view, because the single-file plugin collapses a whole bundle into one document — so a config naming a single entry could not express a package with two views, and would have silently emitted both into one document. The script also passes `configFile: false`, since Vitest auto-loads a `vite.config.ts` when a package has no test config and would otherwise replace the shared root config. +- **Views are checked by a second tsconfig.** `tsconfig.json` excludes `src/ui`, since browser code needs bundler module resolution — `@modelcontextprotocol/ext-apps` re-exports its React entry with extensionless specifiers that `NodeNext` refuses to resolve. +- **Expect a few hundred kilobytes per view.** React, the Apps SDK, and its Zod dependency are all inlined. That is fine for a locally served resource, but it is not a budget for many small views. + ### Styling and design tokens Views are styled with Tailwind utilities, but not stock Tailwind: the default theme is never imported, so `bg-red-500` does not exist. Every utility resolves through `@transcend-io/mcp-server-base/ui/theme.css`, which deliberately splits where a value comes from: @@ -371,6 +435,17 @@ Views are styled with Tailwind utilities, but not stock Tailwind: the default th - **Brand and status colors come from Transcend tokens**, so a view still reads as ours. - **Spacing is Tailwind's own scale.** The MCP Apps spec omits spacing on purpose, since layouts break when it shifts underneath them. +So a view's stylesheet is two statements, which is why the build writes it rather than each view repeating it: + +```css +@import '@transcend-io/mcp-server-base/ui/theme.css'; + +/* The theme sets `source(none)`, so each view registers its own files. */ +@source './**/*.tsx'; +``` + +The `@source` cannot be left implicit even though it is now generated. Tailwind's automatic detection starts at the working directory, which differs depending on whether the build was invoked from the package or from the repo root, so an implicit scan would generate a different set of utilities depending on how it was run. Naming the directory is also what forces the synthesized stylesheet's id to sit _inside_ the view directory: Tailwind resolves `@source` against `path.dirname` of the stylesheet's id. + The namespaces available, all of which are host-aware: | Utilities | Values | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ecb50d6..1db38962 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ catalogs: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0 + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3 '@terrazzo/cli': specifier: 2.0.3 version: 2.0.3 @@ -93,6 +96,9 @@ catalogs: typescript: specifier: ^6.0.0 version: 6.0.3 + vite: + specifier: ^8.0.16 + version: 8.0.16 vitest: specifier: ^4.0.18 version: 4.1.8 @@ -113,6 +119,9 @@ importers: '@graphql-codegen/client-preset': specifier: 'catalog:' version: 6.0.1(graphql@16.14.2) + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.3.3(vite@8.0.16(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@transcend-io/mcp-server-admin': specifier: workspace:* version: link:packages/mcp/mcp-server-admin @@ -158,12 +167,21 @@ importers: syncpack: specifier: 'catalog:' version: 14.3.1 + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 + tsdown: + specifier: 'catalog:' + version: 0.21.10(@arethetypeswrong/core@0.18.3)(publint@0.3.21)(typescript@6.0.3) turbo: specifier: 'catalog:' version: 2.9.18 typescript: specifier: 'catalog:' version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.0.16(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: 'catalog:' version: 4.1.8(@types/node@22.19.21)(vite@8.0.16(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -2669,6 +2687,100 @@ packages: '@stricli/core@1.2.7': resolution: {integrity: sha512-a0HxA/cSWjqHj/9GM+cfc/zGNmBdxVTQQpHIEvY1AbDEgo4ZU84cTCbtywYEQOHw2wIc6Vu+PKv+ZQoqZwkHnQ==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@terrazzo/cli@2.0.3': resolution: {integrity: sha512-p/4Sjv+bycZUOQbgWRHszaH0Q+ahS5CZOhdNappd3Nzy/eCw1bA/yTNcZUGZ8BqiOLn+uyFE0uIvGSCy5U2zEQ==} hasBin: true @@ -3455,6 +3567,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -5171,6 +5287,10 @@ packages: tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -7151,6 +7271,74 @@ snapshots: '@stricli/core@1.2.7': {} + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.0.16(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.0.16(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + '@terrazzo/cli@2.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.19.21)(esbuild@0.28.1)(hono@4.12.25)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)': dependencies: '@clack/prompts': 1.7.0 @@ -7979,6 +8167,11 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -9787,6 +9980,8 @@ snapshots: tailwindcss@4.3.3: {} + tapable@2.3.3: {} + term-size@2.2.1: {} thenify-all@1.6.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ae79af21..a9d19dd2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,12 +10,14 @@ catalog: '@graphql-typed-document-node/core': ^3.2.0 '@modelcontextprotocol/ext-apps': ^1.7.5 '@modelcontextprotocol/sdk': ^1.29.0 + '@tailwindcss/vite': ^4.3.3 '@terrazzo/cli': 2.0.3 '@terrazzo/plugin-css': 2.0.3 '@types/json-schema': ^7.0.15 '@types/lodash': 4.14.202 '@types/node': ^22.19.15 '@types/react': ^19.2.17 + '@types/react-dom': ^19.2.3 '@types/semver': ^7.7.1 colorjs.io: 0.6.1 graphql: ^16.14.0 @@ -26,12 +28,14 @@ catalog: oxlint: ^1.53.0 publint: ^0.3.18 react: ^19.2.8 + react-dom: ^19.2.8 semver: ^7.7.4 syncpack: ^14.2.0 tailwindcss: ^4.3.3 tsdown: ^0.21.2 turbo: ^2.9.14 typescript: ^6.0.0 + vite: ^8.0.16 vitest: ^4.0.18 zod: ^4.3.6 diff --git a/scripts/build-mcp-views.ts b/scripts/build-mcp-views.ts new file mode 100644 index 00000000..b247b44a --- /dev/null +++ b/scripts/build-mcp-views.ts @@ -0,0 +1,65 @@ +/** + * Builds every MCP App view a package ships, one self-contained document each. + * + * Replaces a per-package `vite.views.config.ts`. That file had to name a single + * entry, and because the single-file plugin collapses the whole bundle into one + * document, a second view in the same package would not have failed — it would + * have emitted one document containing both. Views are discovered from `src/ui` + * instead, and each gets its own build. + * + * Usage: + * node ../../../scripts/build-mcp-views.ts # from a package directory + * node scripts/build-mcp-views.ts packages/mcp/mcp-server-docs + * node scripts/build-mcp-views.ts --watch # rebuild on change + */ + +import { rmSync } from 'node:fs'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; + +import { build } from 'vite'; + +import { defineMcpAppView, discoverMcpAppViews, MCP_APP_OUT_DIR } from '../vite.config.base.ts'; +import { logger } from './logger.ts'; + +async function main(): Promise { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { watch: { type: 'boolean', default: false } }, + }); + + const packageDir = path.resolve(positionals[0] ?? process.cwd()); + const views = discoverMcpAppViews(packageDir); + + if (views.length === 0) { + throw new Error( + `No MCP App views found under ${path.join(path.relative(process.cwd(), packageDir) || '.', 'src/ui')}. ` + + 'A view is a directory there holding exactly one *View.tsx.', + ); + } + + // Cleared once here rather than per build, since every view writes into it. + // This is also what retires the document of a view that has been deleted. + rmSync(path.join(packageDir, MCP_APP_OUT_DIR), { recursive: true, force: true }); + + logger.log(`Building ${views.length} view(s): ${views.map((view) => view.name).join(', ')}`); + + for (const view of views) { + const config = defineMcpAppView({ view }); + await build({ + ...config, + // Never inferred from disk: a stray `vite.config.ts` beside a package would + // silently replace all of this. + configFile: false, + root: packageDir, + logLevel: 'warn', + build: { ...config.build, ...(values.watch && { watch: {} }) }, + }); + logger.log(` ${view.name} -> ${path.join(MCP_APP_OUT_DIR, view.fileName)}`); + } +} + +main().catch((error: unknown) => { + logger.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 95d488e3..38e29359 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -8,7 +8,10 @@ "incremental": false, "isolatedDeclarations": false, "noEmit": true, - "rootDir": ".", + // The repo root, not `.`, because the view scripts import the shared Vite + // config that lives there. Nothing is emitted, so this only has to be wide + // enough to contain every file in the program. + "rootDir": "..", "types": ["node", "vitest/globals"], // The MCP description audit imports `@transcend-io/mcp-server-consent`, // which transitively re-exports types from `@transcend-io/airgap.js-types` diff --git a/turbo.json b/turbo.json index 40f3ea79..a8cb2a79 100644 --- a/turbo.json +++ b/turbo.json @@ -7,6 +7,7 @@ "tsconfig.base.json", "tsconfig.ui.base.json", "tsdown.config.base.ts", + "vite.config.base.ts", "vitest.config.ts", "types/**", "assets/**", diff --git a/vite.config.base.ts b/vite.config.base.ts new file mode 100644 index 00000000..fa1088ec --- /dev/null +++ b/vite.config.base.ts @@ -0,0 +1,435 @@ +import { existsSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import tailwindcss from '@tailwindcss/vite'; +import type { Plugin, UserConfig } from 'vite'; + +const repoRoot = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Element id the emitted document exposes for React to mount into. + * + * Exported so a view's entry module and this template cannot drift apart. + */ +export const MCP_APP_ROOT_ID = 'root'; + +/** Directory under a package that holds its views, one per subdirectory. */ +const VIEWS_DIR = path.join('src', 'ui'); + +/** + * Directory the built documents are written to, relative to the package. + * + * Inside `src/` rather than `dist/` because tsdown cleans `dist/` and then + * inlines these documents as strings. Gitignored. + */ +export const MCP_APP_OUT_DIR = path.join('src', 'ui', 'generated'); + +/** + * Names {@link synthesizeMcpAppViews} serves from inside each view directory. + * + * No file exists at either path. They are absolute paths rather than + * `virtual:` ids because both need to behave like real files: Vite's + * `build.lib.entry` takes a path, and Tailwind derives its class-scanning root + * from `path.dirname` of the stylesheet's id — so an id outside the view + * directory would scan the wrong tree. + */ +const SYNTHESIZED_ENTRY = 'mcp-app-entry.tsx'; +const SYNTHESIZED_STYLESHEET = 'mcp-app-theme.css'; + +/** A view found under a package's `src/ui`. */ +export interface McpAppView { + /** Directory name under `src/ui`, which is also the view's id, e.g. `hello` */ + name: string; + /** Absolute path to the view's directory */ + directory: string; + /** Absolute path to the component module, e.g. `.../hello/HelloView.tsx` */ + componentPath: string; + /** Component's exported name, which matches its filename, e.g. `HelloView` */ + componentName: string; + /** Absolute path the synthesized entry module is served from */ + entryId: string; + /** Absolute path the synthesized stylesheet is served from */ + cssId: string; + /** Absolute path to the view's own optional stylesheet, when it has one */ + stylesheet?: string; + /** + * Absolute paths to `_`-prefixed directories under `src/ui`, which hold + * components shared between views. + * + * Carried per view because Tailwind generates utilities per document: a shared + * component's classes have to be scanned for every view that might render it, + * and a view cannot know which those are. + */ + sharedDirectories: string[]; + /** Emitted document's name, e.g. `hello.html` */ + fileName: string; +} + +/** + * Finds a package's views by convention: one directory per view under `src/ui`, + * each holding exactly one `*View.tsx`. + * + * The component's filename determines the export the synthesized entry imports, + * so `HelloView.tsx` must export `HelloView`. A directory holding no `*View.tsx` + * or several is an error rather than a skip: silently ignoring it is how a + * renamed component turns into a view that simply stops existing, with a passing + * build. Prefix a directory with `_` to hold shared code that is not a view. + * + * @param packageDir - Absolute path to the package + * @returns Views in a stable order + */ +export function discoverMcpAppViews(packageDir: string): McpAppView[] { + const viewsDir = path.join(packageDir, VIEWS_DIR); + if (!existsSync(viewsDir)) return []; + + const views: McpAppView[] = []; + const directories = readdirSync(viewsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + const sharedDirectories = directories + .filter((entry) => entry.name.startsWith('_')) + .map((entry) => path.join(viewsDir, entry.name)); + + for (const entry of directories) { + // `generated` holds the built documents, not source, and `_` marks shared + // code rather than a view. + if (entry.name === 'generated' || entry.name.startsWith('_')) continue; + + const directory = path.join(viewsDir, entry.name); + const components = readdirSync(directory) + .filter((file) => file.endsWith('View.tsx')) + .sort(); + + if (components.length !== 1) { + throw new Error( + `MCP App view directory "${path.relative(packageDir, directory)}" holds ${components.length} files matching *View.tsx${components.length > 0 ? ` (${components.join(', ')})` : ''}, but a view is defined by exactly one. ` + + 'Rename the component so a single file matches, or prefix the directory with "_" if it is shared code rather than a view.', + ); + } + + const componentFile = components[0]!; + const stylesheet = path.join(directory, `${entry.name}.css`); + + views.push({ + name: entry.name, + directory, + componentPath: path.join(directory, componentFile), + componentName: path.basename(componentFile, '.tsx'), + entryId: path.join(directory, SYNTHESIZED_ENTRY), + cssId: path.join(directory, SYNTHESIZED_STYLESHEET), + ...(existsSync(stylesheet) && { stylesheet }), + sharedDirectories, + fileName: `${entry.name}.html`, + }); + } + + return views.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Source of the entry module that mounts one view. */ +function synthesizedEntry(view: McpAppView): string { + // A view's own stylesheet comes last, though the order is not what gives it + // precedence: it is unlayered, and unlayered rules outrank every layer the + // theme declares. + const ownStylesheet = + view.stylesheet === undefined ? '' : `import './${path.basename(view.stylesheet)}';\n`; + + return `import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { ${view.componentName} } from './${path.basename(view.componentPath)}'; + +import './${SYNTHESIZED_STYLESHEET}'; +${ownStylesheet} +const container = document.getElementById(${JSON.stringify(MCP_APP_ROOT_ID)}); +if (!container) { + throw new Error( + 'MCP App view "${view.name}" could not start: the document has no #${MCP_APP_ROOT_ID} container', + ); +} + +createRoot(container).render( + + <${view.componentName} /> + , +); +`; +} + +/** Source of the stylesheet that gives one view its utilities. */ +function synthesizedStylesheet(view: McpAppView): string { + // `source(none)` in the theme means utilities are only generated for files a + // stylesheet explicitly claims. This one claims the view's own directory, which + // is why the id has to sit inside it, plus any shared directories — a component + // under `_shared` would otherwise be bundled and render unstyled. + const sources = [ + './**/*.tsx', + ...view.sharedDirectories.map((directory) => globFrom(view.directory, directory)), + ]; + + return [ + `@import '@transcend-io/mcp-server-base/ui/theme.css';`, + '', + ...sources.map((source) => `@source '${source}';`), + '', + ].join('\n'); +} + +/** A `@source` glob for `directory`, written relative to `from` in posix form. */ +function globFrom(from: string, directory: string): string { + const relative = path.relative(from, directory).split(path.sep).join('/'); + return `${relative}/**/*.tsx`; +} + +/** Splits Vite's `?direct`-style suffix off an id. */ +function splitQuery(id: string): [specifier: string, suffix: string] { + const match = /[?#]/.exec(id); + return match === null ? [id, ''] : [id.slice(0, match.index), id.slice(match.index)]; +} + +/** + * Serves each view's entry module and stylesheet without either existing on disk. + * + * Both files are pure boilerplate — they differ between views only by the + * component's name — so writing them per view is repetition that drifts. The + * cost is that a view directory no longer shows how it boots, which the MCP + * README carries instead. + * + * @param views - Views whose modules this plugin should serve + * @returns A Vite plugin + */ +export function synthesizeMcpAppViews(views: readonly McpAppView[]): Plugin { + const sources = new Map string>(); + for (const view of views) { + sources.set(view.entryId, () => synthesizedEntry(view)); + sources.set(view.cssId, () => synthesizedStylesheet(view)); + } + + return { + name: 'transcend:mcp-app-synthesized-views', + // Ahead of Tailwind, which must see the stylesheet's contents to compile it. + enforce: 'pre', + resolveId(source, importer) { + const [specifier, suffix] = splitQuery(source); + + // Requested directly, as `build.lib.entry`. + if (sources.has(specifier)) return source; + + // The entry imports its stylesheet as a sibling. Vite's own resolver would + // look for the file and fail, and in dev it arrives with a `?direct` or + // `?used` suffix that has to survive. + if (importer !== undefined && specifier.startsWith('.')) { + const [importerPath] = splitQuery(importer); + if (sources.has(importerPath)) { + const resolved = path.resolve(path.dirname(importerPath), specifier); + if (sources.has(resolved)) return resolved + suffix; + } + } + + return undefined; + }, + load(id) { + return sources.get(splitQuery(id)[0])?.(); + }, + }; +} + +/** + * Module resolution for the view build. + * + * Separate from {@link defineMcpAppView} because resolution is the half a second + * consumer would need — workspace packages resolved to TypeScript source, plus + * the shared asset alias — without inheriting the settings that collapse a build + * into one inlined document. + */ +export function mcpAppResolve(): NonNullable { + return { + alias: { + '@tools/assets': path.join(repoRoot, 'assets'), + // Stylesheets a view imports from a workspace package are aliased rather + // than left to `conditions` below, because Tailwind resolves `@import` + // with its own resolver: it inherits this `alias` map but replaces + // `conditions` with `['style', ...]`, so `@transcend-io/source` never + // applies and a bare specifier would resolve to `dist/`. That would put + // those packages' builds on the view build graph and, worse, `dist/` is + // briefly empty while they rebuild. + '@transcend-io/mcp-server-base/ui/theme.css': path.join( + repoRoot, + 'packages/mcp/mcp-server-base/src/ui/theme.css', + ), + '@transcend-io/design-tokens/tokens.css': path.join( + repoRoot, + 'packages/design-tokens/src/tokens.css', + ), + }, + // Resolve workspace packages to their TypeScript source, as tsdown and + // Vitest do. A view can then be built without its dependencies being built + // first, which keeps this step off the package build graph. + // The remaining entries restate Vite's defaults, which this key replaces. + conditions: ['@transcend-io/source', 'module', 'browser', 'development|production'], + }; +} + +/** Options for {@link defineMcpAppView}. */ +export interface McpAppViewOptions { + /** The view to build, as returned by {@link discoverMcpAppViews} */ + view: McpAppView; + /** Text of the document's ``; hosts do not surface it */ + title?: string; +} + +/** + * Escapes sequences that would let bundled JavaScript break out of the + * `<script>` element it is inlined into. + * + * An HTML parser ends a script at the first `</script`, even inside a string + * literal, so a view that merely *mentions* that text would otherwise emit a + * broken document. `<\/script` is an equivalent escape everywhere it can legally + * appear in JavaScript. + */ +function escapeForInlineScript(code: string): string { + return code.replace(/<\/(script)/gi, String.raw`<\/$1`).replace(/<!--/g, String.raw`<\!--`); +} + +/** Escapes text interpolated into HTML character data. */ +function escapeHtmlText(value: string): string { + return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); +} + +function decodeSource(source: string | Uint8Array): string { + return typeof source === 'string' ? source : new TextDecoder().decode(source); +} + +/** + * Collapses a view's JavaScript and CSS into one self-contained HTML document. + * + * MCP Apps are delivered as a single string over `resources/read` and rendered in + * a sandboxed iframe that has no same-origin server, so nothing may be left + * behind as a separate file to fetch. Inlining also means the resource needs no + * CSP `resourceDomains` entry at all, and the host's default + * `script-src 'self' 'unsafe-inline'` is enough to run it. + */ +function inlineIntoSingleHtml({ fileName, title }: { fileName: string; title: string }): Plugin { + return { + name: 'transcend:mcp-app-single-file', + enforce: 'post', + generateBundle(_outputOptions, bundle) { + const scripts: string[] = []; + const styles: string[] = []; + const external: string[] = []; + + for (const [name, output] of Object.entries(bundle)) { + if (output.type === 'chunk') { + scripts.push(output.code); + } else if (name.endsWith('.css')) { + styles.push(decodeSource(output.source)); + } else { + external.push(name); + continue; + } + delete bundle[name]; + } + + if (external.length > 0) { + throw new Error( + `MCP App view "${fileName}" emitted ${external.length} asset(s) that would have to be fetched over the network: ${external.join(', ')}. ` + + 'Views render in a sandboxed iframe with no same-origin server, so every byte must be inlined. ' + + 'Import the asset so it becomes a data URI, or remove the dependency.', + ); + } + + const styleTags = styles.map((css) => ` <style>\n${css}\n </style>`).join('\n'); + const scriptTags = scripts + .map((code) => ` <script>\n${escapeForInlineScript(code)}\n </script>`) + .join('\n'); + + this.emitFile({ + type: 'asset', + fileName, + source: `<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>${escapeHtmlText(title)} +${styleTags} + + +
+${scriptTags} + + +`, + }); + }, + }; +} + +/** + * Builds the Vite config for one MCP App view. + * + * The output is a single HTML document with all JavaScript and CSS inlined, + * ready to be handed to `defineUiResource`. React and JSX need no plugin here: + * Vite transforms `.tsx` natively using the `jsx` setting from the view's + * tsconfig, which avoids `@vitejs/plugin-react` and its Babel peers. Tailwind + * does need its plugin. + * + * One config builds one view, because `inlineIntoSingleHtml` collapses the whole + * bundle into one document. A package with several views therefore needs several + * builds, which is what `scripts/build-mcp-views.ts` does. + * + * @param options - The view to build and the document's title + * @returns A Vite config for that one view + */ +export function defineMcpAppView({ + view, + title = 'Transcend MCP App', +}: McpAppViewOptions): UserConfig { + return { + // A view is a standalone document, never hosted at a URL path. + base: './', + // Vite's library mode deliberately leaves `process.env.NODE_ENV` in place for + // downstream bundlers to substitute. A view has no downstream bundler and no + // `process` in a sandboxed iframe, so leaving it would throw a ReferenceError + // on first render — and keep React's development build, which is several + // times larger. + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + resolve: mcpAppResolve(), + build: { + outDir: MCP_APP_OUT_DIR, + // Every view in a package writes here, so emptying it would leave only the + // view that happened to build last. The build script clears it once up + // front instead. + emptyOutDir: false, + // Views run in the host's embedded browser (Chromium in the desktop apps), + // so there is no legacy engine to downlevel for. Matches the `target` the + // Node packages compile to. + target: 'es2022', + // A single IIFE means the document needs no module loader, no dynamic + // import, and no `type="module"` script, which is the most portable thing + // to run from an opaque-origin iframe. + lib: { + entry: view.entryId, + formats: ['iife'], + name: 'TranscendMcpAppView', + fileName: () => 'view.js', + }, + cssCodeSplit: false, + // Inline every asset regardless of size; a file left on disk could not be + // fetched by the iframe. + assetsInlineLimit: Number.MAX_SAFE_INTEGER, + // The script is inlined, so a sourcemap comment would point at a file that + // is not shipped. + sourcemap: false, + reportCompressedSize: false, + }, + plugins: [ + synthesizeMcpAppViews([view]), + tailwindcss(), + inlineIntoSingleHtml({ fileName: view.fileName, title }), + ], + }; +} From 1a928d6c95f9c3cd7c2d0fba79a865abaccd0f97 Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:37:38 -0700 Subject: [PATCH 09/19] fix(mcp): restore the Inspector's missing app sandbox document --- .oxfmtrc.jsonc | 10 +- package.json | 2 +- scripts/inspector-sandbox-proxy.test.ts | 95 +++++++++ scripts/lib/inspector-sandbox-proxy.html | 237 +++++++++++++++++++++++ scripts/lib/mcp-app-dev.ts | 172 ++++++++++++++++ 5 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 scripts/inspector-sandbox-proxy.test.ts create mode 100644 scripts/lib/inspector-sandbox-proxy.html create mode 100644 scripts/lib/mcp-app-dev.ts diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 7135624e..e4f44c34 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -1,6 +1,14 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": [".changeset/*.md", "**/__generated__/**", "schema.graphql", "pnpm-lock.yaml"], + "ignorePatterns": [ + ".changeset/*.md", + "**/__generated__/**", + "schema.graphql", + "pnpm-lock.yaml", + // Vendored verbatim from upstream so `curl | diff` can prove it has not + // drifted; reformatting its inline script would defeat that check. + "scripts/lib/inspector-sandbox-proxy.html" + ], "printWidth": 100, "tabWidth": 2, "semi": true, diff --git a/package.json b/package.json index bab8095a..78249ff2 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "changeset:version": "changeset version", "changeset:version:release": "changeset version && pnpm format:root", "release": "turbo run build && changeset publish", - "test:root": "vitest run scripts/check-changeset.test.ts scripts/package-conventions.test.ts scripts/check-mcp-descriptions.test.ts", + "test:root": "vitest run --dir scripts", "typecheck:root": "tsc -p scripts/tsconfig.json --noEmit", "lint:root": "oxlint", "lint:fix:root": "oxlint --fix", diff --git a/scripts/inspector-sandbox-proxy.test.ts b/scripts/inspector-sandbox-proxy.test.ts new file mode 100644 index 00000000..6aa4906d --- /dev/null +++ b/scripts/inspector-sandbox-proxy.test.ts @@ -0,0 +1,95 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { restoreSandboxProxy } from './lib/mcp-app-dev.ts'; + +const VENDORED_PROXY = fileURLToPath( + new URL('./lib/inspector-sandbox-proxy.html', import.meta.url), +); +const PROXY_PATH = join('clients', 'web', 'static', 'sandbox_proxy.html'); + +const temporaryDirs: string[] = []; + +/** Creates a directory shaped like an Inspector install, minus the proxy. */ +function fakeInstall({ withWebClient = true } = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'inspector-install-')); + temporaryDirs.push(dir); + if (withWebClient) mkdirSync(join(dir, 'clients', 'web', 'dist'), { recursive: true }); + return dir; +} + +afterEach(() => { + let dir = temporaryDirs.pop(); + while (dir !== undefined) { + rmSync(dir, { force: true, recursive: true }); + dir = temporaryDirs.pop(); + } +}); + +describe('restoreSandboxProxy', () => { + it('writes the proxy document when the published package omitted it', () => { + const installDir = fakeInstall(); + + expect(restoreSandboxProxy(installDir)).toBe('written'); + expect(readFileSync(join(installDir, PROXY_PATH), 'utf8')).toBe( + readFileSync(VENDORED_PROXY, 'utf8'), + ); + }); + + it('leaves an existing document untouched', () => { + // The whole point of the workaround is to fill a gap, so a release that + // ships its own proxy — or a newer one than ours — must win. + const installDir = fakeInstall(); + const target = join(installDir, PROXY_PATH); + mkdirSync(join(installDir, 'clients', 'web', 'static'), { recursive: true }); + writeFileSync(target, '

upstream

'); + + expect(restoreSandboxProxy(installDir)).toBe('present'); + expect(readFileSync(target, 'utf8')).toBe('

upstream

'); + }); + + it('creates nothing when the directory is not an Inspector install', () => { + const installDir = fakeInstall({ withWebClient: false }); + + expect(restoreSandboxProxy(installDir)).toBe('unrecognized'); + expect(existsSync(join(installDir, 'clients'))).toBe(false); + }); +}); + +describe('the vendored proxy document', () => { + // Nothing imports this file, so only a test notices if it is deleted as dead + // weight or truncated. These are the two things it has to be: a participant in + // the bridge protocol, whose method names are its contract with the + // Inspector's web client, and the isolation boundary that makes restoring + // upstream's document — rather than improvising a replacement — the right + // call. The hash below pins the bytes; this case is what says which part + // broke. + it('is a document speaking the sandbox bridge protocol, and denying same-origin access', () => { + const html = readFileSync(VENDORED_PROXY, 'utf8'); + + expect(html.startsWith('')).toBe(true); + expect(html).toContain('ui/notifications/sandbox-proxy-ready'); + expect(html).toContain('ui/notifications/sandbox-resource-ready'); + expect(html).toContain('allow-scripts allow-forms'); + expect(html).toMatch(/toLowerCase\(\) !== "allow-same-origin"/); + }); + + it('is byte-identical to upstream', () => { + // This document is a security boundary we did not write, so it should only + // ever change by deliberately re-copying upstream's. Editing it in place — + // to satisfy a linter, or to "just fix" something — would silently change + // what isolates an untrusted view, so make that a failing test instead. + // Update the hash when re-copying, from: + // + // curl -s https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/clients/web/static/sandbox_proxy.html \ + // | shasum -a 256 + const digest = createHash('sha256').update(readFileSync(VENDORED_PROXY)).digest('hex'); + + expect(digest).toBe('895cebc62dce32428350a77af0a433faf3fbba4943cef5f49a28b9ed223f9d99'); + }); +}); diff --git a/scripts/lib/inspector-sandbox-proxy.html b/scripts/lib/inspector-sandbox-proxy.html new file mode 100644 index 00000000..2f4336e8 --- /dev/null +++ b/scripts/lib/inspector-sandbox-proxy.html @@ -0,0 +1,237 @@ + + + + + + + MCP-UI Proxy + + + + + + diff --git a/scripts/lib/mcp-app-dev.ts b/scripts/lib/mcp-app-dev.ts new file mode 100644 index 00000000..16f32bba --- /dev/null +++ b/scripts/lib/mcp-app-dev.ts @@ -0,0 +1,172 @@ +import { spawn } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { logger } from '../logger.ts'; + +/** Directory holding this file, used to locate assets that ship beside it. */ +const scriptsLibDir = dirname(fileURLToPath(import.meta.url)); + +/** Repository root, derived from this file rather than the working directory. */ +export const repoRoot = resolve(scriptsLibDir, '..', '..'); + +/** Package the Inspector specs resolve to. */ +const INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector'; + +/** + * Path the Inspector's web client reads the app sandbox document from, relative + * to its install directory. + */ +const SANDBOX_PROXY_PATH = join('clients', 'web', 'static', 'sandbox_proxy.html'); + +/** Our copy of the document, kept byte-identical to upstream's. */ +const VENDORED_SANDBOX_PROXY = join(scriptsLibDir, 'inspector-sandbox-proxy.html'); + +/** What {@link restoreSandboxProxy} did, for logging and tests. */ +export type SandboxProxyOutcome = 'present' | 'written' | 'unrecognized'; + +/** + * Writes the sandbox proxy document into an Inspector install that is missing it. + * + * TODO: https://github.com/modelcontextprotocol/inspector/issues/1859 — delete + * this, the vendored document, and its call site once a release ships the file. + * The published v2 tarball's `files` list covers `clients/web/build` and + * `clients/web/dist` but not `clients/web/static`, so the one document the Apps + * tab needs is absent. The web server reads it at startup, swallows the ENOENT, + * and substitutes its own error page, which then renders *inside the app frame* + * as "Sandbox not loaded: ENOENT ...". Every other tab works, so it looks like a + * broken view rather than a missing file. Upstream shipped and fixed the same + * omission once before in v1 (issue #1113, for `server/static`). + * + * Restoring the file rather than working around it is deliberate: the proxy is + * the security boundary for app rendering — it holds the untrusted view at an + * opaque origin, strips `allow-same-origin` from anything a server asks for, and + * relays bridge messages between host and view. A substitute of our own would + * make this loop diverge from real hosts on exactly the axis the Inspector is + * here to check, so the vendored copy is upstream's file verbatim — down to the + * bytes, which is why the formatter is told to skip it and a test pins its hash. + * Compare it against upstream with: + * + * ```bash + * curl -s https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/clients/web/static/sandbox_proxy.html \ + * | diff -u - scripts/lib/inspector-sandbox-proxy.html + * ``` + * + * @param installDir - Root of an Inspector installation + * @returns Whether the document was already there, written, or the directory did + * not look like an Inspector install + */ +export function restoreSandboxProxy(installDir: string): SandboxProxyOutcome { + // Absent `clients/web` this is not the layout the fix was written against, so + // creating directories would be guessing at someone else's package. + if (!existsSync(join(installDir, 'clients', 'web'))) return 'unrecognized'; + + const target = join(installDir, SANDBOX_PROXY_PATH); + if (existsSync(target)) return 'present'; + + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(VENDORED_SANDBOX_PROXY, target); + return 'written'; +} + +/** + * Locates the directory `npx` installed a package into. + * + * Derived from the child's own `PATH` rather than by globbing `~/.npm/_npx`, + * because npm decides where that cache lives — it moves with `npm_config_cache`, + * and sandboxes relocate it wholesale. Running the probe under the same spec we + * are about to launch is what guarantees we patch the install that will be used. + * + * @param spec - Package spec to resolve, e.g. `pkg@2` + * @param packageName - Package to find inside the install + * @returns The package directory, or undefined if it could not be located + */ +async function resolveNpxPackageDir( + spec: string, + packageName: string, +): Promise { + const probe = ` + const path = require('node:path'); + const fs = require('node:fs'); + const segments = ${JSON.stringify(packageName.split('/'))}; + for (const dir of (process.env.PATH || '').split(path.delimiter)) { + if (path.basename(dir) !== '.bin') continue; + if (path.basename(path.dirname(dir)) !== 'node_modules') continue; + const manifest = path.join(path.dirname(dir), ...segments, 'package.json'); + if (fs.existsSync(manifest)) { + process.stdout.write(path.dirname(manifest)); + break; + } + } + `; + + const stdout = await new Promise((resolvePromise, reject) => { + const child = spawn('npx', ['-y', `--package=${spec}`, 'node', '-e', probe], { + cwd: repoRoot, + env: process.env, + // npm prints install and peer-dependency warnings to stderr that say + // nothing about whether the probe worked, so keep them out of the way. + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + + let output = ''; + let errors = ''; + child.stdout?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + errors += chunk.toString(); + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolvePromise(output.trim()); + else reject(new Error(`Resolving ${spec} failed with exit code ${code}. ${errors.trim()}`)); + }); + }); + + return stdout === '' ? undefined : stdout; +} + +/** + * Makes sure the Inspector can render an app before we hand it a server that + * serves one. + * + * Warns rather than throws on every failure path. This works around someone + * else's packaging bug, and the Inspector is still useful for tools, resources, + * and the handshake even when the Apps tab cannot paint — refusing to launch over + * it would be a worse outcome than a rendered error the warning explains. See + * {@link restoreSandboxProxy} for the removal condition. + * + * @param spec - Inspector spec about to be launched + */ +export async function ensureInspectorSandboxProxy(spec: string): Promise { + try { + const installDir = await resolveNpxPackageDir(spec, INSPECTOR_PACKAGE_NAME); + if (installDir === undefined) { + logger.log( + `Could not locate the ${spec} install to check its app sandbox document. ` + + 'If the app frame shows "Sandbox not loaded", that is why.', + ); + return; + } + + const outcome = restoreSandboxProxy(installDir); + if (outcome === 'written') { + logger.log( + `Restored the missing app sandbox document in ${spec} ` + + '(upstream inspector issue 1859); the Apps tab would render an ENOENT without it.', + ); + } else if (outcome === 'unrecognized') { + logger.log( + `The ${spec} install has an unfamiliar layout, so its app sandbox document was left alone.`, + ); + } + } catch (error) { + logger.log( + `Could not check the app sandbox document in ${spec}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } +} From e96624fdf69ccf951f4668bc879376fca86c9d2b Mon Sep 17 00:00:00 2001 From: Daniel Sklyar Date: Sun, 2 Aug 2026 16:44:37 -0700 Subject: [PATCH 10/19] feat(dev): add mcp-server-examples with the reference view and form flows --- dev/mcp-server-examples/.gitignore | 2 + dev/mcp-server-examples/README.md | 69 ++++++ dev/mcp-server-examples/package.json | 49 ++++ dev/mcp-server-examples/src/apps/hello.ts | 21 ++ dev/mcp-server-examples/src/cli.ts | 13 ++ dev/mcp-server-examples/src/index.ts | 11 + .../src/tools/elicitation.ts | 218 +++++++++++++++++ .../src/tools/hello_app.ts | 118 ++++++++++ dev/mcp-server-examples/src/tools/index.ts | 8 + .../src/ui/hello/HelloView.tsx | 122 ++++++++++ .../tests/elicitation.test.ts | 146 ++++++++++++ .../tests/mcp-apps-stdio.test.ts | 219 ++++++++++++++++++ dev/mcp-server-examples/tsconfig.json | 12 + dev/mcp-server-examples/tsconfig.ui.json | 8 + dev/mcp-server-examples/tsdown.config.ts | 8 + packages/mcp/README.md | 7 +- pnpm-lock.yaml | 81 ++++++- pnpm-workspace.yaml | 3 + scripts/mcp-app-styling.test.ts | 165 +++++++++++++ tsconfig.base.json | 2 +- tsdown.config.base.ts | 63 ++++- turbo.json | 12 + types/html.d.ts | 12 + vitest.config.ts | 6 +- 24 files changed, 1366 insertions(+), 9 deletions(-) create mode 100644 dev/mcp-server-examples/.gitignore create mode 100644 dev/mcp-server-examples/README.md create mode 100644 dev/mcp-server-examples/package.json create mode 100644 dev/mcp-server-examples/src/apps/hello.ts create mode 100644 dev/mcp-server-examples/src/cli.ts create mode 100644 dev/mcp-server-examples/src/index.ts create mode 100644 dev/mcp-server-examples/src/tools/elicitation.ts create mode 100644 dev/mcp-server-examples/src/tools/hello_app.ts create mode 100644 dev/mcp-server-examples/src/tools/index.ts create mode 100644 dev/mcp-server-examples/src/ui/hello/HelloView.tsx create mode 100644 dev/mcp-server-examples/tests/elicitation.test.ts create mode 100644 dev/mcp-server-examples/tests/mcp-apps-stdio.test.ts create mode 100644 dev/mcp-server-examples/tsconfig.json create mode 100644 dev/mcp-server-examples/tsconfig.ui.json create mode 100644 dev/mcp-server-examples/tsdown.config.ts create mode 100644 scripts/mcp-app-styling.test.ts create mode 100644 types/html.d.ts diff --git a/dev/mcp-server-examples/.gitignore b/dev/mcp-server-examples/.gitignore new file mode 100644 index 00000000..3e7e5aea --- /dev/null +++ b/dev/mcp-server-examples/.gitignore @@ -0,0 +1,2 @@ +# Vite-built MCP App views, rebuilt by `pnpm prebuild` +src/ui/generated/ diff --git a/dev/mcp-server-examples/README.md b/dev/mcp-server-examples/README.md new file mode 100644 index 00000000..ed946a32 --- /dev/null +++ b/dev/mcp-server-examples/README.md @@ -0,0 +1,69 @@ +# `@transcend-io/mcp-server-examples` + +Reference MCP App views and capability-aware tools, as a real MCP server you can +point a host at. Development only — this package is `private` and is never +published. + +## Running it + +```bash +pnpm --filter @transcend-io/mcp-server-examples build +node dev/mcp-server-examples/dist/cli.mjs +``` + +The build turns each view into one self-contained document and inlines it; the +second command then serves both examples below over stdio. Point any MCP host at +it, including the official Inspector: + +```bash +npx -y @modelcontextprotocol/inspector@2 node dev/mcp-server-examples/dist/cli.mjs +``` + +## Why it is not published + +An MCP App view ships as one self-contained document — React, the view's CSS, and +the design tokens all inlined — which is roughly 550 KB per view. While the hello +view lived in `@transcend-io/mcp-server-docs` it was 94% of that package's +published bytes, downloaded by everyone who installs the umbrella server or the +CLI, for a demo tool no end user has a reason to call. + +Keeping it here makes that structural rather than a rule someone has to remember: +a `private` package cannot leak into a tarball, and `@transcend-io/mcp` has no +dependency on it, so the umbrella server does not serve it. Only `--examples` +does. + +## What the hello example demonstrates + +`src/tools/hello_app.ts` is the worked example for `defineToolWithCapabilities`. +One registration serves three experiences, chosen by what the host declared in +`initialize`: + +| Host capability | What the caller gets | +| --------------- | ----------------------------------------------------------- | +| none | a plain text greeting | +| elicitation | a host-rendered form asking who to greet | +| MCP Apps | the interactive `hello` view, plus an app-only refresh tool | + +`src/ui/hello/HelloView.tsx` covers the parts of the MCP Apps contract a static +document cannot: React state, a `tools/call` round trip from inside the iframe, +and re-rendering from the result the host pushes back. + +## What the elicitation example demonstrates + +`src/tools/elicitation.ts` is form collection on its own, with no view. It has no +MCP App variant on purpose: precedence is app, then elicitation, then baseline, so +a tool offering both resolves to its view on every host worth testing against — +including the Inspector. Form-only is what keeps the form reachable from a host +that renders views, instead of only from one that cannot. + +It covers two things `example_hello_app`'s single optional string does not. First, +every field shape the spec allows: a length-bounded string, a titled single-select +(via `oneOf`, not the deprecated `enumNames`), a bounded integer, a titled +multi-select, and a boolean with a default. Second, the four ways a request can +end — the user answers, refuses, dismisses the form, or the host answers with the +wrong types — each reported as a distinct `outcome` rather than collapsed into +"no value". Declining and cancelling stay separate because a refusal should not be +retried and an abandoned dialog reasonably can be. + +See [`packages/mcp/README.md`](../../packages/mcp/README.md) for the full guide to +building views, and the layout conventions this package follows. diff --git a/dev/mcp-server-examples/package.json b/dev/mcp-server-examples/package.json new file mode 100644 index 00000000..278bc217 --- /dev/null +++ b/dev/mcp-server-examples/package.json @@ -0,0 +1,49 @@ +{ + "name": "@transcend-io/mcp-server-examples", + "version": "0.0.0", + "private": true, + "description": "Reference MCP App views and capability-aware tools. Development only, never published.", + "license": "Apache-2.0", + "bin": { + "transcend-mcp-examples": "./dist/cli.mjs" + }, + "type": "module", + "sideEffects": false, + "types": "./dist/index.d.mts", + "exports": { + ".": { + "@transcend-io/source": "./src/index.ts", + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "prebuild": "node ../../scripts/build-mcp-views.ts", + "build": "tsdown", + "build:ui": "node ../../scripts/build-mcp-views.ts", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:ui": "tsc -p tsconfig.ui.json --noEmit" + }, + "dependencies": { + "@transcend-io/mcp-server-base": "workspace:*" + }, + "devDependencies": { + "@modelcontextprotocol/ext-apps": "catalog:", + "@modelcontextprotocol/sdk": "catalog:", + "@transcend-io/design-tokens": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "tailwindcss": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/dev/mcp-server-examples/src/apps/hello.ts b/dev/mcp-server-examples/src/apps/hello.ts new file mode 100644 index 00000000..39d769ad --- /dev/null +++ b/dev/mcp-server-examples/src/apps/hello.ts @@ -0,0 +1,21 @@ +import { defineUiResource, type UiResourceDefinition } from '@transcend-io/mcp-server-base'; + +// Built from src/ui/hello/ by this package's `prebuild` and inlined here as a +// string by tsdown's `.html` text loader. The document is fully self-contained — +// React, the view's CSS, and the design tokens are all inlined — because hosts +// render views in a sandboxed iframe with no same-origin server to fetch +// anything from. +import HELLO_APP_HTML from '../ui/generated/hello.html'; + +/** URI hosts fetch to render the hello-world view. */ +export const HELLO_APP_URI = 'ui://transcend-examples/hello'; + +/** Hello-world view proving the MCP Apps render path end to end. */ +export const HELLO_APP_RESOURCE: UiResourceDefinition = defineUiResource({ + uri: HELLO_APP_URI, + name: 'Transcend MCP App hello world', + description: + 'Minimal interactive view that confirms a host can fetch, sandbox, and render a ui:// resource.', + html: HELLO_APP_HTML, + prefersBorder: false, +}); diff --git a/dev/mcp-server-examples/src/cli.ts b/dev/mcp-server-examples/src/cli.ts new file mode 100644 index 00000000..362c94e8 --- /dev/null +++ b/dev/mcp-server-examples/src/cli.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { createMCPServer } from '@transcend-io/mcp-server-base'; + +import packageJson from '../package.json' with { type: 'json' }; +import { getExampleTools } from './tools/index.js'; + +createMCPServer({ + name: 'transcend-mcp-examples', + version: packageJson.version, + requireStartupAuth: false, + oauthScopes: [], + getTools: getExampleTools, +}); diff --git a/dev/mcp-server-examples/src/index.ts b/dev/mcp-server-examples/src/index.ts new file mode 100644 index 00000000..e3dcbda3 --- /dev/null +++ b/dev/mcp-server-examples/src/index.ts @@ -0,0 +1,11 @@ +export { getExampleTools } from './tools/index.js'; + +export { ExampleHelloAppSchema, type ExampleHelloAppInput } from './tools/hello_app.js'; + +export { + ExampleElicitationSchema, + type ExampleElicitationInput, + type FormOutcome, +} from './tools/elicitation.js'; + +export { HELLO_APP_RESOURCE, HELLO_APP_URI } from './apps/hello.js'; diff --git a/dev/mcp-server-examples/src/tools/elicitation.ts b/dev/mcp-server-examples/src/tools/elicitation.ts new file mode 100644 index 00000000..d63a965d --- /dev/null +++ b/dev/mcp-server-examples/src/tools/elicitation.ts @@ -0,0 +1,218 @@ +import { + createToolResult, + defineToolWithCapabilities, + McpClientCapability, + requestElicitation, + z, + type ElicitFormSchema, + type ToolClients, +} from '@transcend-io/mcp-server-base'; + +/** Prompt shown above the form, which is where a user learns why they are being asked. */ +const FORM_MESSAGE = + 'These values are only echoed back into the conversation. Nothing is stored and no API is called.'; + +/** Options the two select fields offer, with the titles a host displays for them. */ +const PRIORITIES = ['low', 'normal', 'high'] as const; +const PRIORITY_TITLES: Record<(typeof PRIORITIES)[number], string> = { + low: 'Low', + normal: 'Normal', + high: 'High', +}; +const TAGS = ['alpha', 'beta', 'gamma'] as const; +const TAG_TITLES: Record<(typeof TAGS)[number], string> = { + alpha: 'Alpha', + beta: 'Beta', + gamma: 'Gamma', +}; + +/** Fields the form insists on, and therefore the only ones worth interrupting for. */ +const REQUIRED_FIELDS = ['label', 'priority'] as const; + +/** + * Every field shape `elicitation/create` allows, in one form. + * + * The spec restricts this to a flat object of primitives, which is narrower than + * it sounds in two directions worth knowing. A select gets its display titles + * from `oneOf` entries; the `enum` plus `enumNames` pair still validates but is + * deprecated in the SDK, so copying it forward would spread a dead shape. And a + * multi-select is the one legal `array` — of titled `anyOf` items — even though + * arrays are otherwise rejected. `format` on a string is limited to `date`, + * `date-time`, `email`, and `uri`. + * + * Nesting an object anywhere fails at construction, in `assertElicitFormSchema`, + * rather than when a host refuses the request mid-conversation. + */ +const FORM_SCHEMA: ElicitFormSchema = { + type: 'object', + properties: { + label: { + type: 'string', + title: 'Label', + description: 'Any short text. It comes back verbatim in the response.', + minLength: 1, + maxLength: 40, + }, + priority: { + type: 'string', + title: 'Priority', + description: 'How the response labels this request.', + oneOf: PRIORITIES.map((value) => ({ const: value, title: PRIORITY_TITLES[value] })), + }, + repeat: { + type: 'integer', + title: 'Repeat', + description: 'How many times the label is repeated in the echoed string.', + minimum: 1, + maximum: 5, + }, + tags: { + type: 'array', + title: 'Tags', + description: 'Any number of tags to attach to the response.', + maxItems: TAGS.length, + items: { + anyOf: TAGS.map((value) => ({ const: value, title: TAG_TITLES[value] })), + }, + }, + loud: { + type: 'boolean', + title: 'Loud', + description: 'Whether the echoed label is uppercased.', + default: false, + }, + }, + required: [...REQUIRED_FIELDS], +}; + +export const ExampleElicitationSchema = z.object({ + label: z + .string() + .optional() + .describe('Short text to echo back. Collected through a form when the agent omits it.'), + priority: z + .enum(PRIORITIES) + .optional() + .describe('How the response labels this request: low, normal, or high.'), + repeat: z + .number() + .int() + .min(1) + .max(5) + .optional() + .describe('How many times the label is repeated in the echoed string, 1 to 5.'), + tags: z + .array(z.enum(TAGS)) + .optional() + .describe('Tags to attach to the response: any of alpha, beta, or gamma.'), + loud: z.boolean().optional().describe('Whether the echoed label is uppercased.'), +}); +export type ExampleElicitationInput = z.infer; + +/** Why a response holds the values it holds. */ +export type FormOutcome = + /** The user filled the form in */ + | 'answered' + /** The agent had already supplied everything the form would have collected */ + | 'not-asked' + /** The user refused */ + | 'declined' + /** The user dismissed the form without deciding */ + | 'cancelled' + /** The host cannot render a form at all */ + | 'unavailable' + /** The host answered, but not with the shape it was asked for */ + | 'malformed'; + +/** + * Builds the response, which reports how the values were obtained alongside them. + * + * Always a successful result, including when the user declined. The tool did + * exactly what it was asked to; an outcome the agent can read is what lets it + * explain itself rather than retry a refusal as though it were a transient error. + */ +function echoPayload( + /** How the values below were obtained */ + outcome: FormOutcome, + /** Values to echo, from the agent, the form, or both */ + fields: ExampleElicitationInput, + /** Field paths a host answered with the wrong type */ + invalidFields?: string[], +): unknown { + const label = fields.label?.trim(); + const cased = fields.loud === true ? label?.toUpperCase() : label; + + return createToolResult(true, { + outcome, + echo: cased ? Array.from({ length: fields.repeat ?? 1 }, () => cased).join(' ') : undefined, + fields, + ...(invalidFields && { invalidFields }), + }); +} + +/** + * Reference implementation of elicitation on its own. + * + * Deliberately has no MCP App variant. Variant precedence is app, then + * elicitation, then baseline, so a tool offering both resolves to its view on any + * host that supports one — which is every host worth testing against, including + * the Inspector. Keeping this one form-only is what makes the form flow reachable + * from a host that renders views, instead of only from one that cannot. + */ +export function createExampleElicitationTool(_clients?: ToolClients) { + return defineToolWithCapabilities({ + name: 'example_elicitation', + description: + 'Collect a handful of fields through a host-rendered form and echo them back. ' + + 'Demonstrates every field type elicitation allows, and what a tool should do when the ' + + 'user declines, dismisses the form, or the host cannot show one. Stores nothing.', + category: 'Examples', + readOnly: true, + requireAuth: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: ExampleElicitationSchema, + // Hosts here cannot be asked anything, so echo what the agent supplied rather + // than inventing values it never chose. + handler: async (args) => echoPayload('unavailable', args), + variants: { + [McpClientCapability.Elicitation]: { + elicitMessage: FORM_MESSAGE, + elicitSchema: FORM_SCHEMA, + handler: async (args) => { + // Interrupt only for what is actually missing. Re-prompting for an + // argument the agent already chose costs the user a dialog and changes + // nothing about the answer. + if (REQUIRED_FIELDS.every((field) => args[field] !== undefined)) { + return echoPayload('not-asked', args); + } + + const answer = await requestElicitation(FORM_MESSAGE, FORM_SCHEMA); + + // Reachable only outside a session, since this variant runs solely on + // hosts that declared elicitation. + if (!answer) return echoPayload('unavailable', args); + + // Declining and cancelling are kept apart on purpose. A decline is an + // answer — the user does not want this — so a caller should not ask + // again. A cancel is the absence of one, which it reasonably might. + if (answer.action === 'decline') return echoPayload('declined', {}); + if (answer.action === 'cancel') return echoPayload('cancelled', {}); + + // Parsed rather than trusted: `requestedSchema` states what a host + // should collect and nothing enforces that what comes back matches, so + // a wrong type here would otherwise surface as a puzzling echo. + const parsed = ExampleElicitationSchema.safeParse(answer.content ?? {}); + if (!parsed.success) { + return echoPayload( + 'malformed', + args, + parsed.error.issues.map((issue) => issue.path.join('.')), + ); + } + + return echoPayload('answered', { ...args, ...parsed.data }); + }, + }, + }, + }); +} diff --git a/dev/mcp-server-examples/src/tools/hello_app.ts b/dev/mcp-server-examples/src/tools/hello_app.ts new file mode 100644 index 00000000..bdb855ca --- /dev/null +++ b/dev/mcp-server-examples/src/tools/hello_app.ts @@ -0,0 +1,118 @@ +import { + createToolResult, + defineTool, + defineToolWithCapabilities, + describeCapabilities, + getMcpSession, + McpClientCapability, + McpHostClient, + requestElicitation, + z, + type ElicitFormSchema, + type ToolClients, +} from '@transcend-io/mcp-server-base'; + +import { HELLO_APP_RESOURCE } from '../apps/hello.js'; + +/** Prompt shown above the elicitation form. */ +const HELLO_ELICIT_MESSAGE = 'Who should this greeting be addressed to?'; + +/** + * Fields the host collects when it supports elicitation. Flat and primitives-only + * because the spec allows nothing else here. + */ +const HELLO_ELICIT_SCHEMA: ElicitFormSchema = { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Name', + description: 'Name to greet in the response.', + }, + }, +}; + +export const ExampleHelloAppSchema = z.object({ + name: z + .string() + .optional() + .describe('Name to greet in the response. Defaults to a generic greeting when omitted.'), +}); +export type ExampleHelloAppInput = z.infer; + +/** Payload shared by all three variants so the text fallback matches the view. */ +function helloPayload(name: string | undefined): unknown { + const session = getMcpSession(); + return createToolResult(true, { + greeting: `Hello, ${name?.trim() || 'world'}!`, + host: session?.client.host ?? McpHostClient.Unknown, + capabilities: session ? describeCapabilities(session.client) : [], + timestamp: new Date().toISOString(), + }); +} + +/** + * Companion tool that exists only so the view can refresh itself without going + * back through the conversation. Never listed to the model. + */ +function createHelloRefreshTool() { + return defineTool({ + name: 'example_hello_app_refresh', + description: 'Re-read the greeting payload for the example_hello_app view.', + category: 'Examples', + readOnly: true, + requireAuth: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: ExampleHelloAppSchema, + handler: async ({ name }) => helloPayload(name), + }); +} + +/** + * Reference implementation of the capability layer. + * + * The same registration serves three different experiences: a plain text + * greeting on a host with no relevant capabilities, a host-rendered form on one + * that supports elicitation, and an interactive view on one that supports MCP + * Apps. Useful on its own as a smoke test that a host's render path works, and + * as the worked example for adding variants to a real tool. + */ +export function createExampleHelloAppTool(_clients?: ToolClients) { + return defineToolWithCapabilities({ + name: 'example_hello_app', + description: + 'Return a greeting that demonstrates MCP client capability negotiation. ' + + 'Renders as an interactive view on hosts that support MCP Apps, prompts for a ' + + 'name on hosts that support elicitation, and returns plain text everywhere else.', + category: 'Examples', + readOnly: true, + requireAuth: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: ExampleHelloAppSchema, + handler: async ({ name }) => helloPayload(name), + variants: { + [McpClientCapability.Elicitation]: { + elicitMessage: HELLO_ELICIT_MESSAGE, + elicitSchema: HELLO_ELICIT_SCHEMA, + handler: async ({ name }) => { + // Only ask when the caller left it out; re-prompting for an argument the + // agent already supplied is a needless interruption. + if (name?.trim()) return helloPayload(name); + + const elicited = await requestElicitation(HELLO_ELICIT_MESSAGE, HELLO_ELICIT_SCHEMA); + + const answered = + elicited?.action === 'accept' && typeof elicited.content?.name === 'string' + ? elicited.content.name + : undefined; + return helloPayload(answered); + }, + }, + [McpClientCapability.McpApp]: { + resource: HELLO_APP_RESOURCE, + handler: async ({ name }) => helloPayload(name), + appOnlyTools: [createHelloRefreshTool()], + }, + }, + }); +} diff --git a/dev/mcp-server-examples/src/tools/index.ts b/dev/mcp-server-examples/src/tools/index.ts new file mode 100644 index 00000000..cc34669e --- /dev/null +++ b/dev/mcp-server-examples/src/tools/index.ts @@ -0,0 +1,8 @@ +import type { ToolDefinition, ToolClients } from '@transcend-io/mcp-server-base'; + +import { createExampleElicitationTool } from './elicitation.js'; +import { createExampleHelloAppTool } from './hello_app.js'; + +export function getExampleTools(_clients?: ToolClients): ToolDefinition[] { + return [createExampleHelloAppTool(), createExampleElicitationTool()]; +} diff --git a/dev/mcp-server-examples/src/ui/hello/HelloView.tsx b/dev/mcp-server-examples/src/ui/hello/HelloView.tsx new file mode 100644 index 00000000..4a01ec4a --- /dev/null +++ b/dev/mcp-server-examples/src/ui/hello/HelloView.tsx @@ -0,0 +1,122 @@ +import { useMcpApp } from '@transcend-io/mcp-server-base/ui'; +import { useState } from 'react'; + +/** Payload shape returned by `example_hello_app` and its refresh companion. */ +interface HelloData { + /** Greeting line, already personalized by the server */ + greeting?: string; + /** Host the server detected during capability negotiation */ + host?: string; + /** Capabilities the host declared */ + capabilities?: string[]; + /** Server-side timestamp of the response */ + timestamp?: string; +} + +/** + * Classes shared by the card in every state, so the connecting and error states + * cannot drift from the loaded one. + */ +const CARD = 'rounded-lg bg-surface-raised px-6 py-5 shadow-sm'; +const TITLE = 'mb-1 text-heading-md font-semibold text-content'; +const SUBTITLE = 'text-sm text-content-muted'; + +/** One label/value row in the details grid, skipped when the value is empty. */ +function DetailRow({ label, value }: { label: string; value: string | undefined }) { + if (!value) { + return null; + } + return ( + <> +
{label}
+
{value}
+ + ); +} + +/** + * Interactive hello-world view for the `example_hello_app` tool. + * + * Beyond proving the render path works, this exercises the parts of the MCP Apps + * contract that a static document cannot: local React state for the input, a + * `tools/call` round trip back to the server via the app-only refresh tool, and + * re-rendering from the result the host pushes back. + * + * Styled entirely with utilities from `@transcend-io/mcp-server-base/ui/theme.css`, + * so every color and size resolves to a host value or a Transcend token. There is + * no stock Tailwind palette to reach for by accident. + */ +export function HelloView() { + const { data, theme, isConnected, connectionError, toolError, isCallingTool, callTool } = + useMcpApp({ + appInfo: { name: 'transcend-examples-hello', version: '1.0.0' }, + }); + + const [draftName, setDraftName] = useState(''); + + if (connectionError) { + return ( +
+

Could not reach the host

+

{connectionError.message}

+
+ ); + } + + if (!isConnected) { + return ( +
+

Connecting…

+

Waiting for the host handshake.

+
+ ); + } + + return ( +
+

{data?.greeting ?? 'Hello from Transcend'}

+

Rendered by an MCP App served over the Model Context Protocol.

+ +
{ + event.preventDefault(); + void callTool('example_hello_app_refresh', { name: draftName }); + }} + > + +
+ setDraftName(event.target.value)} + /> + +
+
+ + {toolError ? ( +

+ {toolError} +

+ ) : null} + +
+ + + + +
+
+ ); +} diff --git a/dev/mcp-server-examples/tests/elicitation.test.ts b/dev/mcp-server-examples/tests/elicitation.test.ts new file mode 100644 index 00000000..9e8689bd --- /dev/null +++ b/dev/mcp-server-examples/tests/elicitation.test.ts @@ -0,0 +1,146 @@ +/** + * What `example_elicitation` does with each way a host can answer a form. + * + * Scope is the handler's decisions, not the protocol: whether the request is sent + * at all, and what a declined, dismissed, or malformed answer turns into. Sending + * the request and gating it on the declared capability belong to + * `requestElicitation` and are tested in `@transcend-io/mcp-server-base`. + * + * Runs against the source with a scripted host, so it needs no build and no + * transport. `tests/mcp-apps-stdio.test.ts` is where a real client drives the + * built artifact. + */ + +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js'; +import { + McpClientCapability, + McpHostClient, + mcpSessionContext, + resolveToolVariant, + type McpSession, +} from '@transcend-io/mcp-server-base'; +import { describe, expect, it, vi } from 'vitest'; + +import { createExampleElicitationTool } from '../src/tools/elicitation.js'; + +/** The response shape `echoPayload` produces. */ +interface EchoResult { + success: boolean; + data: { + outcome: string; + echo?: string; + fields: Record; + invalidFields?: string[]; + }; +} + +/** + * Runs the tool the way the server would: resolve the variant for a host, then + * call it inside that host's session. + */ +async function callAs( + capabilities: McpClientCapability[], + args: Record, + answer: ElicitResult = { action: 'accept', content: {} }, +): Promise<{ result: EchoResult; elicitInput: ReturnType }> { + const elicitInput = vi.fn().mockResolvedValue(answer); + const session: McpSession = { + client: { capabilities: new Set(capabilities), host: McpHostClient.Claude }, + server: { elicitInput } as unknown as Server, + }; + + const resolved = resolveToolVariant(createExampleElicitationTool(), session.client); + const result = await mcpSessionContext.run(session, async () => resolved.handler(args)); + + return { result: result as EchoResult, elicitInput }; +} + +const ELICITATION = [McpClientCapability.Elicitation]; + +describe('example_elicitation', () => { + it('asks a host that can show a form, and echoes what came back', async () => { + const { result, elicitInput } = await callAs( + ELICITATION, + {}, + { + action: 'accept', + content: { label: 'ping', priority: 'high', repeat: 3, loud: true, tags: ['alpha'] }, + }, + ); + + expect(elicitInput).toHaveBeenCalledTimes(1); + expect(result.data.outcome).toBe('answered'); + expect(result.data.echo).toBe('PING PING PING'); + expect(result.data.fields).toMatchObject({ priority: 'high', tags: ['alpha'] }); + }); + + it('does not interrupt when the agent already supplied every required field', async () => { + const { result, elicitInput } = await callAs(ELICITATION, { label: 'ping', priority: 'low' }); + + expect(elicitInput).not.toHaveBeenCalled(); + expect(result.data.outcome).toBe('not-asked'); + expect(result.data.echo).toBe('ping'); + }); + + it('treats a decline as an answer and a cancel as the absence of one', async () => { + // Both stop the tool, but a caller should read them differently: a decline is + // the user saying no, a cancel leaves the question open. + const declined = await callAs(ELICITATION, {}, { action: 'decline' }); + expect(declined.result.data.outcome).toBe('declined'); + + const cancelled = await callAs(ELICITATION, {}, { action: 'cancel' }); + expect(cancelled.result.data.outcome).toBe('cancelled'); + + // Neither may fall back to the arguments, or refusing would silently proceed. + for (const { result } of [declined, cancelled]) { + expect(result.success).toBe(true); + expect(result.data.echo).toBeUndefined(); + expect(result.data.fields).toEqual({}); + } + }); + + it('reports a host that answers with the wrong types instead of echoing them', async () => { + // Nothing enforces that a host's answer matches `requestedSchema`, so this is + // a real failure rather than a defensive one. + const { result } = await callAs( + ELICITATION, + { label: 'ping' }, + { + action: 'accept', + content: { label: 'ping', priority: 'urgent', repeat: 'three' }, + }, + ); + + expect(result.data.outcome).toBe('malformed'); + expect(result.data.invalidFields).toEqual(expect.arrayContaining(['priority', 'repeat'])); + }); + + it('echoes the agent arguments on a host that cannot be asked at all', async () => { + const { result, elicitInput } = await callAs([], { label: 'ping', repeat: 2 }); + + expect(elicitInput).not.toHaveBeenCalled(); + expect(result.data.outcome).toBe('unavailable'); + expect(result.data.echo).toBe('ping ping'); + }); + + it('keeps the form reachable on a host that also supports MCP Apps', async () => { + // The point of this tool having no app variant. Precedence is app, then + // elicitation, so a tool with both would resolve to its view here and the form + // would never be exercised against a real host. + const { result, elicitInput } = await callAs( + [McpClientCapability.Elicitation, McpClientCapability.McpApp], + {}, + { action: 'accept', content: { label: 'ping', priority: 'normal' } }, + ); + + expect(elicitInput).toHaveBeenCalledTimes(1); + expect(result.data.outcome).toBe('answered'); + expect( + resolveToolVariant(createExampleElicitationTool(), { + capabilities: new Set([McpClientCapability.McpApp]), + host: McpHostClient.Claude, + }).ui, + ).toBeUndefined(); + }); +}); diff --git a/dev/mcp-server-examples/tests/mcp-apps-stdio.test.ts b/dev/mcp-server-examples/tests/mcp-apps-stdio.test.ts new file mode 100644 index 00000000..b8f683e1 --- /dev/null +++ b/dev/mcp-server-examples/tests/mcp-apps-stdio.test.ts @@ -0,0 +1,219 @@ +/** + * End-to-end check that a real MCP host can fetch and render the hello-world + * view over stdio, exercising the built CLI rather than an in-process server. + * + * This is the check that actually answers "would Claude Desktop render this". + * Scope is deliberately limited to what only a built artifact on a real + * transport can show: the shape of the single-file document the view build + * produces, whether `_meta.ui` survives JSON-RPC serialization, and whether the + * session detected during `initialize` reaches a tool handler. + * + * Capability negotiation itself — which variant a host resolves to, when the + * `resources` capability is declared, and how app-only companions are exposed — + * belongs to `@transcend-io/mcp-server-base` and is tested there, against a + * synthetic tool that needs no build. Re-asserting it here would only mean this + * file fails alongside those rather than telling us anything new. + */ + +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Script } from 'node:vm'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { MCP_APP_MIME_TYPE, MCP_UI_EXTENSION_ID } from '@transcend-io/mcp-server-base'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { HELLO_APP_URI } from '../src/apps/hello.js'; + +const cliPath = join(dirname(fileURLToPath(import.meta.url)), '../dist/cli.mjs'); + +// The built CLI is the subject here, so skip rather than fail when only the +// source has been compiled — `pnpm test` runs before `build` on a clean clone. +const describeIfBuilt = existsSync(cliPath) ? describe : describe.skip; + +describeIfBuilt('examples server over stdio (MCP Apps host)', () => { + let client: Client; + + beforeAll(async () => { + client = new Client( + { name: 'claude-ai', version: '1.0.0' }, + { + capabilities: { + elicitation: { form: {} }, + extensions: { [MCP_UI_EXTENSION_ID]: { mimeTypes: [MCP_APP_MIME_TYPE] } }, + }, + }, + ); + await client.connect( + new StdioClientTransport({ command: process.execPath, args: [cliPath, '--transport=stdio'] }), + ); + }, 30_000); + + afterAll(async () => { + await client?.close(); + }); + + it('serves a complete HTML document that speaks the ui/initialize handshake', async () => { + const { contents } = await client.readResource({ uri: HELLO_APP_URI }); + const html = contents[0]!.text as string; + + expect(contents[0]!.mimeType).toBe(MCP_APP_MIME_TYPE); + expect(html.trimStart()).toMatch(/^/i); + // Without this exchange the iframe renders but never receives the result. + expect(html).toContain('ui/initialize'); + expect(html).toContain('ui/notifications/initialized'); + expect(html).toContain('ui/notifications/size-changed'); + expect(html).toContain('ui/notifications/tool-result'); + }); + + it('serves the view as one self-contained document with nothing left to fetch', async () => { + const { contents } = await client.readResource({ uri: HELLO_APP_URI }); + const html = contents[0]!.text as string; + + // A host renders views in a sandboxed iframe with no same-origin server, so a + // reference to a separate file or origin would render as a blank panel. This + // is the invariant the Vite single-file build exists to guarantee. + expect(html).not.toMatch(/]+\bsrc=/i); + expect(html).not.toMatch(/]+\bhref=/i); + expect(html).toContain('
'); + + // React and the design tokens have to be inside the document, not imported. + // `sideEffects: false` on this package makes the CSS import droppable in + // principle, so assert a real token variable survived the bundle, along with + // the theme variable whose fallback chain ends at it. + expect(html).toContain('--background-brand-bold'); + expect(html).toContain('--color-brand'); + + // Tailwind generates only the classes an `@source` glob reaches, so a stale + // or missing glob yields a styleless view rather than a build error. + expect(html).toContain('.bg-surface-raised'); + expect(html).toContain('.text-content-muted'); + + // Vite's library mode leaves `process.env.NODE_ENV` for a downstream bundler + // that a view does not have; unreplaced, it throws on first render. + expect(html).not.toContain('process.env.NODE_ENV'); + }); + + it('inlines the bundle as script that still parses as JavaScript', async () => { + const { contents } = await client.readResource({ uri: HELLO_APP_URI }); + const html = contents[0]!.text as string; + + const script = /