diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5ace4600 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 23a66f86..9fdebc23 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -33,7 +33,7 @@ jobs: github.event_name == 'pull_request_review_comment' || github.event_name == 'pull_request_review' id: pr - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | let prNumber; @@ -54,7 +54,7 @@ jobs: - name: Checkout PR branch if: steps.pr.outcome == 'success' - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ steps.pr.outputs.sha }} repository: ${{ steps.pr.outputs.repo }} @@ -62,7 +62,7 @@ jobs: - name: Checkout repository if: steps.pr.outcome != 'success' - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index ede7643e..106d98f1 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -15,10 +15,10 @@ jobs: run: working-directory: ./cli steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json cache: npm diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index 378905b4..4013a905 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -18,9 +18,9 @@ jobs: sudo apt-get update sudo apt-get install -y libwoff1 - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 id: setup_node with: node-version-file: package.json @@ -29,7 +29,7 @@ jobs: # Cache Playwright browsers - name: Cache Playwright browsers id: cache-playwright - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright # The default Playwright cache path key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json @@ -51,7 +51,7 @@ jobs: run: npm run test:e2e - name: Upload Playwright Report and Screenshots - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 if: steps.playwright-tests.conclusion != 'skipped' with: name: playwright-report diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d01f7175..2cc4537b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,12 +12,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check formatting run: npx prettier --check . - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: package.json cache: npm @@ -50,8 +50,8 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: package.json cache: npm @@ -77,7 +77,7 @@ jobs: attestations: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v3 @@ -109,7 +109,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} - name: Generate artifact attestation - uses: actions/attest-build-provenance@v2 + uses: actions/attest-build-provenance@v3 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/SECURITY.md b/SECURITY.md index 21f8e598..50292420 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,14 +1,21 @@ # Security Policy -Thank you for helping us keep the inspector secure. + +Thank you for helping keep the Model Context Protocol and its ecosystem secure. ## Reporting Security Issues -This project is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. -The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. -Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). +## What to Include -## Vulnerability Disclosure Program +To help us triage and respond quickly, please include: -Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 00000000..4a939857 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/cli/__tests__/helpers/assertions.ts b/cli/__tests__/helpers/assertions.ts index e3ed9d02..3631c00b 100644 --- a/cli/__tests__/helpers/assertions.ts +++ b/cli/__tests__/helpers/assertions.ts @@ -1,18 +1,30 @@ import { expect } from "vitest"; import type { CliResult } from "./cli-runner.js"; +function formatCliOutput(result: CliResult): string { + const out = result.stdout?.trim() || "(empty)"; + const err = result.stderr?.trim() || "(empty)"; + return `stdout: ${out}\nstderr: ${err}`; +} + /** * Assert that CLI command succeeded (exit code 0) */ export function expectCliSuccess(result: CliResult) { - expect(result.exitCode).toBe(0); + expect( + result.exitCode, + `CLI exited with code ${result.exitCode}. ${formatCliOutput(result)}`, + ).toBe(0); } /** * Assert that CLI command failed (non-zero exit code) */ export function expectCliFailure(result: CliResult) { - expect(result.exitCode).not.toBe(0); + expect( + result.exitCode, + `CLI unexpectedly exited with code ${result.exitCode}. ${formatCliOutput(result)}`, + ).not.toBe(0); } /** diff --git a/cli/__tests__/helpers/fixtures.ts b/cli/__tests__/helpers/fixtures.ts index 5914f485..c7f10fc7 100644 --- a/cli/__tests__/helpers/fixtures.ts +++ b/cli/__tests__/helpers/fixtures.ts @@ -1,7 +1,7 @@ -import fs from "fs"; -import path from "path"; -import os from "os"; -import crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import * as crypto from "crypto"; import { getTestMcpServerCommand } from "./test-server-stdio.js"; /** diff --git a/cli/__tests__/helpers/test-server-http.ts b/cli/__tests__/helpers/test-server-http.ts index 4626ef51..d5eadc3f 100644 --- a/cli/__tests__/helpers/test-server-http.ts +++ b/cli/__tests__/helpers/test-server-http.ts @@ -6,6 +6,7 @@ import type { Request, Response } from "express"; import express from "express"; import { createServer as createHttpServer, Server as HttpServer } from "http"; import { createServer as createNetServer } from "net"; +import { randomUUID } from "crypto"; import * as z from "zod/v4"; import type { ServerConfig } from "./test-fixtures.js"; @@ -187,22 +188,24 @@ export class TestServerHttp { } /** - * Start the server with the specified transport + * Start the server with the specified transport. + * When requestedPort is omitted, uses port 0 so the OS assigns a unique port (avoids EADDRINUSE when tests run in parallel). */ async start( transport: "http" | "sse", requestedPort?: number, ): Promise { - const port = requestedPort - ? await findAvailablePort(requestedPort) - : await findAvailablePort(transport === "http" ? 3001 : 3000); - - this.url = `http://localhost:${port}`; + const port = + requestedPort !== undefined ? await findAvailablePort(requestedPort) : 0; if (transport === "http") { - return this.startHttp(port); + const actualPort = await this.startHttp(port); + this.url = `http://localhost:${actualPort}`; + return actualPort; } else { - return this.startSse(port); + const actualPort = await this.startSse(port); + this.url = `http://localhost:${actualPort}`; + return actualPort; } } @@ -213,8 +216,10 @@ export class TestServerHttp { // Create HTTP server this.httpServer = createHttpServer(app); - // Create StreamableHTTP transport - this.transport = new StreamableHTTPServerTransport({}); + // Create StreamableHTTP transport (stateful so it can handle multiple requests per session) + this.transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); // Set up Express route to handle MCP requests app.post("/mcp", async (req: Request, res: Response) => { @@ -292,10 +297,12 @@ export class TestServerHttp { // Connect transport to server await this.mcpServer.connect(this.transport); - // Start listening + // Start listening (port 0 = OS assigns a unique port) return new Promise((resolve, reject) => { this.httpServer!.listen(port, () => { - resolve(port); + const assignedPort = (this.httpServer!.address() as { port: number }) + ?.port; + resolve(assignedPort ?? port); }); this.httpServer!.on("error", reject); }); @@ -371,10 +378,12 @@ export class TestServerHttp { // Note: SSE transport is created per request, so we don't store a single instance this.transport = undefined; - // Start listening + // Start listening (port 0 = OS assigns a unique port) return new Promise((resolve, reject) => { this.httpServer!.listen(port, () => { - resolve(port); + const assignedPort = (this.httpServer!.address() as { port: number }) + ?.port; + resolve(assignedPort ?? port); }); this.httpServer!.on("error", reject); }); diff --git a/cli/package.json b/cli/package.json index ce521bee..78092335 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.19.0", + "version": "0.21.1", "description": "CLI for the Model Context Protocol inspector", "license": "SEE LICENSE IN LICENSE", "author": "Model Context Protocol a Series of LF Projects, LLC.", @@ -12,7 +12,8 @@ "mcp-inspector-cli": "build/cli.js" }, "files": [ - "build" + "build", + "LICENSE" ], "scripts": { "build": "tsc", @@ -25,7 +26,7 @@ "test:cli-metadata": "vitest run metadata.test.ts" }, "devDependencies": { - "@types/express": "^5.0.6", + "@types/express": "^5.0.0", "tsx": "^4.7.0", "vitest": "^4.0.17" }, diff --git a/client/LICENSE b/client/LICENSE new file mode 100644 index 00000000..4a939857 --- /dev/null +++ b/client/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 9b793eae..70485296 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -13,8 +13,15 @@ module.exports = { tsconfig: "tsconfig.jest.json", }, ], + "^.+\\.m?js$": [ + "ts-jest", + { + tsconfig: "tsconfig.jest.json", + }, + ], }, extensionsToTreatAsEsm: [".ts", ".tsx"], + transformIgnorePatterns: ["node_modules/(?!(@modelcontextprotocol)/)"], testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", // Exclude directories and files that don't need to be tested testPathIgnorePatterns: [ diff --git a/client/package.json b/client/package.json index 1feec212..66732f39 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.19.0", + "version": "0.21.1", "description": "Client-side application for the Model Context Protocol inspector", "license": "SEE LICENSE IN LICENSE", "author": "Model Context Protocol a Series of LF Projects, LLC.", @@ -12,7 +12,8 @@ }, "files": [ "bin", - "dist" + "dist", + "LICENSE" ], "scripts": { "dev": "vite --port 6274", @@ -25,6 +26,8 @@ "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { + "@mcp-ui/client": "^6.0.0", + "@modelcontextprotocol/ext-apps": "^1.0.0", "@modelcontextprotocol/sdk": "^1.25.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", diff --git a/client/src/App.tsx b/client/src/App.tsx index 421e75d0..59d15ba0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -30,6 +30,7 @@ import { hasValidMetaPrefix, isReservedMetaKey, } from "@/utils/metaUtils"; +import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { createProxyFetch } from "./lib/proxyFetch"; @@ -52,6 +53,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { + AppWindow, Bell, Files, FolderTree, @@ -76,6 +78,7 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; import TasksTab from "./components/TasksTab"; +import AppsTab from "./components/AppsTab"; import { InspectorConfig } from "./lib/configurationTypes"; import { getMCPProxyAddress, @@ -100,6 +103,27 @@ import MetadataTab from "./components/MetadataTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; +type PrefilledAppsToolCall = { + id: number; + toolName: string; + params: Record; + result: CompatibilityCallToolResult; +}; + +const hasAppResourceUri = (tool: Tool): boolean => { + return Boolean(getToolUiResourceUri(tool)); +}; + +const cloneToolParams = ( + source: Record, +): Record => { + try { + return structuredClone(source); + } catch { + return { ...source }; + } +}; + const filterReservedMetadata = ( metadata: Record, ): Record => { @@ -127,12 +151,17 @@ const App = () => { const [resourceContentMap, setResourceContentMap] = useState< Record >({}); + const [fetchingResources, setFetchingResources] = useState>( + new Set(), + ); const [prompts, setPrompts] = useState([]); const [promptContent, setPromptContent] = useState(""); const [tools, setTools] = useState([]); const [tasks, setTasks] = useState([]); const [toolResult, setToolResult] = useState(null); + const [prefilledAppsToolCall, setPrefilledAppsToolCall] = + useState(null); const [errors, setErrors] = useState>({ resources: null, prompts: null, @@ -287,6 +316,7 @@ const App = () => { const [nextToolCursor, setNextToolCursor] = useState(); const [nextTaskCursor, setNextTaskCursor] = useState(); const progressTokenRef = useRef(0); + const prefilledAppsToolCallIdRef = useRef(0); const [activeTab, setActiveTab] = useState(() => { const hash = window.location.hash.slice(1); @@ -309,11 +339,13 @@ const App = () => { ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []), + "apps", "ping", "sampling", "elicitations", "roots", "auth", + "metadata", ]; if (!validTabs.includes(originatingTab)) return; @@ -441,11 +473,13 @@ const App = () => { ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []), + "apps", "ping", "sampling", "elicitations", "roots", "auth", + "metadata", ]; const isValidTab = validTabs.includes(hash); @@ -474,6 +508,13 @@ const App = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [mcpClient, activeTab]); + useEffect(() => { + if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) { + void listTools(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mcpClient, activeTab, serverCapabilities?.tools]); + useEffect(() => { localStorage.setItem("lastCommand", command); }, [command]); @@ -766,11 +807,13 @@ const App = () => { ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []), + "apps", "ping", "sampling", "elicitations", "roots", "auth", + "metadata", ]; if (validTabs.includes(originatingTab)) { @@ -860,22 +903,48 @@ const App = () => { }; const readResource = async (uri: string) => { + if (fetchingResources.has(uri) || resourceContentMap[uri]) { + return; + } + + console.log("[App] Reading resource:", uri); + setFetchingResources((prev) => new Set(prev).add(uri)); lastToolCallOriginTabRef.current = currentTabRef.current; - const response = await sendMCPRequest( - { - method: "resources/read" as const, - params: { uri }, - }, - ReadResourceResultSchema, - "resources", - ); - const content = JSON.stringify(response, null, 2); - setResourceContent(content); - setResourceContentMap((prev) => ({ - ...prev, - [uri]: content, - })); + try { + const response = await sendMCPRequest( + { + method: "resources/read" as const, + params: { uri }, + }, + ReadResourceResultSchema, + "resources", + ); + console.log("[App] Resource read response:", { + uri, + responseLength: JSON.stringify(response).length, + hasContents: !!(response as { contents?: unknown[] }).contents, + }); + const content = JSON.stringify(response, null, 2); + setResourceContent(content); + setResourceContentMap((prev) => ({ + ...prev, + [uri]: content, + })); + } catch (error) { + console.error(`[App] Failed to read resource ${uri}:`, error); + const errorString = (error as Error).message ?? String(error); + setResourceContentMap((prev) => ({ + ...prev, + [uri]: JSON.stringify({ error: errorString }), + })); + } finally { + setFetchingResources((prev) => { + const next = new Set(prev); + next.delete(uri); + return next; + }); + } }; const subscribeToResource = async (uri: string) => { @@ -942,7 +1011,7 @@ const App = () => { params: Record, toolMetadata?: Record, runAsTask?: boolean, - ) => { + ): Promise => { lastToolCallOriginTabRef.current = currentTabRef.current; try { @@ -1010,7 +1079,7 @@ const App = () => { "_meta" in (response as Record) ? ((response as { _meta?: Record })._meta ?? {}) : undefined; - setToolResult({ + let latestToolResult: CompatibilityCallToolResult = { content: [ { type: "text", @@ -1021,7 +1090,8 @@ const App = () => { ...(initialResponseMeta || {}), "io.modelcontextprotocol/related-task": { taskId }, }, - } as CompatibilityCallToolResult); + }; + setToolResult(latestToolResult); // Polling loop let taskCompleted = false; @@ -1058,12 +1128,13 @@ const App = () => { CompatibilityCallToolResultSchema, ); console.log(`Result received for task ${taskId}:`, result); - setToolResult(result as CompatibilityCallToolResult); + latestToolResult = result as CompatibilityCallToolResult; + setToolResult(latestToolResult); // Refresh tasks list to show completed state void listTasks(); } else { - setToolResult({ + latestToolResult = { content: [ { type: "text", @@ -1071,7 +1142,8 @@ const App = () => { }, ], isError: true, - }); + }; + setToolResult(latestToolResult); // Refresh tasks list to show failed/cancelled state void listTasks(); } @@ -1085,7 +1157,7 @@ const App = () => { ? ((response as { _meta?: Record })._meta ?? {}) : undefined; - setToolResult({ + latestToolResult = { content: [ { type: "text", @@ -1096,13 +1168,14 @@ const App = () => { ...(pollingResponseMeta || {}), "io.modelcontextprotocol/related-task": { taskId }, }, - } as CompatibilityCallToolResult); + }; + setToolResult(latestToolResult); // Refresh tasks list to show progress void listTasks(); } } catch (pollingError) { console.error("Error polling task status:", pollingError); - setToolResult({ + latestToolResult = { content: [ { type: "text", @@ -1110,16 +1183,22 @@ const App = () => { }, ], isError: true, - }); + }; + setToolResult(latestToolResult); taskCompleted = true; } } setIsPollingTask(false); + // Clear any validation errors since tool execution completed + setErrors((prev) => ({ ...prev, tools: null })); + return latestToolResult; } else { - setToolResult(response as CompatibilityCallToolResult); + const directResult = response as CompatibilityCallToolResult; + setToolResult(directResult); + // Clear any validation errors since tool execution completed + setErrors((prev) => ({ ...prev, tools: null })); + return directResult; } - // Clear any validation errors since tool execution completed - setErrors((prev) => ({ ...prev, tools: null })); } catch (e) { const toolResult: CompatibilityCallToolResult = { content: [ @@ -1133,6 +1212,7 @@ const App = () => { setToolResult(toolResult); // Clear validation errors - tool execution errors are shown in ToolResults setErrors((prev) => ({ ...prev, tools: null })); + return toolResult; } }; @@ -1319,6 +1399,10 @@ const App = () => { Tasks + + + Apps + Ping @@ -1453,6 +1537,9 @@ const App = () => { error={errors.prompts} /> { clearError("tools"); @@ -1471,7 +1558,26 @@ const App = () => { ) => { clearError("tools"); setToolResult(null); - await callTool(name, params, metadata, runAsTask); + const result = await callTool( + name, + params, + metadata, + runAsTask, + ); + const calledTool = tools.find( + (tool) => tool.name === name, + ); + if (calledTool && hasAppResourceUri(calledTool)) { + setPrefilledAppsToolCall({ + id: ++prefilledAppsToolCallIdRef.current, + toolName: name, + params: cloneToolParams(params), + result, + }); + } else { + setPrefilledAppsToolCall(null); + } + return result; }} selectedTool={selectedTool} setSelectedTool={(tool) => { @@ -1508,6 +1614,35 @@ const App = () => { error={errors.tasks} nextCursor={nextTaskCursor} /> + { + clearError("tools"); + listTools(); + }} + callTool={async ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => { + clearError("tools"); + setToolResult(null); + return callTool(name, params, metadata, runAsTask); + }} + prefilledToolCall={prefilledAppsToolCall} + onPrefilledToolCallConsumed={(callId) => { + setPrefilledAppsToolCall((prev) => + prev?.id === callId ? null : prev, + ); + }} + error={errors.tools} + mcpClient={mcpClient} + onNotification={(notification) => { + setNotifications((prev) => [...prev, notification]); + }} + /> { diff --git a/client/src/__tests__/App.toolsAppsPrefill.test.tsx b/client/src/__tests__/App.toolsAppsPrefill.test.tsx new file mode 100644 index 00000000..9239f29d --- /dev/null +++ b/client/src/__tests__/App.toolsAppsPrefill.test.tsx @@ -0,0 +1,284 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import App from "../App"; +import { useConnection } from "../lib/hooks/useConnection"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +type ToolListEntry = { + name: string; + inputSchema: { + type: "object"; + properties: Record; + }; + _meta: Record; +}; + +type AppsTabProps = { + tools: ToolListEntry[]; + prefilledToolCall?: { + id: number; + toolName: string; + params: Record; + result: { + content: Array<{ type: string; text: string }>; + }; + } | null; + onPrefilledToolCallConsumed?: (callId: number) => void; +}; + +// Mock auth dependencies first +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: jest.fn(), +})); + +jest.mock("../lib/oauth-state-machine", () => ({ + OAuthStateMachine: jest.fn(), +})); + +jest.mock("../lib/auth", () => ({ + InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ + tokens: jest.fn().mockResolvedValue(null), + clear: jest.fn(), + })), + DebugInspectorOAuthClientProvider: jest.fn(), +})); + +jest.mock("../utils/configUtils", () => ({ + ...jest.requireActual("../utils/configUtils"), + getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), + getMCPProxyAuthToken: jest.fn(() => ({ + token: "", + header: "X-MCP-Proxy-Auth", + })), + getInitialTransportType: jest.fn(() => "stdio"), + getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), + getInitialCommand: jest.fn(() => "mcp-server-everything"), + getInitialArgs: jest.fn(() => ""), + initializeInspectorConfig: jest.fn(() => ({})), + saveInspectorConfig: jest.fn(), +})); + +jest.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: () => ({ + height: 300, + handleDragStart: jest.fn(), + }), + useDraggableSidebar: () => ({ + width: 320, + isDragging: false, + handleDragStart: jest.fn(), + }), +})); + +jest.mock("../components/Sidebar", () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +jest.mock("../components/ResourcesTab", () => ({ + __esModule: true, + default: () =>
ResourcesTab
, +})); + +jest.mock("../components/PromptsTab", () => ({ + __esModule: true, + default: () =>
PromptsTab
, +})); + +jest.mock("../components/TasksTab", () => ({ + __esModule: true, + default: () =>
TasksTab
, +})); + +jest.mock("../components/ConsoleTab", () => ({ + __esModule: true, + default: () =>
ConsoleTab
, +})); + +jest.mock("../components/PingTab", () => ({ + __esModule: true, + default: () =>
PingTab
, +})); + +jest.mock("../components/SamplingTab", () => ({ + __esModule: true, + default: () =>
SamplingTab
, +})); + +jest.mock("../components/RootsTab", () => ({ + __esModule: true, + default: () =>
RootsTab
, +})); + +jest.mock("../components/ElicitationTab", () => ({ + __esModule: true, + default: () =>
ElicitationTab
, +})); + +jest.mock("../components/MetadataTab", () => ({ + __esModule: true, + default: () =>
MetadataTab
, +})); + +jest.mock("../components/AuthDebugger", () => ({ + __esModule: true, + default: () =>
AuthDebugger
, +})); + +jest.mock("../components/HistoryAndNotifications", () => ({ + __esModule: true, + default: () =>
HistoryAndNotifications
, +})); + +jest.mock("../components/ToolsTab", () => ({ + __esModule: true, + default: ({ + listTools, + callTool, + }: { + listTools: () => void; + callTool: ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => Promise; + }) => ( +
+ + +
+ ), +})); + +jest.mock("../components/AppsTab", () => ({ + __esModule: true, + default: (props: AppsTabProps) => { + const prefilled = + props && "prefilledToolCall" in props ? props.prefilledToolCall : null; + const tools = props && "tools" in props ? props.tools : []; + + return ( +
+
{JSON.stringify(tools)}
+
+ {JSON.stringify(prefilled ?? null)} +
+ {prefilled && ( + + )} +
+ ); + }, +})); + +global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); + +jest.mock("../lib/hooks/useConnection", () => ({ + useConnection: jest.fn(), +})); + +describe("App - Tools to Apps prefilled handoff", () => { + const mockUseConnection = jest.mocked(useConnection); + + beforeEach(() => { + jest.clearAllMocks(); + window.location.hash = "#tools"; + }); + + it("passes prefilled call data to AppsTab for tools using _meta['ui/resourceUri']", async () => { + const makeRequest = jest.fn(async (request: { method: string }) => { + if (request.method === "tools/list") { + return { + tools: [ + { + name: "weatherApp", + inputSchema: { + type: "object", + properties: { + city: { type: "string" }, + }, + }, + _meta: { + "ui/resourceUri": "ui://weather-app", + }, + }, + ], + nextCursor: undefined, + }; + } + + if (request.method === "tools/call") { + return { + content: [{ type: "text", text: "weather result" }], + }; + } + + throw new Error(`Unexpected method: ${request.method}`); + }); + + mockUseConnection.mockReturnValue({ + connectionStatus: "connected", + serverCapabilities: { tools: { listChanged: true } }, + serverImplementation: null, + mcpClient: { + request: jest.fn(), + notification: jest.fn(), + close: jest.fn(), + } as unknown as Client, + requestHistory: [], + clearRequestHistory: jest.fn(), + makeRequest, + cancelTask: jest.fn(), + listTasks: jest.fn(), + sendNotification: jest.fn(), + handleCompletion: jest.fn(), + completionsSupported: false, + connect: jest.fn(), + disconnect: jest.fn(), + } as ReturnType); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /mock list tools/i })); + + await waitFor(() => { + const tools = JSON.parse( + screen.getByTestId("apps-tools").textContent || "[]", + ); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe("weatherApp"); + }); + + fireEvent.click(screen.getByRole("button", { name: /mock run app tool/i })); + + await waitFor(() => { + const prefilled = JSON.parse( + screen.getByTestId("apps-prefilled").textContent || "null", + ); + expect(prefilled.toolName).toBe("weatherApp"); + expect(prefilled.params).toEqual({ city: "Lisbon" }); + expect(prefilled.result.content[0].text).toBe("weather result"); + }); + + fireEvent.click(screen.getByRole("button", { name: /consume prefilled/i })); + + await waitFor(() => { + expect(screen.getByTestId("apps-prefilled")).toHaveTextContent("null"); + }); + }); +}); diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx new file mode 100644 index 00000000..e25f35c9 --- /dev/null +++ b/client/src/components/AppRenderer.tsx @@ -0,0 +1,154 @@ +import { useMemo, useState } from "react"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + Tool, + ContentBlock, + CompatibilityCallToolResult, + CallToolResult, + CallToolResultSchema, + ServerNotification, + LoggingMessageNotificationParams, +} from "@modelcontextprotocol/sdk/types.js"; +import { + AppRenderer as McpUiAppRenderer, + type McpUiHostContext, + type RequestHandlerExtra, +} from "@mcp-ui/client"; +import { + type McpUiMessageRequest, + type McpUiMessageResult, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { useToast } from "@/lib/hooks/useToast"; + +interface AppRendererProps { + sandboxPath: string; + tool: Tool; + mcpClient: Client | null; + toolInput?: Record; + toolResult?: CompatibilityCallToolResult | null; + onNotification?: (notification: ServerNotification) => void; +} + +const AppRenderer = ({ + sandboxPath, + tool, + mcpClient, + toolInput, + toolResult, + onNotification, +}: AppRendererProps) => { + const [error, setError] = useState(null); + const { toast } = useToast(); + + const normalizedToolResult = useMemo(() => { + if (!toolResult) { + return undefined; + } + + if ("content" in toolResult) { + const parsedResult = CallToolResultSchema.safeParse(toolResult); + return parsedResult.success ? parsedResult.data : undefined; + } + + if ("toolResult" in toolResult) { + const parsedResult = CallToolResultSchema.safeParse( + toolResult.toolResult, + ); + return parsedResult.success ? parsedResult.data : undefined; + } + + return undefined; + }, [toolResult]); + + const hostContext: McpUiHostContext = useMemo( + () => ({ + theme: document.documentElement.classList.contains("dark") + ? "dark" + : "light", + }), + [], + ); + + const handleOpenLink = async ({ url }: { url: string }) => { + let isError = true; + if (url.startsWith("https://") || url.startsWith("http://")) { + window.open(url, "_blank"); + isError = false; + } + return { isError }; + }; + + const handleMessage = async ( + params: McpUiMessageRequest["params"], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _extra: RequestHandlerExtra, + ): Promise => { + const message = params.content + .filter((block): block is ContentBlock & { type: "text" } => + Boolean(block.type === "text"), + ) + .map((block) => block.text) + .join("\n"); + + if (message) { + toast({ + description: message, + }); + } + + return {}; + }; + + const handleLoggingMessage = (params: LoggingMessageNotificationParams) => { + if (onNotification) { + onNotification({ + method: "notifications/message", + params, + } as ServerNotification); + } + }; + + if (!mcpClient) { + return ( + + + Waiting for MCP client... + + ); + } + + return ( +
+ {error && ( + + + {error} + + )} + +
+ setError(err.message)} + /> +
+
+ ); +}; + +export default AppRenderer; diff --git a/client/src/components/AppsTab.tsx b/client/src/components/AppsTab.tsx new file mode 100644 index 00000000..2b6d7535 --- /dev/null +++ b/client/src/components/AppsTab.tsx @@ -0,0 +1,677 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + AlertCircle, + X, + Play, + Loader2, + ChevronRight, + Maximize2, + Minimize2, +} from "lucide-react"; +import { + Tool, + ServerNotification, + CompatibilityCallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; +import AppRenderer from "./AppRenderer"; +import ListPane from "./ListPane"; +import IconDisplay, { WithIcons } from "./IconDisplay"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm"; +import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; +import { + generateDefaultValue, + isPropertyRequired, + normalizeUnionType, + resolveRef, +} from "@/utils/schemaUtils"; + +interface AppsTabProps { + sandboxPath: string; + tools: Tool[]; + listTools: () => void; + callTool: ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => Promise; + prefilledToolCall?: { + id: number; + toolName: string; + params: Record; + result: CompatibilityCallToolResult; + } | null; + onPrefilledToolCallConsumed?: (callId: number) => void; + error: string | null; + mcpClient: Client | null; + onNotification?: (notification: ServerNotification) => void; +} + +// Type guard to check if a tool has UI metadata +const hasUIMetadata = (tool: Tool): boolean => { + return !!getToolUiResourceUri(tool); +}; + +const cloneToolParams = ( + source: Record, +): Record => { + try { + return structuredClone(source); + } catch { + return { ...source }; + } +}; + +const AppsTab = ({ + sandboxPath, + tools, + listTools, + callTool, + prefilledToolCall, + onPrefilledToolCallConsumed, + error, + mcpClient, + onNotification, +}: AppsTabProps) => { + const [appTools, setAppTools] = useState([]); + const [selectedTool, setSelectedTool] = useState(null); + const [params, setParams] = useState>({}); + const [isAppOpen, setIsAppOpen] = useState(false); + const [isOpeningApp, setIsOpeningApp] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); + const [hasValidationErrors, setHasValidationErrors] = useState(false); + const [submittedParams, setSubmittedParams] = useState< + Record | undefined + >(undefined); + const [submittedToolResult, setSubmittedToolResult] = + useState(null); + const formRefs = useRef>({}); + const openAppRunIdRef = useRef(0); + const prefillingParamsRef = useRef | null>(null); + const consumedPrefilledCallIdRef = useRef(null); + + const buildInitialParams = useCallback((tool: Tool) => { + const initialParams = Object.entries(tool.inputSchema.properties ?? []).map( + ([key, value]) => { + const resolvedValue = resolveRef( + value as JsonSchemaType, + tool.inputSchema as JsonSchemaType, + ); + return [ + key, + generateDefaultValue( + resolvedValue, + key, + tool.inputSchema as JsonSchemaType, + ), + ]; + }, + ); + return Object.fromEntries(initialParams); + }, []); + + // Function to check if any form has validation errors + const checkValidationErrors = useCallback(() => { + const errors = Object.values(formRefs.current).some( + (ref) => ref && !ref.validateJson().isValid, + ); + setHasValidationErrors(errors); + return errors; + }, []); + + // Filter tools that have UI metadata + useEffect(() => { + const filtered = tools.filter(hasUIMetadata); + console.log("[AppsTab] Filtered app tools:", { + totalTools: tools.length, + appTools: filtered.length, + appToolNames: filtered.map((t) => t.name), + }); + setAppTools(filtered); + + // If current selected tool is no longer available, reset selection + if (selectedTool && !filtered.find((t) => t.name === selectedTool.name)) { + setSelectedTool(null); + setIsAppOpen(false); + setSubmittedParams(undefined); + setSubmittedToolResult(null); + } + }, [tools, selectedTool]); + + useEffect(() => { + if (selectedTool) { + const prefillingParams = prefillingParamsRef.current; + if (prefillingParams) { + setParams(prefillingParams); + prefillingParamsRef.current = null; + } else { + setParams(buildInitialParams(selectedTool)); + } + setHasValidationErrors(false); + formRefs.current = {}; + } else { + setParams({}); + setIsAppOpen(false); + setSubmittedParams(undefined); + setSubmittedToolResult(null); + } + }, [buildInitialParams, selectedTool]); + + useEffect(() => { + if (!prefilledToolCall) { + return; + } + + if (consumedPrefilledCallIdRef.current === prefilledToolCall.id) { + return; + } + + const matchingTool = appTools.find( + (tool) => tool.name === prefilledToolCall.toolName, + ); + if (!matchingTool) { + return; + } + + const hydratedParams = cloneToolParams(prefilledToolCall.params); + + openAppRunIdRef.current += 1; + setIsOpeningApp(false); + prefillingParamsRef.current = hydratedParams; + setSelectedTool(matchingTool); + setSubmittedParams(hydratedParams); + setSubmittedToolResult(prefilledToolCall.result); + setIsAppOpen(true); + setIsMaximized(false); + consumedPrefilledCallIdRef.current = prefilledToolCall.id; + onPrefilledToolCallConsumed?.(prefilledToolCall.id); + }, [appTools, onPrefilledToolCallConsumed, prefilledToolCall]); + + const handleRefresh = useCallback(() => { + listTools(); + }, [listTools]); + + const executeToolAndOpenApp = useCallback( + async (tool: Tool, toolParams: Record) => { + const runId = ++openAppRunIdRef.current; + const runParams = cloneToolParams(toolParams); + prefillingParamsRef.current = null; + setIsOpeningApp(true); + setSubmittedParams(runParams); + setSubmittedToolResult(null); + try { + const result = await callTool(tool.name, runParams); + + if (runId !== openAppRunIdRef.current) { + return; + } + + setSubmittedParams(runParams); + setSubmittedToolResult(result); + setIsAppOpen(true); + } catch { + if (runId !== openAppRunIdRef.current) { + return; + } + + setSubmittedToolResult(null); + setIsAppOpen(false); + } finally { + if (runId === openAppRunIdRef.current) { + setIsOpeningApp(false); + } + } + }, + [callTool], + ); + + const handleCloseApp = useCallback(() => { + openAppRunIdRef.current += 1; + setIsOpeningApp(false); + setIsAppOpen(false); + setSubmittedToolResult(null); + }, []); + + const handleOpenApp = useCallback(async () => { + if (!selectedTool || checkValidationErrors()) { + return; + } + + await executeToolAndOpenApp(selectedTool, params); + }, [checkValidationErrors, executeToolAndOpenApp, params, selectedTool]); + + const handleSelectTool = useCallback( + (tool: Tool) => { + openAppRunIdRef.current += 1; + setIsOpeningApp(false); + prefillingParamsRef.current = null; + setSelectedTool(tool); + setSubmittedParams(undefined); + setSubmittedToolResult(null); + const hasFields = + tool.inputSchema.properties && + Object.keys(tool.inputSchema.properties).length > 0; + + if (hasFields) { + setIsAppOpen(false); + return; + } + + const initialParams = buildInitialParams(tool); + void executeToolAndOpenApp(tool, initialParams); + }, + [buildInitialParams, executeToolAndOpenApp], + ); + + const handleDeselectTool = useCallback(() => { + openAppRunIdRef.current += 1; + setIsOpeningApp(false); + prefillingParamsRef.current = null; + setSelectedTool(null); + setIsAppOpen(false); + setIsMaximized(false); + setSubmittedParams(undefined); + setSubmittedToolResult(null); + }, []); + + return ( + +
+ {!isMaximized && ( + { + return ( +
+
+ +
+
+ {tool.name} + {tool.description && ( + + {tool.description} + + )} +
+ +
+ ); + }} + title="MCP Apps" + buttonText="Refresh Apps" + /> + )} + +
+
+
+
+ {selectedTool && ( + + )} +

