* 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> * test(apps): address PR #1272 review feedback - core/mcp/apps.ts: convert getAppResourceUri from a typed const re-export to a function declaration so its export style matches isAppTool. Behavior is unchanged. - core/mcp/apps.ts: document the filter-iteration implication on isAppTool — callers using tools.filter(isAppTool) will halt on a server-malformed URI. - apps.test.ts: add the symmetric isAppTool false-case for `_meta.ui` present without `resourceUri`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
75f74d64c1
commit
8fa1a0c074
6 changed files with 200 additions and 2 deletions
31
clients/web/package-lock.json
generated
31
clients/web/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"@mantine/form": "^8.3.17",
|
||||
"@mantine/hooks": "^8.3.17",
|
||||
"@mantine/notifications": "^8.3.17",
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ajv": "^8.17.1",
|
||||
"react": "^19.2.4",
|
||||
|
|
@ -1360,6 +1361,35 @@
|
|||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/ext-apps": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.1.tgz",
|
||||
"integrity": "sha512-J3WdG1A4JSSKnSWKyU+895dBVYBV2Utgtf7fUsUK45mlkETm53a/1DR6Pm3hUGKqLLQthZLmpxOg8VPzJi/lyg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
|
|
@ -1758,7 +1788,6 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-a11y": {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"@mantine/form": "^8.3.17",
|
||||
"@mantine/hooks": "^8.3.17",
|
||||
"@mantine/notifications": "^8.3.17",
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ajv": "^8.17.1",
|
||||
"react": "^19.2.4",
|
||||
|
|
@ -48,12 +49,12 @@
|
|||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-storybook": "^10.2.19",
|
||||
"globals": "^17.4.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.58.2",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "^10.2.19",
|
||||
|
|
|
|||
94
clients/web/src/test/core/apps.test.ts
Normal file
94
clients/web/src/test/core/apps.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { getAppResourceUri, isAppTool } from "@inspector/core/mcp/apps.js";
|
||||
|
||||
const NESTED_URI = "ui://demo/widget";
|
||||
const FLAT_URI = "ui://legacy/widget";
|
||||
|
||||
const toolWith = (meta: Record<string, unknown> | undefined): Tool =>
|
||||
({
|
||||
name: "demo",
|
||||
inputSchema: { type: "object" },
|
||||
...(meta === undefined ? {} : { _meta: meta }),
|
||||
}) as Tool;
|
||||
|
||||
describe("getAppResourceUri", () => {
|
||||
it("returns the URI from the nested _meta.ui.resourceUri format", () => {
|
||||
expect(
|
||||
getAppResourceUri(toolWith({ ui: { resourceUri: NESTED_URI } })),
|
||||
).toBe(NESTED_URI);
|
||||
});
|
||||
|
||||
it("returns the URI from the deprecated flat _meta['ui/resourceUri'] format", () => {
|
||||
expect(getAppResourceUri(toolWith({ "ui/resourceUri": FLAT_URI }))).toBe(
|
||||
FLAT_URI,
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the nested format when both are present", () => {
|
||||
expect(
|
||||
getAppResourceUri(
|
||||
toolWith({
|
||||
ui: { resourceUri: NESTED_URI },
|
||||
"ui/resourceUri": FLAT_URI,
|
||||
}),
|
||||
),
|
||||
).toBe(NESTED_URI);
|
||||
});
|
||||
|
||||
it("returns undefined when _meta is missing", () => {
|
||||
expect(getAppResourceUri(toolWith(undefined))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when _meta has no UI resource keys", () => {
|
||||
expect(getAppResourceUri(toolWith({ other: "value" }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when _meta.ui exists without resourceUri", () => {
|
||||
expect(
|
||||
getAppResourceUri(toolWith({ ui: { visibility: ["model"] } })),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when the nested URI does not start with ui://", () => {
|
||||
expect(() =>
|
||||
getAppResourceUri(
|
||||
toolWith({ ui: { resourceUri: "https://example.com/app" } }),
|
||||
),
|
||||
).toThrow(/Invalid UI resource URI/);
|
||||
});
|
||||
|
||||
it("throws when the flat URI does not start with ui://", () => {
|
||||
expect(() =>
|
||||
getAppResourceUri(toolWith({ "ui/resourceUri": "javascript:alert(1)" })),
|
||||
).toThrow(/Invalid UI resource URI/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAppTool", () => {
|
||||
it("returns true for a tool with a valid nested URI", () => {
|
||||
expect(isAppTool(toolWith({ ui: { resourceUri: NESTED_URI } }))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a tool with a valid flat URI", () => {
|
||||
expect(isAppTool(toolWith({ "ui/resourceUri": FLAT_URI }))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when _meta is missing", () => {
|
||||
expect(isAppTool(toolWith(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when _meta has no UI resource keys", () => {
|
||||
expect(isAppTool(toolWith({ other: "value" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when _meta.ui exists without resourceUri", () => {
|
||||
expect(isAppTool(toolWith({ ui: { visibility: ["model"] } }))).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates the underlying throw for an invalid URI", () => {
|
||||
expect(() =>
|
||||
isAppTool(toolWith({ ui: { resourceUri: "not-a-ui-uri" } })),
|
||||
).toThrow(/Invalid UI resource URI/);
|
||||
});
|
||||
});
|
||||
37
core/mcp/apps.ts
Normal file
37
core/mcp/apps.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
/**
|
||||
* Returns the UI resource URI advertised by an MCP App tool, or `undefined`
|
||||
* for non-App tools.
|
||||
*
|
||||
* Reads from `tool._meta.ui.resourceUri` (preferred nested format) and falls
|
||||
* back to the deprecated flat `tool._meta["ui/resourceUri"]` key. The nested
|
||||
* format wins when both are present.
|
||||
*
|
||||
* Throws when `_meta` advertises a UI resource URI that is not a string
|
||||
* starting with `ui://`. We surface the underlying ext-apps error rather than
|
||||
* silently dropping the tool, because a malformed URI is a server bug worth
|
||||
* making visible.
|
||||
*
|
||||
* Wraps `getToolUiResourceUri` from `@modelcontextprotocol/ext-apps/app-bridge`
|
||||
* so that web, CLI, and TUI all consume the same implementation through
|
||||
* `@inspector/core`.
|
||||
*/
|
||||
export function getAppResourceUri(tool: Tool): string | undefined {
|
||||
return getToolUiResourceUri(tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for App-tool detection across all Inspector clients.
|
||||
* Wraps {@link getAppResourceUri}; throws on a malformed `_meta.ui.resourceUri`
|
||||
* for the same reason.
|
||||
*
|
||||
* Note: because this throws on malformed URIs, callers using
|
||||
* `tools.filter(isAppTool)` will halt iteration on the first bad tool. Wrap in
|
||||
* a try/catch (or a safe-predicate helper) at the call site if you need to
|
||||
* tolerate mixed-validity tool lists.
|
||||
*/
|
||||
export function isAppTool(tool: Tool): boolean {
|
||||
return getAppResourceUri(tool) !== undefined;
|
||||
}
|
||||
36
package-lock.json
generated
36
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
|
|
@ -25,6 +26,35 @@
|
|||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/ext-apps": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.1.tgz",
|
||||
"integrity": "sha512-J3WdG1A4JSSKnSWKyU+895dBVYBV2Utgtf7fUsUK45mlkETm53a/1DR6Pm3hUGKqLLQthZLmpxOg8VPzJi/lyg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
|
|
@ -65,6 +95,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"test:coverage": "cd ./clients/web/ && npm run test:coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue