Skip to content

Commit a16858a

Browse files
committed
Merge origin/main - use new _meta.ui format
2 parents a885b6f + 37aab03 commit a16858a

File tree

17 files changed

+531
-44
lines changed

17 files changed

+531
-44
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: 71 additions & 11 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

@@ -1123,7 +1165,7 @@ Future versions may add additional settings:
11231165
Servers SHOULD check client (host would-be) capabilities before registering UI-enabled tools:
11241166

11251167
```typescript
1126-
import { registerAppTool, RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps/server";
1168+
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
11271169

11281170
const hasUISupport =
11291171
clientCapabilities?.extensions?.["io.modelcontextprotocol/ui"]?.mimeTypes?.includes("text/html;profile=mcp-app");
@@ -1134,7 +1176,7 @@ if (hasUISupport) {
11341176
description: "Get weather with interactive dashboard",
11351177
inputSchema: { /* ... */ },
11361178
_meta: {
1137-
[RESOURCE_URI_META_KEY]: "ui://weather-server/dashboard"
1179+
ui: { resourceUri: "ui://weather-server/dashboard" }
11381180
}
11391181
}, async (args) => { /* ... */ });
11401182
} else {
@@ -1242,6 +1284,24 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im
12421284
- **Inline styles in tool results:** Rejected; separating theming from data enables caching and updates
12431285
- **CSS-in-JS injection:** Rejected; framework-specific and security concerns with injected code
12441286

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

12471307
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)