Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions packages/hosts/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
"types": "./src/tool-server.ts",
"default": "./src/tool-server.ts"
},
"./tool-server-v2": {
"types": "./src/tool-server-v2.ts",
"default": "./src/tool-server-v2.ts"
},
"./mcp-apps": {
"types": "./src/mcp-apps.ts",
"default": "./src/mcp-apps.ts"
},
"./create-artifact": {
"types": "./src/create-artifact.ts",
"default": "./src/create-artifact.ts"
Expand Down Expand Up @@ -47,13 +55,16 @@
"@cfworker/json-schema": "^4.1.1",
"@executor-js/execution": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/server": "2.0.0",
"effect": "catalog:",
"zod": "4.3.6"
},
"devDependencies": {
"@effect/vitest": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@types/node": "catalog:",
"bun-types": "catalog:",
"vitest": "catalog:"
Expand Down
109 changes: 109 additions & 0 deletions packages/hosts/mcp/src/mcp-apps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from "@effect/vitest";
import { Client } from "@modelcontextprotocol/client";
import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server";

import {
EXTENSION_ID,
getUiCapability,
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
RESOURCE_URI_META_KEY,
} from "./mcp-apps";

const APP_URI = "ui://executor/test.html";

const withClient = async (
configure: (server: McpServer) => void,
run: (client: Client) => Promise<void>,
) => {
const server = new McpServer(
{ name: "apps-helper-test", version: "1.0.0" },
{ capabilities: { resources: {}, tools: {} } },
);
configure(server);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "apps-helper-client", version: "1.0.0" });
await server.connect(serverTransport);
await client.connect(clientTransport);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns both linked transports and always closes them
try {
await run(client);
} finally {
await clientTransport.close();
await serverTransport.close();
}
};

describe("vendored MCP Apps v2 helpers", () => {
it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => {
await withClient(
(server) => {
registerAppTool(
server,
"nested-meta",
{
_meta: {
ui: { resourceUri: APP_URI, visibility: ["model"] },
},
},
async () => ({ content: [{ type: "text", text: "ok" }] }),
);
},
async (client) => {
const tool = (await client.listTools()).tools.find(({ name }) => name === "nested-meta");
expect(tool?._meta).toEqual({
ui: { resourceUri: APP_URI, visibility: ["model"] },
[RESOURCE_URI_META_KEY]: APP_URI,
});
},
);
});

it("mirrors the legacy resourceUri key to nested UI metadata", async () => {
await withClient(
(server) => {
registerAppTool(
server,
"legacy-meta",
{ _meta: { [RESOURCE_URI_META_KEY]: APP_URI } },
async () => ({ content: [{ type: "text", text: "ok" }] }),
);
},
async (client) => {
const tool = (await client.listTools()).tools.find(({ name }) => name === "legacy-meta");
expect(tool?._meta).toEqual({
[RESOURCE_URI_META_KEY]: APP_URI,
ui: { resourceUri: APP_URI },
});
},
);
});

it("defaults app resources to the MCP Apps MIME type", async () => {
await withClient(
(server) => {
registerAppResource(server, "Test App", APP_URI, {}, async () => ({
contents: [{ uri: APP_URI, text: "<html></html>" }],
}));
},
async (client) => {
const resource = (await client.listResources()).resources.find(
({ uri }) => uri === APP_URI,
);
expect(resource?.mimeType).toBe(RESOURCE_MIME_TYPE);
},
);
});

it("extracts the MCP Apps extension capability", () => {
const capability = { mimeTypes: [RESOURCE_MIME_TYPE] };
expect(
getUiCapability({
extensions: { [EXTENSION_ID]: capability },
}),
).toBe(capability);
expect(getUiCapability({})).toBeUndefined();
expect(getUiCapability(undefined)).toBeUndefined();
});
});
118 changes: 118 additions & 0 deletions packages/hosts/mcp/src/mcp-apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Temporary MCP Apps server helpers for the v2 MCP SDK.
*
* This is a wire-compatible local copy of the helpers currently published by
* `@modelcontextprotocol/ext-apps/server`. The upstream package still targets
* the v1 SDK; remove this module once
* https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved.
*/
import type {
ClientCapabilities,
McpServer,
ReadResourceCallback,
RegisteredResource,
RegisteredTool,
ResourceMetadata,
StandardSchemaWithJSON,
ToolAnnotations,
ToolCallback,
} from "@modelcontextprotocol/server";

