docs: add tool visibility and restructure _meta.ui (#131)

* docs: add tool visibility and restructure _meta.ui

- Restructure tool metadata: `_meta["ui/resourceUri"]` → `_meta.ui.resourceUri`
- Add `visibility` array field: ["model"], ["apps"], or ["model", "apps"]
- Default ["model"] preserves standard MCP behavior
- ["apps"] enables widget-only tools hidden from agent
- Add McpUiToolMeta interface for type safety
- Add Design Decision #4 explaining approach vs OpenAI's two-field model

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* address PR review comments

- Change default visibility to ["model", "apps"]
- Add deprecation notice for flat ui/resourceUri format
- Add McpUiToolMeta and McpUiToolVisibility to spec.types.ts
- Improve tools/list and tools/call behavior wording

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: regenerate schemas for McpUiToolMeta types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: tool visibility and nested _meta.ui format

- Rename visibility "apps" → "app" in McpUiToolVisibility type
- Add _meta.ui.resourceUri nested format (deprecate flat format)
- Add getToolUiResourceUri() utility with backward compatibility
- Add visibility demo to system-monitor-server:
  - get-system-stats: visibility ["model"] with resourceUri
  - refresh-stats: visibility ["app"] (app-only polling)
- Update all example servers to use new _meta.ui format
- Add 11 unit tests for getToolUiResourceUri()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: regenerate schemas with updated dependencies

* feat: export McpUiToolMeta type and add type annotations

- Export McpUiToolMeta and McpUiToolVisibility types from types.ts
- Export corresponding Zod schemas (McpUiToolMetaSchema, McpUiToolVisibilitySchema)
- Add `as McpUiToolMeta` type annotations to all example servers
- Update docs/quickstart.md with proper typing

Ensures type safety for `_meta.ui` tool metadata across the codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add server helpers and optional connect() transport

Add `src/server/` with convenience functions for registering MCP App tools
and resources:

- `registerAppTool(server, name, config, handler)`
- `registerAppResource(server, name, uri, config, callback)`

The `transport` parameter in `App.connect()` is now optional, defaulting to
`PostMessageTransport(window.parent)`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update src/server/index.ts

Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>

* feat: update McpUiAppToolConfig to support both _meta.ui and flat formats

* refactor: simplify server helper imports

* docs: add deprecation notice and improve resourceUri documentation

* feat: add backward compat normalization in registerAppTool

- If _meta.ui.resourceUri is set, also set legacy flat key
- If legacy flat key is set, also set _meta.ui.resourceUri
- Preserves existing visibility when merging
- Does not overwrite if both formats already set

* refactor: avoid mutating config arg in registerAppTool

* style: format with prettier

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Olivier Chafik <ochafik@anthropic.com>
Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>
This commit is contained in:
Anton Pidkuiko 2025-12-17 04:47:40 +07:00 • committed by GitHub
commit 37aab03de0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 530 additions and 43 deletions

View file

@ -97,7 +97,10 @@ Create `server.ts`:
```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps";
import {
RESOURCE_MIME_TYPE,
type McpUiToolMeta,
} from "@modelcontextprotocol/ext-apps";
import cors from "cors";
import express from "express";
import fs from "node:fs/promises";
@ -119,7 +122,7 @@ server.registerTool(
description: "Returns the current server time.",
inputSchema: {},
outputSchema: { time: z.string() },
_meta: { [RESOURCE_URI_META_KEY]: resourceUri }, // Links tool to UI
_meta: { ui: { resourceUri } as McpUiToolMeta }, // Links tool to UI
},
async () => {
const time = new Date().toISOString();

View file

@ -1,4 +1,4 @@
import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
@ -77,7 +77,7 @@ export function callTool(
const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise };
const uiResourceUri = getUiResourceUri(tool);
const uiResourceUri = getToolUiResourceUri(tool);
if (uiResourceUri) {
toolCallInfo.appResourcePromise = getUiResource(serverInfo, uiResourceUri);
}
@ -86,16 +86,6 @@ export function callTool(
}
function getUiResourceUri(tool: Tool): string | undefined {
const uri = tool._meta?.[RESOURCE_URI_META_KEY];
if (typeof uri === "string" && uri.startsWith("ui://")) {
return uri;
} else if (uri !== undefined) {
throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`);
}
}
async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiResourceData> {
log.info("Reading UI resource:", uri);
const resource = await serverInfo.client.readResource({ uri });

View file

@ -47,7 +47,7 @@ Exposes a single `get-budget-data` tool that returns:
- Historical data (~120 data points) - 24 months of allocation history per category
- Industry benchmarks (~60 data points) - Aggregated percentile data by company stage
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
The tool is linked to a UI resource via `_meta.ui.resourceUri`.
### App (`src/mcp-app.ts`)

View file

@ -48,7 +48,7 @@ Exposes a single `get-customer-data` tool that returns:
- Segment summary with counts and colors for each group
- Optional segment filter parameter
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
The tool is linked to a UI resource via `_meta.ui.resourceUri`.
### App (`src/mcp-app.ts`)

View file

@ -47,7 +47,7 @@ Exposes a single `get-scenario-data` tool that returns:
- Default input values for the sliders
- Optionally computes custom projections when `customInputs` are provided
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
The tool is linked to a UI resource via `_meta.ui.resourceUri`.
### App (`src/`)

View file

@ -46,7 +46,7 @@ Exposes a single `get-system-stats` tool that returns:
- Memory usage (used/total/percentage)
- System info (hostname, platform, uptime)
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
The tool is linked to a UI resource via `_meta.ui.resourceUri`.
### App (`src/mcp-app.ts`)

View file

@ -260,7 +260,7 @@ const app = new App({ name: "System Monitor", version: "1.0.0" });
async function fetchStats(): Promise<void> {
try {
const result = await app.callServerTool({
name: "get-system-stats",
name: "refresh-stats", // Use app-only tool for polling
arguments: {},
});

View file

@ -245,21 +245,35 @@ Example:
### Resource Discovery
Tools are associated with UI resources through the `_meta` field:
Tools are associated with UI resources through the `_meta.ui` field:
```typescript
interface McpUiToolMeta {
/** URI of UI resource for rendering tool results */
resourceUri?: string;
/**
* Who can access this tool. Default: ["model", "app"]
* - "model": Tool visible to and callable by the agent
* - "app": Tool callable by the app from this server only
*/
visibility?: Array<"model" | "app">;
}
interface Tool {
name: string;
description: string;
inputSchema: object;
_meta?: {
// Required: URI of the UI resource to use for rendering
ui?: McpUiToolMeta;
/** @deprecated Use `ui.resourceUri` instead. Will be removed before GA. */
"ui/resourceUri"?: string;
};
}
```
Example:
> **Deprecation notice:** The flat `_meta["ui/resourceUri"]` format is deprecated. Use `_meta.ui.resourceUri` instead. The deprecated format will be removed before GA.
Example (tool visible to both model and app):
```json
{
@ -272,20 +286,48 @@ Example:
}
},
"_meta": {
"ui/resourceUri": "ui://weather-server/dashboard-template"
"ui": {
"resourceUri": "ui://weather-server/dashboard-template",
"visibility": ["model", "app"]
}
}
}
```
Example (app-only tool, hidden from model):
```json
{
"name": "refresh_dashboard",
"description": "Refresh dashboard data",
"inputSchema": { "type": "object" },
"_meta": {
"ui": {
"resourceUri": "ui://weather-server/dashboard-template",
"visibility": ["app"]
}
}
}
```
#### Behavior:
- If `ui/resourceUri` is present and host supports MCP Apps, host renders tool results using the specified UI resource
- If `ui.resourceUri` is present and host supports MCP Apps, host renders tool results using the specified UI resource
- If host does not support MCP Apps, tool behaves as standard tool (text-only fallback)
- Resource MUST exist on the server
- Host MUST use `resources/read` to fetch the referenced resource URI.
- Host MUST use `resources/read` to fetch the referenced resource URI
- Host MAY prefetch and cache UI resource content for performance optimization
- Since UI resources are primarily discovered through tool metadata, Servers MAY omit UI-only resources from `resources/list` and `notifications/resources/list_changed`
#### Visibility:
- `visibility` defaults to `["model", "app"]` if omitted
- `"model"`: Tool is visible to and callable by the agent
- `"app"`: Tool is callable by the app from the same server connection only
- **tools/list behavior:** Host MUST NOT include tools in the agent's tool list when their visibility does not include `"model"` (e.g., `visibility: ["app"]`)
- **tools/call behavior:** Host MUST reject `tools/call` requests from apps for tools that don't include `"app"` in visibility
- Cross-server tool calls are always blocked for app-only tools
#### Benefits:
- **Performance:** Host can preload templates before tool execution
@ -879,7 +921,7 @@ sequenceDiagram
autonumber
S -->> H: resources/list (includes ui:// resources)
S -->> H: tools/list (includes tools with ui/resourceUri metadata)
S -->> H: tools/list (includes tools with _meta.ui metadata)
```
#### 2. UI Initialization (Desktop/Native Hosts)
@ -893,7 +935,7 @@ sequenceDiagram
autonumber
par UI Tool call
H ->> S: tools/call to Tool with ui/resourceUri metadata
H ->> S: tools/call to Tool with _meta.ui metadata
and UI initialization
alt Desktop/Native hosts
H ->> H: Render Guest UI in an iframe (HTML from the ui:// resource)
@ -1079,7 +1121,7 @@ await client.callTool("get_weather", { location: "New York" });
This pattern enables interactive, self-updating widgets.
Note: The called tool may not appear in `tools/list` responses. MCP servers MAY expose private tools specifically designed for UI interaction that are not visible to the agent. UI implementations SHOULD attempt to call tools by name regardless of discoverability. The specification for Private Tools will be covered in a future SEP.
Note: Tools with `visibility: ["app"]` are hidden from the agent but remain callable by apps via `tools/call`. This enables UI-only interactions (refresh buttons, form submissions) without exposing implementation details to the model. See the Visibility section under Resource Discovery for details.
### Client\<\>Server Capability Negotiation
@ -1132,7 +1174,7 @@ if (hasUISupport) {
description: "Get weather with interactive dashboard",
inputSchema: { /* ... */ },
_meta: {
"ui/resourceUri": "ui://weather-server/dashboard"
ui: { resourceUri: "ui://weather-server/dashboard" }
}
});
} else {
@ -1241,6 +1283,24 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im
- **Inline styles in tool results:** Rejected; separating theming from data enables caching and updates
- **CSS-in-JS injection:** Rejected; framework-specific and security concerns with injected code
#### 5. Tool Visibility via Metadata
**Decision:** Use `_meta.ui.visibility` array to control tool accessibility between model and app.
**Rationale:**
- Nested `_meta.ui` structure groups all UI-related metadata cleanly
- Array format (`["model", "app"]`) allows flexible combinations
- Default `["model", "app"]` allows both agent and app to access tools
- `"app"` scope is per-server, preventing cross-server tool calls
- Cleaner than OpenAI's two-field approach (`widgetAccessible` + `visibility`)
**Alternatives considered:**
- **Two separate fields:** OpenAI uses `widgetAccessible` and `visibility` separately. Rejected as redundant; single `visibility` array covers all cases.
- **Boolean `private` flag:** Simpler but less flexible; doesn't express model-only tools.
- **Flat `ui/visibility` key:** Rejected in favor of nested structure for consistency with future `_meta.ui` fields.
### Backward Compatibility
The proposal builds on the existing core protocol. There are no incompatibilities.

View file

@ -14,7 +14,11 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { App } from "./app";
import { AppBridge, type McpUiHostCapabilities } from "./app-bridge";
import {
AppBridge,
getToolUiResourceUri,
type McpUiHostCapabilities,
} from "./app-bridge";
/** Wait for pending microtasks to complete */
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
@ -707,3 +711,107 @@ describe("App <-> AppBridge integration", () => {
});
});
});
describe("getToolUiResourceUri", () => {
describe("new nested format (_meta.ui.resourceUri)", () => {
it("extracts resourceUri from _meta.ui.resourceUri", () => {
const tool = {
name: "test-tool",
_meta: {
ui: { resourceUri: "ui://server/app.html" },
},
};
expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html");
});
it("extracts resourceUri when visibility is also present", () => {
const tool = {
name: "test-tool",
_meta: {
ui: {
resourceUri: "ui://server/app.html",
visibility: ["model"],
},
},
};
expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html");
});
});
describe("deprecated flat format (_meta['ui/resourceUri'])", () => {
it("extracts resourceUri from deprecated format", () => {
const tool = {
name: "test-tool",
_meta: { "ui/resourceUri": "ui://server/app.html" },
};
expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html");
});
});
describe("format precedence", () => {
it("prefers new nested format over deprecated format", () => {
const tool = {
name: "test-tool",
_meta: {
ui: { resourceUri: "ui://server/new.html" },
"ui/resourceUri": "ui://server/old.html",
},
};
expect(getToolUiResourceUri(tool)).toBe("ui://server/new.html");
});
});
describe("missing resourceUri", () => {
it("returns undefined when no resourceUri in empty _meta", () => {
const tool = { name: "test-tool", _meta: {} };
expect(getToolUiResourceUri(tool)).toBeUndefined();
});
it("returns undefined when _meta is missing", () => {
const tool = {} as { _meta?: Record<string, unknown> };
expect(getToolUiResourceUri(tool)).toBeUndefined();
});
it("returns undefined for app-only tools with visibility but no resourceUri", () => {
const tool = {
name: "refresh-stats",
_meta: {
ui: { visibility: ["app"] },
},
};
expect(getToolUiResourceUri(tool)).toBeUndefined();
});
});
describe("validation", () => {
it("throws for invalid URI (not starting with ui://)", () => {
const tool = {
name: "test-tool",
_meta: { ui: { resourceUri: "https://example.com" } },
};
expect(() => getToolUiResourceUri(tool)).toThrow(
"Invalid UI resource URI",
);
});
it("throws for non-string resourceUri", () => {
const tool = {
name: "test-tool",
_meta: { ui: { resourceUri: 123 } },
};
expect(() => getToolUiResourceUri(tool)).toThrow(
"Invalid UI resource URI",
);
});
it("throws for null resourceUri", () => {
const tool = {
name: "test-tool",
_meta: { ui: { resourceUri: null } },
};
expect(() => getToolUiResourceUri(tool)).toThrow(
"Invalid UI resource URI",
);
});
});
});

View file

@ -77,8 +77,53 @@ import {
} from "./types";
export * from "./types";
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app";
import { RESOURCE_URI_META_KEY } from "./app";
export { PostMessageTransport } from "./message-transport";
/**
* Extract UI resource URI from tool metadata.
*
* Supports both the new nested format (`_meta.ui.resourceUri`) and the
* deprecated flat format (`_meta["ui/resourceUri"]`). The new nested format
* takes precedence if both are present.
*
* @param tool - A tool object with optional `_meta` property
* @returns The UI resource URI if valid, undefined if not present
* @throws Error if resourceUri is present but invalid (not starting with "ui://")
*
* @example
* ```typescript
* // New nested format (preferred)
* const uri = getToolUiResourceUri({
* _meta: { ui: { resourceUri: "ui://server/app.html" } }
* });
*
* // Deprecated flat format (still supported)
* const uri = getToolUiResourceUri({
* _meta: { "ui/resourceUri": "ui://server/app.html" }
* });
* ```
*/
export function getToolUiResourceUri(tool: {
_meta?: Record<string, unknown>;
}): string | undefined {
// Try new nested format first: _meta.ui.resourceUri
const uiMeta = tool._meta?.ui as { resourceUri?: unknown } | undefined;
let uri: unknown = uiMeta?.resourceUri;
// Fall back to deprecated flat format: _meta["ui/resourceUri"]
if (uri === undefined) {
uri = tool._meta?.[RESOURCE_URI_META_KEY];
}
if (typeof uri === "string" && uri.startsWith("ui://")) {
return uri;
} else if (uri !== undefined) {
throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`);
}
return undefined;
}
/**
* Options for configuring AppBridge behavior.
*

View file

@ -3796,6 +3796,34 @@
"required": ["method", "params"],
"additionalProperties": false
},
"McpUiToolMeta": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"resourceUri": {
"type": "string"
},
"visibility": {
"description": "Who can access this tool. Default: [\"model\", \"app\"]\n- \"model\": Tool visible to and callable by the agent\n- \"app\": Tool callable by the app from this server only",
"type": "array",
"items": {
"description": "Tool visibility scope - who can access the tool.",
"anyOf": [
{
"type": "string",
"const": "model"
},
{
"type": "string",
"const": "app"
}
]
}
}
},
"required": ["resourceUri"],
"additionalProperties": false
},
"McpUiToolResultNotification": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
@ -4154,6 +4182,20 @@
},
"required": ["method", "params"],
"additionalProperties": false
},
"McpUiToolVisibility": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Tool visibility scope - who can access the tool.",
"anyOf": [
{
"type": "string",
"const": "model"
},
{
"type": "string",
"const": "app"
}
]
}
}
}

View file

@ -103,6 +103,14 @@ export type McpUiRequestDisplayModeResultSchemaInferredType = z.infer<
typeof generated.McpUiRequestDisplayModeResultSchema
>;
export type McpUiToolVisibilitySchemaInferredType = z.infer<
typeof generated.McpUiToolVisibilitySchema
>;
export type McpUiToolMetaSchemaInferredType = z.infer<
typeof generated.McpUiToolMetaSchema
>;
export type McpUiMessageRequestSchemaInferredType = z.infer<
typeof generated.McpUiMessageRequestSchema
>;
@ -237,6 +245,14 @@ expectType<spec.McpUiRequestDisplayModeResult>(
expectType<McpUiRequestDisplayModeResultSchemaInferredType>(
{} as spec.McpUiRequestDisplayModeResult,
);
expectType<spec.McpUiToolVisibility>(
{} as McpUiToolVisibilitySchemaInferredType,
);
expectType<McpUiToolVisibilitySchemaInferredType>(
{} as spec.McpUiToolVisibility,
);
expectType<spec.McpUiToolMeta>({} as McpUiToolMetaSchemaInferredType);
expectType<McpUiToolMetaSchemaInferredType>({} as spec.McpUiToolMeta);
expectType<spec.McpUiMessageRequest>(
{} as McpUiMessageRequestSchemaInferredType,
);

View file

@ -449,6 +449,37 @@ export const McpUiRequestDisplayModeResultSchema = z.looseObject({
),
});
/**
* @description Tool visibility scope - who can access the tool.
*/
export const McpUiToolVisibilitySchema = z
.union([z.literal("model"), z.literal("app")])
.describe("Tool visibility scope - who can access the tool.");
/**
* @description UI-related metadata for tools.
*/
export const McpUiToolMetaSchema = z.object({
/**
* URI of the UI resource to display for this tool.
* This is converted to `_meta["ui/resourceUri"]`.
*
* @example "ui://weather/widget.html"
*/
resourceUri: z.string(),
/**
* @description Who can access this tool. Default: ["model", "app"]
* - "model": Tool visible to and callable by the agent
* - "app": Tool callable by the app from this server only
*/
visibility: z
.array(McpUiToolVisibilitySchema)
.optional()
.describe(
'Who can access this tool. Default: ["model", "app"]\n- "model": Tool visible to and callable by the agent\n- "app": Tool callable by the app from this server only',
),
});
/**
* @description Request to send a message to the host's chat interface.
* @see {@link app.App.sendMessage} for the method that sends this request

View file

@ -52,6 +52,147 @@ describe("registerAppTool", () => {
).toBe("ui://test/widget.html");
expect(capturedHandler).toBe(handler);
});
describe("backward compatibility", () => {
it("should set legacy key when _meta.ui.resourceUri is provided", () => {
let capturedConfig: Record<string, unknown> | undefined;
const mockServer = {
registerTool: mock(
(
_name: string,
config: Record<string, unknown>,
_handler: unknown,
) => {
capturedConfig = config;
},
),
};
registerAppTool(
mockServer as unknown as Pick<McpServer, "registerTool">,
"my-tool",
{
_meta: {
ui: { resourceUri: "ui://test/widget.html" },
},
},
async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
);
const meta = capturedConfig?._meta as Record<string, unknown>;
// New format should be preserved
expect((meta.ui as { resourceUri: string }).resourceUri).toBe(
"ui://test/widget.html",
);
// Legacy key should also be set
expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://test/widget.html");
});
it("should set _meta.ui.resourceUri when legacy key is provided", () => {
let capturedConfig: Record<string, unknown> | undefined;
const mockServer = {
registerTool: mock(
(
_name: string,
config: Record<string, unknown>,
_handler: unknown,
) => {
capturedConfig = config;
},
),
};
registerAppTool(
mockServer as unknown as Pick<McpServer, "registerTool">,
"my-tool",
{
_meta: {
[RESOURCE_URI_META_KEY]: "ui://test/widget.html",
},
},
async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
);
const meta = capturedConfig?._meta as Record<string, unknown>;
// Legacy key should be preserved
expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://test/widget.html");
// New format should also be set
expect((meta.ui as { resourceUri: string }).resourceUri).toBe(
"ui://test/widget.html",
);
});
it("should preserve visibility when converting from legacy format", () => {
let capturedConfig: Record<string, unknown> | undefined;
const mockServer = {
registerTool: mock(
(
_name: string,
config: Record<string, unknown>,
_handler: unknown,
) => {
capturedConfig = config;
},
),
};
registerAppTool(
mockServer as unknown as Pick<McpServer, "registerTool">,
"my-tool",
{
_meta: {
ui: { visibility: ["app"] },
[RESOURCE_URI_META_KEY]: "ui://test/widget.html",
},
} as any,
async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
);
const meta = capturedConfig?._meta as Record<string, unknown>;
const ui = meta.ui as { resourceUri: string; visibility: string[] };
// Should have merged resourceUri into existing ui object
expect(ui.resourceUri).toBe("ui://test/widget.html");
expect(ui.visibility).toEqual(["app"]);
});
it("should not overwrite if both formats are already set", () => {
let capturedConfig: Record<string, unknown> | undefined;
const mockServer = {
registerTool: mock(
(
_name: string,
config: Record<string, unknown>,
_handler: unknown,
) => {
capturedConfig = config;
},
),
};
registerAppTool(
mockServer as unknown as Pick<McpServer, "registerTool">,
"my-tool",
{
_meta: {
ui: { resourceUri: "ui://new/widget.html" },
[RESOURCE_URI_META_KEY]: "ui://old/widget.html",
},
} as any,
async () => ({ content: [{ type: "text" as const, text: "ok" }] }),
);
const meta = capturedConfig?._meta as Record<string, unknown>;
// Both should remain unchanged
expect((meta.ui as { resourceUri: string }).resourceUri).toBe(
"ui://new/widget.html",
);
expect(meta[RESOURCE_URI_META_KEY]).toBe("ui://old/widget.html");
});
});
});
describe("registerAppResource", () => {

View file

@ -8,6 +8,7 @@ import {
RESOURCE_URI_META_KEY,
RESOURCE_MIME_TYPE,
McpUiResourceMeta,
McpUiToolMeta,
} from "../app.js";
import type {
McpServer,
@ -38,25 +39,31 @@ export interface ToolConfig {
*/
export interface McpUiAppToolConfig extends ToolConfig {
_meta: {
// Soon: `ui: McpUiToolMeta;` (https://github.com/modelcontextprotocol/ext-apps/pull/131)
/**
* URI of the UI resource to display for this tool.
* This is converted to `_meta["ui/resourceUri"]`.
*
* @example "ui://weather/widget.html"
*/
[RESOURCE_URI_META_KEY]: string;
[key: string]: unknown;
};
} & (
| {
ui: McpUiToolMeta;
}
| {
/**
* URI of the UI resource to display for this tool.
* This is converted to `_meta["ui/resourceUri"]`.
*
* @example "ui://weather/widget.html"
*
* @deprecated Use `_meta.ui.resourceUri` instead.
*/
[RESOURCE_URI_META_KEY]?: string;
}
);
}
/**
* MCP App Resource configuration for `registerAppResource`.
*/
export interface McpUiAppResourceConfig extends ResourceMetadata {
_meta?: {
ui?: McpUiResourceMeta;
_meta: {
ui: McpUiResourceMeta;
[key: string]: unknown;
};
}
@ -95,7 +102,23 @@ export function registerAppTool(
config: McpUiAppToolConfig,
handler: ToolCallback<ZodRawShape>,
): void {
server.registerTool(name, config, handler);
// Normalize metadata for backward compatibility:
// - If _meta.ui.resourceUri is set, also set the legacy flat key
// - If the legacy flat key is set, also set _meta.ui.resourceUri
const meta = config._meta;
const uiMeta = meta.ui as McpUiToolMeta | undefined;
const legacyUri = meta[RESOURCE_URI_META_KEY] as string | undefined;
let normalizedMeta = meta;
if (uiMeta?.resourceUri && !legacyUri) {
// New format -> also set legacy key
normalizedMeta = { ...meta, [RESOURCE_URI_META_KEY]: uiMeta.resourceUri };
} else if (legacyUri && !uiMeta?.resourceUri) {
// Legacy format -> also set new format
normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } };
}
server.registerTool(name, { ...config, _meta: normalizedMeta }, handler);
}
/**

View file

@ -513,3 +513,27 @@ export interface McpUiRequestDisplayModeResult {
*/
[key: string]: unknown;
}
/**
* @description Tool visibility scope - who can access the tool.
*/
export type McpUiToolVisibility = "model" | "app";
/**
* @description UI-related metadata for tools.
*/
export interface McpUiToolMeta {
/**
* URI of the UI resource to display for this tool.
* This is converted to `_meta["ui/resourceUri"]`.
*
* @example "ui://weather/widget.html"
*/
resourceUri: string;
/**
* @description Who can access this tool. Default: ["model", "app"]
* - "model": Tool visible to and callable by the agent
* - "app": Tool callable by the app from this server only
*/
visibility?: McpUiToolVisibility[];
}

View file

@ -41,6 +41,8 @@ export {
type McpUiResourceMeta,
type McpUiRequestDisplayModeRequest,
type McpUiRequestDisplayModeResult,
type McpUiToolVisibility,
type McpUiToolMeta,
} from "./spec.types.js";
// Import types needed for protocol type unions (not re-exported, just used internally)
@ -95,6 +97,8 @@ export {
McpUiResourceMetaSchema,
McpUiRequestDisplayModeRequestSchema,
McpUiRequestDisplayModeResultSchema,
McpUiToolVisibilitySchema,
McpUiToolMetaSchema,
} from "./generated/schema.js";
// Re-export SDK types used in protocol type unions