Skip to content

Commit 2580071

Browse files
authored
feat(inspector): add @mcp-apps-kit/inspector package for MCP server testing and debugging (#129)
1 parent 3c5e4b3 commit 2580071

File tree

148 files changed

+39320
-61
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

148 files changed

+39320
-61
lines changed

eslint.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default [
1818
"packages/testing/**/*.ts",
1919
"packages/ui-react-builder/**/*.ts",
2020
"packages/codegen/**/*.ts",
21+
"packages/inspector/**/*.ts",
2122
],
2223
languageOptions: {
2324
parser: tsparser,
@@ -28,6 +29,7 @@ export default [
2829
"./packages/testing/tsconfig.json",
2930
"./packages/ui-react-builder/tsconfig.json",
3031
"./packages/codegen/tsconfig.json",
32+
"./packages/inspector/tsconfig.json",
3133
],
3234
tsconfigRootDir: import.meta.dirname,
3335
},

examples/weather-app/mcp.config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ export default defineConfig({
3131
origin: true,
3232
},
3333
debug: {
34-
logTool: true,
3534
level: "debug",
3635
},
3736
},

examples/weather-app/tests/integration/server.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ describe("Weather App MCP Server", () => {
4747
it("should list all weather tools", async () => {
4848
const tools = await env.client.listTools();
4949

50-
// 4 weather tools + 1 log_debug tool (from debug.logTool config)
51-
expect(tools.length).toBe(5);
50+
// 4 weather tools: getCurrentWeather, getForecast, getWeatherAlerts, dailyBriefing
51+
expect(tools.length).toBe(4);
5252
// File-based naming: get-current-weather.ts -> getCurrentWeather (camelCase)
5353
expect(tools.some((t) => t.name === "getCurrentWeather")).toBe(true);
5454
expect(tools.some((t) => t.name === "getForecast")).toBe(true);

examples/weather-app/ui/styles.css

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* to avoid Tailwind layer specificity issues.
1010
*/
1111

12+
/* Light theme (default) */
1213
:root {
1314
--weather-bg-primary: #f5f7fa;
1415
--weather-bg-card: #ffffff;
@@ -21,8 +22,21 @@
2122
--weather-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
2223
}
2324

25+
/* Dark theme - applied via class from useDocumentTheme hook */
26+
body.dark {
27+
--weather-bg-primary: #111827;
28+
--weather-bg-card: #1f2937;
29+
--weather-bg-inset: #374151;
30+
--weather-text-primary: #f9fafb;
31+
--weather-text-secondary: #d1d5db;
32+
--weather-text-muted: #9ca3af;
33+
--weather-border: #374151;
34+
--weather-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
35+
}
36+
37+
/* Fallback: also support OS preference when no class is set */
2438
@media (prefers-color-scheme: dark) {
25-
:root {
39+
body:not(.light):not(.dark) {
2640
--weather-bg-primary: #111827;
2741
--weather-bg-card: #1f2937;
2842
--weather-bg-inset: #374151;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"lint-staged": "^16.2.7",
5353
"nx": "^22.4.2",
5454
"prettier": "^3.8.1",
55+
"playwright": "^1.58.0",
5556
"tsup": "^8.5.1",
5657
"typedoc": "^0.28.16",
5758
"typedoc-plugin-markdown": "^4.9.0",

packages/core/src/constants.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Constants for MCP Apps Kit
3+
*/
4+
5+
/**
6+
* MIME type for MCP Apps (Claude Desktop) widgets
7+
*
8+
* Used to identify UI resources that should be rendered using the
9+
* MCP Apps protocol (JSON-RPC over postMessage via ext-apps).
10+
*/
11+
export const MCP_WIDGET_MIME_TYPE = "text/html;profile=mcp-app";
12+
13+
/**
14+
* MIME type for OpenAI Apps (ChatGPT) widgets
15+
*
16+
* Used to identify UI resources that should be rendered using the
17+
* OpenAI Apps SDK protocol (window.openai + DOM events).
18+
*/
19+
export const OPENAI_WIDGET_MIME_TYPE = "text/html+skybridge";

packages/core/src/createApp.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,21 @@ function createSingleVersionApp<T extends ToolDefs>(config: AppConfig<T>): App<T
473473
configureDebugLogger(normalizedConfig.config.debug);
474474
}
475475

476+
// Handle autoInspector config - inject dev-inspector plugin
477+
if (normalizedConfig.config?.autoInspector) {
478+
// Dynamic import to avoid bundling inspector in production builds
479+
// eslint-disable-next-line @typescript-eslint/no-require-imports
480+
const { createDevInspectorPlugin } = require("./plugins/builtin/dev-inspector") as {
481+
createDevInspectorPlugin: typeof import("./plugins/builtin/dev-inspector").createDevInspectorPlugin;
482+
};
483+
const serverRoute = normalizedConfig.config.serverRoute ?? "/mcp";
484+
const inspectorPlugin = createDevInspectorPlugin(
485+
normalizedConfig.config.autoInspector,
486+
serverRoute
487+
);
488+
normalizedConfig.plugins = [...(normalizedConfig.plugins ?? []), inspectorPlugin];
489+
}
490+
476491
// Initialize plugin manager (but defer init() call to app.start())
477492
const pluginManager = new PluginManager(normalizedConfig.plugins ?? []);
478493
let pluginInitialized = false;
@@ -768,6 +783,26 @@ function createMultiVersionApp<T extends ToolDefs>(config: VersionsConfig<T>): A
768783
// because it's a global singleton. If version-specific debug configs are needed, they would
769784
// require per-version logger instances, which is a larger architectural change.
770785

786+
// Handle autoInspector config - inject dev-inspector plugin
787+
// Only inject for the first version to avoid multiple inspectors
788+
if (normalizedVersionConfig.config?.autoInspector && versionApps.size === 0) {
789+
// Dynamic import to avoid bundling inspector in production builds
790+
// eslint-disable-next-line @typescript-eslint/no-require-imports
791+
const { createDevInspectorPlugin } = require("./plugins/builtin/dev-inspector") as {
792+
createDevInspectorPlugin: typeof import("./plugins/builtin/dev-inspector").createDevInspectorPlugin;
793+
};
794+
// For multi-version, use the version-specific route
795+
const serverRoute = `/${versionKey}/mcp`;
796+
const inspectorPlugin = createDevInspectorPlugin(
797+
normalizedVersionConfig.config.autoInspector,
798+
serverRoute
799+
);
800+
normalizedVersionConfig.plugins = [
801+
...(normalizedVersionConfig.plugins ?? []),
802+
inspectorPlugin,
803+
];
804+
}
805+
771806
// Initialize version-specific plugin manager
772807
const versionPluginManager = new PluginManager(normalizedVersionConfig.plugins ?? []);
773808
let versionPluginInitialized = false;

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ export type {
323323
ToolBuilderConfigurable,
324324
} from "./builder/tool-builder";
325325

326+
// Constants
327+
export { MCP_WIDGET_MIME_TYPE, OPENAI_WIDGET_MIME_TYPE } from "./constants";
328+
326329
// =============================================================================
327330
// WORKFLOW ENGINE
328331
// =============================================================================
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Dev Inspector Plugin
3+
*
4+
* Automatically spawns the MCP Inspector and connects it to this server.
5+
* Used internally by createApp when autoInspector config is enabled.
6+
*
7+
* @internal - Not exported publicly, used by createApp when autoInspector is enabled
8+
*/
9+
10+
import { spawn, type ChildProcess } from "child_process";
11+
import type { Plugin } from "../types";
12+
import type { AutoInspectorConfig } from "../../types/config";
13+
14+
/**
15+
* Resolve auto-inspector config to full config object with defaults
16+
*/
17+
function resolveConfig(config: AutoInspectorConfig | true): Required<AutoInspectorConfig> {
18+
if (config === true) {
19+
return {
20+
port: 6274,
21+
debug: false,
22+
devOnly: true,
23+
};
24+
}
25+
return {
26+
port: config.port ?? 6274,
27+
debug: config.debug ?? false,
28+
devOnly: config.devOnly ?? true,
29+
};
30+
}
31+
32+
/**
33+
* Create dev inspector plugin with given config
34+
*
35+
* @param config - Auto-inspector configuration
36+
* @param serverRoute - The MCP server route path (e.g., "/mcp")
37+
* @returns Plugin that spawns inspector on start
38+
*
39+
* @internal - Not exported publicly, used by createApp when autoInspector is enabled
40+
*/
41+
export function createDevInspectorPlugin(
42+
config: AutoInspectorConfig | true,
43+
serverRoute: string
44+
): Plugin {
45+
const resolvedConfig = resolveConfig(config);
46+
47+
// Keep inspector process state scoped to this plugin instance
48+
let inspectorProcess: ChildProcess | null = null;
49+
50+
return {
51+
name: "dev-inspector",
52+
version: "1.0.0",
53+
54+
onStart: async (context) => {
55+
// Skip in production if devOnly is true
56+
if (resolvedConfig.devOnly && process.env.NODE_ENV === "production") {
57+
return;
58+
}
59+
60+
// Only works with HTTP transport
61+
if (context.transport !== "http" || !context.port) {
62+
// eslint-disable-next-line no-console
63+
console.warn("[autoInspector] Skipped: only works with HTTP transport");
64+
return;
65+
}
66+
67+
const serverUrl = `http://localhost:${context.port}${serverRoute}`;
68+
const args = ["--url", serverUrl, "--port", String(resolvedConfig.port)];
69+
70+
if (resolvedConfig.debug) {
71+
args.push("--debug");
72+
}
73+
74+
// eslint-disable-next-line no-console
75+
console.log(`[autoInspector] Starting inspector on port ${resolvedConfig.port}`);
76+
// eslint-disable-next-line no-console
77+
console.log(`[autoInspector] Connecting to: ${serverUrl}`);
78+
79+
// Spawn mcp-inspector using npx to find the CLI
80+
// We use npx to ensure we find the installed mcp-inspector binary
81+
inspectorProcess = spawn("npx", ["mcp-inspector", ...args], {
82+
stdio: "inherit",
83+
detached: false,
84+
shell: true,
85+
});
86+
87+
inspectorProcess.on("error", (err) => {
88+
// eslint-disable-next-line no-console
89+
console.error(`[autoInspector] Failed to spawn inspector: ${err.message}`);
90+
});
91+
92+
inspectorProcess.on("exit", (code) => {
93+
if (code !== null && code !== 0) {
94+
// eslint-disable-next-line no-console
95+
console.warn(`[autoInspector] Inspector exited with code ${code}`);
96+
}
97+
inspectorProcess = null;
98+
});
99+
},
100+
101+
onShutdown: async () => {
102+
if (inspectorProcess) {
103+
// eslint-disable-next-line no-console
104+
console.log("[autoInspector] Stopping inspector...");
105+
inspectorProcess.kill();
106+
inspectorProcess = null;
107+
}
108+
},
109+
};
110+
}

packages/core/src/server/index.ts

Lines changed: 8 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
88
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
910
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1011
import {
1112
mcpAuthMetadataRouter,
@@ -498,7 +499,7 @@ export function createServerInstance<T extends ToolDefs>(
498499
req: globalThis.Request,
499500
_env?: unknown
500501
): Promise<globalThis.Response> => {
501-
// Serverless handler using Streamable HTTP transport
502+
// Serverless handler using Web Standard Streamable HTTP transport
502503
try {
503504
const url = new URL(req.url);
504505

@@ -543,60 +544,22 @@ export function createServerInstance<T extends ToolDefs>(
543544
);
544545
}
545546

546-
// For serverless, create a one-shot transport
547-
const transport = new StreamableHTTPServerTransport({
547+
// Use Web Standard transport for serverless - accepts Request, returns Response
548+
const transport = new WebStandardStreamableHTTPServerTransport({
548549
sessionIdGenerator: undefined,
549550
enableJsonResponse: true,
550551
});
551552

552553
// Connect to current MCP server (supports hot reload)
553554
await currentMcpServer.connect(transport);
554555

555-
// Convert Web Request to Express-like request
556-
const body: unknown = req.method === "POST" ? await req.json() : undefined;
557-
558-
// Create a mock response object to capture the output
559-
let responseBody = "";
560-
let responseStatus = 200;
561-
const responseHeaders: Record<string, string> = {};
562-
563-
const mockRes = {
564-
status: (code: number) => {
565-
responseStatus = code;
566-
return mockRes;
567-
},
568-
setHeader: (name: string, value: string) => {
569-
responseHeaders[name] = value;
570-
},
571-
json: (data: unknown) => {
572-
responseBody = JSON.stringify(data);
573-
responseHeaders["Content-Type"] = "application/json";
574-
},
575-
send: (data: string) => {
576-
responseBody = data;
577-
},
578-
end: (): void => {
579-
// No-op for serverless mock
580-
},
581-
on: () => mockRes,
582-
write: (chunk: string) => {
583-
responseBody += chunk;
584-
},
585-
};
586-
587-
const mockReq = { body, headers: Object.fromEntries(req.headers) };
588-
await transport.handleRequest(
589-
mockReq as unknown as Request,
590-
mockRes as unknown as Response,
591-
body
592-
);
556+
// Handle the request directly - returns a Response
557+
const response = await transport.handleRequest(req);
593558

559+
// Close transport after handling
594560
await transport.close();
595561

596-
return new globalThis.Response(responseBody, {
597-
status: responseStatus,
598-
headers: responseHeaders,
599-
});
562+
return response;
600563
} catch (error) {
601564
const appError = wrapError(error);
602565
return new globalThis.Response(JSON.stringify({ error: appError.message }), {

0 commit comments

Comments
 (0)