feat(apps): add isAppTool helper in core (#1260) (#1272)

* 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:
Cliff Hall 2026-05-01 17:03:11 -04:00 • committed by GitHub
commit 8fa1a0c074
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View 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/);
});
});