Skip to content

Commit 37aab03

Browse files
antonpk1claudeochafikjonathanhefner
authored
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 <[email protected]> * 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 <[email protected]> * chore: regenerate schemas for McpUiToolMeta types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * Update src/server/index.ts Co-authored-by: Jonathan Hefner <[email protected]> * 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 <[email protected]> Co-authored-by: Olivier Chafik <[email protected]> Co-authored-by: Jonathan Hefner <[email protected]>
1 parent bea0ae2 commit 37aab03

File tree

17 files changed

+530
-43
lines changed

17 files changed

+530
-43
lines changed

docs/quickstart.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ Create `server.ts`:
9797
```typescript
9898
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9999
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
100-
import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps";
100+
import {
101+
RESOURCE_MIME_TYPE,
102+
type McpUiToolMeta,
103+
} from "@modelcontextprotocol/ext-apps";
101104
import cors from "cors";
102105
import express from "express";
103106
import fs from "node:fs/promises";
@@ -119,7 +122,7 @@ server.registerTool(
119122
description: "Returns the current server time.",
120123
inputSchema: {},
121124
outputSchema: { time: z.string() },
122-
_meta: { [RESOURCE_URI_META_KEY]: resourceUri }, // Links tool to UI
125+
_meta: { ui: { resourceUri } as McpUiToolMeta }, // Links tool to UI
123126
},
124127
async () => {
125128
const time = new Date().toISOString();

examples/basic-host/src/implementation.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
1+
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
22
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
44
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
@@ -77,7 +77,7 @@ export function callTool(
7777

7878
const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise };
7979

80-
const uiResourceUri = getUiResourceUri(tool);
80+
const uiResourceUri = getToolUiResourceUri(tool);
8181
if (uiResourceUri) {
8282
toolCallInfo.appResourcePromise = getUiResource(serverInfo, uiResourceUri);
8383
}
@@ -86,16 +86,6 @@ export function callTool(
8686
}
8787

8888

89-
function getUiResourceUri(tool: Tool): string | undefined {
90-
const uri = tool._meta?.[RESOURCE_URI_META_KEY];
91-
if (typeof uri === "string" && uri.startsWith("ui://")) {
92-
return uri;
93-
} else if (uri !== undefined) {
94-
throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`);
95-
}
96-
}
97-
98-
9989
async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiResourceData> {
10090
log.info("Reading UI resource:", uri);
10191
const resource = await serverInfo.client.readResource({ uri });

examples/budget-allocator-server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Exposes a single `get-budget-data` tool that returns:
4747
- Historical data (~120 data points) - 24 months of allocation history per category
4848
- Industry benchmarks (~60 data points) - Aggregated percentile data by company stage
4949

50-
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
50+
The tool is linked to a UI resource via `_meta.ui.resourceUri`.
5151

5252
### App (`src/mcp-app.ts`)
5353

examples/customer-segmentation-server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Exposes a single `get-customer-data` tool that returns:
4848
- Segment summary with counts and colors for each group
4949
- Optional segment filter parameter
5050

51-
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
51+
The tool is linked to a UI resource via `_meta.ui.resourceUri`.
5252

5353
### App (`src/mcp-app.ts`)
5454

examples/scenario-modeler-server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Exposes a single `get-scenario-data` tool that returns:
4747
- Default input values for the sliders
4848
- Optionally computes custom projections when `customInputs` are provided
4949

50-
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
50+
The tool is linked to a UI resource via `_meta.ui.resourceUri`.
5151

5252
### App (`src/`)
5353

examples/system-monitor-server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Exposes a single `get-system-stats` tool that returns:
4646
- Memory usage (used/total/percentage)
4747
- System info (hostname, platform, uptime)
4848

49-
The tool is linked to a UI resource via `_meta[RESOURCE_URI_META_KEY]`.
49+
The tool is linked to a UI resource via `_meta.ui.resourceUri`.
5050

5151
### App (`src/mcp-app.ts`)
5252

examples/system-monitor-server/src/mcp-app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ const app = new App({ name: "System Monitor", version: "1.0.0" });
260260
async function fetchStats(): Promise<void> {
261261
try {
262262
const result = await app.callServerTool({
263-
name: "get-system-stats",
263+
name: "refresh-stats", // Use app-only tool for polling
264264
arguments: {},
265265
});
266266

specification/draft/apps.mdx

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -245,21 +245,35 @@ Example:
245245

246246
### Resource Discovery
247247

248-
Tools are associated with UI resources through the `_meta` field:
248+
Tools are associated with UI resources through the `_meta.ui` field:
249249

250250
```typescript
251+
interface McpUiToolMeta {
252+
/** URI of UI resource for rendering tool results */
253+
resourceUri?: string;
254+
/**
255+
* Who can access this tool. Default: ["model", "app"]
256+
* - "model": Tool visible to and callable by the agent
257+
* - "app": Tool callable by the app from this server only
258+
*/
259+
visibility?: Array<"model" | "app">;
260+
}
261+
251262
interface Tool {
252263
name: string;
253264
description: string;
254265
inputSchema: object;
255266
_meta?: {
256-
// Required: URI of the UI resource to use for rendering
267+
ui?: McpUiToolMeta;
268+
/** @deprecated Use `ui.resourceUri` instead. Will be removed before GA. */
257269
"ui/resourceUri"?: string;
258270
};
259271
}
260272
```
261273

262-
Example:
274+
> **Deprecation notice:** The flat `_meta["ui/resourceUri"]` format is deprecated. Use `_meta.ui.resourceUri` instead. The deprecated format will be removed before GA.
275+
276+
Example (tool visible to both model and app):
263277

264278
```json
265279
{
@@ -272,20 +286,48 @@ Example:
272286
}
273287
},
274288
"_meta": {
275-
"ui/resourceUri": "ui://weather-server/dashboard-template"
289+
"ui": {
290+
"resourceUri": "ui://weather-server/dashboard-template",
291+
"visibility": ["model", "app"]
292+
}
293+
}
294+
}
295+
```
296+
297+
Example (app-only tool, hidden from model):
298+
299+
```json
300+
{
301+
"name": "refresh_dashboard",
302+
"description": "Refresh dashboard data",
303+
"inputSchema": { "type": "object" },
304+
"_meta": {
305+
"ui": {
306+
"resourceUri": "ui://weather-server/dashboard-template",
307+
"visibility": ["app"]
308+
}
276309
}
277310
}
278311
```
279312

280313
#### Behavior:
281314

282-
- If `ui/resourceUri` is present and host supports MCP Apps, host renders tool results using the specified UI resource
315+
- If `ui.resourceUri` is present and host supports MCP Apps, host renders tool results using the specified UI resource
283316
- If host does not support MCP Apps, tool behaves as standard tool (text-only fallback)
284317
- Resource MUST exist on the server
285-
- Host MUST use `resources/read` to fetch the referenced resource URI.
318+
- Host MUST use `resources/read` to fetch the referenced resource URI
286319
- Host MAY prefetch and cache UI resource content for performance optimization
287320
- Since UI resources are primarily discovered through tool metadata, Servers MAY omit UI-only resources from `resources/list` and `notifications/resources/list_changed`
288321

322+
#### Visibility:
323+
324+
- `visibility` defaults to `["model", "app"]` if omitted
325+
- `"model"`: Tool is visible to and callable by the agent
326+
- `"app"`: Tool is callable by the app from the same server connection only
327+
- **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"]`)
328+
- **tools/call behavior:** Host MUST reject `tools/call` requests from apps for tools that don't include `"app"` in visibility
329+
- Cross-server tool calls are always blocked for app-only tools
330+
289331
#### Benefits:
290332

291333
- **Performance:** Host can preload templates before tool execution
@@ -879,7 +921,7 @@ sequenceDiagram
879921
880922
autonumber
881923
S -->> H: resources/list (includes ui:// resources)
882-
S -->> H: tools/list (includes tools with ui/resourceUri metadata)
924+
S -->> H: tools/list (includes tools with _meta.ui metadata)
883925
```
884926

885927
#### 2. UI Initialization (Desktop/Native Hosts)
@@ -893,7 +935,7 @@ sequenceDiagram
893935
894936
autonumber
895937
par UI Tool call
896-
H ->> S: tools/call to Tool with ui/resourceUri metadata
938+
H ->> S: tools/call to Tool with _meta.ui metadata
897939
and UI initialization
898940
alt Desktop/Native hosts
899941
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" });
10791121

10801122
This pattern enables interactive, self-updating widgets.
10811123

1082-
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.
1124+
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.
10831125

10841126
### Client\<\>Server Capability Negotiation
10851127

@@ -1132,7 +1174,7 @@ if (hasUISupport) {
11321174
description: "Get weather with interactive dashboard",
11331175
inputSchema: { /* ... */ },
11341176
_meta: {
1135-
"ui/resourceUri": "ui://weather-server/dashboard"
1177+
ui: { resourceUri: "ui://weather-server/dashboard" }
11361178
}
11371179
});
11381180
} else {
@@ -1241,6 +1283,24 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im
12411283
- **Inline styles in tool results:** Rejected; separating theming from data enables caching and updates
12421284
- **CSS-in-JS injection:** Rejected; framework-specific and security concerns with injected code
12431285

1286+
#### 5. Tool Visibility via Metadata
1287+
1288+
**Decision:** Use `_meta.ui.visibility` array to control tool accessibility between model and app.
1289+
1290+
**Rationale:**
1291+
1292+
- Nested `_meta.ui` structure groups all UI-related metadata cleanly
1293+
- Array format (`["model", "app"]`) allows flexible combinations
1294+
- Default `["model", "app"]` allows both agent and app to access tools
1295+
- `"app"` scope is per-server, preventing cross-server tool calls
1296+
- Cleaner than OpenAI's two-field approach (`widgetAccessible` + `visibility`)
1297+
1298+
**Alternatives considered:**
1299+
1300+
- **Two separate fields:** OpenAI uses `widgetAccessible` and `visibility` separately. Rejected as redundant; single `visibility` array covers all cases.
1301+
- **Boolean `private` flag:** Simpler but less flexible; doesn't express model-only tools.
1302+
- **Flat `ui/visibility` key:** Rejected in favor of nested structure for consistency with future `_meta.ui` fields.
1303+
12441304
### Backward Compatibility
12451305

12461306
The proposal builds on the existing core protocol. There are no incompatibilities.

src/app-bridge.test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import {
1414
} from "@modelcontextprotocol/sdk/types.js";
1515

1616
import { App } from "./app";
17-
import { AppBridge, type McpUiHostCapabilities } from "./app-bridge";
17+
import {
18+
AppBridge,
19+
getToolUiResourceUri,
20+
type McpUiHostCapabilities,
21+
} from "./app-bridge";
1822

1923
/** Wait for pending microtasks to complete */
2024
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -707,3 +711,107 @@ describe("App <-> AppBridge integration", () => {
707711
});
708712
});
709713
});
714+
715+
describe("getToolUiResourceUri", () => {
716+
describe("new nested format (_meta.ui.resourceUri)", () => {
717+
it("extracts resourceUri from _meta.ui.resourceUri", () => {
718+
const tool = {
719+
name: "test-tool",
720+
_meta: {
721+
ui: { resourceUri: "ui://server/app.html" },
722+
},
723+
};
724+
expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html");
725+
});
726+
727+
it("extracts resourceUri when visibility is also present", () => {
728+
const tool = {
729+
name: "test-tool",
730+
_meta: {
731+
ui: {
732+
resourceUri: "ui://server/app.html",
733+
visibility: ["model"],
734+
},
735+
},
736+
};
737+
expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html");
738+
});
739+
});
740+
741+
describe("deprecated flat format (_meta['ui/resourceUri'])", () => {
742+
it("extracts resourceUri from deprecated format", () => {
743+
const tool = {
744+
name: "test-tool",
745+
_meta: { "ui/resourceUri": "ui://server/app.html" },
746+
};
747+
expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html");
748+
});
749+
});
750+
751+
describe("format precedence", () => {
752+
it("prefers new nested format over deprecated format", () => {
753+
const tool = {
754+
name: "test-tool",
755+
_meta: {
756+
ui: { resourceUri: "ui://server/new.html" },
757+
"ui/resourceUri": "ui://server/old.html",
758+
},
759+
};
760+
expect(getToolUiResourceUri(tool)).toBe("ui://server/new.html");
761+
});
762+
});
763+
764+
describe("missing resourceUri", () => {
765+
it("returns undefined when no resourceUri in empty _meta", () => {
766+
const tool = { name: "test-tool", _meta: {} };
767+
expect(getToolUiResourceUri(tool)).toBeUndefined();
768+
});
769+
770+
it("returns undefined when _meta is missing", () => {
771+
const tool = {} as { _meta?: Record<string, unknown> };
772+
expect(getToolUiResourceUri(tool)).toBeUndefined();
773+
});
774+
775+
it("returns undefined for app-only tools with visibility but no resourceUri", () => {
776+
const tool = {
777+
name: "refresh-stats",
778+
_meta: {
779+
ui: { visibility: ["app"] },
780+
},
781+
};
782+
expect(getToolUiResourceUri(tool)).toBeUndefined();
783+
});
784+
});
785+
786+
describe("validation", () => {
787+
it("throws for invalid URI (not starting with ui://)", () => {
788+
const tool = {
789+
name: "test-tool",
790+
_meta: { ui: { resourceUri: "https://example.com" } },
791+
};
792+
expect(() => getToolUiResourceUri(tool)).toThrow(
793+
"Invalid UI resource URI",
794+
);
795+
});
796+
797+
it("throws for non-string resourceUri", () => {
798+
const tool = {
799+
name: "test-tool",
800+
_meta: { ui: { resourceUri: 123 } },
801+
};
802+
expect(() => getToolUiResourceUri(tool)).toThrow(
803+
"Invalid UI resource URI",
804+
);
805+
});
806+
807+
it("throws for null resourceUri", () => {
808+
const tool = {
809+
name: "test-tool",
810+
_meta: { ui: { resourceUri: null } },
811+
};
812+
expect(() => getToolUiResourceUri(tool)).toThrow(
813+
"Invalid UI resource URI",
814+
);
815+
});
816+
});
817+
});

0 commit comments

Comments
 (0)