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/.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/.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/.changeset/mcp-server-base-dev-view-html.md b/.changeset/mcp-server-base-dev-view-html.md new file mode 100644 index 00000000..c18dabfa --- /dev/null +++ b/.changeset/mcp-server-base-dev-view-html.md @@ -0,0 +1,7 @@ +--- +'@transcend-io/mcp-server-base': minor +--- + +Add `viewHtml`, which lets a UI resource serve its built document from disk instead of the copy inlined at build time when `TRANSCEND_MCP_DEV_VIEWS` is set. + +Production behaviour is unchanged: without the variable the inlined string is returned, so the document is still validated once at construction. With it set, each `resources/read` re-reads the built file, so a view rebuild reaches the host without restarting the server or reconnecting the client. 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/.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/.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/.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/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..df1f17ad --- /dev/null +++ b/dev/mcp-server-examples/README.md @@ -0,0 +1,64 @@ +# `@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 mcp:inspect --examples # the official Inspector, over stdio +``` + +That builds the package first, then serves both examples below, rebuilding the +`hello` view on save. `pnpm mcp:new-view examples ` scaffolds another view +here. + +## 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 in the normal +`pnpm mcp:inspect` loop rather than only under `--v1`. + +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..79917e94 --- /dev/null +++ b/dev/mcp-server-examples/src/apps/hello.ts @@ -0,0 +1,31 @@ +import { + defineUiResource, + viewHtml, + 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.', + // Reads from disk instead when TRANSCEND_MCP_DEV_VIEWS is set, so `pnpm mcp:inspect` + // picks up a view rebuild without restarting the server. + html: viewHtml({ + bundled: HELLO_APP_HTML, + moduleUrl: import.meta.url, + view: 'hello', + }), + 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..6c7da3e0 --- /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 + * in the normal `pnpm mcp:inspect` loop rather than only under `--v1`. + */ +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 = / + + diff --git a/scripts/lib/mcp-app-dev.ts b/scripts/lib/mcp-app-dev.ts new file mode 100644 index 00000000..fa4ecc7b --- /dev/null +++ b/scripts/lib/mcp-app-dev.ts @@ -0,0 +1,551 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { discoverMcpAppViews, MCP_APP_OUT_DIR, type McpAppView } from '../../vite.config.base.ts'; +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, '..', '..'); + +/** + * Directories searched for MCP servers, in the order they are listed. + * + * `dev` is included because the example server lives there rather than beside the + * published packages: its views inline hundreds of kilobytes each, and a private + * package cannot leak them into a tarball. Nothing else distinguishes it — it is + * built, served, and inspected exactly like a published server. + */ +const PACKAGE_ROOTS = [join(repoRoot, 'packages', 'mcp'), join(repoRoot, 'dev')]; + +/** Root whose packages are development-only, matched against {@link PACKAGE_ROOTS}. */ +const DEV_ROOT = join(repoRoot, 'dev'); + +/** The aggregate server, which exposes every sub-package's tools and views. */ +export const UMBRELLA_PACKAGE = '@transcend-io/mcp'; + +/** The example server, which `--examples` selects. */ +export const EXAMPLES_PACKAGE = '@transcend-io/mcp-server-examples'; + +/** + * The Inspector release `pnpm mcp:inspect` uses by default. + * + * v2 is the right default, measured rather than assumed. All three of its clients + * (web, CLI, TUI) declare `extensions["io.modelcontextprotocol/ui"]` in + * `initialize`, which is what a spec-correct server requires before it will bind + * a view to a tool, and its CLI has an `--app-info` probe that reports a tool's + * app metadata directly. Pinned to the major so security fixes land without a + * surprise rewrite. + */ +export const INSPECTOR_V2_SPEC = '@modelcontextprotocol/inspector@2'; + +/** + * The v1 Inspector, available behind `--v1`. + * + * Kept as an escape hatch, not a default. v1 ships an Apps tab that reads + * `_meta["ui/resourceUri"]`, but its client declares `capabilities: {}`, so a + * spec-correct server withholds every view and the tab renders empty. `--v1` + * therefore also turns on the capability override to compensate. + */ +export const INSPECTOR_V1_SPEC = '@modelcontextprotocol/inspector@v1-latest'; + +/** Package the Inspector specs above 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(ZEL-8153): https://github.com/modelcontextprotocol/inspector/issues/1859 — + * delete this, the vendored document, and its call site once a release ships the + * file. Tracking ticket: https://linear.app/transcend/issue/ZEL-8153 + * 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)}`, + ); + } +} + +/** Environment variable that makes servers read views from disk per request. */ +export const DEV_VIEWS_ENV_VAR = 'TRANSCEND_MCP_DEV_VIEWS'; + +/** Environment variable that forces client capabilities on for local debugging. */ +export const ASSUME_CAPABILITIES_ENV_VAR = 'TRANSCEND_MCP_ASSUME_CAPABILITIES'; + +/** Variables a stdio server needs from us, in the order they are passed. */ +const INSPECTOR_FORWARDED_ENV_VARS = [DEV_VIEWS_ENV_VAR, ASSUME_CAPABILITIES_ENV_VAR] as const; + +/** + * Builds the `-e KEY=VALUE` arguments a stdio Inspector launch needs. + * + * The Inspector does not give a stdio server our environment. Its proxy builds + * the child's environment from a fixed allowlist — `HOME`, `LOGNAME`, `PATH`, + * `SHELL`, `TERM`, `USER` on POSIX — and merges in only what `-e` supplied, so + * exporting a variable in this process reaches the Inspector and stops there. + * + * That gap is worth a comment this long because its symptom accuses the wrong + * thing. Without {@link DEV_VIEWS_ENV_VAR} the server serves each view as it was + * inlined at package build time, so a rebuilt view keeps rendering its old markup + * through app reopens, page reloads, reconnects, and Inspector restarts — while + * the watcher reports success on every save. It reads as a stuck host or a broken + * watcher rather than a dropped variable. + * + * Credentials are deliberately absent: arguments are readable by anyone on the + * machine (`ps -o command`), which is no place for an API key. Use `--http` when + * a tool needs to reach the Transcend API, since we spawn the server there and it + * inherits the environment normally. + * + * @param env - Environment to read, defaulting to this process's + * @returns Inspector arguments, as `-e KEY=VALUE` pairs + */ +export function inspectorEnvArgs(env: NodeJS.ProcessEnv = process.env): string[] { + return INSPECTOR_FORWARDED_ENV_VARS.flatMap((name) => { + const value = env[name]; + // An empty value is an error to the Inspector, not a no-op, so drop it here. + return value === undefined || value === '' ? [] : ['-e', `${name}=${value}`]; + }); +} + +/** One MCP App view found in a package. */ +export interface DiscoveredView extends McpAppView { + /** Built document's absolute path, once the view build has run */ + builtHtml: string; +} + +/** One MCP package, with whatever views it ships. */ +export interface McpPackage { + /** Package name, e.g. `@transcend-io/mcp-server-docs` */ + name: string; + /** Directory name within its root, e.g. `mcp-server-docs` */ + dirName: string; + /** Absolute package directory */ + dir: string; + /** Absolute path to the built CLI entry point */ + cliPath: string; + /** Whether the package declares a `bin`, and so can be served at all */ + hasCli: boolean; + /** Whether the package lives under `dev`, and so is never published or aggregated */ + devOnly: boolean; + /** Views this package ships */ + views: DiscoveredView[]; +} + +interface PackageManifest { + /** Published package name */ + name?: string; + /** Executables the package publishes */ + bin?: Record; +} + +function readManifest(packageDir: string): PackageManifest | undefined { + const manifestPath = join(packageDir, 'package.json'); + if (!existsSync(manifestPath)) return undefined; + try { + return JSON.parse(readFileSync(manifestPath, 'utf8')) as PackageManifest; + } catch { + return undefined; + } +} + +/** + * Finds a package's views, using the same discovery the build uses. + * + * Shared deliberately: this used to look for `src/ui//main.tsx` while each + * package's Vite config named its entry separately, so the watchers and the build + * could disagree about what a package's views even were. + */ +function discoverViews(packageDir: string): DiscoveredView[] { + return discoverMcpAppViews(packageDir).map((view) => ({ + ...view, + builtHtml: join(packageDir, MCP_APP_OUT_DIR, view.fileName), + })); +} + +/** Every MCP package in the workspace, sorted by directory name. */ +export function discoverMcpPackages(): McpPackage[] { + const packages: McpPackage[] = []; + + for (const root of PACKAGE_ROOTS) { + if (!existsSync(root)) continue; + + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = join(root, entry.name); + const manifest = readManifest(dir); + if (!manifest?.name) continue; + + packages.push({ + name: manifest.name, + dirName: entry.name, + dir, + cliPath: join(dir, 'dist', 'cli.mjs'), + hasCli: manifest.bin !== undefined && Object.keys(manifest.bin).length > 0, + devOnly: root === DEV_ROOT, + views: discoverViews(dir), + }); + } + } + + return packages.sort((a, b) => a.dirName.localeCompare(b.dirName)); +} + +/** How a command-line invocation chose its server. */ +export interface TargetSelection { + /** Positional package argument, e.g. `docs` */ + argument?: string | undefined; + /** Whether `--examples` was passed */ + examples?: boolean; +} + +/** + * Resolves a command-line invocation to the server to run. + * + * Omitting everything selects the umbrella server, so the default shows every app + * across every published package. `--examples` selects the example server, which + * the umbrella deliberately does not aggregate. A positional argument accepts the + * short form (`docs`), the directory name (`mcp-server-docs`), or the full package + * name. + */ +export function resolveTarget( + selection: string | undefined | TargetSelection, + packages: McpPackage[], +): McpPackage { + const { argument, examples } = + typeof selection === 'string' || selection === undefined ? { argument: selection } : selection; + + if (examples === true) { + if (argument !== undefined && argument !== '') { + throw new Error( + `Pass either --examples or a package name, not both (got "${argument}" alongside --examples).`, + ); + } + const examplesPackage = packages.find((pkg) => pkg.name === EXAMPLES_PACKAGE); + if (!examplesPackage) { + throw new Error(`Could not find the example server ${EXAMPLES_PACKAGE}.`); + } + return examplesPackage; + } + + if (argument === undefined) { + const umbrella = packages.find((pkg) => pkg.name === UMBRELLA_PACKAGE); + if (!umbrella) { + throw new Error(`Could not find the umbrella package ${UMBRELLA_PACKAGE}.`); + } + return umbrella; + } + + // A package without a `bin` is a library, so there is nothing to serve. + const servable = packages.filter((pkg) => pkg.hasCli); + const needle = argument.trim().toLowerCase(); + const match = servable.find( + (pkg) => + pkg.name.toLowerCase() === needle || + pkg.dirName.toLowerCase() === needle || + pkg.dirName.toLowerCase() === `mcp-server-${needle}`, + ); + + if (!match) { + const options = servable + .filter((pkg) => pkg.name !== UMBRELLA_PACKAGE && !pkg.devOnly) + .map((pkg) => pkg.dirName.replace(/^mcp-server-/, '')) + .join(', '); + throw new Error( + `Unknown server "${argument}". Pass one of: ${options}, --examples for the example server, ` + + 'or omit it to run the umbrella server.', + ); + } + return match; +} + +/** + * Packages whose views should be watched while `target` is being served. + * + * The umbrella aggregates every published sub-package, so working against it means + * every one of their views is reachable and all of them need a watcher. A single + * package needs only its own. + * + * Development-only packages are excluded from umbrella scope because the umbrella + * does not depend on them, so it owns neither their tools nor their `ui://` + * resources. Watching them anyway would rebuild a view no running server can + * serve, which reads as a broken rebuild rather than a server that was never + * asked to serve it. Select them with `--examples` instead. + */ +export function viewPackagesInScope(target: McpPackage, packages: McpPackage[]): McpPackage[] { + const candidates = + target.name === UMBRELLA_PACKAGE ? packages.filter((pkg) => !pkg.devOnly) : [target]; + return candidates.filter((pkg) => pkg.views.length > 0); +} + +/** + * Loads `secret.env` into `process.env` when present. + * + * Processes we spawn inherit the result, which covers the server under `--http` + * and every view watcher. A stdio server spawned by the Inspector does not, for + * the reason {@link inspectorEnvArgs} explains. + */ +export function loadSecretEnv(): void { + const secretEnv = join(repoRoot, 'secret.env'); + if (existsSync(secretEnv)) process.loadEnvFile(secretEnv); +} + +/** A child process this script owns, tracked so it can be torn down together. */ +interface TrackedChild { + label: string; + child: ChildProcess; +} + +const children: TrackedChild[] = []; +let shuttingDown = false; + +/** + * Starts a long-lived child process and registers it for shutdown. + * + * @param label - Name used in log output + * @param command - Executable to run + * @param args - Arguments for the executable + * @param options - Working directory and extra environment + * @returns The spawned process + */ +export function startProcess( + label: string, + command: string, + args: string[], + options: { cwd?: string; env?: Record } = {}, +): ChildProcess { + const child = spawn(command, args, { + cwd: options.cwd === undefined ? repoRoot : resolve(repoRoot, options.cwd), + env: { ...process.env, ...options.env }, + stdio: 'inherit', + shell: false, + }); + + children.push({ label, child }); + + child.on('exit', (code, signal) => { + if (shuttingDown) return; + // One process dying leaves the rest useless, so fail the whole command + // rather than leaving a half-running environment that looks healthy. + logger.error(`\n[${label}] exited with ${signal ? `signal ${signal}` : `code ${code}`}.`); + shutdown(code ?? 1); + }); + + return child; +} + +/** Terminates every tracked child and exits. */ +export function shutdown(code: number): void { + if (shuttingDown) return; + shuttingDown = true; + for (const { child } of children) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + } + process.exit(code); +} + +/** Installs SIGINT and SIGTERM handlers that tear the whole group down. */ +export function installShutdownHandlers(): void { + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => shutdown(0)); + } +} + +/** + * Runs a command to completion, rejecting when it fails. + * + * @param label - Name used in log output + * @param command - Executable to run + * @param args - Arguments for the executable + * @param options - Working directory + */ +export async function runToCompletion( + label: string, + command: string, + args: string[], + options: { cwd?: string } = {}, +): Promise { + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: process.env, + stdio: 'inherit', + shell: false, + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolvePromise(); + else reject(new Error(`${label} failed with exit code ${code}.`)); + }); + }); +} + +/** Builds the target package and everything it depends on. */ +export async function buildTarget(target: McpPackage): Promise { + logger.log(`\nBuilding ${target.name} and its dependencies...`); + await runToCompletion('turbo build', 'pnpm', [ + 'turbo', + 'run', + 'build', + `--filter=${target.name}...`, + ]); +} + +/** Starts a watching view build for each package whose views are in scope. */ +export function startViewWatchers(packages: McpPackage[]): void { + for (const pkg of packages) { + logger.log(`Watching views in ${pkg.name} (${pkg.views.map((v) => v.name).join(', ')})`); + startProcess( + `views:${pkg.dirName}`, + 'node', + [join(repoRoot, 'scripts', 'build-mcp-views.ts'), '--watch'], + { cwd: pkg.dir }, + ); + } +} diff --git a/scripts/mcp-app-styling.test.ts b/scripts/mcp-app-styling.test.ts new file mode 100644 index 00000000..289f0b7c --- /dev/null +++ b/scripts/mcp-app-styling.test.ts @@ -0,0 +1,165 @@ +import { readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { readRepoFile, repoRoot } from './lib/repo-files.ts'; + +/** + * The one file allowed to name a literal color. + * + * Every `@theme` chain has to bottom out somewhere for hosts that send no style + * variables, and that somewhere is here. Anywhere else a literal means a view + * has stopped following the host. + */ +const THEME_STYLESHEET = 'packages/mcp/mcp-server-base/src/ui/theme.css'; + +/** CSS length units that indicate an arbitrary value is off the spacing scale. */ +const LENGTH_UNITS = [ + 'px', + 'rem', + 'em', + 'vh', + 'vw', + 'vmin', + 'vmax', + 'ch', + 'ex', + 'pt', + 'pc', + 'cm', + 'mm', + 'q', + 'in', +]; + +const LENGTH_PATTERN = new RegExp( + String.raw`(?:^|[\s,(/_])-?\.?\d[\d.]*(?:${LENGTH_UNITS.join('|')})\b`, + 'i', +); + +const COLOR_FUNCTIONS = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i; + +/** Matches `#rgb`, `#rgba`, `#rrggbb`, and `#rrggbbaa`, but not a longer word. */ +const HEX_LITERAL = /#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})\b/i; + +/** Matches the `[…]` payload of a Tailwind arbitrary value, e.g. the `12px` of `p-[12px]`. */ +const ARBITRARY_VALUE = /-\[([^\]]+)\]/g; + +/** + * Why an arbitrary value is disallowed, or `undefined` when it is fine. + * + * Only colors and lengths are rejected. Structural values have no theme + * namespace to live in — `grid-cols-[max-content_1fr]` cannot be expressed any + * other way — so banning every arbitrary value would just push views back to + * handwritten CSS. + */ +function describeArbitraryValue(value: string): string | undefined { + if (HEX_LITERAL.test(value) || COLOR_FUNCTIONS.test(value)) { + return 'a literal color'; + } + if (/^var\(--color-/.test(value)) { + // Reaching for the variable directly skips the utility that already exists + // for it, and skips the host fallback chain behind it. + return 'a raw color variable'; + } + if (LENGTH_PATTERN.test(value)) { + return 'an off-scale length'; + } + return undefined; +} + +/** + * Every `.tsx` and `.css` file under an MCP package's `src/ui` directory. + * + * Both roots are searched because the reference views live in `dev/`, and these + * rules are exactly the ones a reference implementation has to keep. + */ +function findViewFiles(): string[] { + const roots = [join(repoRoot, 'packages/mcp'), join(repoRoot, 'dev')]; + const found: string[] = []; + + function walk(directory: string): void { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + // `generated/` holds built single-file documents, which contain the + // compiled output of these rules rather than authored source. + if (entry.name !== 'generated' && entry.name !== 'node_modules') { + walk(entryPath); + } + } else if (/\.(?:tsx|css)$/.test(entry.name)) { + found.push(relative(repoRoot, entryPath)); + } + } + } + + for (const root of roots) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + try { + walk(join(root, entry.name, 'src/ui')); + } catch { + // Most MCP packages ship no views. + } + } + } + + return found.sort(); +} + +const viewFiles = findViewFiles(); + +describe('MCP app styling', () => { + test('there are view files to check', () => { + // Guards against the walk silently finding nothing and every case below + // passing vacuously. + expect(viewFiles).toContain(THEME_STYLESHEET); + expect(viewFiles.length).toBeGreaterThan(1); + }); + + test.for(viewFiles)('%s uses no arbitrary color or length values', (filePath) => { + const offenders = [...readRepoFile(filePath).matchAll(ARBITRARY_VALUE)].flatMap((match) => { + const reason = describeArbitraryValue(match[1] ?? ''); + return reason ? [`${match[0]} is ${reason}`] : []; + }); + + expect(offenders, `Use a theme value instead, or add one to ${THEME_STYLESHEET}`).toEqual([]); + }); + + test.for(viewFiles.filter((filePath) => filePath !== THEME_STYLESHEET))( + '%s names no literal colors', + (filePath) => { + const offendingLines = readRepoFile(filePath) + .split('\n') + .flatMap((line, index) => { + const match = HEX_LITERAL.exec(line); + return match ? [`line ${index + 1}: ${match[0]}`] : []; + }); + + expect( + offendingLines, + `Colors belong in the @theme block of ${THEME_STYLESHEET}, where they can fall back to a host value`, + ).toEqual([]); + }, + ); + + test('the rules catch what they are meant to catch', () => { + // These assertions are the specification, since a passing scan above proves + // nothing on its own about whether the patterns match anything at all. + expect(describeArbitraryValue('#fff')).toBe('a literal color'); + expect(describeArbitraryValue('rgb(0_0_0)')).toBe('a literal color'); + expect(describeArbitraryValue('var(--color-surface)')).toBe('a raw color variable'); + expect(describeArbitraryValue('20px')).toBe('an off-scale length'); + expect(describeArbitraryValue('1.5rem')).toBe('an off-scale length'); + expect(describeArbitraryValue('calc(100%_-_4px)')).toBe('an off-scale length'); + + // Structural values stay legal. + expect(describeArbitraryValue('max-content_1fr')).toBeUndefined(); + expect(describeArbitraryValue('&>svg')).toBeUndefined(); + expect(describeArbitraryValue('auto_1fr')).toBeUndefined(); + + expect(HEX_LITERAL.test('#f4f4f6')).toBe(true); + expect(HEX_LITERAL.test('id="hello-name"')).toBe(false); + }); +}); diff --git a/scripts/mcp-app-views.test.ts b/scripts/mcp-app-views.test.ts new file mode 100644 index 00000000..5e3a51f3 --- /dev/null +++ b/scripts/mcp-app-views.test.ts @@ -0,0 +1,108 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { discoverMcpAppViews } from '../vite.config.base.ts'; +import { discoverMcpPackages } from './lib/mcp-app-dev.ts'; +import { readRepoFile, repoRoot } from './lib/repo-files.ts'; + +/** + * Every view across every MCP package. + * + * Discovery itself is the first assertion: it throws on a directory that does not + * hold exactly one `*View.tsx`, so a malformed view fails here at module load + * rather than mid-build with a message about a module that does not exist. + */ +const views = discoverMcpPackages().flatMap((pkg) => + pkg.views.map((view) => ({ ...view, packageName: pkg.name })), +); + +/** Builds a package directory holding one view directory with the given files. */ +function fakePackageWithViewFiles(files: string[]): string { + const root = mkdtempSync(join(tmpdir(), 'mcp-view-convention-')); + const viewDir = join(root, 'src', 'ui', 'broken'); + mkdirSync(viewDir, { recursive: true }); + for (const file of files) writeFileSync(join(viewDir, file), ''); + return root; +} + +describe('MCP app view convention', () => { + test('there are views to check', () => { + // Without this, every case below passes vacuously the moment discovery + // changes shape and finds nothing. + expect(views.map((view) => `${view.packageName}/${view.name}`)).toContain( + '@transcend-io/mcp-server-examples/hello', + ); + }); + + test.for(views)('$packageName/$name exports the component its filename promises', (view) => { + const source = readRepoFile(relative(repoRoot, view.componentPath)); + + // The synthesized entry imports this exact name, so a mismatch is what turns + // a rename into a build failure about a file nobody wrote. + expect( + new RegExp(String.raw`^export (?:function|const|class) ${view.componentName}\b`, 'm').test( + source, + ), + `${relative(repoRoot, view.componentPath)} must export "${view.componentName}" to match its filename`, + ).toBe(true); + }); + + test.for(views)('$packageName/$name has no hand-written entry or stylesheet', (view) => { + // Both are synthesized during the build. A leftover copy on disk is dead + // code that looks authoritative. + const stale = ['main.tsx', 'mcp-app-entry.tsx', 'mcp-app-theme.css'].filter((file) => + existsSync(join(view.directory, file)), + ); + expect(stale).toEqual([]); + }); + + test('a view directory holding files but not exactly one *View.tsx is rejected', () => { + for (const [files, expected] of [ + [['helpers.ts'], '0 files matching *View.tsx'], + [['HelloView.tsx', 'DetailView.tsx'], '2 files matching *View.tsx'], + ] as const) { + const root = fakePackageWithViewFiles([...files]); + try { + expect(() => discoverMcpAppViews(root)).toThrow(expected); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + }); + + test('an empty view directory is what a deleted view leaves behind, so it is skipped', () => { + // Not merely tolerated for its own sake: the generator discovers views before + // writing anything, so erroring here is what made re-scaffolding a deleted + // view fail on a directory git could not have removed. + const root = fakePackageWithViewFiles([]); + try { + expect(discoverMcpAppViews(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('an underscore-prefixed directory is shared code rather than a broken view', () => { + const root = mkdtempSync(join(tmpdir(), 'mcp-view-convention-')); + try { + mkdirSync(join(root, 'src', 'ui', '_shared'), { recursive: true }); + mkdirSync(join(root, 'src', 'ui', 'ok'), { recursive: true }); + writeFileSync(join(root, 'src', 'ui', '_shared', 'Button.tsx'), ''); + writeFileSync(join(root, 'src', 'ui', 'ok', 'OkView.tsx'), ''); + + const [view] = discoverMcpAppViews(root); + expect(view?.name).toBe('ok'); + + // Every view has to carry the shared directories, because Tailwind + // generates utilities per document by scanning files. A shared component + // left unscanned still bundles and still renders — just unstyled, which is + // the kind of break that looks like a CSS bug rather than a missing source. + expect(view?.sharedDirectories).toEqual([join(root, 'src', 'ui', '_shared')]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/mcp-inspect.ts b/scripts/mcp-inspect.ts new file mode 100644 index 00000000..cdf4d90d --- /dev/null +++ b/scripts/mcp-inspect.ts @@ -0,0 +1,153 @@ +/** + * Opens the official MCP Inspector against a Transcend MCP server. + * + * This is the development loop for a view: a real host, a real `initialize` + * handshake, real `_meta.ui` binding, and a real sandboxed iframe. Views are + * rebuilt on save and read from disk per `resources/read`, so seeing an edit takes + * a rebuild and reopening the app rather than a server restart. + * + * Usage: + * pnpm mcp:inspect # umbrella server: every app, every published package + * pnpm mcp:inspect --examples # the example server, which the umbrella does not aggregate + * pnpm mcp:inspect docs # one package, which builds faster + * pnpm mcp:inspect --http # serve over Streamable HTTP instead of stdio + * pnpm mcp:inspect --v1 # the older Inspector, with the capability override + * pnpm mcp:inspect --assume-app # force the MCP Apps capability on + * pnpm mcp:inspect --no-build # skip the build, when dist is already current + */ + +import { parseArgs } from 'node:util'; + +import { + ASSUME_CAPABILITIES_ENV_VAR, + buildTarget, + DEV_VIEWS_ENV_VAR, + discoverMcpPackages, + ensureInspectorSandboxProxy, + INSPECTOR_V1_SPEC, + INSPECTOR_V2_SPEC, + inspectorEnvArgs, + installShutdownHandlers, + loadSecretEnv, + resolveTarget, + startProcess, + startViewWatchers, + UMBRELLA_PACKAGE, + viewPackagesInScope, +} from './lib/mcp-app-dev.ts'; +import { logger } from './logger.ts'; + +const DEFAULT_HTTP_PORT = 3457; + +async function main(): Promise { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + http: { type: 'boolean', default: false }, + port: { type: 'string' }, + v1: { type: 'boolean', default: false }, + examples: { type: 'boolean', default: false }, + 'assume-app': { type: 'boolean', default: false }, + 'no-build': { type: 'boolean', default: false }, + 'no-watch': { type: 'boolean', default: false }, + }, + }); + + const packages = discoverMcpPackages(); + const target = resolveTarget({ argument: positionals[0], examples: values.examples }, packages); + const viewPackages = viewPackagesInScope(target, packages); + const inspectorSpec = values.v1 ? INSPECTOR_V1_SPEC : INSPECTOR_V2_SPEC; + + logger.log(`Inspecting ${target.name} with ${inspectorSpec}`); + if (target.name === UMBRELLA_PACKAGE) { + logger.log( + "Umbrella server selected, so every package's apps are available. Pass a package name " + + '(e.g. "docs") for a faster build.', + ); + } + if (viewPackages.length === 0) { + logger.log( + 'No MCP App views in scope, so the Apps tab will be empty. ' + + 'Pass --examples for the reference views, which the umbrella does not aggregate.', + ); + } + + loadSecretEnv(); + installShutdownHandlers(); + + // Independent of each other, so overlap them: the sandbox check costs an `npx` + // resolution that the build's several seconds hides entirely. v1 keeps its + // static assets somewhere else and is unaffected. + await Promise.all([ + values['no-build'] ? Promise.resolve() : buildTarget(target), + values.v1 ? Promise.resolve() : ensureInspectorSandboxProxy(inspectorSpec), + ]); + + // Serving views from disk means a rebuild is picked up by reopening the app in + // the Inspector, with no server restart and no reconnect. Set here for the + // server we spawn under `--http`; a stdio server is handed it explicitly below, + // because the Inspector does not pass our environment on. + process.env[DEV_VIEWS_ENV_VAR] = '1'; + + // v1 declares no capabilities at all, so without this its Apps tab is empty no + // matter how correct the server is. v2 declares the extension properly and + // needs nothing, which is why it is the default and this stays opt-in: forcing + // the capability on would mask a genuine negotiation failure. + if (values.v1 || values['assume-app']) { + process.env[ASSUME_CAPABILITIES_ENV_VAR] ??= 'MCP_APP'; + const forced = process.env[ASSUME_CAPABILITIES_ENV_VAR]; + // Set but empty is deliberate: it is how you reach a tool's baseline branch, + // since v1 declares nothing and the override cannot subtract a capability. + logger.log( + forced === '' + ? `${ASSUME_CAPABILITIES_ENV_VAR} is set but empty, so capabilities stay exactly as the client declared them.` + : `Forcing client capabilities on (${forced}) because ` + + `${values.v1 ? 'v1 does not advertise the MCP Apps extension' : '--assume-app was passed'}.`, + ); + } + + if (!values['no-watch']) startViewWatchers(viewPackages); + + if (values.http) { + const port = values.port ?? String(DEFAULT_HTTP_PORT); + const url = `http://127.0.0.1:${port}/mcp`; + + startProcess('server', 'node', [target.cliPath, '--transport=http', `--port=${port}`]); + + if (values.v1) { + startProcess('inspector', 'npx', ['-y', inspectorSpec]); + logger.log( + `\nServer listening on ${url}.\n` + + 'In the Inspector, choose the "Streamable HTTP" transport and paste that URL, ' + + 'then open the Apps tab.\n', + ); + return; + } + + // v2 infers the transport from the target, and a /mcp path means Streamable HTTP. + startProcess('inspector', 'npx', ['-y', inspectorSpec, url]); + logger.log(`\nServer listening on ${url}. Open the Apps tab once the Inspector connects.\n`); + return; + } + + logger.log('\nLaunching the Inspector. Open the Apps tab once it connects.\n'); + + // v1 forwards trailing arguments to the server; v2 parses them as its own, so + // rely on the server defaulting to stdio there instead of passing a flag. + const serverArgs = values.v1 ? [target.cliPath, '--transport=stdio'] : [target.cliPath]; + + // The `-e` pairs precede the command because both Inspector versions parse + // their own options first and treat the rest as the server to spawn. + startProcess('inspector', 'npx', [ + '-y', + inspectorSpec, + ...inspectorEnvArgs(), + 'node', + ...serverArgs, + ]); +} + +main().catch((error: unknown) => { + logger.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/mcp-new-view.ts b/scripts/mcp-new-view.ts new file mode 100644 index 00000000..d7e3331b --- /dev/null +++ b/scripts/mcp-new-view.ts @@ -0,0 +1,448 @@ +/** + * Scaffolds a new MCP App view. + * + * A view is three files: the component, the Node-side module that binds the built + * document to a `ui://` resource, and the tool that opens it. Everything + * mechanical — the entry that mounts React, the stylesheet that generates the + * utilities, the Vite config — is synthesized at build time by + * `vite.config.base.ts`, which is what keeps this template short enough not to + * drift from what the build expects. + * + * The generated tool is deliberately left unregistered. A tool's name and + * description are public API on a published package, so exporting a placeholder + * is a decision a person should make; writing the `defineToolWithCapabilities` + * shape for them is not. + * + * The first view in a package also needs package-level wiring, which this adds + * only when it is missing: `tsconfig.ui.json`, the gitignore entry for the built + * documents, three scripts, and the browser-side devDependencies. + * + * Usage: + * pnpm mcp:new-view docs usage-chart + * pnpm mcp:new-view mcp-server-consent consent-summary + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { parseArgs } from 'node:util'; + +import { + discoverMcpPackages, + repoRoot, + resolveTarget, + UMBRELLA_PACKAGE, + type McpPackage, +} from './lib/mcp-app-dev.ts'; +import { logger } from './logger.ts'; + +/** Scripts a package needs to build and check its views, and where to put them. */ +const VIEW_SCRIPTS: { + /** Script name */ + name: string; + /** Existing script to sit next to, so the result reads in a sensible order */ + anchor: { before: string } | { after: string }; + /** Script body */ + value: string; +}[] = [ + { + name: 'prebuild', + anchor: { before: 'build' }, + value: 'node ../../../scripts/build-mcp-views.ts', + }, + { + name: 'build:ui', + anchor: { after: 'build' }, + value: 'node ../../../scripts/build-mcp-views.ts', + }, + { + name: 'typecheck:ui', + anchor: { after: 'typecheck' }, + value: 'tsc -p tsconfig.ui.json --noEmit', + }, +]; + +/** + * devDependencies a view needs. + * + * React and its types because the component is a React component; `vite` and + * `tailwindcss` because the view build runs them from the package directory; and + * `@transcend-io/design-tokens` because the shared theme resolves the tokens + * through it. + */ +const VIEW_DEV_DEPENDENCIES: Record = { + '@transcend-io/design-tokens': 'workspace:*', + '@types/react': 'catalog:', + '@types/react-dom': 'catalog:', + react: 'catalog:', + 'react-dom': 'catalog:', + tailwindcss: 'catalog:', + vite: 'catalog:', +}; + +/** Turns a view's directory name into its component's name, e.g. `UsageChart`. */ +function toPascalCase(name: string): string { + return name + .split(/[^a-zA-Z0-9]+/) + .filter((part) => part !== '') + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(''); +} + +/** Writes a file, refusing to touch one that already exists. */ +function writeNew(path: string, contents: string): void { + if (existsSync(path)) { + throw new Error( + `${relative(repoRoot, path)} already exists. Delete it first, or pick a different view name.`, + ); + } + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, contents); + logger.log(` wrote ${relative(repoRoot, path)}`); +} + +/** Component source: the whole view, since nothing else about it is a file. */ +function componentSource(componentName: string, appName: string): string { + return `import { useMcpApp } from '@transcend-io/mcp-server-base/ui'; + +/** Payload shape the tool behind this view returns. */ +interface ${componentName}Data { + /** Replace with the fields the tool actually returns */ + message?: string; +} + +/** + * TODO: describe what this view shows and why it is a view rather than text. + * + * Styled 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, and arbitrary values for color + * or length are rejected by \`scripts/mcp-app-styling.test.ts\`. + */ +export function ${componentName}() { + const { data, isConnected, connectionError } = useMcpApp<${componentName}Data>({ + appInfo: { name: '${appName}', version: '1.0.0' }, + }); + + if (connectionError) { + return ( +
+

