spec: Add tool registration for Apps, to be called by Host (WebMCP-style!) (#72)
* feat: Add tool registration and bidirectional tool support This PR adds comprehensive tool support for MCP Apps, enabling apps to register their own tools and handle tool calls from the host. - Add `registerTool()` method for registering tools with input/output schemas - Add `oncalltool` setter for handling tool call requests from host - Add `onlisttools` setter for handling tool list requests from host - Add `sendToolListChanged()` for notifying host of tool updates - Registered tools support enable/disable/update/remove operations - Add `sendCallTool()` method for calling tools on the app - Add `sendListTools()` method for listing available app tools - Fix: Use correct ListToolsResultSchema (was ListToolsRequestSchema) - Add comprehensive tests for tool registration lifecycle - Add tests for input/output schema validation - Add tests for bidirectional tool call communication - Add tests for tool list change notifications - All 27 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * nits * Update apps.mdx * feat: Add automatic request handlers for app tool registration Implement automatic `oncalltool` and `onlisttools` handlers that are initialized when apps register tools. This removes the need for manual handler setup and ensures tools work seamlessly out of the box. - Add automatic `oncalltool` handler that routes calls to registered tools - Add automatic `onlisttools` handler that returns full Tool objects with JSON schemas - Convert Zod schemas to MCP-compliant JSON Schema using `zod-to-json-schema` - Add 27 comprehensive tests covering automatic handlers and tool lifecycle - Test coverage includes error handling, schema validation, and multi-app isolation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * type updates * fix: Ensure tools/list returns valid JSON Schema for all tools - Always return inputSchema as object (never undefined) - Keep filter for enabled tools only in list - Update test to match behavior (only enabled tools in list) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * type updates * rm zod-to-json-schema * fix: Update RegisteredTool to use 'handler' instead of 'callback' (SDK API change) * refactor: Rename sendCallTool/sendListTools to callTool/listTools Avoid double-verb naming pattern for consistency with existing API. * feat: Add screenshot and click support to SDK - Add McpUiScreenshotRequest/Result and McpUiClickRequest/Result types - Add onscreenshot and onclick handlers to App class - Add screenshot() and click() methods to AppBridge class - Generate updated Zod schemas * feat(map-server): Enable registerTool with navigate-to and get-current-view - Uncomment and fix the navigate-to tool for animated navigation - Add get-current-view tool to query camera position and bounding box - Add flyToBoundingBox function for smooth camera animation - Add setLabel function for displaying location labels * feat(pdf-server): Add widget interaction tools - get-document-info: Get title, current page, total pages, zoom level - go-to-page: Navigate to a specific page - get-page-text: Extract text from a page - search-text: Search for text across the document - set-zoom: Adjust zoom level * feat(shadertoy-server): Add widget interaction tools * feat(examples): Add widget interaction tools to budget-allocator, shadertoy, wiki-explorer, and threejs budget-allocator: - get-allocations: Get current budget allocations - set-allocation: Set allocation for a category - set-total-budget: Adjust total budget - set-company-stage: Change stage for benchmarks - get-benchmark-comparison: Compare against benchmarks shadertoy: - set-shader-source: Update shader source code - get-shader-info: Get shader source and compilation status - Sends errors via updateModelContext wiki-explorer: - search-article: Search for Wikipedia articles - get-current-article: Get current article info - highlight-node: Highlight a graph node - get-visible-nodes: List visible nodes threejs: - set-scene-source: Update the Three.js scene source code - get-scene-info: Get current scene state and any errors - Sends syntax errors to model via updateModelContext * feat: Add expand-node tool to wiki-explorer and update tool descriptions Wiki Explorer: - Add expand-node tool - the critical missing tool for graph exploration - Claude can now programmatically expand nodes to discover linked articles Server descriptions updated to mention widget tools: - map-server: navigate-to, get-current-view - pdf-server: go-to-page, get-page-text, search-text, set-zoom, get-document-info - budget-allocator: get-allocations, set-allocation, set-total-budget, etc. - shadertoy: set-shader-source, get-shader-info - wiki-explorer: expand-node, search-article, highlight-node, etc. All descriptions now mention 'Use list_widget_tools to discover available actions.' * refactor: Simplify tool descriptions - remove client implementation details The server tool descriptions now just mention that widgets are interactive and can be controlled, without teaching the model about list_widget_tools (which is the client's responsibility to teach). Before: 'The widget exposes tools: X, Y, Z. Use list_widget_tools to discover...' After: 'The widget is interactive and exposes tools for X and Y.' * Remove ui/screenshot and ui/click protocol methods These will be proposed separately from the app tool registration changes. * fix(app): auto-register tools capability in registerTool When registerTool is called before connect() on an App created without explicit tools capability, setRequestHandler's capability assertion would throw, breaking app initialization at module load. Auto-register { tools: { listChanged: true } } on first registerTool call (pre-connect only), mirroring McpServer.registerTool behavior. Fixes pdf-annotations e2e failures where the PDF canvas never rendered because registerTool threw at module scope. * pdf-server: replace app-tools with 1:1 interact command mapping Converts the 5 placeholder app-registered tools into 12 tools that map directly to the server's interact commands (navigate, search, find, search_navigate, zoom, add_annotations, update_annotations, remove_annotations, highlight_text, fill_form, get_text, get_screenshot) plus the existing get-document-info. Implementation stays DRY by dispatching through the existing processCommands() handler — each tool callback constructs a PdfCommand and runs it via a small runCommand() wrapper. For get_text and get_screenshot, the page-data collection is extracted from handleGetPages into a shared collectPageData() helper so results can be returned directly instead of round-tripping through the server. Tool names and zod schemas mirror the interact command parameter shapes from server.ts so the model sees the same surface whether it goes through interact or app-tools. The server-side interact tool is unchanged and remains available for hosts without app-tool support. * chore: drop unused test vars and stale Request/Result imports * docs(spec): address review feedback on app tool registration - Add outputSchema to Tool struct (matches SDK + core MCP) - Reword "black box" bullets: drop stale screenshots reference, clarify no DOM access - Clarify push vs pull contrast with explicit setWidgetState + ui/update-model-context refs - Add Task-reference future-work bullet for tools outliving render lifecycle * docs(spec): clarify app DOM is not host-introspectable; frame tools as pull complementing update-model-context push * fix(app): emit title and omit absent outputSchema in tools/list * feat(app): accept Standard Schema in registerTool/registerAppTool App.registerTool and registerAppTool now accept any schema implementing the Standard Schema spec (Zod ≥3.25, ArkType, Valibot, …) instead of zod-only. Uses ~standard.validate / ~standard.jsonSchema directly, so app.ts no longer imports zod at runtime — zod is now an optional peer. Mirrors the StandardSchemaWithJSON type from modelcontextprotocol/typescript-sdk#1689 so bumping to SDK v2 is a drop-in import swap. Adds @standard-schema/spec (types-only) and a hand-rolled non-zod test. * docs(spec): reference core MCP Tasks for long-running app tools App tools/call MAY be task-augmented per the core MCP Tasks utility (spec 2025-11-25). Adds a Long-Running App Tools subsection: hosts proxy tasks/* while the app is mounted; apps SHOULD delegate long-lived work to the server so the task survives iframe teardown. Removes the stale 'future extension' bullet — Tasks is shipped. * docs(spec): note Schema Validation accepts any Standard Schema library * chore: clean unused-var and no-op-await diagnostics in app.ts and tests * refactor(app): fold error prefix into validateStandardSchema; clarify registerAppTool runtime caveat * chore(typedoc): mark RequestHandlerExtra as intentionally not exported AppToolCallback (introduced with the Standard Schema migration) references the local RequestHandlerExtra alias in its parameter type. The alias is a private Parameters<> derivation and isn't part of the public API surface, so add it to intentionallyNotExported alongside AppOptions/MethodSchema. * fix(app): restore zod v3.25 compat via lazy z.toJSONSchema fallback zod v3.25.x implements ~standard.validate but not ~standard.jsonSchema, so constraining to StandardSchemaWithJSON broke the ^3.25.0 peer range. Widen registerTool to StandardSchemaV1 and fall back to a lazy zod/v4 import for serialization when ~standard.jsonSchema is absent and vendor==='zod'. Non-zod schemas without jsonSchema get a clear error. * docs(spec): slim Long-Running App Tools to a pointer into core MCP Tasks * chore: add TODO(sdk-v2) marker in standard-schema.ts * fix(app): align registerTool runtime with declared types - Handler reads registeredTool.{input,output}Schema so update() takes effect - Call cb(extra) when no inputSchema, matching AppToolCallback<undefined> - Throw on duplicate name; guard list_changed pre-connect; notify on register - Drop misleading zod optional peer (still required by generated/schema.ts) - examples: fix pdf search/find stale match count; threejs set-scene-source re-render - spec: Zod → Standard Schema; clarify tools availability via tools/list after init * feat(app): type structuredContent from outputSchema in registerTool AppToolCallback gains an Out param: when outputSchema is provided, structuredContent is required and typed via StandardSchemaV1.InferOutput, with an `isError: true` escape hatch. Without outputSchema, return type is unchanged (CallToolResult). Uses intersection rather than Omit since CallToolResult's index signature swallows Omit's known keys. Also drops the `as any` casts on z.object(...) in tests — zod 4 satisfies StandardSchemaV1 directly. * fix(app): registerTool edge cases + example tool correctness App.registerTool: - Skip outputSchema validation when result.isError (matches AppToolResult type) - Pin callback arity to original config.inputSchema; only the validation schema is mutable via update() - Default missing tools/call arguments to {} - remove() is a no-op if the handle is stale (re-registered or already removed) - Gate list_changed notifications on the listChanged capability Examples: - threejs: propagate executeThreeCode failures to onSceneError/onSceneRendering; catch updateModelContext rejection - wiki-explorer: search-article preserves graph on no-match - budget-allocator: fix category/stage values in tool descriptions * feat(app,app-bridge): guard requests sent before handshake completes (#620) * feat(app,app-bridge): guard requests sent before handshake completes Calling host-bound methods before the ui/initialize → ui/notifications/initialized handshake completes can race the handshake on strict hosts and leave the iframe permanently hidden. App (View side): - All 8 host-bound methods (callServerTool, readServerResource, listServerResources, sendMessage, updateModelContext, openLink, downloadFile, requestDisplayMode) now check that connect() has sent the initialized notification. - New AppOptions.strict (default false): console.error when false, throw when true. AppOptions is now exported. - useApp() forwards strict to the App constructor. - Flag resets on reconnect. AppBridge (Host side): - replaceRequestHandler is overridden to wrap every host-bound handler with a console.warn if the request arrives before ui/notifications/initialized. Never throws. ui/initialize and ping use setRequestHandler directly and are exempt; notifications are unaffected. - Catches Views that hand-roll postMessage without the SDK. - Flag resets on reconnect. Refs anthropics/claude-ai-mcp#61, anthropics/claude-ai-mcp#149. * fix(docs): drop AppOptions from intentionallyNotExported now that it is exported --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
926dcb4fe9
commit
3fb521b71f
22 changed files with 3864 additions and 120 deletions
|
|
@ -5,4 +5,5 @@ examples/basic-server-*/**/*.tsx
|
||||||
examples/quickstart/**/*.ts
|
examples/quickstart/**/*.ts
|
||||||
**/vendor/**
|
**/vendor/**
|
||||||
**/.build/**
|
**/.build/**
|
||||||
|
**/.claude/**
|
||||||
SKILL.md
|
SKILL.md
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ export function createServer(): McpServer {
|
||||||
{
|
{
|
||||||
title: "Get Budget Data",
|
title: "Get Budget Data",
|
||||||
description:
|
description:
|
||||||
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
|
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage. The widget is interactive and exposes tools for reading/modifying allocations, adjusting budgets, and comparing against industry benchmarks.",
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
outputSchema: BudgetDataResponseSchema,
|
outputSchema: BudgetDataResponseSchema,
|
||||||
_meta: { ui: { resourceUri } },
|
_meta: { ui: { resourceUri } },
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
||||||
import { Chart, registerables } from "chart.js";
|
import { Chart, registerables } from "chart.js";
|
||||||
|
import { z } from "zod";
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
import "./mcp-app.css";
|
import "./mcp-app.css";
|
||||||
|
|
||||||
|
|
@ -626,6 +627,295 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
|
||||||
|
|
||||||
app.onhostcontextchanged = handleHostContextChanged;
|
app.onhostcontextchanged = handleHostContextChanged;
|
||||||
|
|
||||||
|
// Register tools for model interaction
|
||||||
|
app.registerTool(
|
||||||
|
"get-allocations",
|
||||||
|
{
|
||||||
|
title: "Get Budget Allocations",
|
||||||
|
description:
|
||||||
|
"Get the current budget allocations including total budget, percentages, and amounts per category",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
if (!state.config) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: "Error: Configuration not loaded" },
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allocations: Record<string, { percent: number; amount: number }> = {};
|
||||||
|
for (const category of state.config.categories) {
|
||||||
|
const percent = state.allocations.get(category.id) ?? 0;
|
||||||
|
allocations[category.id] = {
|
||||||
|
percent,
|
||||||
|
amount: (percent / 100) * state.totalBudget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
totalBudget: state.totalBudget,
|
||||||
|
currency: state.config.currency,
|
||||||
|
currencySymbol: state.config.currencySymbol,
|
||||||
|
selectedStage: state.selectedStage,
|
||||||
|
allocations,
|
||||||
|
categories: state.config.categories.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
color: c.color,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: JSON.stringify(result, null, 2) },
|
||||||
|
],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"set-allocation",
|
||||||
|
{
|
||||||
|
title: "Set Category Allocation",
|
||||||
|
description: "Set the allocation percentage for a specific budget category",
|
||||||
|
inputSchema: z.object({
|
||||||
|
categoryId: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Category ID (e.g., 'marketing', 'engineering', 'operations', 'sales', 'rd')",
|
||||||
|
),
|
||||||
|
percent: z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.max(100)
|
||||||
|
.describe("Allocation percentage (0-100)"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
if (!state.config) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: "Error: Configuration not loaded" },
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = state.config.categories.find(
|
||||||
|
(c) => c.id === args.categoryId,
|
||||||
|
);
|
||||||
|
if (!category) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error: Category "${args.categoryId}" not found. Available: ${state.config.categories.map((c) => c.id).join(", ")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSliderChange(args.categoryId, args.percent);
|
||||||
|
|
||||||
|
// Also update the slider UI
|
||||||
|
const slider = document.querySelector(
|
||||||
|
`.slider-row[data-category-id="${args.categoryId}"] .slider`,
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (slider) {
|
||||||
|
slider.value = String(args.percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = (args.percent / 100) * state.totalBudget;
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Set ${category.name} allocation to ${args.percent.toFixed(1)}% (${state.config.currencySymbol}${amount.toLocaleString()})`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"set-total-budget",
|
||||||
|
{
|
||||||
|
title: "Set Total Budget",
|
||||||
|
description: "Set the total budget amount",
|
||||||
|
inputSchema: z.object({
|
||||||
|
amount: z.number().positive().describe("Total budget amount"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
if (!state.config) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: "Error: Configuration not loaded" },
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.totalBudget = args.amount;
|
||||||
|
|
||||||
|
// Update the budget selector if this amount is a preset
|
||||||
|
const budgetSelector = document.getElementById(
|
||||||
|
"budget-selector",
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
if (budgetSelector) {
|
||||||
|
const option = Array.from(budgetSelector.options).find(
|
||||||
|
(opt) => parseInt(opt.value) === args.amount,
|
||||||
|
);
|
||||||
|
if (option) {
|
||||||
|
budgetSelector.value = String(args.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAllSliderAmounts();
|
||||||
|
updateStatusBar();
|
||||||
|
updateComparisonSummary();
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Total budget set to ${state.config.currencySymbol}${args.amount.toLocaleString()}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"set-company-stage",
|
||||||
|
{
|
||||||
|
title: "Set Company Stage",
|
||||||
|
description:
|
||||||
|
"Set the company stage for benchmark comparison (e.g., 'Seed', 'Series A', 'Series B', 'Growth')",
|
||||||
|
inputSchema: z.object({
|
||||||
|
stage: z.string().describe("Company stage ID"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
if (!state.analytics) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: "Error: Analytics not loaded" },
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.analytics.stages.includes(args.stage)) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error: Stage "${args.stage}" not found. Available: ${state.analytics.stages.join(", ")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedStage = args.stage;
|
||||||
|
|
||||||
|
// Update the stage selector UI
|
||||||
|
const stageSelector = document.getElementById(
|
||||||
|
"stage-selector",
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
if (stageSelector) {
|
||||||
|
stageSelector.value = args.stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all badges and summary
|
||||||
|
if (state.config) {
|
||||||
|
for (const category of state.config.categories) {
|
||||||
|
updatePercentileBadge(category.id);
|
||||||
|
}
|
||||||
|
updateComparisonSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Company stage set to "${args.stage}"`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"get-benchmark-comparison",
|
||||||
|
{
|
||||||
|
title: "Get Benchmark Comparison",
|
||||||
|
description:
|
||||||
|
"Compare current allocations against industry benchmarks for the selected stage",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
if (!state.config || !state.analytics) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "Error: Data not loaded" }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const benchmark = state.analytics.benchmarks.find(
|
||||||
|
(b) => b.stage === state.selectedStage,
|
||||||
|
);
|
||||||
|
if (!benchmark) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error: No benchmark data for stage "${state.selectedStage}"`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparison: Record<
|
||||||
|
string,
|
||||||
|
{ current: number; p25: number; p50: number; p75: number; status: string }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const category of state.config.categories) {
|
||||||
|
const current = state.allocations.get(category.id) ?? 0;
|
||||||
|
const benchmarkData = benchmark.categoryBenchmarks[category.id];
|
||||||
|
let status = "within range";
|
||||||
|
if (current < benchmarkData.p25) status = "below p25";
|
||||||
|
else if (current > benchmarkData.p75) status = "above p75";
|
||||||
|
|
||||||
|
comparison[category.id] = {
|
||||||
|
current,
|
||||||
|
p25: benchmarkData.p25,
|
||||||
|
p50: benchmarkData.p50,
|
||||||
|
p75: benchmarkData.p75,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
stage: state.selectedStage,
|
||||||
|
comparison,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: JSON.stringify(result, null, 2) },
|
||||||
|
],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Handle theme changes
|
// Handle theme changes
|
||||||
window
|
window
|
||||||
.matchMedia("(prefers-color-scheme: dark)")
|
.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export function createServer(): McpServer {
|
||||||
{
|
{
|
||||||
title: "Show Map",
|
title: "Show Map",
|
||||||
description:
|
description:
|
||||||
"Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location.",
|
"Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location. The widget is interactive and exposes tools for navigation (fly to locations) and querying the current view.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
west: z
|
west: z
|
||||||
.number()
|
.number()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
import { App } from "@modelcontextprotocol/ext-apps";
|
import { App } from "@modelcontextprotocol/ext-apps";
|
||||||
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
|
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
// TypeScript declaration for Cesium loaded from CDN
|
// TypeScript declaration for Cesium loaded from CDN
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
@ -559,6 +560,76 @@ function setViewToBoundingBox(cesiumViewer: any, bbox: BoundingBox): void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fly camera to view a bounding box with animation
|
||||||
|
*/
|
||||||
|
function flyToBoundingBox(
|
||||||
|
cesiumViewer: any,
|
||||||
|
bbox: BoundingBox,
|
||||||
|
duration = 2,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const { destination, centerLon, centerLat, height } =
|
||||||
|
calculateDestination(bbox);
|
||||||
|
|
||||||
|
log.info("flyTo destination:", centerLon, centerLat, "height:", height);
|
||||||
|
|
||||||
|
cesiumViewer.camera.flyTo({
|
||||||
|
destination,
|
||||||
|
orientation: {
|
||||||
|
heading: 0,
|
||||||
|
pitch: Cesium.Math.toRadians(-90), // Look straight down
|
||||||
|
roll: 0,
|
||||||
|
},
|
||||||
|
duration,
|
||||||
|
complete: () => {
|
||||||
|
log.info(
|
||||||
|
"flyTo complete, camera height:",
|
||||||
|
cesiumViewer.camera.positionCartographic.height,
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
log.warn("flyTo cancelled");
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label element for displaying location info
|
||||||
|
let labelElement: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or clear the label displayed on the map
|
||||||
|
*/
|
||||||
|
function setLabel(text?: string): void {
|
||||||
|
if (!labelElement) {
|
||||||
|
labelElement = document.createElement("div");
|
||||||
|
labelElement.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(labelElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
labelElement.textContent = text;
|
||||||
|
labelElement.style.display = "block";
|
||||||
|
} else {
|
||||||
|
labelElement.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for globe tiles to finish loading
|
* Wait for globe tiles to finish loading
|
||||||
*/
|
*/
|
||||||
|
|
@ -801,57 +872,110 @@ app.ontoolinput = async (params) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
// Register tools for the model to interact with this component
|
||||||
Register tools for the model to interact w/ this component
|
app.registerTool(
|
||||||
Needs https://github.com/modelcontextprotocol/ext-apps/pull/72
|
"navigate-to",
|
||||||
*/
|
{
|
||||||
// app.registerTool(
|
title: "Navigate To",
|
||||||
// "navigate-to",
|
description: "Navigate the globe to a new bounding box location",
|
||||||
// {
|
inputSchema: z.object({
|
||||||
// title: "Navigate To",
|
west: z.number().describe("Western longitude (-180 to 180)"),
|
||||||
// description: "Navigate the globe to a new bounding box location",
|
south: z.number().describe("Southern latitude (-90 to 90)"),
|
||||||
// inputSchema: z.object({
|
east: z.number().describe("Eastern longitude (-180 to 180)"),
|
||||||
// west: z.number().describe("Western longitude (-180 to 180)"),
|
north: z.number().describe("Northern latitude (-90 to 90)"),
|
||||||
// south: z.number().describe("Southern latitude (-90 to 90)"),
|
duration: z
|
||||||
// east: z.number().describe("Eastern longitude (-180 to 180)"),
|
.number()
|
||||||
// north: z.number().describe("Northern latitude (-90 to 90)"),
|
.optional()
|
||||||
// duration: z
|
.describe("Animation duration in seconds (default: 2)"),
|
||||||
// .number()
|
label: z.string().optional().describe("Optional label to display"),
|
||||||
// .optional()
|
}),
|
||||||
// .describe("Animation duration in seconds (default: 2)"),
|
},
|
||||||
// label: z.string().optional().describe("Optional label to display"),
|
async (args) => {
|
||||||
// }),
|
if (!viewer) {
|
||||||
// },
|
return {
|
||||||
// async (args) => {
|
content: [
|
||||||
// if (!viewer) {
|
{ type: "text" as const, text: "Error: Viewer not initialized" },
|
||||||
// return {
|
],
|
||||||
// content: [
|
isError: true,
|
||||||
// { type: "text" as const, text: "Error: Viewer not initialized" },
|
};
|
||||||
// ],
|
}
|
||||||
// isError: true,
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const bbox: BoundingBox = {
|
const bbox: BoundingBox = {
|
||||||
// west: args.west,
|
west: args.west,
|
||||||
// south: args.south,
|
south: args.south,
|
||||||
// east: args.east,
|
east: args.east,
|
||||||
// north: args.north,
|
north: args.north,
|
||||||
// };
|
};
|
||||||
|
|
||||||
// await flyToBoundingBox(viewer, bbox, args.duration ?? 2);
|
await flyToBoundingBox(viewer, bbox, args.duration ?? 2);
|
||||||
// setLabel(args.label);
|
setLabel(args.label);
|
||||||
|
|
||||||
// return {
|
return {
|
||||||
// content: [
|
content: [
|
||||||
// {
|
{
|
||||||
// type: "text" as const,
|
type: "text" as const,
|
||||||
// text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`,
|
text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`,
|
||||||
// },
|
},
|
||||||
// ],
|
],
|
||||||
// };
|
};
|
||||||
// },
|
},
|
||||||
// );
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"get-current-view",
|
||||||
|
{
|
||||||
|
title: "Get Current View",
|
||||||
|
description:
|
||||||
|
"Get the current camera position and bounding box visible on the globe",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
if (!viewer) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: "Error: Viewer not initialized" },
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = viewer.camera;
|
||||||
|
const positionCartographic = camera.positionCartographic;
|
||||||
|
const latitude = Cesium.Math.toDegrees(positionCartographic.latitude);
|
||||||
|
const longitude = Cesium.Math.toDegrees(positionCartographic.longitude);
|
||||||
|
const height = positionCartographic.height;
|
||||||
|
|
||||||
|
// Get the visible bounding box
|
||||||
|
const rectangle = viewer.camera.computeViewRectangle();
|
||||||
|
let bbox = null;
|
||||||
|
if (rectangle) {
|
||||||
|
bbox = {
|
||||||
|
west: Cesium.Math.toDegrees(rectangle.west),
|
||||||
|
south: Cesium.Math.toDegrees(rectangle.south),
|
||||||
|
east: Cesium.Math.toDegrees(rectangle.east),
|
||||||
|
north: Cesium.Math.toDegrees(rectangle.north),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewData = {
|
||||||
|
camera: {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
bbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify(viewData, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: viewData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Handle tool result - extract viewUUID and restore persisted view if available
|
// Handle tool result - extract viewUUID and restore persisted view if available
|
||||||
app.ontoolresult = async (result) => {
|
app.ontoolresult = async (result) => {
|
||||||
|
|
|
||||||
|
|
@ -1366,7 +1366,7 @@ export function createServer(options: CreateServerOptions = {}): McpServer {
|
||||||
description: disableInteract
|
description: disableInteract
|
||||||
? `Show and render a PDF in a read-only viewer.
|
? `Show and render a PDF in a read-only viewer.
|
||||||
|
|
||||||
Use this tool when the user wants to view or read a PDF. The renderer displays the document for viewing.
|
Use this tool when the user wants to view or read a PDF. The renderer displays the document for viewing. The widget exposes app-registered tools for page navigation, text extraction, searching, and zoom control.
|
||||||
|
|
||||||
Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.`
|
Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.`
|
||||||
: `Open a PDF in an interactive viewer. Call this ONCE per PDF.
|
: `Open a PDF in an interactive viewer. Call this ONCE per PDF.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "@modelcontextprotocol/ext-apps";
|
} from "@modelcontextprotocol/ext-apps";
|
||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
|
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
|
||||||
|
import { z } from "zod";
|
||||||
import * as pdfjsLib from "pdfjs-dist";
|
import * as pdfjsLib from "pdfjs-dist";
|
||||||
import { AnnotationLayer, AnnotationMode, TextLayer } from "pdfjs-dist";
|
import { AnnotationLayer, AnnotationMode, TextLayer } from "pdfjs-dist";
|
||||||
import "pdfjs-dist/web/pdf_viewer.css";
|
import "pdfjs-dist/web/pdf_viewer.css";
|
||||||
|
|
@ -2572,17 +2573,21 @@ async function handleGetViewerState(requestId: string): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetPages(cmd: {
|
/**
|
||||||
requestId: string;
|
* Collect text and/or screenshots for a set of page intervals.
|
||||||
intervals: Array<{ start?: number; end?: number }>;
|
* Shared by the server-driven `get_pages` command (via handleGetPages)
|
||||||
getText: boolean;
|
* and the app-registered `get_text` / `get_screenshot` tools.
|
||||||
getScreenshots: boolean;
|
*/
|
||||||
}): Promise<void> {
|
async function collectPageData(
|
||||||
const allPages = expandIntervals(cmd.intervals);
|
intervals: Array<{ start?: number; end?: number }>,
|
||||||
|
getText: boolean,
|
||||||
|
getScreenshots: boolean,
|
||||||
|
): Promise<Array<{ page: number; text?: string; image?: string }>> {
|
||||||
|
const allPages = expandIntervals(intervals);
|
||||||
const pages = allPages.slice(0, MAX_GET_PAGES);
|
const pages = allPages.slice(0, MAX_GET_PAGES);
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`get_pages: ${pages.length} pages (${pages[0]}..${pages[pages.length - 1]}), text=${cmd.getText}, screenshots=${cmd.getScreenshots}`,
|
`collectPageData: ${pages.length} pages (${pages[0]}..${pages[pages.length - 1]}), text=${getText}, screenshots=${getScreenshots}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const results: Array<{
|
const results: Array<{
|
||||||
|
|
@ -2596,7 +2601,7 @@ async function handleGetPages(cmd: {
|
||||||
page: pageNum,
|
page: pageNum,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cmd.getText) {
|
if (getText) {
|
||||||
// Use cached text if available, otherwise extract on the fly
|
// Use cached text if available, otherwise extract on the fly
|
||||||
let text = pageTextCache.get(pageNum);
|
let text = pageTextCache.get(pageNum);
|
||||||
if (text == null && pdfDocument) {
|
if (text == null && pdfDocument) {
|
||||||
|
|
@ -2609,7 +2614,7 @@ async function handleGetPages(cmd: {
|
||||||
pageTextCache.set(pageNum, text);
|
pageTextCache.set(pageNum, text);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(
|
log.error(
|
||||||
`get_pages: text extraction failed for page ${pageNum}:`,
|
`collectPageData: text extraction failed for page ${pageNum}:`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
text = "";
|
text = "";
|
||||||
|
|
@ -2618,17 +2623,35 @@ async function handleGetPages(cmd: {
|
||||||
entry.text = text ?? "";
|
entry.text = text ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.getScreenshots) {
|
if (getScreenshots) {
|
||||||
try {
|
try {
|
||||||
entry.image = await renderPageOffscreen(pageNum);
|
entry.image = await renderPageOffscreen(pageNum);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(`get_pages: screenshot failed for page ${pageNum}:`, err);
|
log.error(
|
||||||
|
`collectPageData: screenshot failed for page ${pageNum}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push(entry);
|
results.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetPages(cmd: {
|
||||||
|
requestId: string;
|
||||||
|
intervals: Array<{ start?: number; end?: number }>;
|
||||||
|
getText: boolean;
|
||||||
|
getScreenshots: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const results = await collectPageData(
|
||||||
|
cmd.intervals,
|
||||||
|
cmd.getText,
|
||||||
|
cmd.getScreenshots,
|
||||||
|
);
|
||||||
|
|
||||||
// Submit results back to server
|
// Submit results back to server
|
||||||
try {
|
try {
|
||||||
await app.callServerTool({
|
await app.callServerTool({
|
||||||
|
|
@ -5091,6 +5114,398 @@ app.onteardown = async () => {
|
||||||
|
|
||||||
app.onhostcontextchanged = handleHostContextChanged;
|
app.onhostcontextchanged = handleHostContextChanged;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// App-registered tools — 1:1 with the server's `interact` commands.
|
||||||
|
//
|
||||||
|
// Each tool constructs the matching PdfCommand and dispatches through
|
||||||
|
// processCommands(), so the command-handling logic lives in exactly one
|
||||||
|
// place. The server-side `interact` tool remains for hosts that don't
|
||||||
|
// support app-registered tools.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Shared zod shapes mirroring server.ts interact schema. */
|
||||||
|
const FormFieldSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.union([z.string(), z.boolean()]),
|
||||||
|
});
|
||||||
|
const PageIntervalSchema = z.object({
|
||||||
|
start: z.number().min(1).optional(),
|
||||||
|
end: z.number().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Dispatch a command via processCommands and return a text result. */
|
||||||
|
async function runCommand(
|
||||||
|
cmd: PdfCommand,
|
||||||
|
okText: string | (() => string),
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
if (!pdfDocument) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "Error: No document loaded" }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await processCommands([cmd]);
|
||||||
|
const text = typeof okText === "function" ? okText() : okText;
|
||||||
|
return { content: [{ type: "text" as const, text }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"get-document-info",
|
||||||
|
{
|
||||||
|
title: "Get Document Info",
|
||||||
|
description:
|
||||||
|
"Get information about the current PDF document including title, current page, total pages, and zoom level",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
if (!pdfDocument) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "Error: No document loaded" }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const info = {
|
||||||
|
title: pdfTitle || "Untitled",
|
||||||
|
url: pdfUrl,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
scale,
|
||||||
|
displayMode: currentDisplayMode,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(info, null, 2) }],
|
||||||
|
structuredContent: info,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"navigate",
|
||||||
|
{
|
||||||
|
title: "Navigate",
|
||||||
|
description: "Jump to a specific page in the document",
|
||||||
|
inputSchema: z.object({
|
||||||
|
page: z.number().int().min(1).describe("Page number (1-indexed)"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ page }) => {
|
||||||
|
if (pdfDocument && (page < 1 || page > totalPages)) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error: Page ${page} out of range (1-${totalPages})`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return runCommand(
|
||||||
|
{ type: "navigate", page },
|
||||||
|
`Navigated to page ${page}/${totalPages}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"search",
|
||||||
|
{
|
||||||
|
title: "Search",
|
||||||
|
description:
|
||||||
|
"Search for text and highlight matches in the viewer UI. Opens the search bar and jumps to the first match.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe("Text to search for"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ query }) =>
|
||||||
|
runCommand(
|
||||||
|
{ type: "search", query },
|
||||||
|
() => `Searched for "${query}": ${allMatches.length} match(es)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"find",
|
||||||
|
{
|
||||||
|
title: "Find",
|
||||||
|
description:
|
||||||
|
"Silent search — locate matches without opening the search UI. Use before search_navigate.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe("Text to search for"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ query }) =>
|
||||||
|
runCommand(
|
||||||
|
{ type: "find", query },
|
||||||
|
() => `Found ${allMatches.length} match(es) for "${query}"`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"search_navigate",
|
||||||
|
{
|
||||||
|
title: "Search Navigate",
|
||||||
|
description:
|
||||||
|
"Jump to the Nth search match (0-indexed). Call search or find first.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
matchIndex: z.number().int().min(0).describe("Match index (0-indexed)"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ matchIndex }) => {
|
||||||
|
if (allMatches.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: "Error: No search results. Call search or find first.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (matchIndex >= allMatches.length) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error: matchIndex ${matchIndex} out of range (0-${allMatches.length - 1})`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return runCommand(
|
||||||
|
{ type: "search_navigate", matchIndex },
|
||||||
|
`Jumped to match ${matchIndex + 1}/${allMatches.length} on page ${allMatches[matchIndex].pageNum}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"zoom",
|
||||||
|
{
|
||||||
|
title: "Zoom",
|
||||||
|
description: "Set the zoom scale for the document",
|
||||||
|
inputSchema: z.object({
|
||||||
|
scale: z
|
||||||
|
.number()
|
||||||
|
.min(0.5)
|
||||||
|
.max(3.0)
|
||||||
|
.describe("Zoom scale, 1.0 = 100% (range: 0.5-3.0)"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ scale }) =>
|
||||||
|
runCommand(
|
||||||
|
{ type: "zoom", scale },
|
||||||
|
`Zoom set to ${Math.round(scale * 100)}%`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"add_annotations",
|
||||||
|
{
|
||||||
|
title: "Add Annotations",
|
||||||
|
description:
|
||||||
|
"Add one or more annotations (highlight, note, rectangle, circle, line, stamp, image, freetext). Each needs id, type, page, and type-specific geometry.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
annotations: z
|
||||||
|
.array(z.record(z.string(), z.any()))
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"Annotation objects. Each needs: id, type, page, plus type-specific fields (x, y, width, height, rects, color, content, etc.)",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ annotations }) =>
|
||||||
|
runCommand(
|
||||||
|
{
|
||||||
|
type: "add_annotations",
|
||||||
|
annotations: annotations as PdfAnnotationDef[],
|
||||||
|
},
|
||||||
|
`Added ${annotations.length} annotation(s)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"update_annotations",
|
||||||
|
{
|
||||||
|
title: "Update Annotations",
|
||||||
|
description:
|
||||||
|
"Patch existing annotations by id. Only id and type are required; other fields are merged.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
annotations: z
|
||||||
|
.array(z.record(z.string(), z.any()))
|
||||||
|
.min(1)
|
||||||
|
.describe("Partial annotation objects. Each needs: id, type."),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ annotations }) =>
|
||||||
|
runCommand(
|
||||||
|
{
|
||||||
|
type: "update_annotations",
|
||||||
|
annotations: annotations as Extract<
|
||||||
|
PdfCommand,
|
||||||
|
{ type: "update_annotations" }
|
||||||
|
>["annotations"],
|
||||||
|
},
|
||||||
|
`Updated ${annotations.length} annotation(s)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"remove_annotations",
|
||||||
|
{
|
||||||
|
title: "Remove Annotations",
|
||||||
|
description: "Delete annotations by id",
|
||||||
|
inputSchema: z.object({
|
||||||
|
ids: z.array(z.string()).min(1).describe("Annotation IDs to remove"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ ids }) =>
|
||||||
|
runCommand(
|
||||||
|
{ type: "remove_annotations", ids },
|
||||||
|
`Removed ${ids.length} annotation(s)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"highlight_text",
|
||||||
|
{
|
||||||
|
title: "Highlight Text",
|
||||||
|
description:
|
||||||
|
"Auto-locate text and add a highlight annotation. Searches the document (or a specific page) and highlights the first match.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe("Text to locate and highlight"),
|
||||||
|
page: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Restrict search to this page"),
|
||||||
|
color: z.string().optional().describe("Highlight color (CSS color)"),
|
||||||
|
content: z.string().optional().describe("Tooltip/note content"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ query, page, color, content }) => {
|
||||||
|
const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
return runCommand(
|
||||||
|
{ type: "highlight_text", id, query, page, color, content },
|
||||||
|
`Highlighted "${query}"${page ? ` on page ${page}` : ""} (id: ${id})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"fill_form",
|
||||||
|
{
|
||||||
|
title: "Fill Form",
|
||||||
|
description: "Fill PDF form fields by name",
|
||||||
|
inputSchema: z.object({
|
||||||
|
fields: z
|
||||||
|
.array(FormFieldSchema)
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"Form fields: { name, value } where value is string or boolean",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ fields }) =>
|
||||||
|
runCommand(
|
||||||
|
{ type: "fill_form", fields },
|
||||||
|
`Filled ${fields.length} field(s): ${fields.map((f) => f.name).join(", ")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"get_text",
|
||||||
|
{
|
||||||
|
title: "Get Text",
|
||||||
|
description:
|
||||||
|
"Extract text from one or more pages. Returns one text block per page.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
page: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Single page (shorthand for intervals: [{start:N, end:N}])"),
|
||||||
|
intervals: z
|
||||||
|
.array(PageIntervalSchema)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Page ranges. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ page, intervals }) => {
|
||||||
|
if (!pdfDocument) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "Error: No document loaded" }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const resolved = intervals ?? (page ? [{ start: page, end: page }] : [{}]);
|
||||||
|
const data = await collectPageData(resolved, true, false);
|
||||||
|
const parts = data
|
||||||
|
.filter((e) => e.text != null)
|
||||||
|
.map((e) => ({
|
||||||
|
type: "text" as const,
|
||||||
|
text: `--- Page ${e.page} ---\n${e.text}`,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
content:
|
||||||
|
parts.length > 0
|
||||||
|
? parts
|
||||||
|
: [{ type: "text" as const, text: "No text content returned" }],
|
||||||
|
structuredContent: { pages: data },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.registerTool(
|
||||||
|
"get_screenshot",
|
||||||
|
{
|
||||||
|
title: "Get Screenshot",
|
||||||
|
description: "Render a page to a JPEG image for visual analysis",
|
||||||
|
inputSchema: z.object({
|
||||||
|
page: z.number().int().min(1).describe("Page number to render"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ page }) => {
|
||||||
|
if (!pdfDocument) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "Error: No document loaded" }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = await collectPageData(
|
||||||
|
[{ start: page, end: page }],
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const entry = data[0];
|
||||||
|
if (entry?.image) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image" as const,
|
||||||
|
data: entry.image,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Error: screenshot failed for page ${page}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Connect to host
|
// Connect to host
|
||||||
app
|
app
|
||||||
.connect()
|
.connect()
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,9 @@ LIMITATIONS - Do NOT use:
|
||||||
- VR features (mainVR not available)
|
- VR features (mainVR not available)
|
||||||
|
|
||||||
For procedural noise:
|
For procedural noise:
|
||||||
float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }`;
|
float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
|
||||||
|
|
||||||
|
The widget is interactive and exposes tools for updating shader source code and querying compilation status. Compilation errors are sent to model context automatically.`;
|
||||||
|
|
||||||
const DEFAULT_FRAGMENT_SHADER = `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
const DEFAULT_FRAGMENT_SHADER = `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
vec2 uv = fragCoord / iResolution.xy;
|
vec2 uv = fragCoord / iResolution.xy;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
applyHostStyleVariables,
|
applyHostStyleVariables,
|
||||||
applyDocumentTheme,
|
applyDocumentTheme,
|
||||||
} from "@modelcontextprotocol/ext-apps";
|
} from "@modelcontextprotocol/ext-apps";
|
||||||
|
import { z } from "zod";
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
import "./mcp-app.css";
|
import "./mcp-app.css";
|
||||||
import ShaderToyLite, {
|
import ShaderToyLite, {
|
||||||
|
|
@ -39,7 +40,8 @@ const log = {
|
||||||
// Get element references
|
// Get element references
|
||||||
const mainEl = document.querySelector(".main") as HTMLElement;
|
const mainEl = document.querySelector(".main") as HTMLElement;
|
||||||
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
|
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
|
||||||
const codePreview = document.getElementById("code-preview") as HTMLPreElement;
|
// TODO: code-preview streaming toggle was removed when ontoolinputpartial was
|
||||||
|
// dropped during app-tool refactor — element still exists in HTML, restore if needed
|
||||||
const fullscreenBtn = document.getElementById(
|
const fullscreenBtn = document.getElementById(
|
||||||
"fullscreen-btn",
|
"fullscreen-btn",
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
|
|
@ -100,6 +102,39 @@ fullscreenBtn.addEventListener("click", toggleFullscreen);
|
||||||
// ShaderToyLite instance
|
// ShaderToyLite instance
|
||||||
let shaderToy: ShaderToyLiteInstance | null = null;
|
let shaderToy: ShaderToyLiteInstance | null = null;
|
||||||
|
|
||||||
|
// Track current shader sources
|
||||||
|
let currentShaderSources: ShaderInput = {
|
||||||
|
fragmentShader: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track compilation status
|
||||||
|
interface CompilationStatus {
|
||||||
|
success: boolean;
|
||||||
|
errors: string[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
let lastCompilationStatus: CompilationStatus = {
|
||||||
|
success: true,
|
||||||
|
errors: [],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intercept console.error to capture shader compilation errors
|
||||||
|
const originalConsoleError = console.error.bind(console);
|
||||||
|
const capturedErrors: string[] = [];
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
originalConsoleError(...args);
|
||||||
|
// Capture shader compilation errors
|
||||||
|
const message = args.map((arg) => String(arg)).join(" ");
|
||||||
|
if (
|
||||||
|
message.includes("Shader compilation failed") ||
|
||||||
|
message.includes("Program initialization failed") ||
|
||||||
|
message.includes("Failed to compile")
|
||||||
|
) {
|
||||||
|
capturedErrors.push(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Create app instance
|
// Create app instance
|
||||||
const app = new App({ name: "ShaderToy Renderer", version: "1.0.0" });
|
const app = new App({ name: "ShaderToy Renderer", version: "1.0.0" });
|
||||||
|
|
||||||
|
|
@ -111,35 +146,18 @@ app.onteardown = async () => {
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
app.ontoolinputpartial = (params) => {
|
// Helper function to compile shader and update status
|
||||||
// Show code preview, hide canvas
|
function compileAndUpdateStatus(input: ShaderInput): void {
|
||||||
codePreview.classList.add("visible");
|
// Clear captured errors before compilation
|
||||||
canvas.classList.add("hidden");
|
capturedErrors.length = 0;
|
||||||
const code = params.arguments?.fragmentShader;
|
|
||||||
codePreview.textContent = typeof code === "string" ? code : "";
|
|
||||||
codePreview.scrollTop = codePreview.scrollHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
app.ontoolinput = (params) => {
|
|
||||||
log.info("Received shader input");
|
|
||||||
|
|
||||||
// Hide code preview, show canvas
|
|
||||||
codePreview.classList.remove("visible");
|
|
||||||
canvas.classList.remove("hidden");
|
|
||||||
|
|
||||||
if (!isShaderInput(params.arguments)) {
|
|
||||||
log.error("Invalid tool input");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { fragmentShader, common, bufferA, bufferB, bufferC, bufferD } =
|
|
||||||
params.arguments;
|
|
||||||
|
|
||||||
// Initialize ShaderToyLite if needed
|
// Initialize ShaderToyLite if needed
|
||||||
if (!shaderToy) {
|
if (!shaderToy) {
|
||||||
shaderToy = new ShaderToyLite("canvas");
|
shaderToy = new ShaderToyLite("canvas");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { fragmentShader, common, bufferA, bufferB, bufferC, bufferD } = input;
|
||||||
|
|
||||||
// Set common code (shared across all shaders)
|
// Set common code (shared across all shaders)
|
||||||
shaderToy.setCommon(common || "");
|
shaderToy.setCommon(common || "");
|
||||||
|
|
||||||
|
|
@ -167,6 +185,45 @@ app.ontoolinput = (params) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
shaderToy.play();
|
shaderToy.play();
|
||||||
|
|
||||||
|
// Update compilation status
|
||||||
|
const hasErrors = capturedErrors.length > 0;
|
||||||
|
lastCompilationStatus = {
|
||||||
|
success: !hasErrors,
|
||||||
|
errors: [...capturedErrors],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store current sources
|
||||||
|
currentShaderSources = { ...input };
|
||||||
|
|
||||||
|
// Send compilation status to model context if there are errors
|
||||||
|
if (hasErrors) {
|
||||||
|
app
|
||||||
|
.updateModelContext({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Shader compilation failed:\n${capturedErrors.join("\n")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
compilationStatus: lastCompilationStatus,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) => log.error("Failed to update model context:", err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.ontoolinput = (params) => {
|
||||||
|
log.info("Received shader input");
|
||||||
|
|
||||||
|
if (!isShaderInput(params.arguments)) {
|
||||||
|
log.error("Invalid tool input");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
compileAndUpdateStatus(params.arguments);
|
||||||
log.info("Setup complete");
|
log.info("Setup complete");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -174,6 +231,97 @@ app.onerror = log.error;
|
||||||
|
|
||||||
app.onhostcontextchanged = handleHostContextChanged;
|
app.onhostcontextchanged = handleHostContextChanged;
|
||||||
|
|
||||||
|
// Register tool: set-shader-source
|
||||||
|
app.registerTool(
|
||||||
|
"set-shader-source",
|
||||||
|
{
|
||||||
|
title: "Set Shader Source",
|
||||||
|
description:
|
||||||
|
"Update the shader source code. Compiles and runs the new shader immediately.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
fragmentShader: z
|
||||||
|
.string()
|
||||||
|
.describe("The main fragment shader source code (mainImage function)"),
|
||||||
|
common: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Common code shared across all shaders"),
|
||||||
|
bufferA: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Buffer A shader source (for multi-pass rendering)"),
|
||||||
|
bufferB: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Buffer B shader source (for multi-pass rendering)"),
|
||||||
|
bufferC: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Buffer C shader source (for multi-pass rendering)"),
|
||||||
|
bufferD: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Buffer D shader source (for multi-pass rendering)"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
log.info("set-shader-source tool called");
|
||||||
|
|
||||||
|
compileAndUpdateStatus(args);
|
||||||
|
|
||||||
|
const result = lastCompilationStatus.success
|
||||||
|
? "Shader compiled and running successfully."
|
||||||
|
: `Shader compilation failed:\n${lastCompilationStatus.errors.join("\n")}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: result }],
|
||||||
|
structuredContent: {
|
||||||
|
success: lastCompilationStatus.success,
|
||||||
|
errors: lastCompilationStatus.errors,
|
||||||
|
timestamp: lastCompilationStatus.timestamp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register tool: get-shader-info
|
||||||
|
app.registerTool(
|
||||||
|
"get-shader-info",
|
||||||
|
{
|
||||||
|
title: "Get Shader Info",
|
||||||
|
description: "Get the current shader source code and compilation status.",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
log.info("get-shader-info tool called");
|
||||||
|
|
||||||
|
const hasShader = currentShaderSources.fragmentShader.length > 0;
|
||||||
|
const isPlaying = shaderToy?.isPlaying() ?? false;
|
||||||
|
|
||||||
|
let statusText = "";
|
||||||
|
if (!hasShader) {
|
||||||
|
statusText = "No shader loaded.";
|
||||||
|
} else if (lastCompilationStatus.success) {
|
||||||
|
statusText = `Shader is ${isPlaying ? "running" : "paused"}.`;
|
||||||
|
} else {
|
||||||
|
statusText = `Shader has compilation errors:\n${lastCompilationStatus.errors.join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `${statusText}\n\nCurrent fragment shader:\n${currentShaderSources.fragmentShader || "(none)"}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
sources: currentShaderSources,
|
||||||
|
compilationStatus: lastCompilationStatus,
|
||||||
|
isPlaying,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Pause/resume shader based on visibility
|
// Pause/resume shader based on visibility
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@
|
||||||
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
||||||
import { useApp } from "@modelcontextprotocol/ext-apps/react";
|
import { useApp } from "@modelcontextprotocol/ext-apps/react";
|
||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { StrictMode, useState, useEffect } from "react";
|
import { StrictMode, useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { z } from "zod";
|
||||||
import ThreeJSApp from "./threejs-app.tsx";
|
import ThreeJSApp from "./threejs-app.tsx";
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
|
|
||||||
|
|
@ -16,6 +17,20 @@ import "./global.css";
|
||||||
// Types
|
// Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene state tracked for view interaction tools.
|
||||||
|
*/
|
||||||
|
export interface SceneState {
|
||||||
|
/** Current Three.js code */
|
||||||
|
code: string | null;
|
||||||
|
/** Canvas height */
|
||||||
|
height: number;
|
||||||
|
/** Last error message if any */
|
||||||
|
error: string | null;
|
||||||
|
/** Whether the scene is currently rendering */
|
||||||
|
isRendering: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props passed to the view component.
|
* Props passed to the view component.
|
||||||
* This interface can be reused for other views.
|
* This interface can be reused for other views.
|
||||||
|
|
@ -31,6 +46,105 @@ export interface ViewProps<TToolInput = Record<string, unknown>> {
|
||||||
toolResult: CallToolResult | null;
|
toolResult: CallToolResult | null;
|
||||||
/** Host context (theme, dimensions, locale, etc.) */
|
/** Host context (theme, dimensions, locale, etc.) */
|
||||||
hostContext: McpUiHostContext | null;
|
hostContext: McpUiHostContext | null;
|
||||||
|
/** Call a tool on the MCP server */
|
||||||
|
callServerTool: App["callServerTool"];
|
||||||
|
/** Send a message to the host's chat */
|
||||||
|
sendMessage: App["sendMessage"];
|
||||||
|
/** Request the host to open a URL */
|
||||||
|
openLink: App["openLink"];
|
||||||
|
/** Send log messages to the host */
|
||||||
|
sendLog: App["sendLog"];
|
||||||
|
/** Callback to report scene errors */
|
||||||
|
onSceneError: (error: string | null) => void;
|
||||||
|
/** Callback to report scene is rendering */
|
||||||
|
onSceneRendering: (isRendering: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Widget Interaction Tools
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers widget interaction tools on the App instance.
|
||||||
|
* These tools allow the model to interact with the Three.js scene.
|
||||||
|
*/
|
||||||
|
function registerWidgetTools(
|
||||||
|
app: App,
|
||||||
|
sceneStateRef: React.RefObject<SceneState>,
|
||||||
|
setToolInputs: (args: Record<string, unknown>) => void,
|
||||||
|
): void {
|
||||||
|
// Tool: set-scene-source - Update the scene source/configuration
|
||||||
|
app.registerTool(
|
||||||
|
"set-scene-source",
|
||||||
|
{
|
||||||
|
title: "Set Scene Source",
|
||||||
|
description:
|
||||||
|
"Update the Three.js scene source code. The code will be executed in a sandboxed environment with access to THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, and height.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
code: z.string().describe("JavaScript code to render the 3D scene"),
|
||||||
|
height: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.describe("Height in pixels (optional, defaults to current)"),
|
||||||
|
}),
|
||||||
|
outputSchema: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
code: z.string(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
sceneStateRef.current.code = args.code;
|
||||||
|
if (args.height !== undefined) {
|
||||||
|
sceneStateRef.current.height = args.height;
|
||||||
|
}
|
||||||
|
sceneStateRef.current.error = null;
|
||||||
|
setToolInputs({ code: args.code, height: sceneStateRef.current.height });
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
success: true,
|
||||||
|
code: args.code,
|
||||||
|
height: sceneStateRef.current.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: get-scene-info - Get current scene state and any errors
|
||||||
|
app.registerTool(
|
||||||
|
"get-scene-info",
|
||||||
|
{
|
||||||
|
title: "Get Scene Info",
|
||||||
|
description:
|
||||||
|
"Get the current Three.js scene state including source code, dimensions, rendering status, and any errors.",
|
||||||
|
outputSchema: z.object({
|
||||||
|
code: z.string().nullable(),
|
||||||
|
height: z.number(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
isRendering: z.boolean(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const state = sceneStateRef.current;
|
||||||
|
const result = {
|
||||||
|
code: state.code,
|
||||||
|
height: state.height,
|
||||||
|
error: state.error,
|
||||||
|
isRendering: state.isRendering,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -48,14 +162,38 @@ function McpAppWrapper() {
|
||||||
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
|
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
|
||||||
const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null);
|
const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null);
|
||||||
|
|
||||||
|
// Scene state for widget interaction tools
|
||||||
|
const sceneStateRef = useRef<SceneState>({
|
||||||
|
code: null,
|
||||||
|
height: 400,
|
||||||
|
error: null,
|
||||||
|
isRendering: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reference to app for tools to access updateModelContext
|
||||||
|
const appRef = useRef<App | null>(null);
|
||||||
|
|
||||||
const { app, error } = useApp({
|
const { app, error } = useApp({
|
||||||
appInfo: { name: "Three.js View", version: "1.0.0" },
|
appInfo: { name: "Three.js View", version: "1.0.0" },
|
||||||
capabilities: {},
|
capabilities: { tools: {} },
|
||||||
onAppCreated: (app) => {
|
onAppCreated: (app) => {
|
||||||
|
appRef.current = app;
|
||||||
|
|
||||||
|
// Register widget interaction tools before connect()
|
||||||
|
registerWidgetTools(app, sceneStateRef, setToolInputs);
|
||||||
|
|
||||||
// Complete tool input (streaming finished)
|
// Complete tool input (streaming finished)
|
||||||
app.ontoolinput = (params) => {
|
app.ontoolinput = (params) => {
|
||||||
setToolInputs(params.arguments as Record<string, unknown>);
|
const args = params.arguments as Record<string, unknown>;
|
||||||
|
setToolInputs(args);
|
||||||
setToolInputsPartial(null);
|
setToolInputsPartial(null);
|
||||||
|
// Update scene state from tool input
|
||||||
|
if (typeof args.code === "string") {
|
||||||
|
sceneStateRef.current.code = args.code;
|
||||||
|
}
|
||||||
|
if (typeof args.height === "number") {
|
||||||
|
sceneStateRef.current.height = args.height;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Partial tool input (streaming in progress)
|
// Partial tool input (streaming in progress)
|
||||||
app.ontoolinputpartial = (params) => {
|
app.ontoolinputpartial = (params) => {
|
||||||
|
|
@ -110,6 +248,53 @@ function McpAppWrapper() {
|
||||||
}
|
}
|
||||||
}, [app]);
|
}, [app]);
|
||||||
|
|
||||||
|
// Memoized callbacks that forward to app methods
|
||||||
|
const callServerTool = useCallback<App["callServerTool"]>(
|
||||||
|
(params, options) => app!.callServerTool(params, options),
|
||||||
|
[app],
|
||||||
|
);
|
||||||
|
const sendMessage = useCallback<App["sendMessage"]>(
|
||||||
|
(params, options) => app!.sendMessage(params, options),
|
||||||
|
[app],
|
||||||
|
);
|
||||||
|
const openLink = useCallback<App["openLink"]>(
|
||||||
|
(params, options) => app!.openLink(params, options),
|
||||||
|
[app],
|
||||||
|
);
|
||||||
|
const sendLog = useCallback<App["sendLog"]>(
|
||||||
|
(params) => app!.sendLog(params),
|
||||||
|
[app],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Callback for scene to report errors
|
||||||
|
const onSceneError = useCallback((sceneError: string | null) => {
|
||||||
|
sceneStateRef.current.error = sceneError;
|
||||||
|
|
||||||
|
// Send errors to model context for awareness
|
||||||
|
if (sceneError && appRef.current) {
|
||||||
|
void appRef.current
|
||||||
|
.updateModelContext({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Three.js Scene Error: ${sceneError}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
type: "scene_error",
|
||||||
|
error: sceneError,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Callback for scene to report rendering state
|
||||||
|
const onSceneRendering = useCallback((isRendering: boolean) => {
|
||||||
|
sceneStateRef.current.isRendering = isRendering;
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="error">Error: {error.message}</div>;
|
return <div className="error">Error: {error.message}</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +310,12 @@ function McpAppWrapper() {
|
||||||
toolInputsPartial={toolInputsPartial}
|
toolInputsPartial={toolInputsPartial}
|
||||||
toolResult={toolResult}
|
toolResult={toolResult}
|
||||||
hostContext={hostContext}
|
hostContext={hostContext}
|
||||||
|
callServerTool={callServerTool}
|
||||||
|
sendMessage={sendMessage}
|
||||||
|
openLink={openLink}
|
||||||
|
sendLog={sendLog}
|
||||||
|
onSceneError={onSceneError}
|
||||||
|
onSceneRendering={onSceneRendering}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,12 @@ export default function ThreeJSApp({
|
||||||
toolInputs,
|
toolInputs,
|
||||||
toolInputsPartial,
|
toolInputsPartial,
|
||||||
hostContext,
|
hostContext,
|
||||||
|
callServerTool: _callServerTool,
|
||||||
|
sendMessage: _sendMessage,
|
||||||
|
openLink: _openLink,
|
||||||
|
sendLog: _sendLog,
|
||||||
|
onSceneError,
|
||||||
|
onSceneRendering,
|
||||||
}: ThreeJSAppProps) {
|
}: ThreeJSAppProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentDisplayMode, setCurrentDisplayMode] = useState<
|
const [currentDisplayMode, setCurrentDisplayMode] = useState<
|
||||||
|
|
@ -304,6 +310,9 @@ export default function ThreeJSApp({
|
||||||
animControllerRef.current = createAnimationController();
|
animControllerRef.current = createAnimationController();
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
onSceneError(null);
|
||||||
|
onSceneRendering(true);
|
||||||
|
|
||||||
const w = containerWidth || containerRef.current.offsetWidth || 800;
|
const w = containerWidth || containerRef.current.offsetWidth || 800;
|
||||||
const h = isFullscreen && hostHeight > 0 ? hostHeight : height;
|
const h = isFullscreen && hostHeight > 0 ? hostHeight : height;
|
||||||
executeThreeCode(
|
executeThreeCode(
|
||||||
|
|
@ -312,12 +321,18 @@ export default function ThreeJSApp({
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
animControllerRef.current.visibilityAwareRAF,
|
animControllerRef.current.visibilityAwareRAF,
|
||||||
).catch((e) => setError(e instanceof Error ? e.message : "Unknown error"));
|
).catch((e) => {
|
||||||
|
const msg = e instanceof Error ? e.message : "Unknown error";
|
||||||
|
setError(msg);
|
||||||
|
onSceneError(msg);
|
||||||
|
onSceneRendering(false);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
canvas.removeEventListener("touchstart", preventDefault);
|
canvas.removeEventListener("touchstart", preventDefault);
|
||||||
canvas.removeEventListener("touchmove", preventDefault);
|
canvas.removeEventListener("touchmove", preventDefault);
|
||||||
animControllerRef.current?.cleanup();
|
animControllerRef.current?.cleanup();
|
||||||
|
onSceneRendering(false);
|
||||||
};
|
};
|
||||||
}, [code, height, containerWidth, isFullscreen, hostHeight]);
|
}, [code, height, containerWidth, isFullscreen, hostHeight]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export function createServer(): McpServer {
|
||||||
{
|
{
|
||||||
title: "Get First-Degree Links",
|
title: "Get First-Degree Links",
|
||||||
description:
|
description:
|
||||||
"Returns all Wikipedia pages that the given page links to directly.",
|
"Returns all Wikipedia pages that the given page links to directly. The widget is interactive and exposes tools for exploring the graph (expanding nodes to see their links), searching for articles, and querying visible nodes.",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z
|
url: z
|
||||||
.string()
|
.string()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
forceCenter,
|
forceCenter,
|
||||||
forceCollide,
|
forceCollide,
|
||||||
|
|
@ -397,6 +398,346 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
|
||||||
|
|
||||||
app.onhostcontextchanged = handleHostContextChanged;
|
app.onhostcontextchanged = handleHostContextChanged;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Widget Interaction Tools
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Tool: Search for a Wikipedia article and navigate to it
|
||||||
|
app.registerTool(
|
||||||
|
"search-article",
|
||||||
|
{
|
||||||
|
title: "Search Article",
|
||||||
|
description:
|
||||||
|
"Search for a Wikipedia article and add it to the graph as the new starting point",
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().describe("Search query for Wikipedia article"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const { query } = args as { query: string };
|
||||||
|
|
||||||
|
// Construct Wikipedia search URL that redirects to the article
|
||||||
|
const searchUrl = `https://en.wikipedia.org/wiki/Special:Search?go=Go&search=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
// Use the server tool to fetch the article
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "get-first-degree-links",
|
||||||
|
arguments: { url: searchUrl },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = result.structuredContent as unknown as ToolResponse;
|
||||||
|
if (response && response.page) {
|
||||||
|
// Clear existing graph and start fresh with this article
|
||||||
|
graphData.nodes = [];
|
||||||
|
graphData.links = [];
|
||||||
|
initialUrl = response.page.url;
|
||||||
|
addNode(response.page.url, response.page.title, "default", {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
graph.warmupTicks(100);
|
||||||
|
handleToolResultData(result);
|
||||||
|
graph.centerAt(0, 0, 500);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Navigated to article: ${response.page.title}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: true,
|
||||||
|
article: response.page,
|
||||||
|
linksFound: response.links?.length ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: `Could not find article for: ${query}` },
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: "Article not found",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: Get information about the currently displayed article
|
||||||
|
app.registerTool(
|
||||||
|
"get-current-article",
|
||||||
|
{
|
||||||
|
title: "Get Current Article",
|
||||||
|
description:
|
||||||
|
"Get information about the currently selected or initial article in the graph",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const currentUrl = selectedNodeUrl || initialUrl;
|
||||||
|
|
||||||
|
if (!currentUrl) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: "No article is currently selected" },
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
hasSelection: false,
|
||||||
|
article: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = graphData.nodes.find((n) => n.url === currentUrl);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: "Selected article not found in graph",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
hasSelection: false,
|
||||||
|
article: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Current article: ${node.title}\nURL: ${node.url}\nState: ${node.state}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
hasSelection: true,
|
||||||
|
article: {
|
||||||
|
url: node.url,
|
||||||
|
title: node.title,
|
||||||
|
state: node.state,
|
||||||
|
isExpanded: node.state === "expanded",
|
||||||
|
hasError: node.state === "error",
|
||||||
|
errorMessage: node.errorMessage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: Highlight a specific node in the graph
|
||||||
|
app.registerTool(
|
||||||
|
"highlight-node",
|
||||||
|
{
|
||||||
|
title: "Highlight Node",
|
||||||
|
description:
|
||||||
|
"Highlight and center on a specific node in the graph by title or URL",
|
||||||
|
inputSchema: z.object({
|
||||||
|
identifier: z
|
||||||
|
.string()
|
||||||
|
.describe("The title or URL of the node to highlight"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const { identifier } = args as { identifier: string };
|
||||||
|
const lowerIdentifier = identifier.toLowerCase();
|
||||||
|
|
||||||
|
// Find node by title (case-insensitive partial match) or exact URL
|
||||||
|
const node = graphData.nodes.find(
|
||||||
|
(n) =>
|
||||||
|
n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: `Node not found: ${identifier}` },
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: "Node not found in graph",
|
||||||
|
availableNodes: graphData.nodes.map((n) => n.title),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center on the node and select it
|
||||||
|
selectedNodeUrl = node.url;
|
||||||
|
if (node.x !== undefined && node.y !== undefined) {
|
||||||
|
graph.centerAt(node.x, node.y, 500);
|
||||||
|
graph.zoom(2, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: `Highlighted node: ${node.title}` },
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: true,
|
||||||
|
node: {
|
||||||
|
url: node.url,
|
||||||
|
title: node.title,
|
||||||
|
state: node.state,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: Expand a node to show its linked pages
|
||||||
|
app.registerTool(
|
||||||
|
"expand-node",
|
||||||
|
{
|
||||||
|
title: "Expand Node",
|
||||||
|
description:
|
||||||
|
"Expand a node to fetch and display all Wikipedia pages it links to. This is the core way to explore the graph.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
identifier: z.string().describe("The title or URL of the node to expand"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const { identifier } = args as { identifier: string };
|
||||||
|
const lowerIdentifier = identifier.toLowerCase();
|
||||||
|
|
||||||
|
// Find node by title (case-insensitive partial match) or exact URL
|
||||||
|
const node = graphData.nodes.find(
|
||||||
|
(n) =>
|
||||||
|
n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text" as const, text: `Node not found: ${identifier}` },
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: "Node not found in graph",
|
||||||
|
availableNodes: graphData.nodes.map((n) => n.title),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.state === "expanded") {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Node "${node.title}" is already expanded`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: true,
|
||||||
|
alreadyExpanded: true,
|
||||||
|
node: { url: node.url, title: node.title },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.state === "error") {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Node "${node.title}" has an error: ${node.errorMessage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: node.errorMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the linked pages using the server tool
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "get-first-degree-links",
|
||||||
|
arguments: { url: node.url },
|
||||||
|
});
|
||||||
|
|
||||||
|
graph.warmupTicks(0);
|
||||||
|
handleToolResultData(result);
|
||||||
|
|
||||||
|
const response = result.structuredContent as unknown as ToolResponse;
|
||||||
|
const linksAdded = response?.links?.length ?? 0;
|
||||||
|
|
||||||
|
// Center on the expanded node
|
||||||
|
if (node.x !== undefined && node.y !== undefined) {
|
||||||
|
graph.centerAt(node.x, node.y, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Expanded "${node.title}" - found ${linksAdded} linked articles`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: true,
|
||||||
|
node: { url: node.url, title: node.title },
|
||||||
|
linksAdded,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
setNodeState(node.url, "error", "Request failed");
|
||||||
|
updateGraph();
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Failed to expand "${node.title}": ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: String(e),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: Get list of currently visible nodes in the graph
|
||||||
|
app.registerTool(
|
||||||
|
"get-visible-nodes",
|
||||||
|
{
|
||||||
|
title: "Get Visible Nodes",
|
||||||
|
description: "Get a list of all nodes currently visible in the graph",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const nodes = graphData.nodes.map((n) => ({
|
||||||
|
url: n.url,
|
||||||
|
title: n.title,
|
||||||
|
state: n.state,
|
||||||
|
isExpanded: n.state === "expanded",
|
||||||
|
hasError: n.state === "error",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const expandedCount = nodes.filter((n) => n.isExpanded).length;
|
||||||
|
const errorCount = nodes.filter((n) => n.hasError).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Graph contains ${nodes.length} nodes:\n${nodes.map((n) => `- ${n.title} (${n.state})`).join("\n")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: {
|
||||||
|
totalNodes: nodes.length,
|
||||||
|
expandedNodes: expandedCount,
|
||||||
|
errorNodes: errorCount,
|
||||||
|
nodes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Connect to host
|
// Connect to host
|
||||||
app.connect().then(() => {
|
app.connect().then(() => {
|
||||||
const ctx = app.getHostContext();
|
const ctx = app.getHostContext();
|
||||||
|
|
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -11,6 +11,9 @@
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"examples/*"
|
"examples/*"
|
||||||
],
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
|
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
|
@ -3696,6 +3699,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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/@sveltejs/acorn-typescript": {
|
"node_modules/@sveltejs/acorn-typescript": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -125,5 +125,8 @@
|
||||||
"seroval-plugins": "1.4.2",
|
"seroval-plugins": "1.4.2",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"@types/node": "20.19.27"
|
"@types/node": "20.19.27"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -506,11 +506,23 @@ If the Host is a web page, it MUST wrap the View and communicate with it through
|
||||||
|
|
||||||
### Standard MCP Messages
|
### Standard MCP Messages
|
||||||
|
|
||||||
UI iframes can use the following subset of standard MCP protocol messages:
|
UI iframes can use the following subset of standard MCP protocol messages.
|
||||||
|
|
||||||
|
Note that `tools/call` and `tools/list` flow **bidirectionally**:
|
||||||
|
- **App → Host → Server**: Apps call server tools (requires host `serverTools` capability)
|
||||||
|
- **Host → App**: Host calls app-registered tools (requires app `tools` capability)
|
||||||
|
|
||||||
**Tools:**
|
**Tools:**
|
||||||
|
|
||||||
- `tools/call` - Execute a tool on the MCP server
|
- `tools/call` - Execute a tool (bidirectional)
|
||||||
|
- **App → Host**: Call server tool via host proxy
|
||||||
|
- **Host → App**: Call app-registered tool
|
||||||
|
- `tools/list` - List available tools (bidirectional)
|
||||||
|
- **App → Host**: List server tools
|
||||||
|
- **Host → App**: List app-registered tools
|
||||||
|
- `notifications/tools/list_changed` - Notify when tool list changes (bidirectional)
|
||||||
|
- **Server → Host → App**: Server tools changed
|
||||||
|
- **App → Host**: App-registered tools changed
|
||||||
|
|
||||||
**Resources:**
|
**Resources:**
|
||||||
|
|
||||||
|
|
@ -1200,6 +1212,95 @@ Host behavior:
|
||||||
- If multiple updates are received before the next user message, Host SHOULD only send the last update to the model
|
- If multiple updates are received before the next user message, Host SHOULD only send the last update to the model
|
||||||
- MAY display context updates to the user
|
- MAY display context updates to the user
|
||||||
|
|
||||||
|
#### Requests (Host → App)
|
||||||
|
|
||||||
|
When Apps declare the `tools` capability, the Host can send standard MCP tool requests to the App:
|
||||||
|
|
||||||
|
`tools/call` - Execute an App-registered tool
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request (Host → App)
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: string, // Name of app-registered tool to execute
|
||||||
|
arguments?: object // Tool arguments (validated against inputSchema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success Response (App → Host)
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
result: {
|
||||||
|
content: Array<ContentBlock>, // Result for model context
|
||||||
|
structuredContent?: object, // Optional structured data for UI
|
||||||
|
isError?: boolean, // True if tool execution failed
|
||||||
|
_meta?: object // Optional metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Response
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
error: {
|
||||||
|
code: number,
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**App Behavior:**
|
||||||
|
- Apps MUST implement `oncalltool` handler if they declare `tools` capability
|
||||||
|
- Apps SHOULD validate tool names and arguments
|
||||||
|
- Apps MAY use `app.registerTool()` SDK helper for automatic validation
|
||||||
|
- Apps SHOULD return `isError: true` for tool execution failures
|
||||||
|
|
||||||
|
`tools/list` - List App-registered tools
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request (Host → App)
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "tools/list",
|
||||||
|
params: {
|
||||||
|
cursor?: string // Optional pagination cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response (App → Host)
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
result: {
|
||||||
|
tools: Array<Tool>, // List of available tools
|
||||||
|
nextCursor?: string // Pagination cursor if more tools exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tool Structure** (identical to [core MCP `Tool`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool)):
|
||||||
|
```typescript
|
||||||
|
interface Tool {
|
||||||
|
name: string; // Unique tool identifier
|
||||||
|
title?: string; // Display name
|
||||||
|
description?: string; // Human-readable description
|
||||||
|
inputSchema: object; // JSON Schema for arguments
|
||||||
|
outputSchema?: object; // JSON Schema for structuredContent
|
||||||
|
annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint)
|
||||||
|
_meta?: object;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**App Behavior:**
|
||||||
|
- Apps MUST implement `onlisttools` handler if they declare `tools` capability
|
||||||
|
- Apps SHOULD return complete tool metadata including schemas
|
||||||
|
- Apps MAY filter tools based on context or permissions
|
||||||
|
|
||||||
#### Notifications (Host → View)
|
#### Notifications (Host → View)
|
||||||
|
|
||||||
`ui/notifications/tool-input` - Host MUST send this notification with the complete tool arguments after the View's initialize request completes.
|
`ui/notifications/tool-input` - Host MUST send this notification with the complete tool arguments after the View's initialize request completes.
|
||||||
|
|
@ -1611,6 +1712,446 @@ This pattern enables interactive, self-updating views.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### App-Provided Tools
|
||||||
|
|
||||||
|
Apps can register their own tools that hosts and agents can call, making apps **introspectable and accessible** to the model. This complements the existing capability where apps call server tools (via host proxy).
|
||||||
|
|
||||||
|
#### Motivation: Semantic Introspection
|
||||||
|
|
||||||
|
Without tool registration, apps are opaque to the model:
|
||||||
|
- The host renders the app in a sandboxed iframe and has no guaranteed access to its internals — apps may embed further cross-origin iframes (via `frameDomains`), and even where same-origin, DOM access is not part of the host↔app contract
|
||||||
|
- The model therefore cannot query app state or discover what operations the app supports
|
||||||
|
|
||||||
|
With tool registration, apps expose a semantic interface:
|
||||||
|
- The model discovers available operations via `tools/list`
|
||||||
|
- The model queries app state via tools (e.g., `get_board_state`)
|
||||||
|
- The model executes actions via tools (e.g., `make_move`)
|
||||||
|
- Apps return structured data rather than relying on the host to interpret rendered output
|
||||||
|
|
||||||
|
This is *pull*-based and complements the *push*-based [`ui/update-model-context`](#uiupdate-model-context) request already defined in this spec (and analogous mechanisms elsewhere, e.g. OAI Apps SDK's [`setWidgetState()`](https://developers.openai.com/apps-sdk/reference/sdk#setwidgetstate)). Push lets an app proactively keep the model's context current; pull lets the agent query and command the app on demand. Apps may use either or both.
|
||||||
|
|
||||||
|
#### App Tool Registration
|
||||||
|
|
||||||
|
Apps register tools using the SDK's `registerTool()` method:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { App } from '@modelcontextprotocol/ext-apps';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const app = new App(
|
||||||
|
{ name: "TicTacToe", version: "1.0.0" },
|
||||||
|
{ tools: { listChanged: true } } // Declare tool capability
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register a tool with schema validation
|
||||||
|
const moveTool = app.registerTool(
|
||||||
|
"tictactoe_move",
|
||||||
|
{
|
||||||
|
description: "Make a move in the tic-tac-toe game",
|
||||||
|
inputSchema: z.object({
|
||||||
|
position: z.number().int().min(0).max(8),
|
||||||
|
player: z.enum(['X', 'O'])
|
||||||
|
}),
|
||||||
|
outputSchema: z.object({
|
||||||
|
board: z.array(z.string()).length(9),
|
||||||
|
winner: z.enum(['X', 'O', 'draw', null]).nullable()
|
||||||
|
}),
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false // This tool has side effects
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
// Validate and execute move
|
||||||
|
const newBoard = makeMove(params.position, params.player);
|
||||||
|
const winner = checkWinner(newBoard);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Move made at position ${params.position}`
|
||||||
|
}],
|
||||||
|
structuredContent: {
|
||||||
|
board: newBoard,
|
||||||
|
winner
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.connect(new PostMessageTransport(window.parent));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Registration Options:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | string | Yes | Unique tool identifier |
|
||||||
|
| `description` | string | No | Human-readable description for agent |
|
||||||
|
| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` |
|
||||||
|
| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` |
|
||||||
|
| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) |
|
||||||
|
| `_meta` | object | No | Custom metadata |
|
||||||
|
|
||||||
|
Apps can also implement tool handling manually without the SDK:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.oncalltool = async (params, extra) => {
|
||||||
|
if (params.name === "tictactoe_move") {
|
||||||
|
// Manual validation
|
||||||
|
if (typeof params.arguments?.position !== 'number') {
|
||||||
|
throw new Error("Invalid position");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute tool
|
||||||
|
const newBoard = makeMove(params.arguments.position, params.arguments.player);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Move made" }],
|
||||||
|
structuredContent: { board: newBoard }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown tool: ${params.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
app.onlisttools = async () => {
|
||||||
|
return {
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "tictactoe_move",
|
||||||
|
description: "Make a move in the game",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
position: { type: "number", minimum: 0, maximum: 8 },
|
||||||
|
player: { type: "string", enum: ["X", "O"] }
|
||||||
|
},
|
||||||
|
required: ["position", "player"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tool Lifecycle
|
||||||
|
|
||||||
|
Registered tools support dynamic lifecycle management:
|
||||||
|
|
||||||
|
**Enable/Disable:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const tool = app.registerTool("my_tool", config, callback);
|
||||||
|
|
||||||
|
// Disable tool (hide from tools/list)
|
||||||
|
tool.disable();
|
||||||
|
|
||||||
|
// Re-enable tool
|
||||||
|
tool.enable();
|
||||||
|
```
|
||||||
|
|
||||||
|
When a tool is disabled/enabled, the app automatically sends `notifications/tools/list_changed` (if the app declared `listChanged: true` capability).
|
||||||
|
|
||||||
|
**Update:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Update tool description or schema
|
||||||
|
tool.update({
|
||||||
|
description: "New description",
|
||||||
|
inputSchema: newSchema
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates also trigger `notifications/tools/list_changed`.
|
||||||
|
|
||||||
|
**Remove:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Permanently remove tool
|
||||||
|
tool.remove();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Long-Running App Tools
|
||||||
|
|
||||||
|
App tools are bounded by the app's render lifecycle. For work that may outlive a single render, app `tools/call` MAY be task-augmented per [core MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks); hosts proxy the `tasks/*` methods to the app exactly as they proxy `tools/call`. Because the iframe can be torn down at any time, apps SHOULD delegate long-lived work to the server (via `callServerTool()`) and return the server's task handle, so the host can continue polling the server after teardown.
|
||||||
|
|
||||||
|
#### Schema Validation
|
||||||
|
|
||||||
|
The SDK validates input and output against any [Standard Schema](https://standardschema.dev/)–compatible library (Zod, ArkType, Valibot, …). Examples below use Zod:
|
||||||
|
|
||||||
|
**Input Validation:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.registerTool(
|
||||||
|
"search",
|
||||||
|
{
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z.string().min(1).max(100),
|
||||||
|
limit: z.number().int().positive().default(10)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
// params.query is guaranteed to be a string (1-100 chars)
|
||||||
|
// params.limit is guaranteed to be a positive integer (default 10)
|
||||||
|
return performSearch(params.query, params.limit);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
If the host sends invalid arguments, the tool automatically returns an error before the callback is invoked.
|
||||||
|
|
||||||
|
**Output Validation:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.registerTool(
|
||||||
|
"get_status",
|
||||||
|
{
|
||||||
|
outputSchema: z.object({
|
||||||
|
status: z.enum(['ready', 'busy', 'error']),
|
||||||
|
timestamp: z.string().datetime()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Status retrieved" }],
|
||||||
|
structuredContent: {
|
||||||
|
status: 'ready',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
If the callback returns data that doesn't match `outputSchema`, the tool returns an error.
|
||||||
|
|
||||||
|
#### Complete Example: Introspectable Tic-Tac-Toe
|
||||||
|
|
||||||
|
This example demonstrates how apps expose semantic interfaces through tools:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { App } from '@modelcontextprotocol/ext-apps';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Game state
|
||||||
|
let board: Array<'X' | 'O' | null> = Array(9).fill(null);
|
||||||
|
let currentPlayer: 'X' | 'O' = 'X';
|
||||||
|
let moveHistory: number[] = [];
|
||||||
|
|
||||||
|
const app = new App(
|
||||||
|
{ name: "TicTacToe", version: "1.0.0" },
|
||||||
|
{ tools: { listChanged: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agent can query semantic state directly
|
||||||
|
app.registerTool(
|
||||||
|
"get_board_state",
|
||||||
|
{
|
||||||
|
description: "Get current game state including board, current player, and winner",
|
||||||
|
outputSchema: z.object({
|
||||||
|
board: z.array(z.enum(['X', 'O', null])).length(9),
|
||||||
|
currentPlayer: z.enum(['X', 'O']),
|
||||||
|
winner: z.enum(['X', 'O', 'draw', null]).nullable(),
|
||||||
|
moveHistory: z.array(z.number())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Board: ${board.map(c => c || '-').join('')}, Player: ${currentPlayer}`
|
||||||
|
}],
|
||||||
|
structuredContent: {
|
||||||
|
board,
|
||||||
|
currentPlayer,
|
||||||
|
winner: checkWinner(board),
|
||||||
|
moveHistory
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agent can execute moves
|
||||||
|
app.registerTool(
|
||||||
|
"make_move",
|
||||||
|
{
|
||||||
|
description: "Place a piece at the specified position",
|
||||||
|
inputSchema: z.object({
|
||||||
|
position: z.number().int().min(0).max(8)
|
||||||
|
}),
|
||||||
|
annotations: { readOnlyHint: false }
|
||||||
|
},
|
||||||
|
async ({ position }) => {
|
||||||
|
if (board[position] !== null) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Position already taken" }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
board[position] = currentPlayer;
|
||||||
|
moveHistory.push(position);
|
||||||
|
const winner = checkWinner(board);
|
||||||
|
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Player ${board[position]} moved to position ${position}` +
|
||||||
|
(winner ? `. ${winner} wins!` : '')
|
||||||
|
}],
|
||||||
|
structuredContent: {
|
||||||
|
board,
|
||||||
|
currentPlayer,
|
||||||
|
winner,
|
||||||
|
moveHistory
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agent can reset game
|
||||||
|
app.registerTool(
|
||||||
|
"reset_game",
|
||||||
|
{
|
||||||
|
description: "Reset the game board to initial state",
|
||||||
|
annotations: { readOnlyHint: false }
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
board = Array(9).fill(null);
|
||||||
|
currentPlayer = 'X';
|
||||||
|
moveHistory = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Game reset" }],
|
||||||
|
structuredContent: { board, currentPlayer, moveHistory }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.connect(new PostMessageTransport(window.parent));
|
||||||
|
|
||||||
|
function checkWinner(board: Array<'X' | 'O' | null>): 'X' | 'O' | 'draw' | null {
|
||||||
|
const lines = [
|
||||||
|
[0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
|
||||||
|
[0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
|
||||||
|
[0, 4, 8], [2, 4, 6] // diagonals
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [a, b, c] of lines) {
|
||||||
|
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
|
||||||
|
return board[a];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return board.every(cell => cell !== null) ? 'draw' : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent Interaction:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Discover available operations
|
||||||
|
const { tools } = await bridge.sendListTools({});
|
||||||
|
// → ["get_board_state", "make_move", "reset_game"]
|
||||||
|
|
||||||
|
// 2. Query semantic state (not visual/DOM)
|
||||||
|
const state = await bridge.sendCallTool({
|
||||||
|
name: "get_board_state",
|
||||||
|
arguments: {}
|
||||||
|
});
|
||||||
|
// → { board: [null, null, null, ...], currentPlayer: 'X', winner: null }
|
||||||
|
|
||||||
|
// 3. Execute actions based on semantic understanding
|
||||||
|
if (state.structuredContent.board[4] === null) {
|
||||||
|
await bridge.sendCallTool({
|
||||||
|
name: "make_move",
|
||||||
|
arguments: { position: 4 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Query updated state
|
||||||
|
const newState = await bridge.sendCallTool({
|
||||||
|
name: "get_board_state",
|
||||||
|
arguments: {}
|
||||||
|
});
|
||||||
|
// → { board: [null, null, null, null, 'X', null, ...], currentPlayer: 'O', ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent interacts with the app through semantic operations rather than visual interpretation.
|
||||||
|
|
||||||
|
#### Tool Flow Directions
|
||||||
|
|
||||||
|
**Existing Flow (unchanged): App → Host → Server**
|
||||||
|
|
||||||
|
Apps call server tools (proxied by host):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App calls server tool
|
||||||
|
const result = await app.callServerTool("get_weather", { location: "NYC" });
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires host `serverTools` capability.
|
||||||
|
|
||||||
|
**New Flow: Host/Agent → App**
|
||||||
|
|
||||||
|
Host/Agent calls app-registered tools:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Host calls app tool
|
||||||
|
const result = await bridge.sendCallTool({
|
||||||
|
name: "tictactoe_move",
|
||||||
|
arguments: { position: 4 }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires app `tools` capability.
|
||||||
|
|
||||||
|
**Key Distinction:**
|
||||||
|
|
||||||
|
| Aspect | Server Tools | App Tools |
|
||||||
|
|--------|-------------|-----------|
|
||||||
|
| **Lifetime** | Persistent (server process) | Ephemeral (while app loaded) |
|
||||||
|
| **Source** | MCP Server | App JavaScript |
|
||||||
|
| **Trust** | Trusted | Sandboxed (untrusted) |
|
||||||
|
| **Discovery** | Server `tools/list` | App `tools/list` (when app declares capability) |
|
||||||
|
| **When Available** | Always | Only while app is loaded |
|
||||||
|
|
||||||
|
#### Use Cases
|
||||||
|
|
||||||
|
**Introspection:** Agent queries app state semantically — the app's DOM is not exposed to the host or model
|
||||||
|
|
||||||
|
**Voice mode:** Agent drives app interactions programmatically based on voice commands
|
||||||
|
|
||||||
|
**Accessibility:** Structured state and operations more accessible than visual rendering
|
||||||
|
|
||||||
|
**Complex workflows:** Agent discovers available operations and coordinates multi-step interactions
|
||||||
|
|
||||||
|
**Stateful apps:** Apps expose operations (move, reset, query) rather than pushing state updates via messages
|
||||||
|
|
||||||
|
#### Security Implications
|
||||||
|
|
||||||
|
App tools run in **sandboxed iframes** (untrusted). See Security Implications section for detailed mitigations.
|
||||||
|
|
||||||
|
Key considerations:
|
||||||
|
- App tools could provide misleading descriptions
|
||||||
|
- Tool namespacing needed to avoid conflicts with server tools
|
||||||
|
- Resource limits (max tools, execution timeouts)
|
||||||
|
- Audit trail for app tool invocations
|
||||||
|
- User confirmation for tools with side effects
|
||||||
|
|
||||||
|
#### Relation to WebMCP
|
||||||
|
|
||||||
|
This feature is inspired by [WebMCP](https://github.com/webmachinelearning/webmcp) (W3C incubation), which proposes allowing web pages to register JavaScript functions as tools via `navigator.modelContext.registerTool()`.
|
||||||
|
|
||||||
|
Key differences:
|
||||||
|
- **WebMCP**: General web pages, browser API, manifest-based discovery
|
||||||
|
- **This spec**: MCP Apps, standard MCP messages, capability-based negotiation
|
||||||
|
|
||||||
|
Similar to WebMCP but without turning the App (embedded page) into an MCP server - apps register tools within the App/Host architecture.
|
||||||
|
|
||||||
|
See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35) for discussion.
|
||||||
|
|
||||||
### Client\<\>Server Capability Negotiation
|
### Client\<\>Server Capability Negotiation
|
||||||
|
|
||||||
Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724).
|
Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724).
|
||||||
|
|
@ -1681,15 +2222,104 @@ if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) {
|
||||||
- Tools MUST return meaningful content array even when UI is available
|
- Tools MUST return meaningful content array even when UI is available
|
||||||
- Servers MAY register different tool variants based on host capabilities
|
- Servers MAY register different tool variants based on host capabilities
|
||||||
|
|
||||||
|
#### App (Guest UI) Capabilities
|
||||||
|
|
||||||
|
Apps advertise their capabilities in the `ui/initialize` request to the host. When an app supports tool registration, it includes the `tools` capability:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "ui/initialize",
|
||||||
|
"params": {
|
||||||
|
"appInfo": {
|
||||||
|
"name": "TicTacToe",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"appCapabilities": {
|
||||||
|
"tools": {
|
||||||
|
"listChanged": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The host responds with its own capabilities, including support for proxying server tools:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"hostInfo": {
|
||||||
|
"name": "claude-desktop",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"hostCapabilities": {
|
||||||
|
"serverTools": {
|
||||||
|
"listChanged": true
|
||||||
|
},
|
||||||
|
"openLinks": {},
|
||||||
|
"logging": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**App Capability: `tools`**
|
||||||
|
|
||||||
|
When present, the app can register tools that the host and agent can call.
|
||||||
|
|
||||||
|
- `listChanged` (boolean, optional): If `true`, the app will send `notifications/tools/list_changed` when tools are added, removed, or modified
|
||||||
|
|
||||||
|
**Host Capability: `serverTools`**
|
||||||
|
|
||||||
|
When present, the host can proxy calls from the app to MCP server tools.
|
||||||
|
|
||||||
|
- `listChanged` (boolean, optional): If `true`, the host will send `notifications/tools/list_changed` when server tools change
|
||||||
|
|
||||||
|
These capabilities are independent - an app can have one, both, or neither.
|
||||||
|
|
||||||
|
**TypeScript Types:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface McpUiAppCapabilities {
|
||||||
|
tools?: {
|
||||||
|
listChanged?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpUiHostCapabilities {
|
||||||
|
serverTools?: {
|
||||||
|
listChanged?: boolean;
|
||||||
|
};
|
||||||
|
openLinks?: {};
|
||||||
|
logging?: {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Extensibility
|
### Extensibility
|
||||||
|
|
||||||
This specification defines the Minimum Viable Product (MVP) for MCP Apps. Future extensions may include:
|
This specification defines the Minimum Viable Product (MVP) for MCP Apps.
|
||||||
|
|
||||||
|
**Included in MVP:**
|
||||||
|
|
||||||
|
- **App-Provided Tools:** Apps can register tools via `app.registerTool()` that agents can call
|
||||||
|
- Bidirectional tool flow (Apps consume server tools AND provide app tools)
|
||||||
|
- Full lifecycle management (enable/disable/update/remove)
|
||||||
|
- Schema validation via [Standard Schema](https://standardschema.dev/) (Zod, ArkType, Valibot, …)
|
||||||
|
- Tool list change notifications
|
||||||
|
|
||||||
**Content Types (deferred from MVP):**
|
**Content Types (deferred from MVP):**
|
||||||
|
|
||||||
- `externalUrl`: Embed external web applications (e.g., `text/uri-list`)
|
- `externalUrl`: Embed external web applications (e.g., `text/uri-list`)
|
||||||
|
|
||||||
**Advanced Features (see Future Considerations):**
|
**Advanced Tool Features (future extensions):**
|
||||||
|
|
||||||
|
- Tool namespacing standards and conventions
|
||||||
|
- Standardized permission model specifications
|
||||||
|
- Tool categories/tags for organization
|
||||||
|
- Cross-app tool composition
|
||||||
|
- Tool marketplace/discovery mechanisms
|
||||||
|
|
||||||
|
**Other Advanced Features (see Future Considerations):**
|
||||||
|
|
||||||
- Support multiple UI resources in a tool response
|
- Support multiple UI resources in a tool response
|
||||||
- State persistence and restoration
|
- State persistence and restoration
|
||||||
|
|
@ -1727,7 +2357,7 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im
|
||||||
|
|
||||||
- Reuses existing MCP infrastructure (type definitions, error handling, timeouts)
|
- Reuses existing MCP infrastructure (type definitions, error handling, timeouts)
|
||||||
- UI developers can use standard MCP SDK (`@modelcontextprotocol/sdk`) or alternatives
|
- UI developers can use standard MCP SDK (`@modelcontextprotocol/sdk`) or alternatives
|
||||||
- Automatic compatibility with future MCP features (long-running tools, sampling, etc.)
|
- Automatic compatibility with core MCP features (Tasks for long-running calls, sampling, etc.)
|
||||||
- Better auditability through structured JSON-RPC messages
|
- Better auditability through structured JSON-RPC messages
|
||||||
- Reduces maintenance burden (no parallel protocol to evolve)
|
- Reduces maintenance burden (no parallel protocol to evolve)
|
||||||
|
|
||||||
|
|
@ -1789,6 +2419,37 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im
|
||||||
- **Boolean `private` flag:** Simpler but less flexible; doesn't express model-only tools.
|
- **Boolean `private` flag:** Simpler but less flexible; doesn't express model-only tools.
|
||||||
- **Flat `ui/visibility` key:** Rejected in favor of nested structure for consistency with future `_meta.ui` fields.
|
- **Flat `ui/visibility` key:** Rejected in favor of nested structure for consistency with future `_meta.ui` fields.
|
||||||
|
|
||||||
|
#### 6. App Tool Registration Support
|
||||||
|
|
||||||
|
**Decision:** Enable Apps to register tools using standard MCP `tools/call` and `tools/list` messages, making tools flow bidirectionally between Apps and Hosts.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
|
||||||
|
- **Semantic introspection:** Apps expose state and operations in a structured, machine-readable form. Hosts have no guaranteed DOM access into an app (which may itself nest cross-origin frames via `frameDomains`), so a tool surface is the only reliable introspection path
|
||||||
|
- **Protocol reuse:** Reuses existing MCP tool infrastructure (`tools/call`, `tools/list`, `notifications/tools/list_changed`) instead of inventing new message types
|
||||||
|
- **WebMCP alignment:** Brings WebMCP's vision of "JavaScript functions as tools" to MCP Apps while staying MCP-native
|
||||||
|
- **Agent-driven interaction:** Agents can pull app state and issue commands on demand, complementing push-style [`ui/update-model-context`](#uiupdate-model-context)
|
||||||
|
- **Bidirectional symmetry:** Apps act as both MCP clients (calling server tools) and MCP servers (providing app tools), creating clean architectural symmetry
|
||||||
|
- **Use case coverage:** Enables interactive games, stateful forms, complex workflows, and reusable widgets
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
|
||||||
|
- **Custom app-action API:** Rejected because it would duplicate MCP's existing tool infrastructure and create parallel protocol semantics. Using standard `tools/call` means automatic compatibility with future MCP features and better ecosystem integration.
|
||||||
|
- **Server-side proxy tools:** Apps could expose operations by having the server register proxy tools that communicate back to the app. Rejected because it doesn't leverage the app's JavaScript execution environment, adds unnecessary round-trips, and couples app functionality to server implementation.
|
||||||
|
- **Resources instead of tools:** Apps could expose state via `resources/read` rather than tools. Rejected because resources have wrong semantics (passive data retrieval vs. active operations), don't support parameters well, and don't convey operational intent.
|
||||||
|
|
||||||
|
**Security implications:**
|
||||||
|
|
||||||
|
Apps are forward-deployed emanations of server tools, running in the client context. Hosts should consider how to handle tool call approval:
|
||||||
|
|
||||||
|
- Per-app-instance approval (confirm each time a specific app instance calls a tool)
|
||||||
|
- Per-server approval (approve all apps from a trusted server)
|
||||||
|
- Per-tool approval (approve based on tool semantics and annotations)
|
||||||
|
- Clear attribution showing which app instance is calling a tool
|
||||||
|
- Audit trails for app tool calls
|
||||||
|
|
||||||
|
See [Security Implications: App-Provided Tools Security](#5-app-provided-tools-security) for detailed considerations.
|
||||||
|
|
||||||
### Backward Compatibility
|
### Backward Compatibility
|
||||||
|
|
||||||
The proposal builds on the existing core protocol. There are no incompatibilities.
|
The proposal builds on the existing core protocol. There are no incompatibilities.
|
||||||
|
|
@ -1881,6 +2542,83 @@ const allowAttribute = allowList.join(' ');
|
||||||
- Host SHOULD warn users when UI requires external domain access
|
- Host SHOULD warn users when UI requires external domain access
|
||||||
- Host MAY implement global domain allowlists/blocklists
|
- Host MAY implement global domain allowlists/blocklists
|
||||||
|
|
||||||
|
#### 5. App-Provided Tools Security
|
||||||
|
|
||||||
|
Apps can register their own tools that agents can call. Apps are forward-deployed emanations of server tools, running in the client context. Hosts need to decide how to handle approval for app tool calls.
|
||||||
|
|
||||||
|
**Approval Considerations:**
|
||||||
|
|
||||||
|
App-provided tools introduce additional approval considerations:
|
||||||
|
|
||||||
|
- **Tool description accuracy:** Apps may describe tools in ways that don't fully capture side effects
|
||||||
|
- **Namespace conflicts:** Apps could register tools with names conflicting with server tools
|
||||||
|
- **Resource consumption:** Apps could register many tools or implement slow callbacks
|
||||||
|
- **Data validation:** Tool results should match declared schemas
|
||||||
|
- **Semantic clarity:** Tool operations should be clear from their descriptions
|
||||||
|
|
||||||
|
**Approval Granularity:**
|
||||||
|
|
||||||
|
Hosts have discretion in how they handle app tool call approval:
|
||||||
|
|
||||||
|
1. **Per-app-instance approval:** Confirm each time a specific app instance's tool is called
|
||||||
|
2. **Per-server approval:** Trust all apps from servers the user has approved
|
||||||
|
3. **Per-tool approval:** Approve based on tool annotations (e.g., `readOnlyHint`)
|
||||||
|
4. **Hybrid approaches:** Combine strategies (e.g., auto-approve read-only tools from trusted servers)
|
||||||
|
|
||||||
|
**Host Protections:**
|
||||||
|
|
||||||
|
Hosts SHOULD implement the following protections for app-provided tools:
|
||||||
|
|
||||||
|
1. **Clear Attribution:**
|
||||||
|
- Display tool source in agent's tool list (e.g., "Tool from TicTacToe App")
|
||||||
|
- Visually distinguish app tools from server tools in UI
|
||||||
|
- Show app name and version in tool call confirmations
|
||||||
|
|
||||||
|
2. **User Confirmation:**
|
||||||
|
- Require explicit user approval for tools with `readOnlyHint: false`
|
||||||
|
- Consider auto-approving tools with `readOnlyHint: true` after review
|
||||||
|
- Implement per-app permission settings (always allow, always deny, ask)
|
||||||
|
|
||||||
|
3. **Namespace Management:**
|
||||||
|
- Recommend or enforce tool name prefixes (e.g., `app:move`, `tictactoe:move`)
|
||||||
|
- Prevent apps from registering tool names that conflict with server tools
|
||||||
|
- Document namespace conventions for app developers
|
||||||
|
|
||||||
|
4. **Resource Limits:**
|
||||||
|
- Limit maximum number of tools per app (recommended: 50)
|
||||||
|
- Enforce execution timeouts for tool callbacks (recommended: 30 seconds)
|
||||||
|
- Limit tool result sizes (recommended: 10 MB)
|
||||||
|
- Throttle `tools/list_changed` notifications to prevent spam
|
||||||
|
|
||||||
|
5. **Audit Trail:**
|
||||||
|
- Log all app tool registrations with timestamps
|
||||||
|
- Log all app tool calls with arguments and results
|
||||||
|
- Provide audit interface for users to review app tool activity
|
||||||
|
|
||||||
|
6. **Result Validation:**
|
||||||
|
- Validate tool results match declared schemas
|
||||||
|
- Sanitize result content before displaying to user or agent
|
||||||
|
- Reject results that appear malicious (e.g., phishing content)
|
||||||
|
|
||||||
|
**Permission Model:**
|
||||||
|
|
||||||
|
Hosts MAY implement different permission levels based on tool annotations:
|
||||||
|
|
||||||
|
| Annotation | Recommended Permission | Example |
|
||||||
|
|---------------------|------------------------|-------------------|
|
||||||
|
| `readOnlyHint: true`| Auto-approve (with caution) | `get_board_state()` |
|
||||||
|
| `readOnlyHint: false` | User confirmation required | `make_move()` |
|
||||||
|
| No annotation | User confirmation required (safe default) | Any tool |
|
||||||
|
|
||||||
|
**App Tool Lifecycle:**
|
||||||
|
|
||||||
|
App tools MUST be tied to the app's lifecycle:
|
||||||
|
|
||||||
|
- Tools become available once the app advertises the `tools` capability in `ui/initialize` and the host issues `tools/list`; subsequent changes are signaled via `notifications/tools/list_changed`
|
||||||
|
- Tools automatically disappear when app iframe is torn down
|
||||||
|
- Hosts MUST NOT persist app tool registrations across sessions
|
||||||
|
- Calling a tool from a closed app MUST return an error
|
||||||
|
|
||||||
### Other risks
|
### Other risks
|
||||||
|
|
||||||
- **Social engineering:** UI can still display misleading content. Hosts should clearly indicate sandboxed UI boundaries.
|
- **Social engineering:** UI can still display misleading content. Hosts should clearly indicate sandboxed UI boundaries.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -304,9 +304,21 @@ function App_onlisttools_returnTools(app: App) {
|
||||||
app.onlisttools = async (params, extra) => {
|
app.onlisttools = async (params, extra) => {
|
||||||
return {
|
return {
|
||||||
tools: [
|
tools: [
|
||||||
{ name: "greet", inputSchema: { type: "object" as const } },
|
{
|
||||||
{ name: "calculate", inputSchema: { type: "object" as const } },
|
name: "greet",
|
||||||
{ name: "format", inputSchema: { type: "object" as const } },
|
description: "Greet the user",
|
||||||
|
inputSchema: { type: "object" as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "calculate",
|
||||||
|
description: "Perform a calculation",
|
||||||
|
inputSchema: { type: "object" as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "format",
|
||||||
|
description: "Format text",
|
||||||
|
inputSchema: { type: "object" as const },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
266
src/app.ts
266
src/app.ts
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
type RequestOptions,
|
type RequestOptions,
|
||||||
|
mergeCapabilities,
|
||||||
ProtocolOptions,
|
ProtocolOptions,
|
||||||
} from "@modelcontextprotocol/sdk/shared/protocol.js";
|
} from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||||
|
|
||||||
|
|
@ -26,6 +27,9 @@ import {
|
||||||
ReadResourceRequest,
|
ReadResourceRequest,
|
||||||
ReadResourceResult,
|
ReadResourceResult,
|
||||||
ReadResourceResultSchema,
|
ReadResourceResultSchema,
|
||||||
|
Tool,
|
||||||
|
ToolAnnotations,
|
||||||
|
ToolListChangedNotification,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { AppNotification, AppRequest, AppResult } from "./types";
|
import { AppNotification, AppRequest, AppResult } from "./types";
|
||||||
import { ProtocolWithEvents } from "./events";
|
import { ProtocolWithEvents } from "./events";
|
||||||
|
|
@ -65,6 +69,16 @@ import {
|
||||||
McpUiRequestDisplayModeResultSchema,
|
McpUiRequestDisplayModeResultSchema,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
|
import {
|
||||||
|
StandardSchemaV1,
|
||||||
|
standardSchemaToJsonSchema,
|
||||||
|
validateStandardSchema,
|
||||||
|
} from "./standard-schema";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
StandardSchemaV1,
|
||||||
|
StandardSchemaWithJSON,
|
||||||
|
} from "./standard-schema";
|
||||||
|
|
||||||
export { PostMessageTransport } from "./message-transport";
|
export { PostMessageTransport } from "./message-transport";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|
@ -179,6 +193,66 @@ type RequestHandlerExtra = Parameters<
|
||||||
Parameters<App["setRequestHandler"]>[1]
|
Parameters<App["setRequestHandler"]>[1]
|
||||||
>[1];
|
>[1];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of an app-registered tool callback. When `Out` is provided,
|
||||||
|
* `structuredContent` is required and typed (unless `isError: true`).
|
||||||
|
*/
|
||||||
|
export type AppToolResult<
|
||||||
|
Out extends StandardSchemaV1 | undefined = undefined,
|
||||||
|
> = Out extends StandardSchemaV1
|
||||||
|
?
|
||||||
|
| (CallToolResult & {
|
||||||
|
structuredContent: StandardSchemaV1.InferOutput<Out>;
|
||||||
|
isError?: false;
|
||||||
|
})
|
||||||
|
| (CallToolResult & { isError: true })
|
||||||
|
: CallToolResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for an app-registered tool. When `In` is provided, `args` is the
|
||||||
|
* validated/parsed input; when `In` is `undefined`, the callback receives only
|
||||||
|
* `extra`. When `Out` is provided, the return's `structuredContent` is typed.
|
||||||
|
*
|
||||||
|
* Mirrors `ToolCallback` from `@modelcontextprotocol/sdk/server/mcp.js` but is
|
||||||
|
* parameterized over {@link StandardSchemaV1} instead of zod, so any
|
||||||
|
* Standard-Schema-compatible library (Zod, ArkType, Valibot, …) can be used.
|
||||||
|
*/
|
||||||
|
export type AppToolCallback<
|
||||||
|
In extends StandardSchemaV1 | undefined = undefined,
|
||||||
|
Out extends StandardSchemaV1 | undefined = undefined,
|
||||||
|
> = In extends StandardSchemaV1
|
||||||
|
? (
|
||||||
|
args: StandardSchemaV1.InferOutput<In>,
|
||||||
|
extra: RequestHandlerExtra,
|
||||||
|
) => AppToolResult<Out> | Promise<AppToolResult<Out>>
|
||||||
|
: (
|
||||||
|
extra: RequestHandlerExtra,
|
||||||
|
) => AppToolResult<Out> | Promise<AppToolResult<Out>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle returned by {@link App.registerTool}. Mirrors `RegisteredTool` from
|
||||||
|
* `@modelcontextprotocol/sdk/server/mcp.js` but stores
|
||||||
|
* {@link StandardSchemaV1} schemas.
|
||||||
|
*/
|
||||||
|
export type RegisteredAppTool = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: StandardSchemaV1;
|
||||||
|
outputSchema?: StandardSchemaV1;
|
||||||
|
annotations?: ToolAnnotations;
|
||||||
|
_meta?: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
enable(): void;
|
||||||
|
disable(): void;
|
||||||
|
remove(): void;
|
||||||
|
update(updates: Partial<Omit<RegisteredAppTool, "update">>): void;
|
||||||
|
/** @internal */
|
||||||
|
handler: (
|
||||||
|
args: unknown,
|
||||||
|
extra: RequestHandlerExtra,
|
||||||
|
) => Promise<CallToolResult>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps DOM-style event names to their notification `params` types.
|
* Maps DOM-style event names to their notification `params` types.
|
||||||
*
|
*
|
||||||
|
|
@ -262,6 +336,7 @@ export class App extends ProtocolWithEvents<
|
||||||
private _hostCapabilities?: McpUiHostCapabilities;
|
private _hostCapabilities?: McpUiHostCapabilities;
|
||||||
private _hostInfo?: Implementation;
|
private _hostInfo?: Implementation;
|
||||||
private _hostContext?: McpUiHostContext;
|
private _hostContext?: McpUiHostContext;
|
||||||
|
private _registeredTools: { [name: string]: RegisteredAppTool } = {};
|
||||||
private _initializedSent = false;
|
private _initializedSent = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -340,6 +415,177 @@ export class App extends ProtocolWithEvents<
|
||||||
this.setEventHandler("hostcontextchanged", undefined);
|
this.setEventHandler("hostcontextchanged", undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private registerCapabilities(capabilities: McpUiAppCapabilities): void {
|
||||||
|
if (this.transport) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot register capabilities after transport is established",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerTool<
|
||||||
|
OutputArgs extends undefined | StandardSchemaV1 = undefined,
|
||||||
|
InputArgs extends undefined | StandardSchemaV1 = undefined,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
config: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: InputArgs;
|
||||||
|
outputSchema?: OutputArgs;
|
||||||
|
annotations?: ToolAnnotations;
|
||||||
|
_meta?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
cb: AppToolCallback<InputArgs, OutputArgs>,
|
||||||
|
): RegisteredAppTool {
|
||||||
|
if (this._registeredTools[name]) {
|
||||||
|
throw new Error(`Tool ${name} is already registered`);
|
||||||
|
}
|
||||||
|
const app = this;
|
||||||
|
const notify = () => {
|
||||||
|
if (app._initializedSent && app._capabilities.tools?.listChanged) {
|
||||||
|
void app.sendToolListChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Arity is fixed at registration time even if inputSchema is later
|
||||||
|
// update()d, since cb's signature can't change.
|
||||||
|
const cbTakesArgs = config.inputSchema !== undefined;
|
||||||
|
const registeredTool: RegisteredAppTool = {
|
||||||
|
title: config.title,
|
||||||
|
description: config.description,
|
||||||
|
inputSchema: config.inputSchema,
|
||||||
|
outputSchema: config.outputSchema,
|
||||||
|
annotations: config.annotations,
|
||||||
|
_meta: config._meta,
|
||||||
|
enabled: true,
|
||||||
|
enable(): void {
|
||||||
|
this.enabled = true;
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
disable(): void {
|
||||||
|
this.enabled = false;
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
update(updates) {
|
||||||
|
Object.assign(this, updates);
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
remove() {
|
||||||
|
if (app._registeredTools[name] !== registeredTool) return;
|
||||||
|
delete app._registeredTools[name];
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
handler: async (rawArgs, extra) => {
|
||||||
|
if (!registeredTool.enabled) {
|
||||||
|
throw new Error(`Tool ${name} is disabled`);
|
||||||
|
}
|
||||||
|
let result: CallToolResult;
|
||||||
|
if (cbTakesArgs) {
|
||||||
|
const schema = registeredTool.inputSchema;
|
||||||
|
const parsedArgs = schema
|
||||||
|
? await validateStandardSchema(
|
||||||
|
schema,
|
||||||
|
rawArgs ?? {},
|
||||||
|
`Invalid input for tool ${name}: `,
|
||||||
|
)
|
||||||
|
: (rawArgs ?? {});
|
||||||
|
result = await (
|
||||||
|
cb as AppToolCallback<StandardSchemaV1, StandardSchemaV1>
|
||||||
|
)(parsedArgs, extra);
|
||||||
|
} else {
|
||||||
|
result = await (cb as AppToolCallback<undefined, StandardSchemaV1>)(
|
||||||
|
extra,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (registeredTool.outputSchema && !result.isError) {
|
||||||
|
result.structuredContent = (await validateStandardSchema(
|
||||||
|
registeredTool.outputSchema,
|
||||||
|
result.structuredContent,
|
||||||
|
`Invalid output for tool ${name}: `,
|
||||||
|
)) as CallToolResult["structuredContent"];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this._registeredTools[name] = registeredTool;
|
||||||
|
|
||||||
|
// Auto-register tools capability so setRequestHandler's capability check
|
||||||
|
// passes. Mirrors McpServer.registerTool behavior — callers shouldn't need
|
||||||
|
// to declare { tools: {} } in the constructor just to use registerTool.
|
||||||
|
// Only do this pre-connect; post-connect the capability was already
|
||||||
|
// advertised (or wasn't) and can't change.
|
||||||
|
if (!this._capabilities.tools && !this.transport) {
|
||||||
|
this.registerCapabilities({ tools: { listChanged: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensureToolHandlersInitialized();
|
||||||
|
notify();
|
||||||
|
return registeredTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toolHandlersInitialized = false;
|
||||||
|
private ensureToolHandlersInitialized(): void {
|
||||||
|
if (this._toolHandlersInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._toolHandlersInitialized = true;
|
||||||
|
|
||||||
|
this.oncalltool = async (params, extra) => {
|
||||||
|
const tool = this._registeredTools[params.name];
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error(`Tool ${params.name} not found`);
|
||||||
|
}
|
||||||
|
return tool.handler(params.arguments, extra);
|
||||||
|
};
|
||||||
|
this.onlisttools = async (_params, _extra) => {
|
||||||
|
const tools: Tool[] = await Promise.all(
|
||||||
|
Object.entries(this._registeredTools)
|
||||||
|
.filter(([_, tool]) => tool.enabled)
|
||||||
|
.map(async ([name, tool]) => {
|
||||||
|
const result: Tool = {
|
||||||
|
name,
|
||||||
|
title: tool.title,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: (tool.inputSchema
|
||||||
|
? await standardSchemaToJsonSchema(tool.inputSchema, "input")
|
||||||
|
: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {},
|
||||||
|
}) as Tool["inputSchema"],
|
||||||
|
};
|
||||||
|
// outputSchema is optional in core MCP — only emit when the app
|
||||||
|
// provided one, otherwise hosts would assume structuredContent.
|
||||||
|
if (tool.outputSchema) {
|
||||||
|
result.outputSchema = (await standardSchemaToJsonSchema(
|
||||||
|
tool.outputSchema,
|
||||||
|
"output",
|
||||||
|
)) as Tool["outputSchema"];
|
||||||
|
}
|
||||||
|
if (tool.annotations) {
|
||||||
|
result.annotations = tool.annotations;
|
||||||
|
}
|
||||||
|
if (tool._meta) {
|
||||||
|
result._meta = tool._meta;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { tools };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendToolListChanged(
|
||||||
|
params: ToolListChangedNotification["params"] = {},
|
||||||
|
): Promise<void> {
|
||||||
|
this._assertInitialized("sendToolListChanged");
|
||||||
|
await this.notification(<ToolListChangedNotification>{
|
||||||
|
method: "notifications/tools/list_changed",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the host's capabilities discovered during initialization.
|
* Get the host's capabilities discovered during initialization.
|
||||||
*
|
*
|
||||||
|
|
@ -765,9 +1011,21 @@ export class App extends ProtocolWithEvents<
|
||||||
* app.onlisttools = async (params, extra) => {
|
* app.onlisttools = async (params, extra) => {
|
||||||
* return {
|
* return {
|
||||||
* tools: [
|
* tools: [
|
||||||
* { name: "greet", inputSchema: { type: "object" as const } },
|
* {
|
||||||
* { name: "calculate", inputSchema: { type: "object" as const } },
|
* name: "greet",
|
||||||
* { name: "format", inputSchema: { type: "object" as const } },
|
* description: "Greet the user",
|
||||||
|
* inputSchema: { type: "object" as const },
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* name: "calculate",
|
||||||
|
* description: "Perform a calculation",
|
||||||
|
* inputSchema: { type: "object" as const },
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* name: "format",
|
||||||
|
* description: "Format text",
|
||||||
|
* inputSchema: { type: "object" as const },
|
||||||
|
* },
|
||||||
* ],
|
* ],
|
||||||
* };
|
* };
|
||||||
* };
|
* };
|
||||||
|
|
@ -844,7 +1102,7 @@ export class App extends ProtocolWithEvents<
|
||||||
* Verify that the app supports the capability required for the given notification method.
|
* Verify that the app supports the capability required for the given notification method.
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
assertNotificationCapability(method: AppNotification["method"]): void {
|
assertNotificationCapability(_method: AppNotification["method"]): void {
|
||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import type {
|
||||||
AnySchema,
|
AnySchema,
|
||||||
ZodRawShapeCompat,
|
ZodRawShapeCompat,
|
||||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||||
|
import type { StandardSchemaWithJSON } from "../standard-schema";
|
||||||
import type {
|
import type {
|
||||||
ClientCapabilities,
|
ClientCapabilities,
|
||||||
ReadResourceResult,
|
ReadResourceResult,
|
||||||
|
|
@ -69,8 +70,8 @@ export type { ResourceMetadata, ToolCallback };
|
||||||
export interface ToolConfig {
|
export interface ToolConfig {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
inputSchema?: ZodRawShapeCompat | AnySchema;
|
inputSchema?: ZodRawShapeCompat | StandardSchemaWithJSON;
|
||||||
outputSchema?: ZodRawShapeCompat | AnySchema;
|
outputSchema?: ZodRawShapeCompat | StandardSchemaWithJSON;
|
||||||
annotations?: ToolAnnotations;
|
annotations?: ToolAnnotations;
|
||||||
_meta?: Record<string, unknown>;
|
_meta?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
@ -214,8 +215,9 @@ export interface McpUiAppResourceConfig extends ResourceMetadata {
|
||||||
* @see {@link registerAppResource `registerAppResource`} to register the HTML resource referenced by the tool
|
* @see {@link registerAppResource `registerAppResource`} to register the HTML resource referenced by the tool
|
||||||
*/
|
*/
|
||||||
export function registerAppTool<
|
export function registerAppTool<
|
||||||
OutputArgs extends ZodRawShapeCompat | AnySchema,
|
OutputArgs extends ZodRawShapeCompat | StandardSchemaWithJSON,
|
||||||
InputArgs extends undefined | ZodRawShapeCompat | AnySchema = undefined,
|
InputArgs extends undefined | ZodRawShapeCompat | StandardSchemaWithJSON =
|
||||||
|
undefined,
|
||||||
>(
|
>(
|
||||||
server: Pick<McpServer, "registerTool">,
|
server: Pick<McpServer, "registerTool">,
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -223,7 +225,15 @@ export function registerAppTool<
|
||||||
inputSchema?: InputArgs;
|
inputSchema?: InputArgs;
|
||||||
outputSchema?: OutputArgs;
|
outputSchema?: OutputArgs;
|
||||||
},
|
},
|
||||||
cb: ToolCallback<InputArgs>,
|
// The widened constraint signals the v2 API shape, but NOTE: McpServer in
|
||||||
|
// sdk@1.x still calls zod internals at runtime, so non-zod schemas will fail
|
||||||
|
// here until we depend on sdk v2. Zod (which all current callers use) is
|
||||||
|
// unaffected. The cast below bridges the 1.x type signature.
|
||||||
|
cb: ToolCallback<
|
||||||
|
InputArgs extends undefined | ZodRawShapeCompat | AnySchema
|
||||||
|
? InputArgs
|
||||||
|
: AnySchema
|
||||||
|
>,
|
||||||
): RegisteredTool {
|
): RegisteredTool {
|
||||||
// Normalize metadata for backward compatibility:
|
// Normalize metadata for backward compatibility:
|
||||||
// - If _meta.ui.resourceUri is set, also set the legacy flat key
|
// - If _meta.ui.resourceUri is set, also set the legacy flat key
|
||||||
|
|
@ -241,7 +251,14 @@ export function registerAppTool<
|
||||||
normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } };
|
normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } };
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.registerTool(name, { ...config, _meta: normalizedMeta }, cb);
|
// Cast bridges the widened StandardSchemaWithJSON constraint to the
|
||||||
|
// sdk@1.x zod-typed signature. Drops once we depend on sdk v2.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return server.registerTool(
|
||||||
|
name,
|
||||||
|
{ ...config, _meta: normalizedMeta } as any,
|
||||||
|
cb as any,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpUiReadResourceResult = ReadResourceResult & {
|
export type McpUiReadResourceResult = ReadResourceResult & {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue