Skip to content

Commit 5c4eb7d

Browse files
committed
basic-host: filter app-only tools, prioritize tools with UIs
- Add getToolVisibility() and isToolVisibleToModel() helpers - Filter out tools with visibility: ['app'] from host tool list - Sort tools to show UI-enabled tools first - Add comprehensive tests for visibility helpers
1 parent 8370237 commit 5c4eb7d

File tree

3 files changed

+149
-6
lines changed

3 files changed

+149
-6
lines changed

examples/basic-host/src/index.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
1+
import { getToolUiResourceUri, isToolVisibleToModel } from "@modelcontextprotocol/ext-apps/app-bridge";
22
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
33
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
44
import { createRoot } from "react-dom/client";
@@ -81,10 +81,17 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
8181
const [selectedTool, setSelectedTool] = useState("");
8282
const [inputJson, setInputJson] = useState("{}");
8383

84-
// Only show tools that have a UI resource
84+
// Filter out app-only tools, prioritize tools with UIs
8585
const toolNames = selectedServer
8686
? Array.from(selectedServer.tools.values())
87-
.filter((tool) => !!getToolUiResourceUri(tool))
87+
.filter((tool) => isToolVisibleToModel(tool))
88+
.sort((a, b) => {
89+
const aHasUi = !!getToolUiResourceUri(a);
90+
const bHasUi = !!getToolUiResourceUri(b);
91+
if (aHasUi && !bHasUi) return -1;
92+
if (!aHasUi && bHasUi) return 1;
93+
return a.name.localeCompare(b.name);
94+
})
8895
.map((tool) => tool.name)
8996
: [];
9097

@@ -99,9 +106,17 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
99106

100107
const handleServerSelect = (server: ServerInfo) => {
101108
setSelectedServer(server);
102-
// Only consider tools with UI resources
103-
const uiTools = Array.from(server.tools.values()).filter((tool) => !!getToolUiResourceUri(tool));
104-
const firstTool = uiTools[0]?.name ?? "";
109+
// Filter out app-only tools, prioritize tools with UIs
110+
const visibleTools = Array.from(server.tools.values())
111+
.filter((tool) => isToolVisibleToModel(tool))
112+
.sort((a, b) => {
113+
const aHasUi = !!getToolUiResourceUri(a);
114+
const bHasUi = !!getToolUiResourceUri(b);
115+
if (aHasUi && !bHasUi) return -1;
116+
if (!aHasUi && bHasUi) return 1;
117+
return a.name.localeCompare(b.name);
118+
});
119+
const firstTool = visibleTools[0]?.name ?? "";
105120
setSelectedTool(firstTool);
106121
// Set input JSON to tool defaults (if any)
107122
setInputJson(getToolDefaults(server.tools.get(firstTool)));

src/app-bridge.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { App } from "./app";
1717
import {
1818
AppBridge,
1919
getToolUiResourceUri,
20+
getToolVisibility,
21+
isToolVisibleToModel,
2022
type McpUiHostCapabilities,
2123
} from "./app-bridge";
2224

@@ -921,3 +923,72 @@ describe("getToolUiResourceUri", () => {
921923
});
922924
});
923925
});
926+
927+
describe("getToolVisibility", () => {
928+
it("returns default visibility when no metadata", () => {
929+
const tool = {};
930+
expect(getToolVisibility(tool)).toEqual(["model", "app"]);
931+
});
932+
933+
it("returns default visibility when no ui metadata", () => {
934+
const tool = { _meta: {} };
935+
expect(getToolVisibility(tool)).toEqual(["model", "app"]);
936+
});
937+
938+
it("returns default visibility when ui metadata has no visibility", () => {
939+
const tool = { _meta: { ui: { resourceUri: "ui://test/app.html" } } };
940+
expect(getToolVisibility(tool)).toEqual(["model", "app"]);
941+
});
942+
943+
it("returns app-only visibility", () => {
944+
const tool = { _meta: { ui: { visibility: ["app"] } } };
945+
expect(getToolVisibility(tool)).toEqual(["app"]);
946+
});
947+
948+
it("returns model-only visibility", () => {
949+
const tool = { _meta: { ui: { visibility: ["model"] } } };
950+
expect(getToolVisibility(tool)).toEqual(["model"]);
951+
});
952+
953+
it("returns both visibility values", () => {
954+
const tool = { _meta: { ui: { visibility: ["model", "app"] } } };
955+
expect(getToolVisibility(tool)).toEqual(["model", "app"]);
956+
});
957+
958+
it("filters out invalid visibility values", () => {
959+
const tool = { _meta: { ui: { visibility: ["model", "invalid", "app"] } } };
960+
expect(getToolVisibility(tool)).toEqual(["model", "app"]);
961+
});
962+
963+
it("returns empty array for invalid visibility type", () => {
964+
const tool = { _meta: { ui: { visibility: "model" } } };
965+
expect(getToolVisibility(tool)).toEqual(["model", "app"]);
966+
});
967+
});
968+
969+
describe("isToolVisibleToModel", () => {
970+
it("returns true when no metadata", () => {
971+
const tool = {};
972+
expect(isToolVisibleToModel(tool)).toBe(true);
973+
});
974+
975+
it("returns true when visibility includes model", () => {
976+
const tool = { _meta: { ui: { visibility: ["model", "app"] } } };
977+
expect(isToolVisibleToModel(tool)).toBe(true);
978+
});
979+
980+
it("returns true when visibility is model-only", () => {
981+
const tool = { _meta: { ui: { visibility: ["model"] } } };
982+
expect(isToolVisibleToModel(tool)).toBe(true);
983+
});
984+
985+
it("returns false when visibility is app-only", () => {
986+
const tool = { _meta: { ui: { visibility: ["app"] } } };
987+
expect(isToolVisibleToModel(tool)).toBe(false);
988+
});
989+
990+
it("returns false when visibility is empty array", () => {
991+
const tool = { _meta: { ui: { visibility: [] } } };
992+
expect(isToolVisibleToModel(tool)).toBe(false);
993+
});
994+
});