/** The legacy flat metadata key understood by older MCP Apps hosts. */
export const RESOURCE_URI_META_KEY = "ui/resourceUri";

/** MIME type used by MCP Apps HTML resources. */
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";

/** MCP capability-extension identifier for MCP Apps support. */
export const EXTENSION_ID = "io.modelcontextprotocol/ui";

/** Model/app visibility scopes supported by MCP Apps tool metadata. */
export type McpAppToolVisibility = "model" | "app";

/** MCP Apps metadata attached to a tool. */
export type McpAppToolMeta = {
readonly resourceUri?: string;
readonly visibility?: readonly McpAppToolVisibility[];
};

/** MCP Apps capability data advertised by a client. */
export type McpUiClientCapabilities = {
readonly mimeTypes?: readonly string[];
};

/** Client capabilities shape carrying the MCP Apps extension. */
export type McpAppsClientCapabilities = ClientCapabilities & {
readonly extensions?: Record<string, McpUiClientCapabilities>;
};

/** Tool configuration accepted by {@link registerAppTool}. */
export type McpAppToolConfig<
InputArgs extends StandardSchemaWithJSON | undefined = undefined,
OutputArgs extends StandardSchemaWithJSON | undefined = undefined,
> = {
readonly title?: string;
readonly description?: string;
readonly inputSchema?: InputArgs;
readonly outputSchema?: OutputArgs;
readonly annotations?: ToolAnnotations;
readonly _meta: Record<string, unknown> & {
readonly ui?: McpAppToolMeta;
readonly [RESOURCE_URI_META_KEY]?: string;
};
};

/** Resource configuration accepted by {@link registerAppResource}. */
export type McpAppResourceConfig = ResourceMetadata & {
readonly _meta?: Record<string, unknown> & {
readonly ui?: Record<string, unknown>;
};
};

/**
* Register an MCP Apps tool while mirroring nested and legacy resource URI
* metadata in both directions.
*/
export const registerAppTool = <
InputArgs extends StandardSchemaWithJSON | undefined = undefined,
OutputArgs extends StandardSchemaWithJSON | undefined = undefined,
>(
server: Pick<McpServer, "registerTool">,
name: string,
config: McpAppToolConfig<InputArgs, OutputArgs>,
callback: ToolCallback<InputArgs>,
): RegisteredTool => {
const ui = config._meta.ui;
const legacyResourceUri = config._meta[RESOURCE_URI_META_KEY];
let metadata = config._meta;

if (ui?.resourceUri && !legacyResourceUri) {
metadata = { ...config._meta, [RESOURCE_URI_META_KEY]: ui.resourceUri };
} else if (legacyResourceUri && !ui?.resourceUri) {
metadata = { ...config._meta, ui: { ...ui, resourceUri: legacyResourceUri } };
}

return server.registerTool(
name,
{
...config,
_meta: metadata,
},
callback,
);
};

/** Register an MCP Apps resource, defaulting its MIME type when omitted. */
export const registerAppResource = (
server: Pick<McpServer, "registerResource">,
name: string,
uri: string,
config: McpAppResourceConfig,
readCallback: ReadResourceCallback,
): RegisteredResource =>
server.registerResource(name, uri, { mimeType: RESOURCE_MIME_TYPE, ...config }, readCallback);

/** Read MCP Apps extension data from a client's capabilities. */
export const getUiCapability = (
clientCapabilities: McpAppsClientCapabilities | null | undefined,
): McpUiClientCapabilities | undefined => clientCapabilities?.extensions?.[EXTENSION_ID];
Loading
Loading