Skip to content

Commit 63bd135

Browse files
cliffhallclaude
andcommitted
feat(apps): add isAppTool helper in core (#1260)
Adds @modelcontextprotocol/ext-apps as a dependency (root + clients/web) and a thin core/mcp/apps.ts wrapper exposing: - getAppResourceUri(tool): re-export of getToolUiResourceUri so all Inspector clients consume the same nested/flat _meta resolution. - isAppTool(tool): boolean predicate built on top of getAppResourceUri. Both surface the underlying ext-apps throw on a malformed _meta.ui.resourceUri (string that does not start with "ui://") rather than swallowing it — server bugs should be visible. Tests cover nested format, deprecated flat format, nested-wins precedence, missing _meta, and the invalid-URI throw. Refs #1260 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 75f74d6 commit 63bd135

6 files changed

Lines changed: 189 additions & 2 deletions

File tree

clients/web/package-lock.json

Lines changed: 30 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clients/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@mantine/form": "^8.3.17",
2525
"@mantine/hooks": "^8.3.17",
2626
"@mantine/notifications": "^8.3.17",
27+
"@modelcontextprotocol/ext-apps": "^1.7.1",
2728
"@modelcontextprotocol/sdk": "^1.29.0",
2829
"ajv": "^8.17.1",
2930
"react": "^19.2.4",
@@ -48,12 +49,12 @@
4849
"@vitejs/plugin-react": "^6.0.0",
4950
"@vitest/browser-playwright": "^4.1.0",
5051
"@vitest/coverage-v8": "^4.1.0",
51-
"happy-dom": "^20.9.0",
5252
"eslint": "^9.39.4",
5353
"eslint-plugin-react-hooks": "^7.0.1",
5454
"eslint-plugin-react-refresh": "^0.5.2",
5555
"eslint-plugin-storybook": "^10.2.19",
5656
"globals": "^17.4.0",
57+
"happy-dom": "^20.9.0",
5758
"playwright": "^1.58.2",
5859
"prettier": "^3.8.1",
5960
"storybook": "^10.2.19",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, it, expect } from "vitest";
2+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
3+
import { getAppResourceUri, isAppTool } from "@inspector/core/mcp/apps.js";
4+
5+
const NESTED_URI = "ui://demo/widget";
6+
const FLAT_URI = "ui://legacy/widget";
7+
8+
const toolWith = (meta: Record<string, unknown> | undefined): Tool =>
9+
({
10+
name: "demo",
11+
inputSchema: { type: "object" },
12+
...(meta === undefined ? {} : { _meta: meta }),
13+
}) as Tool;
14+
15+
describe("getAppResourceUri", () => {
16+
it("returns the URI from the nested _meta.ui.resourceUri format", () => {
17+
expect(
18+
getAppResourceUri(toolWith({ ui: { resourceUri: NESTED_URI } })),
19+
).toBe(NESTED_URI);
20+
});
21+
22+
it("returns the URI from the deprecated flat _meta['ui/resourceUri'] format", () => {
23+
expect(getAppResourceUri(toolWith({ "ui/resourceUri": FLAT_URI }))).toBe(
24+
FLAT_URI,
25+
);
26+
});
27+
28+
it("prefers the nested format when both are present", () => {
29+
expect(
30+
getAppResourceUri(
31+
toolWith({
32+
ui: { resourceUri: NESTED_URI },
33+
"ui/resourceUri": FLAT_URI,
34+
}),
35+
),
36+
).toBe(NESTED_URI);
37+
});
38+
39+
it("returns undefined when _meta is missing", () => {
40+
expect(getAppResourceUri(toolWith(undefined))).toBeUndefined();
41+
});
42+
43+
it("returns undefined when _meta has no UI resource keys", () => {
44+
expect(getAppResourceUri(toolWith({ other: "value" }))).toBeUndefined();
45+
});
46+
47+
it("returns undefined when _meta.ui exists without resourceUri", () => {
48+
expect(
49+
getAppResourceUri(toolWith({ ui: { visibility: ["model"] } })),
50+
).toBeUndefined();
51+
});
52+
53+
it("throws when the nested URI does not start with ui://", () => {
54+
expect(() =>
55+
getAppResourceUri(
56+
toolWith({ ui: { resourceUri: "https://example.com/app" } }),
57+
),
58+
).toThrow(/Invalid UI resource URI/);
59+
});
60+
61+
it("throws when the flat URI does not start with ui://", () => {
62+
expect(() =>
63+
getAppResourceUri(toolWith({ "ui/resourceUri": "javascript:alert(1)" })),
64+
).toThrow(/Invalid UI resource URI/);
65+
});
66+
});
67+
68+
describe("isAppTool", () => {
69+
it("returns true for a tool with a valid nested URI", () => {
70+
expect(isAppTool(toolWith({ ui: { resourceUri: NESTED_URI } }))).toBe(true);
71+
});
72+
73+
it("returns true for a tool with a valid flat URI", () => {
74+
expect(isAppTool(toolWith({ "ui/resourceUri": FLAT_URI }))).toBe(true);
75+
});
76+
77+
it("returns false when _meta is missing", () => {
78+
expect(isAppTool(toolWith(undefined))).toBe(false);
79+
});
80+
81+
it("returns false when _meta has no UI resource keys", () => {
82+
expect(isAppTool(toolWith({ other: "value" }))).toBe(false);
83+
});
84+
85+
it("propagates the underlying throw for an invalid URI", () => {
86+
expect(() =>
87+
isAppTool(toolWith({ ui: { resourceUri: "not-a-ui-uri" } })),
88+
).toThrow(/Invalid UI resource URI/);
89+
});
90+
});

core/mcp/apps.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
2+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
3+
4+
/**
5+
* Returns the UI resource URI advertised by an MCP App tool, or `undefined`
6+
* for non-App tools.
7+
*
8+
* Reads from `tool._meta.ui.resourceUri` (preferred nested format) and falls
9+
* back to the deprecated flat `tool._meta["ui/resourceUri"]` key. The nested
10+
* format wins when both are present.
11+
*
12+
* Throws when `_meta` advertises a UI resource URI that is not a string
13+
* starting with `ui://`. We surface the underlying ext-apps error rather than
14+
* silently dropping the tool, because a malformed URI is a server bug worth
15+
* making visible.
16+
*
17+
* Re-exported from `@modelcontextprotocol/ext-apps/app-bridge` so that web,
18+
* CLI, and TUI all consume the same implementation through `@inspector/core`.
19+
*/
20+
export const getAppResourceUri: (tool: Tool) => string | undefined =
21+
getToolUiResourceUri;
22+
23+
/**
24+
* Single source of truth for App-tool detection across all Inspector clients.
25+
* Wraps {@link getAppResourceUri}; throws on a malformed `_meta.ui.resourceUri`
26+
* for the same reason.
27+
*/
28+
export function isAppTool(tool: Tool): boolean {
29+
return getAppResourceUri(tool) !== undefined;
30+
}

package-lock.json

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"test:coverage": "cd ./clients/web/ && npm run test:coverage"
2525
},
2626
"dependencies": {
27+
"@modelcontextprotocol/ext-apps": "^1.7.1",
2728
"@modelcontextprotocol/sdk": "^1.29.0",
2829
"zod": "^4.3.6"
2930
}

0 commit comments

Comments
 (0)