Could not reach the host

+

{connectionError.message}

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

Connecting…

+

Waiting for the host handshake.

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

${componentName}

+

{data?.message ?? 'No data yet.'}

+
+ ); +} +`; +} + +/** Node-side source binding the built document to a `ui://` resource. */ +function resourceSource(view: string, constant: string, uri: string): string { + return `import { + defineUiResource, + viewHtml, + type UiResourceDefinition, +} from '@transcend-io/mcp-server-base'; + +// Built from src/ui/${view}/ by this package's \`prebuild\` and inlined here as a +// string by tsdown's \`.html\` text loader. The document is fully self-contained +// because hosts render views in a sandboxed iframe with no same-origin server to +// fetch anything from. +import ${constant}_HTML from '../ui/generated/${view}.html'; + +/** URI hosts fetch to render the ${view} view. */ +export const ${constant}_URI = '${uri}'; + +/** TODO: describe the view in a sentence; hosts may show this. */ +export const ${constant}_RESOURCE: UiResourceDefinition = defineUiResource({ + uri: ${constant}_URI, + name: 'TODO: a short human-readable name', + description: 'TODO: what a host sees when it lists this resource.', + // Reads from disk instead when TRANSCEND_MCP_DEV_VIEWS is set, so \`pnpm mcp:inspect\` + // picks up a view rebuild without restarting the server. + html: viewHtml({ + bundled: ${constant}_HTML, + moduleUrl: import.meta.url, + view: '${view}', + }), + prefersBorder: false, +}); +`; +} + +/** Options for {@link toolSource}. */ +interface ToolSourceOptions { + /** Constant prefix used by the resource module, e.g. `USAGE_CHART_APP` */ + constant: string; + /** Exported factory's name, e.g. `createUsageChartAppTool` */ + factory: string; + /** Payload helper's name, e.g. `usageChartPayload` */ + payload: string; + /** Zod schema constant's name, e.g. `UsageChartAppSchema` */ + schema: string; + /** Tool's name on the wire, e.g. `docs_usage_chart` */ + toolName: string; + /** View's directory name, e.g. `usage-chart` */ + view: string; +} + +/** Source for the tool that opens the view. */ +function toolSource({ + constant, + factory, + payload, + schema, + toolName, + view, +}: ToolSourceOptions): string { + return `import { + createToolResult, + defineToolWithCapabilities, + McpClientCapability, + z, + type ToolClients, +} from '@transcend-io/mcp-server-base'; + +import { ${constant}_RESOURCE } from '../apps/${view}.js'; + +export const ${schema} = z.object({ + // TODO: replace with the arguments this tool takes. Every field needs a + // description: it is what the model reads to decide how to call this. + message: z.string().optional().describe('Message to show in the view.'), +}); +export type ${schema.replace(/Schema$/, 'Input')} = z.infer; + +/** + * Payload shared by both variants, so the text a host without MCP Apps shows + * describes the same result the view renders. + */ +function ${payload}(message: string | undefined): unknown { + return createToolResult(true, { + message: message ?? 'TODO: return the data this view renders.', + }); +} + +/** + * TODO: describe what this tool does and why its result is worth a view. + * + * Not registered yet. Add \`${factory}()\` to the array its package returns from + * \`src/tools/index.ts\`, which is the point at which the name and description + * below become public API. + */ +export function ${factory}(_clients?: ToolClients) { + return defineToolWithCapabilities({ + name: '${toolName}', + description: 'TODO: what this returns, and when the model should call it.', + category: 'TODO', + readOnly: true, + requireAuth: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: ${schema}, + // Baseline, used by every host that cannot render a view. + handler: async ({ message }) => ${payload}(message), + variants: { + [McpClientCapability.McpApp]: { + resource: ${constant}_RESOURCE, + handler: async ({ message }) => ${payload}(message), + }, + }, + }); +} +`; +} + +/** Inserts a key beside its anchor, appending when the anchor is absent. */ +function insertBeside( + target: Record, + anchor: { before: string } | { after: string }, + key: string, + value: T, +): void { + const name = 'before' in anchor ? anchor.before : anchor.after; + const keys = Object.keys(target); + if (!keys.includes(name)) { + target[key] = value; + return; + } + + const rebuilt: Record = {}; + for (const existing of keys) { + if (existing === name && 'before' in anchor) rebuilt[key] = value; + rebuilt[existing] = target[existing]!; + if (existing === name && 'after' in anchor) rebuilt[key] = value; + } + for (const existing of keys) delete target[existing]; + Object.assign(target, rebuilt); +} + +/** Adds the scripts and devDependencies a view build needs, if absent. */ +function wirePackageManifest(pkg: McpPackage): boolean { + const manifestPath = join(pkg.dir, 'package.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + scripts?: Record; + devDependencies?: Record; + }; + const scripts = (manifest.scripts ??= {}); + const devDependencies = (manifest.devDependencies ??= {}); + let changed = false; + + for (const { name, anchor, value } of VIEW_SCRIPTS) { + if (scripts[name] !== undefined) continue; + insertBeside(scripts, anchor, name, value); + changed = true; + } + + for (const [name, version] of Object.entries(VIEW_DEV_DEPENDENCIES)) { + if (devDependencies[name] !== undefined) continue; + devDependencies[name] = version; + changed = true; + } + + if (!changed) return false; + + manifest.devDependencies = Object.fromEntries( + Object.entries(devDependencies).sort(([a], [b]) => a.localeCompare(b)), + ); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + logger.log(` updated ${relative(repoRoot, manifestPath)}`); + return true; +} + +/** + * Installs the devDependencies just added to the manifest. + * + * Done here rather than left as an instruction because skipping it does not fail + * where you would look for it: the view build's entry point is synthesized, so an + * uninstalled React surfaces as a resolver error naming a file that exists + * nowhere on disk. + */ +function installDevDependencies(): void { + logger.log('\nInstalling the devDependencies just added...'); + const result = spawnSync('pnpm', ['install'], { cwd: repoRoot, stdio: 'inherit' }); + if (result.status !== 0) { + throw new Error( + 'pnpm install failed, so the view cannot build yet. Fix the install and re-run it; ' + + 'the generated files are already in place.', + ); + } +} + +/** Adds `tsconfig.ui.json` and the gitignore entry, if absent. */ +function wirePackageFiles(pkg: McpPackage): void { + const tsconfigPath = join(pkg.dir, 'tsconfig.ui.json'); + if (!existsSync(tsconfigPath)) { + writeNew( + tsconfigPath, + `${JSON.stringify( + { + $schema: 'https://json.schemastore.org/tsconfig', + extends: '../../../tsconfig.ui.base.json', + include: ['src/ui/**/*.ts', 'src/ui/**/*.tsx'], + exclude: ['src/ui/generated/**'], + }, + null, + 2, + )}\n`, + ); + } + + const gitignorePath = join(pkg.dir, '.gitignore'); + const entry = 'src/ui/generated/'; + const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : ''; + if (!existing.split('\n').includes(entry)) { + const header = '# Vite-built MCP App views, rebuilt by `pnpm prebuild`'; + const prefix = existing === '' || existing.endsWith('\n') ? existing : `${existing}\n`; + writeFileSync(gitignorePath, `${prefix}${header}\n${entry}\n`); + logger.log(` updated ${relative(repoRoot, gitignorePath)}`); + } +} + +async function main(): Promise { + const { positionals } = parseArgs({ allowPositionals: true, options: {} }); + const [packageArgument, viewName] = positionals; + + if (packageArgument === undefined || viewName === undefined) { + throw new Error( + 'Usage: pnpm mcp:new-view , e.g. pnpm mcp:new-view docs usage-chart', + ); + } + if (!/^[a-z][a-z0-9-]*$/.test(viewName)) { + throw new Error( + `View name "${viewName}" must be lowercase kebab-case: it becomes a directory name and the last segment of the view's ui:// uri.`, + ); + } + + const packages = discoverMcpPackages(); + const pkg = resolveTarget(packageArgument, packages); + if (pkg.name === UMBRELLA_PACKAGE) { + throw new Error( + `${UMBRELLA_PACKAGE} aggregates the other servers rather than owning views. Pass the package the view belongs to.`, + ); + } + + const shortName = pkg.dirName.replace(/^mcp-server-/, ''); + const pascalCase = toPascalCase(viewName); + const componentName = `${pascalCase}View`; + const snakeCase = viewName.replace(/-/g, '_'); + const constant = `${pascalCase.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase()}_APP`; + const factory = `create${pascalCase}AppTool`; + const hadViews = pkg.views.length > 0; + + logger.log(`Adding view "${viewName}" to ${pkg.name}`); + + writeNew( + join(pkg.dir, 'src', 'ui', viewName, `${componentName}.tsx`), + componentSource(componentName, `transcend-${shortName}-${viewName}`), + ); + writeNew( + join(pkg.dir, 'src', 'apps', `${viewName}.ts`), + resourceSource(viewName, constant, `ui://transcend-${shortName}/${viewName}`), + ); + writeNew( + join(pkg.dir, 'src', 'tools', `${snakeCase}_app.ts`), + toolSource({ + constant, + factory, + payload: `${pascalCase[0]!.toLowerCase()}${pascalCase.slice(1)}Payload`, + schema: `${pascalCase}AppSchema`, + toolName: `${shortName.replace(/-/g, '_')}_${snakeCase}`, + view: viewName, + }), + ); + + wirePackageFiles(pkg); + if (wirePackageManifest(pkg)) installDevDependencies(); + + logger.log('\nStill to do by hand:'); + logger.log( + ` 1. Add ${factory}() to the tools src/tools/index.ts returns, which is what makes the view reachable.`, + ); + logger.log(' 2. Replace the TODOs in all three files, starting with the tool name.'); + logger.log( + `\nThen: pnpm --filter ${pkg.name} build:ui, and pnpm mcp:inspect ${shortName} to iterate on it.`, + ); + if (!hadViews) { + logger.log(`This is ${pkg.name}'s first view, so its package-level wiring was added too.`); + } +} + +main().catch((error: unknown) => { + logger.error(error instanceof Error ? error.message : error); + process.exit(1); +}); 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', + }); } }, ); 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/tsconfig.base.json b/tsconfig.base.json index 5cb871e4..a66c06e5 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/html.d.ts", "./types/css.d.ts", "./types/markdown.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/tsdown.config.base.ts b/tsdown.config.base.ts index 18bccd57..94c50b57 100644 --- a/tsdown.config.base.ts +++ b/tsdown.config.base.ts @@ -1,19 +1,88 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { UserConfig } from 'tsdown'; +import type { TsdownPlugin, UserConfig } from 'tsdown'; const repoRoot = path.dirname(fileURLToPath(import.meta.url)); +/** + * Extensions imported for their text rather than parsed as code. + * + * `.svg` and `.html` cover assets and prebuilt MCP App views; `.md` covers + * agent-facing guides served by tools. Exported because `vitest.config.ts` has + * to load the same set the same way — a build that inlines an extension the + * test runner does not know about fails only once something imports it. + */ +export const TEXT_ASSET_EXTENSIONS = ['.svg', '.html', '.md'] as const; + +/** Source extensions whose text is inlined verbatim and so is useless in a sourcemap. */ +const OPAQUE_SOURCE_EXTENSIONS = ['.html', '.svg']; + +/** + * Drops the embedded text of inlined assets from emitted sourcemaps. + * + * A prebuilt MCP App view is a single self-contained document — bundled, + * minified, and hundreds of kilobytes — that the `.html` loader turns into one + * string literal. Sourcemaps embed every input's text in `sourcesContent`, so + * each view ends up shipped twice: once in the chunk and once again in the map. + * For `mcp-server-examples` that is 554 KB of a 572 KB map, and it maps into a + * string literal, so a debugger gains nothing from it. + * + * Only these entries are blanked, rather than setting `sourcemapExcludeSources`, + * because the TypeScript sources are worth keeping: packages publish `dist` only, + * so `sourcesContent` is the only way a stack trace from an installed copy can + * show a code frame. + */ +function stripInlinedAssetSources(): TsdownPlugin { + return { + name: 'transcend:strip-inlined-asset-sourcemap-content', + // Rewrites the emitted `.map` asset rather than the `map` on its chunk: + // rolldown has already serialized the map into the asset by the time this + // hook runs, so mutating `chunk.map` changes nothing on disk. + generateBundle(_options, bundle) { + for (const [fileName, output] of Object.entries(bundle)) { + if (!fileName.endsWith('.map') || output.type !== 'asset') continue; + + const raw = + typeof output.source === 'string' + ? output.source + : new TextDecoder().decode(output.source); + const map = JSON.parse(raw) as { + /** Input paths the map refers to */ + sources: (string | null)[]; + /** Verbatim text of each input, positionally matched to `sources` */ + sourcesContent?: (string | null)[]; + }; + if (!map.sourcesContent) continue; + + let stripped = false; + map.sourcesContent = map.sourcesContent.map((content, index) => { + const source = map.sources[index]; + if (!OPAQUE_SOURCE_EXTENSIONS.some((extension) => source?.endsWith(extension))) { + return content; + } + stripped = true; + return null; + }); + + if (stripped) output.source = JSON.stringify(map); + } + }, + }; +} + const sharedLibraryConfig: UserConfig = { clean: true, dts: true, format: ['esm'], sourcemap: true, - loader: { '.svg': 'text' }, + loader: Object.fromEntries( + TEXT_ASSET_EXTENSIONS.map((extension) => [extension, 'text']), + ) as UserConfig['loader'], alias: { '@tools/assets': path.join(repoRoot, 'assets'), }, + plugins: [stripInlinedAssetSources()], }; export default sharedLibraryConfig; diff --git a/turbo.json b/turbo.json index 556273d0..7e0f352d 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,9 @@ "mise.toml", "mise.lock", "tsconfig.base.json", + "tsconfig.ui.base.json", "tsdown.config.base.ts", + "vite.config.base.ts", "vitest.config.ts", "types/**", "assets/**", @@ -35,6 +37,7 @@ "quality:checks": { "dependsOn": [ "typecheck", + "typecheck:ui", "//#typecheck:root", "//#check:packages", "check:deps", @@ -50,6 +53,10 @@ "dependsOn": ["transit", "//#codegen"], "outputs": [] }, + "typecheck:ui": { + "dependsOn": ["transit"], + "outputs": [] + }, "//#codegen": { "inputs": [ "codegen.ts", @@ -101,6 +108,26 @@ "//#format:check:root": {}, "//#check:packages": {}, "//#check:deps:root": {}, + "@transcend-io/mcp-server-examples#build": { + "dependsOn": ["^build", "//#codegen"], + "outputs": ["dist/**", "src/ui/generated/**"] + }, + "@transcend-io/mcp-server-examples#typecheck": { + "dependsOn": ["build"], + "outputs": [] + }, + "@transcend-io/mcp-server-examples#test": { + "dependsOn": ["build"], + "outputs": [] + }, + "@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"], 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'; diff --git a/types/html.d.ts b/types/html.d.ts new file mode 100644 index 00000000..8fa9f1a4 --- /dev/null +++ b/types/html.d.ts @@ -0,0 +1,12 @@ +/** + * Ambient module declaration for HTML files imported as text. + * + * Used by MCP App views: Vite prebuilds each view into one self-contained + * document, and the server package imports it as a string to serve over + * `resources/read`. + */ +declare module '*.html' { + /** Contents of the file, inlined as a string at build time. */ + const contents: string; + export default contents; +} diff --git a/types/markdown.d.ts b/types/markdown.d.ts new file mode 100644 index 00000000..4b19142e --- /dev/null +++ b/types/markdown.d.ts @@ -0,0 +1,12 @@ +/** + * Ambient module declaration for Markdown imported as text. + * + * Used by agent-facing guides: the markdown is authored as a real `.md` file so + * it stays readable and reviewable, and the server inlines it as a string at + * build time so it ships inside `dist` with no runtime file reads. + */ +declare module '*.md' { + /** Contents of the file, inlined as a string at build time. */ + const contents: string; + export default contents; +} diff --git a/vite.config.base.ts b/vite.config.base.ts new file mode 100644 index 00000000..13ea8116 --- /dev/null +++ b/vite.config.base.ts @@ -0,0 +1,441 @@ +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 files but 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 files = readdirSync(directory); + + // An entirely empty directory can only be what a deleted view left behind: + // git cannot track one and no build step creates one. Erroring on it would + // mean deleting a view and scaffolding it again fails on the leftover folder. + if (files.length === 0) continue; + + const components = files.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 }), + ], + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 617cdb01..7e1ab5ec 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +import { TEXT_ASSET_EXTENSIONS } from './tsdown.config.base.ts'; + const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const sourceConditions = ['@transcend-io/source']; const toolsAssetsAlias = { @@ -23,9 +25,11 @@ export default defineConfig({ }, plugins: [ { - name: 'svg-text-loader', + // Shares the extension list with tsdown.config.base.ts so tests resolve + // these imports the same way the published bundles do. + name: 'text-asset-loader', load(id) { - if (id.endsWith('.svg')) { + if (TEXT_ASSET_EXTENSIONS.some((extension) => id.endsWith(extension))) { return `export default ${JSON.stringify(readFileSync(id, 'utf8'))}`; } },