diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..a4fd6c52 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,64 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + ( + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ) && + ( + github.actor == 'ihrpr' || + github.actor == 'olaservo' + ) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test diff --git a/.gitignore b/.gitignore index 8e184de8..eeee17fe 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ client/tsconfig.app.tsbuildinfo client/tsconfig.node.tsbuildinfo cli/build test-output +# symlinked by `npm run link:sdk`: +sdk client/playwright-report/ client/results.json client/test-results/ - diff --git a/README.md b/README.md index 41aabf06..d381a2de 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,12 @@ Development mode: ```bash npm run dev + +# To co-develop with the typescript-sdk package (assuming it's cloned in ../typescript-sdk; set MCP_SDK otherwise): +npm run dev:sdk "cd sdk && npm run examples:simple-server:w" +# then open http://localhost:3000/mcp as SHTTP in the inspector. +# To go back to the deployed SDK version: +# npm run unlink:sdk && npm i ``` > **Note for Windows users:** diff --git a/cli/package.json b/cli/package.json index bfb173a8..f12600d2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.15.0", + "version": "0.16.1", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 1f87b516..de0153b0 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -50,28 +50,15 @@ function delay(ms: number): Promise { } async function runWebClient(args: Args): Promise { - const inspectorServerPath = resolve( - __dirname, - "../../", - "server", - "build", - "index.js", - ); - // Path to the client entry point const inspectorClientPath = resolve( __dirname, "../../", "client", "bin", - "client.js", + "start.js", ); - const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274"; - const SERVER_PORT: string = process.env.SERVER_PORT ?? "6277"; - - console.log("Starting MCP inspector..."); - const abort = new AbortController(); let cancelled: boolean = false; process.on("SIGINT", () => { @@ -79,42 +66,13 @@ async function runWebClient(args: Args): Promise { abort.abort(); }); - let server: ReturnType; - let serverOk: unknown; - try { - server = spawnPromise( - "node", - [ - inspectorServerPath, - ...(args.command ? [`--env`, args.command] : []), - ...(args.args ? [`--args=${args.args.join(" ")}`] : []), - ], - { - env: { - ...process.env, - PORT: SERVER_PORT, - MCP_ENV_VARS: JSON.stringify(args.envArgs), - }, - signal: abort.signal, - echoOutput: true, - }, - ); - - // Make sure server started before starting client - serverOk = await Promise.race([server, delay(2 * 1000)]); - } catch (error) {} - - if (serverOk) { - try { - await spawnPromise("node", [inspectorClientPath], { - env: { ...process.env, PORT: CLIENT_PORT }, - signal: abort.signal, - echoOutput: true, - }); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } + await spawnPromise("node", [inspectorClientPath], { + signal: abort.signal, + echoOutput: true, + }); + } catch (e) { + if (!cancelled || process.env.DEBUG) throw e; } } diff --git a/client/package.json b/client/package.json index 59bf2288..ee3f7c30 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.15.0", + "version": "0.16.1", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/client/src/App.tsx b/client/src/App.tsx index 3ceafcae..cff51b4f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -80,6 +80,9 @@ const App = () => { ResourceTemplate[] >([]); const [resourceContent, setResourceContent] = useState(""); + const [resourceContentMap, setResourceContentMap] = useState< + Record + >({}); const [prompts, setPrompts] = useState([]); const [promptContent, setPromptContent] = useState(""); const [tools, setTools] = useState([]); @@ -461,7 +464,12 @@ const App = () => { ReadResourceResultSchema, "resources", ); - setResourceContent(JSON.stringify(response, null, 2)); + const content = JSON.stringify(response, null, 2); + setResourceContent(content); + setResourceContentMap((prev) => ({ + ...prev, + [uri]: content, + })); }; const subscribeToResource = async (uri: string) => { @@ -863,6 +871,11 @@ const App = () => { toolResult={toolResult} nextCursor={nextToolCursor} error={errors.tools} + resourceContent={resourceContentMap} + onReadResource={(uri: string) => { + clearError("resources"); + readResource(uri); + }} /> void; +} + +const ResourceLinkView = memo( + ({ + uri, + name, + description, + mimeType, + resourceContent, + onReadResource, + }: ResourceLinkViewProps) => { + const [{ expanded, loading }, setState] = useState({ + expanded: false, + loading: false, + }); + + const expandedContent = useMemo( + () => + expanded && resourceContent ? ( +
+
+ Resource: +
+ +
+ ) : null, + [expanded, resourceContent], + ); + + const handleClick = useCallback(() => { + if (!onReadResource) return; + if (!expanded) { + setState((prev) => ({ ...prev, expanded: true, loading: true })); + onReadResource(uri); + setState((prev) => ({ ...prev, loading: false })); + } else { + setState((prev) => ({ ...prev, expanded: false })); + } + }, [expanded, onReadResource, uri]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.key === "Enter" || e.key === " ") && onReadResource) { + e.preventDefault(); + handleClick(); + } + }, + [handleClick, onReadResource], + ); + + return ( +
+
+
+
+ + {uri} + +
+ {mimeType && ( + + {mimeType} + + )} + {onReadResource && ( +
+ {name && ( +
+ {name} +
+ )} + {description && ( +

+ {description} +

+ )} +
+
+ {expandedContent} +
+ ); + }, +); + +export default ResourceLinkView; diff --git a/client/src/components/ToolResults.tsx b/client/src/components/ToolResults.tsx index c6d90700..a3cecf55 100644 --- a/client/src/components/ToolResults.tsx +++ b/client/src/components/ToolResults.tsx @@ -1,4 +1,5 @@ import JsonView from "./JsonView"; +import ResourceLinkView from "./ResourceLinkView"; import { CallToolResultSchema, CompatibilityCallToolResult, @@ -9,6 +10,8 @@ import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils"; interface ToolResultsProps { toolResult: CompatibilityCallToolResult | null; selectedTool: Tool | null; + resourceContent: Record; + onReadResource?: (uri: string) => void; } const checkContentCompatibility = ( @@ -19,49 +22,54 @@ const checkContentCompatibility = ( [key: string]: unknown; }>, ): { isCompatible: boolean; message: string } => { - if ( - unstructuredContent.length !== 1 || - unstructuredContent[0].type !== "text" - ) { + // Look for at least one text content block that matches the structured content + const textBlocks = unstructuredContent.filter( + (block) => block.type === "text", + ); + + if (textBlocks.length === 0) { return { isCompatible: false, - message: "Unstructured content is not a single text block", + message: "No text blocks to match structured content", }; } - const textContent = unstructuredContent[0].text; - if (!textContent) { - return { - isCompatible: false, - message: "Text content is empty", - }; - } - - try { - const parsedContent = JSON.parse(textContent); - const isEqual = - JSON.stringify(parsedContent) === JSON.stringify(structuredContent); - - if (isEqual) { - return { - isCompatible: true, - message: "Unstructured content matches structured content", - }; - } else { - return { - isCompatible: false, - message: "Parsed JSON does not match structured content", - }; + // Check if any text block contains JSON that matches the structured content + for (const textBlock of textBlocks) { + const textContent = textBlock.text; + if (!textContent) { + continue; + } + + try { + const parsedContent = JSON.parse(textContent); + const isEqual = + JSON.stringify(parsedContent) === JSON.stringify(structuredContent); + + if (isEqual) { + return { + isCompatible: true, + message: `Structured content matches text block${textBlocks.length > 1 ? " (multiple blocks)" : ""}${unstructuredContent.length > textBlocks.length ? " + other content" : ""}`, + }; + } + } catch { + // Continue to next text block if this one doesn't parse as JSON + continue; } - } catch { - return { - isCompatible: false, - message: "Unstructured content is not valid JSON", - }; } + + return { + isCompatible: false, + message: "No text block matches structured content", + }; }; -const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => { +const ToolResults = ({ + toolResult, + selectedTool, + resourceContent, + onReadResource, +}: ToolResultsProps) => { if (!toolResult) return null; if ("content" in toolResult) { @@ -200,6 +208,16 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => { ) : ( ))} + {item.type === "resource_link" && ( + + )}
))} diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 9e4bc69a..3c7db84e 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -28,6 +28,8 @@ const ToolsTab = ({ setSelectedTool, toolResult, nextCursor, + resourceContent, + onReadResource, }: { tools: Tool[]; listTools: () => void; @@ -38,6 +40,8 @@ const ToolsTab = ({ toolResult: CompatibilityCallToolResult | null; nextCursor: ListToolsResult["nextCursor"]; error: string | null; + resourceContent: Record; + onReadResource?: (uri: string) => void; }) => { const [params, setParams] = useState>({}); const [isToolRunning, setIsToolRunning] = useState(false); @@ -267,6 +271,8 @@ const ToolsTab = ({ ) : ( diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index dc353ba5..9b399af9 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -55,6 +55,8 @@ describe("ToolsTab", () => { toolResult: null, nextCursor: "", error: null, + resourceContent: {}, + onReadResource: jest.fn(), }; const renderToolsTab = (props = {}) => { @@ -249,10 +251,12 @@ describe("ToolsTab", () => { }, }; - it("should display structured content when present", () => { - // Cache the tool's output schema so hasOutputSchema returns true + beforeEach(() => { + // Cache the tool's output schema before each test cacheToolOutputSchemas([toolWithOutputSchema]); + }); + it("should display structured content when present", () => { const structuredResult = { content: [], structuredContent: { @@ -261,6 +265,7 @@ describe("ToolsTab", () => { }; renderToolsTab({ + tools: [toolWithOutputSchema], selectedTool: toolWithOutputSchema, toolResult: structuredResult, }); @@ -272,8 +277,6 @@ describe("ToolsTab", () => { }); it("should show validation error for invalid structured content", () => { - cacheToolOutputSchemas([toolWithOutputSchema]); - const invalidResult = { content: [], structuredContent: { @@ -282,6 +285,7 @@ describe("ToolsTab", () => { }; renderToolsTab({ + tools: [toolWithOutputSchema], selectedTool: toolWithOutputSchema, toolResult: invalidResult, }); @@ -290,14 +294,13 @@ describe("ToolsTab", () => { }); it("should show error when tool with output schema doesn't return structured content", () => { - cacheToolOutputSchemas([toolWithOutputSchema]); - const resultWithoutStructured = { content: [{ type: "text", text: "some result" }], // No structuredContent }; renderToolsTab({ + tools: [toolWithOutputSchema], selectedTool: toolWithOutputSchema, toolResult: resultWithoutStructured, }); @@ -310,14 +313,13 @@ describe("ToolsTab", () => { }); it("should show unstructured content title when both structured and unstructured exist", () => { - cacheToolOutputSchemas([toolWithOutputSchema]); - const resultWithBoth = { content: [{ type: "text", text: '{"temperature": 25}' }], structuredContent: { temperature: 25 }, }; renderToolsTab({ + tools: [toolWithOutputSchema], selectedTool: toolWithOutputSchema, toolResult: resultWithBoth, }); @@ -342,26 +344,100 @@ describe("ToolsTab", () => { }); it("should show compatibility check when tool has output schema", () => { - cacheToolOutputSchemas([toolWithOutputSchema]); - const compatibleResult = { content: [{ type: "text", text: '{"temperature": 25}' }], structuredContent: { temperature: 25 }, }; renderToolsTab({ + tools: [toolWithOutputSchema], selectedTool: toolWithOutputSchema, toolResult: compatibleResult, }); // Should show compatibility result expect( - screen.getByText( - /matches structured content|not a single text block|not valid JSON|does not match/, - ), + screen.getByText(/structured content matches/i), ).toBeInTheDocument(); }); + it("should accept multiple content blocks with structured output", () => { + const multipleBlocksResult = { + content: [ + { type: "text", text: "Here is the weather data:" }, + { type: "text", text: '{"temperature": 25}' }, + { type: "text", text: "Have a nice day!" }, + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: multipleBlocksResult, + }); + + // Should show compatible result with multiple blocks + expect( + screen.getByText(/structured content matches.*multiple/i), + ).toBeInTheDocument(); + }); + + it("should accept mixed content types with structured output", () => { + const mixedContentResult = { + content: [ + { type: "text", text: "Weather report:" }, + { type: "text", text: '{"temperature": 25}' }, + { type: "image", data: "base64data", mimeType: "image/png" }, + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: mixedContentResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + + it("should reject when no text blocks match structured content", () => { + const noMatchResult = { + content: [ + { type: "text", text: "Some text" }, + { type: "text", text: '{"humidity": 60}' }, // Different structure + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: noMatchResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + + it("should reject when no text blocks are present", () => { + const noTextBlocksResult = { + content: [{ type: "image", data: "base64data", mimeType: "image/png" }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: noTextBlocksResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + it("should not show compatibility check when tool has no output schema", () => { const resultWithBoth = { content: [{ type: "text", text: '{"data": "value"}' }], @@ -376,9 +452,108 @@ describe("ToolsTab", () => { // Should not show any compatibility messages expect( screen.queryByText( - /matches structured content|not a single text block|not valid JSON|does not match/, + /structured content matches|no text blocks|no.*matches/i, ), ).not.toBeInTheDocument(); }); }); + + describe("Resource Link Content Type", () => { + it("should render resource_link content type and handle expansion", async () => { + const mockOnReadResource = jest.fn(); + const resourceContent = { + "test://static/resource/1": JSON.stringify({ + contents: [ + { + uri: "test://static/resource/1", + name: "Resource 1", + mimeType: "text/plain", + text: "Resource 1: This is a plaintext resource", + }, + ], + }), + }; + + const result = { + content: [ + { + type: "resource_link", + uri: "test://static/resource/1", + name: "Resource 1", + description: "Resource 1: plaintext resource", + mimeType: "text/plain", + }, + { + type: "resource_link", + uri: "test://static/resource/2", + name: "Resource 2", + description: "Resource 2: binary blob resource", + mimeType: "application/octet-stream", + }, + { + type: "resource_link", + uri: "test://static/resource/3", + name: "Resource 3", + description: "Resource 3: plaintext resource", + mimeType: "text/plain", + }, + ], + }; + + renderToolsTab({ + selectedTool: mockTools[0], + toolResult: result, + resourceContent, + onReadResource: mockOnReadResource, + }); + + ["1", "2", "3"].forEach((id) => { + expect( + screen.getByText(`test://static/resource/${id}`), + ).toBeInTheDocument(); + expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument(); + }); + + expect(screen.getAllByText("text/plain")).toHaveLength(2); + expect(screen.getByText("application/octet-stream")).toBeInTheDocument(); + + const expandButtons = screen.getAllByRole("button", { + name: /expand resource/i, + }); + expect(expandButtons).toHaveLength(3); + expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); + + expandButtons.forEach((button) => { + expect(button).toHaveAttribute("aria-expanded", "false"); + }); + + const resource1Button = screen.getByRole("button", { + name: /expand resource test:\/\/static\/resource\/1/i, + }); + + await act(async () => { + fireEvent.click(resource1Button); + }); + + expect(mockOnReadResource).toHaveBeenCalledWith( + "test://static/resource/1", + ); + expect(screen.getByText("Resource:")).toBeInTheDocument(); + expect(document.body).toHaveTextContent("contents:"); + expect(document.body).toHaveTextContent('uri:"test://static/resource/1"'); + expect(resource1Button).toHaveAttribute("aria-expanded", "true"); + + await act(async () => { + fireEvent.click(resource1Button); + }); + + expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("contents:"); + expect(document.body).not.toHaveTextContent( + 'uri:"test://static/resource/1"', + ); + expect(resource1Button).toHaveAttribute("aria-expanded", "false"); + expect(mockOnReadResource).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index 075b0a28..709ce621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.15.0", + "version": "0.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.15.0", + "version": "0.16.1", "license": "MIT", "workspaces": [ "client", @@ -14,9 +14,9 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.15.0", - "@modelcontextprotocol/inspector-client": "^0.15.0", - "@modelcontextprotocol/inspector-server": "^0.15.0", + "@modelcontextprotocol/inspector-cli": "^0.16.1", + "@modelcontextprotocol/inspector-client": "^0.16.1", + "@modelcontextprotocol/inspector-server": "^0.16.1", "@modelcontextprotocol/sdk": "^1.13.1", "concurrently": "^9.0.1", "open": "^10.1.0", @@ -44,7 +44,7 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.15.0", + "version": "0.16.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.13.1", @@ -67,7 +67,7 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.15.0", + "version": "0.16.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.13.1", @@ -10987,7 +10987,7 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.15.0", + "version": "0.16.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.13.1", diff --git a/package.json b/package.json index 5d5fe52f..b0a2e98e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.15.0", + "version": "0.16.1", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -29,6 +29,9 @@ "clean": "rimraf ./node_modules ./client/node_modules ./cli/node_modules ./build ./client/dist ./server/build ./cli/build ./package-lock.json && npm install", "dev": "node client/bin/start.js --dev", "dev:windows": "node client/bin/start.js --dev", + "dev:sdk": "npm run link:sdk && concurrently \"npm run dev\" \"cd sdk && npm run build:esm:w\"", + "link:sdk": "(test -d sdk || ln -sf ${MCP_SDK:-$PWD/../typescript-sdk} sdk) && (cd sdk && npm link && (test -d node_modules || npm i)) && npm link @modelcontextprotocol/sdk", + "unlink:sdk": "(cd sdk && npm unlink -g) && rm sdk && npm unlink @modelcontextprotocol/sdk", "start": "node client/bin/start.js", "start-server": "cd server && npm run start", "start-client": "cd client && npm run preview", @@ -44,9 +47,9 @@ "check-version": "node scripts/check-version-consistency.js" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.15.0", - "@modelcontextprotocol/inspector-client": "^0.15.0", - "@modelcontextprotocol/inspector-server": "^0.15.0", + "@modelcontextprotocol/inspector-cli": "^0.16.1", + "@modelcontextprotocol/inspector-client": "^0.16.1", + "@modelcontextprotocol/inspector-server": "^0.16.1", "@modelcontextprotocol/sdk": "^1.13.1", "concurrently": "^9.0.1", "open": "^10.1.0", diff --git a/server/package.json b/server/package.json index b43fc289..10574070 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.15.0", + "version": "0.16.1", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)",