feat(debug-server): add interactive debug server example (#321)
* feat(examples): add debug-server example A comprehensive debug/development server for testing MCP Apps SDK features: - Vanilla TypeScript UI with collapsible panels - Message visualization (sent, received, errors) - Server tool calling interface - Multiple tool endpoints for testing * refactor(debug-server): convert to React - Replace vanilla TypeScript with React components - Add comprehensive Host Styles panel showing all CSS variables grouped by category - Use React hooks (useApp, useHostStyles, useAutoResize) - Cleaner state management with useReducer for event log - Collapsible sections for better organization - CSS modules for scoped styling * fix(debug-server): correct server name in main.ts * Revert "refactor(debug-server): convert to React" This reverts commit 5d22d2daa9a98ba412892447202ef10fbdb62f5b. * fix(debug-server): update default values for multipleBlocks and includeMeta * fix(debug-server): correct serve script and server name - serve script now runs main.ts instead of server.ts - Updated server name to 'Debug Server' - Fixed npx command in comment * fix: increase maxHeight to 6000px and add tools capability to debug app - basic-host: Increase maxHeight from 600 to 6000px - debug-server: Add tools capability to enable oncalltool/onlisttools handlers * fix(debug-server): use consistent callback names for event tracking Event types now match callback names (ontoolinput, ontoolresult, etc.) so the Callback Status panel shows correct counts and payloads. * feat(debug-server): add Update (Image) button for model context * feat(debug-server): add expandable event log entries - Click on any event log row to expand/collapse - Arrow indicator rotates when expanded - Full JSON payload shown indented in pre block - Max height with scroll for large payloads * style(debug-server): format long line in fullPayload function * test(debug-server): add to E2E tests and screenshot specs * chore: regenerate package-lock.json for cross-platform compatibility * update dep
This commit is contained in:
parent
023f1fd233
commit
8a877b36b4
1 changed files with 2253 additions and 435 deletions
294
examples/debug-server/server.ts
Normal file
294
examples/debug-server/server.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import {
|
||||
registerAppResource,
|
||||
registerAppTool,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from "@modelcontextprotocol/ext-apps/server";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import fs from "node:fs/promises";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
// Works both from source (server.ts) and compiled (dist/server.js)
|
||||
const DIST_DIR = import.meta.filename.endsWith(".ts")
|
||||
? path.join(import.meta.dirname, "dist")
|
||||
: import.meta.dirname;
|
||||
|
||||
// Track call counter across requests (stateful for demo purposes)
|
||||
let callCounter = 0;
|
||||
|
||||
// Parse --log-file argument or use default
|
||||
const DEFAULT_LOG_FILE = "/tmp/mcp-apps-debug-server.log";
|
||||
function getLogFilePath(): string {
|
||||
const logFileArg = process.argv.find((arg) => arg.startsWith("--log-file="));
|
||||
if (logFileArg) {
|
||||
return logFileArg.split("=")[1];
|
||||
}
|
||||
return process.env.DEBUG_LOG_FILE ?? DEFAULT_LOG_FILE;
|
||||
}
|
||||
|
||||
const logFilePath = getLogFilePath();
|
||||
|
||||
/**
|
||||
* Append a log entry to the log file
|
||||
*/
|
||||
function appendToLogFile(entry: {
|
||||
timestamp: string;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
}): void {
|
||||
try {
|
||||
const line = JSON.stringify(entry) + "\n";
|
||||
appendFileSync(logFilePath, line, "utf-8");
|
||||
} catch (e) {
|
||||
console.error("[debug-server] Failed to write to log file:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal 1x1 blue PNG (base64)
|
||||
const BLUE_PNG_1X1 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPj/HwADBwIAMCbHYQAAAABJRU5ErkJggg==";
|
||||
|
||||
// Minimal silent WAV (base64) - 44 byte header + 1 sample
|
||||
const SILENT_WAV =
|
||||
"UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAAAAA==";
|
||||
|
||||
/**
|
||||
* Input schema for the debug-tool
|
||||
*/
|
||||
const DebugInputSchema = z.object({
|
||||
// Content configuration
|
||||
contentType: z
|
||||
.enum(["text", "image", "audio", "resource", "resourceLink", "mixed"])
|
||||
.default("text"),
|
||||
multipleBlocks: z.boolean().default(true),
|
||||
includeStructuredContent: z.boolean().default(true),
|
||||
includeMeta: z.boolean().default(true),
|
||||
|
||||
// Streaming test (large input)
|
||||
largeInput: z.string().optional(),
|
||||
|
||||
// Error/delay simulation
|
||||
simulateError: z.boolean().default(false),
|
||||
delayMs: z.number().optional(),
|
||||
});
|
||||
|
||||
type DebugInput = z.infer<typeof DebugInputSchema>;
|
||||
|
||||
/**
|
||||
* Output schema for structured content
|
||||
*/
|
||||
const DebugOutputSchema = z.object({
|
||||
config: z.record(z.string(), z.unknown()),
|
||||
timestamp: z.string(),
|
||||
counter: z.number(),
|
||||
largeInputLength: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds content blocks based on configuration
|
||||
*/
|
||||
function buildContent(args: DebugInput): CallToolResult["content"] {
|
||||
const count = args.multipleBlocks ? 3 : 1;
|
||||
const content: CallToolResult["content"] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const suffix = args.multipleBlocks ? ` #${i + 1}` : "";
|
||||
|
||||
switch (args.contentType) {
|
||||
case "text":
|
||||
content.push({ type: "text", text: `Debug text content${suffix}` });
|
||||
break;
|
||||
case "image":
|
||||
content.push({
|
||||
type: "image",
|
||||
data: BLUE_PNG_1X1,
|
||||
mimeType: "image/png",
|
||||
});
|
||||
break;
|
||||
case "audio":
|
||||
content.push({
|
||||
type: "audio",
|
||||
data: SILENT_WAV,
|
||||
mimeType: "audio/wav",
|
||||
});
|
||||
break;
|
||||
case "resource":
|
||||
content.push({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: `debug://embedded-resource${suffix.replace(/\s/g, "-")}`,
|
||||
text: `Embedded resource content${suffix}`,
|
||||
mimeType: "text/plain",
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "resourceLink":
|
||||
content.push({
|
||||
type: "resource_link",
|
||||
uri: `debug://linked-resource${suffix.replace(/\s/g, "-")}`,
|
||||
name: `Linked Resource${suffix}`,
|
||||
mimeType: "text/plain",
|
||||
});
|
||||
break;
|
||||
case "mixed":
|
||||
// Return one of each type (ignore multipleBlocks for mixed)
|
||||
return [
|
||||
{ type: "text", text: "Mixed content: text block" },
|
||||
{ type: "image", data: BLUE_PNG_1X1, mimeType: "image/png" },
|
||||
{ type: "audio", data: SILENT_WAV, mimeType: "audio/wav" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new MCP server instance with debug tools registered.
|
||||
*/
|
||||
export function createServer(): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "Debug MCP App Server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
const resourceUri = "ui://debug-tool/mcp-app.html";
|
||||
|
||||
// Main debug tool - exercises all result variations
|
||||
registerAppTool(
|
||||
server,
|
||||
"debug-tool",
|
||||
{
|
||||
title: "Debug Tool",
|
||||
description:
|
||||
"Comprehensive debug tool for testing MCP Apps SDK. Configure content types, error simulation, delays, and more.",
|
||||
inputSchema: DebugInputSchema,
|
||||
outputSchema: DebugOutputSchema,
|
||||
_meta: { ui: { resourceUri } },
|
||||
},
|
||||
async (args): Promise<CallToolResult> => {
|
||||
// Apply delay if requested
|
||||
if (args.delayMs && args.delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, args.delayMs));
|
||||
}
|
||||
|
||||
// Build content based on config
|
||||
const content = buildContent(args);
|
||||
|
||||
// Build result
|
||||
const result: CallToolResult = { content };
|
||||
|
||||
// Add structured content if requested
|
||||
if (args.includeStructuredContent) {
|
||||
result.structuredContent = {
|
||||
config: args,
|
||||
timestamp: new Date().toISOString(),
|
||||
counter: ++callCounter,
|
||||
...(args.largeInput
|
||||
? { largeInputLength: args.largeInput.length }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Add _meta if requested
|
||||
if (args.includeMeta) {
|
||||
result._meta = {
|
||||
debugInfo: {
|
||||
processedAt: Date.now(),
|
||||
serverVersion: "1.0.0",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Set error flag if requested
|
||||
if (args.simulateError) {
|
||||
result.isError = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// App-only refresh tool (hidden from model)
|
||||
registerAppTool(
|
||||
server,
|
||||
"debug-refresh",
|
||||
{
|
||||
title: "Refresh Debug Info",
|
||||
description:
|
||||
"App-only tool for polling server state. Not visible to the model.",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: z.object({ timestamp: z.string(), counter: z.number() }),
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri,
|
||||
visibility: ["app"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (): Promise<CallToolResult> => {
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
content: [{ type: "text", text: `Server timestamp: ${timestamp}` }],
|
||||
structuredContent: { timestamp, counter: callCounter },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// App-only log tool - writes events to log file
|
||||
registerAppTool(
|
||||
server,
|
||||
"debug-log",
|
||||
{
|
||||
title: "Log to File",
|
||||
description:
|
||||
"App-only tool for logging events to the server log file. Not visible to the model.",
|
||||
inputSchema: z.object({
|
||||
type: z.string(),
|
||||
payload: z.unknown(),
|
||||
}),
|
||||
outputSchema: z.object({ logged: z.boolean(), logFile: z.string() }),
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri,
|
||||
visibility: ["app"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (args): Promise<CallToolResult> => {
|
||||
const timestamp = new Date().toISOString();
|
||||
appendToLogFile({ timestamp, type: args.type, payload: args.payload });
|
||||
return {
|
||||
content: [{ type: "text", text: `Logged to ${logFilePath}` }],
|
||||
structuredContent: { logged: true, logFile: logFilePath },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Register the resource which returns the bundled HTML/JavaScript for the UI
|
||||
registerAppResource(
|
||||
server,
|
||||
resourceUri,
|
||||
resourceUri,
|
||||
{ mimeType: RESOURCE_MIME_TYPE },
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
const html = await fs.readFile(
|
||||
path.join(DIST_DIR, "mcp-app.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
Loading…
Reference in a new issue