src/app-bridge.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,63 @@ export function getToolUiResourceUri(tool: {
127127
return undefined;
128128
}
129129

130+
/**
131+
* Get tool visibility from metadata.
132+
*
133+
* @param tool - A tool object with optional `_meta` property
134+
* @returns The visibility array, or `["model", "app"]` (default) if not specified
135+
*
136+
* @example
137+
* ```typescript
138+
* const visibility = getToolVisibility({
139+
* _meta: { ui: { visibility: ["app"] } }
140+
* });
141+
* // Returns: ["app"]
142+
*
143+
* // Tool without visibility defaults to visible to both
144+
* const visibility = getToolVisibility({});
145+
* // Returns: ["model", "app"]
146+
* ```
147+
*/
148+
export function getToolVisibility(tool: {
149+
_meta?: Record<string, unknown>;
150+
}): ("model" | "app")[] {
151+
const uiMeta = tool._meta?.ui as { visibility?: unknown } | undefined;
152+
const visibility = uiMeta?.visibility;
153+
154+
if (Array.isArray(visibility)) {
155+
return visibility.filter(
156+
(v): v is "model" | "app" => v === "model" || v === "app",
157+
);
158+
}
159+
return ["model", "app"];
160+
}
161+
162+
/**
163+
* Check if a tool is visible to the model (agent).
164+
*
165+
* Tools with `visibility: ["app"]` are only callable by the app and should
166+
* not be shown in tool lists presented to the model.
167+
*
168+
* @param tool - A tool object with optional `_meta` property
169+
* @returns true if the tool is visible to the model
170+
*
171+
* @example
172+
* ```typescript
173+
* // Tool visible to model
174+
* isToolVisibleToModel({ _meta: { ui: { visibility: ["model", "app"] } } }); // true
175+
* isToolVisibleToModel({}); // true (default)
176+
*
177+
* // App-only tool
178+
* isToolVisibleToModel({ _meta: { ui: { visibility: ["app"] } } }); // false
179+
* ```
180+
*/
181+
export function isToolVisibleToModel(tool: {
182+
_meta?: Record<string, unknown>;
183+
}): boolean {
184+
return getToolVisibility(tool).includes("model");
185+
}
186+
130187
/**
131188
* Build iframe `allow` attribute string from permissions.
132189
*

0 commit comments

Comments
 (0)