+ {selectedTool ? selectedTool.name : "Select an app"} +

+
+
+ {selectedTool && isAppOpen && ( + + )} + {selectedTool && ( + + )} +
+
+
+ +
+ {error && ( + + + {error} + + )} + + {selectedTool ? ( + (() => { + const hasFields = + selectedTool.inputSchema.properties && + Object.keys(selectedTool.inputSchema.properties).length > 0; + + return ( +
+ {!isAppOpen ? ( +
+ {selectedTool.description && ( +

+ {selectedTool.description} +

+ )} + +
+

App Input

+ {Object.entries( + selectedTool.inputSchema.properties ?? [], + ).map(([key, value]) => { + // First resolve any $ref references + const resolvedValue = resolveRef( + value as JsonSchemaType, + selectedTool.inputSchema as JsonSchemaType, + ); + const prop = normalizeUnionType(resolvedValue); + const inputSchema = + selectedTool.inputSchema as JsonSchemaType; + const required = isPropertyRequired( + key, + inputSchema, + ); + + return ( +
+
+ + {prop.nullable ? ( +
+ + setParams({ + ...params, + [key]: checked + ? null + : prop.type === "array" + ? undefined + : prop.default !== null + ? prop.default + : prop.type === "boolean" + ? false + : prop.type === "string" + ? "" + : undefined, + }) + } + /> + +
+ ) : null} +
+ +
+ {prop.type === "boolean" ? ( +
+ + setParams({ + ...params, + [key]: checked, + }) + } + /> + +
+ ) : prop.type === "string" && prop.enum ? ( + + ) : prop.type === "string" ? ( +