Merge branch 'main' into web-client-oauth-proxy-fetch
This commit is contained in:
commit
26c9f60d43
39 changed files with 4329 additions and 358 deletions
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
6
.github/workflows/claude.yml
vendored
6
.github/workflows/claude.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
.github/workflows/cli_tests.yml
vendored
4
.github/workflows/cli_tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/e2e_tests.yml
vendored
8
.github/workflows/e2e_tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
.github/workflows/main.yml
vendored
12
.github/workflows/main.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
19
SECURITY.md
19
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)
|
||||
|
|
|
|||
216
cli/LICENSE
Normal file
216
cli/LICENSE
Normal file
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
216
client/LICENSE
Normal file
216
client/LICENSE
Normal file
|
|
@ -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.
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
result: CompatibilityCallToolResult;
|
||||
};
|
||||
|
||||
const hasAppResourceUri = (tool: Tool): boolean => {
|
||||
return Boolean(getToolUiResourceUri(tool));
|
||||
};
|
||||
|
||||
const cloneToolParams = (
|
||||
source: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
try {
|
||||
return structuredClone(source);
|
||||
} catch {
|
||||
return { ...source };
|
||||
}
|
||||
};
|
||||
|
||||
const filterReservedMetadata = (
|
||||
metadata: Record<string, string>,
|
||||
): Record<string, string> => {
|
||||
|
|
@ -127,12 +151,17 @@ const App = () => {
|
|||
const [resourceContentMap, setResourceContentMap] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [fetchingResources, setFetchingResources] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
||||
const [promptContent, setPromptContent] = useState<string>("");
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [toolResult, setToolResult] =
|
||||
useState<CompatibilityCallToolResult | null>(null);
|
||||
const [prefilledAppsToolCall, setPrefilledAppsToolCall] =
|
||||
useState<PrefilledAppsToolCall | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string | null>>({
|
||||
resources: null,
|
||||
prompts: null,
|
||||
|
|
@ -287,6 +316,7 @@ const App = () => {
|
|||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||
const [nextTaskCursor, setNextTaskCursor] = useState<string | undefined>();
|
||||
const progressTokenRef = useRef(0);
|
||||
const prefilledAppsToolCallIdRef = useRef(0);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
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<string, unknown>,
|
||||
toolMetadata?: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => {
|
||||
): Promise<CompatibilityCallToolResult> => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
try {
|
||||
|
|
@ -1010,7 +1079,7 @@ const App = () => {
|
|||
"_meta" in (response as Record<string, unknown>)
|
||||
? ((response as { _meta?: Record<string, unknown> })._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<string, unknown> })._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 = () => {
|
|||
<ListTodo className="w-4 h-4 mr-2" />
|
||||
Tasks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="apps">
|
||||
<AppWindow className="w-4 h-4 mr-2" />
|
||||
Apps
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ping">
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Ping
|
||||
|
|
@ -1453,6 +1537,9 @@ const App = () => {
|
|||
error={errors.prompts}
|
||||
/>
|
||||
<ToolsTab
|
||||
serverSupportsTaskRequests={
|
||||
!!serverCapabilities?.tasks?.requests?.tools?.call
|
||||
}
|
||||
tools={tools}
|
||||
listTools={() => {
|
||||
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}
|
||||
/>
|
||||
<AppsTab
|
||||
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
|
||||
tools={tools}
|
||||
listTools={() => {
|
||||
clearError("tools");
|
||||
listTools();
|
||||
}}
|
||||
callTool={async (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
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]);
|
||||
}}
|
||||
/>
|
||||
<ConsoleTab />
|
||||
<PingTab
|
||||
onPingClick={() => {
|
||||
|
|
|
|||
284
client/src/__tests__/App.toolsAppsPrefill.test.tsx
Normal file
284
client/src/__tests__/App.toolsAppsPrefill.test.tsx
Normal file
|
|
@ -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<string, unknown>;
|
||||
};
|
||||
_meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AppsTabProps = {
|
||||
tools: ToolListEntry[];
|
||||
prefilledToolCall?: {
|
||||
id: number;
|
||||
toolName: string;
|
||||
params: Record<string, unknown>;
|
||||
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: () => <div>Sidebar</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/ResourcesTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>ResourcesTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/PromptsTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>PromptsTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/TasksTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>TasksTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/ConsoleTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>ConsoleTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/PingTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>PingTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/SamplingTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>SamplingTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/RootsTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>RootsTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/ElicitationTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>ElicitationTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/MetadataTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>MetadataTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/AuthDebugger", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>AuthDebugger</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/HistoryAndNotifications", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>HistoryAndNotifications</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/ToolsTab", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
listTools,
|
||||
callTool,
|
||||
}: {
|
||||
listTools: () => void;
|
||||
callTool: (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => Promise<unknown>;
|
||||
}) => (
|
||||
<div data-testid="tools-tab">
|
||||
<button type="button" onClick={listTools}>
|
||||
mock list tools
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void callTool("weatherApp", { city: "Lisbon" });
|
||||
}}
|
||||
>
|
||||
mock run app tool
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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 (
|
||||
<div data-testid="apps-tab">
|
||||
<div data-testid="apps-tools">{JSON.stringify(tools)}</div>
|
||||
<div data-testid="apps-prefilled">
|
||||
{JSON.stringify(prefilled ?? null)}
|
||||
</div>
|
||||
{prefilled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props?.onPrefilledToolCallConsumed?.(prefilled.id)}
|
||||
>
|
||||
consume prefilled
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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<typeof useConnection>);
|
||||
|
||||
render(<App />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
154
client/src/components/AppRenderer.tsx
Normal file
154
client/src/components/AppRenderer.tsx
Normal file
|
|
@ -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<string, unknown>;
|
||||
toolResult?: CompatibilityCallToolResult | null;
|
||||
onNotification?: (notification: ServerNotification) => void;
|
||||
}
|
||||
|
||||
const AppRenderer = ({
|
||||
sandboxPath,
|
||||
tool,
|
||||
mcpClient,
|
||||
toolInput,
|
||||
toolResult,
|
||||
onNotification,
|
||||
}: AppRendererProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const normalizedToolResult = useMemo<CallToolResult | undefined>(() => {
|
||||
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<McpUiMessageResult> => {
|
||||
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 (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Waiting for MCP client...</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 border rounded overflow-hidden"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
<McpUiAppRenderer
|
||||
client={mcpClient}
|
||||
onOpenLink={handleOpenLink}
|
||||
onMessage={handleMessage}
|
||||
onLoggingMessage={handleLoggingMessage}
|
||||
toolName={tool.name}
|
||||
hostContext={hostContext}
|
||||
toolInput={toolInput}
|
||||
toolResult={normalizedToolResult}
|
||||
sandbox={{
|
||||
url: new URL(sandboxPath, window.location.origin),
|
||||
}}
|
||||
onError={(err) => setError(err.message)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRenderer;
|
||||
677
client/src/components/AppsTab.tsx
Normal file
677
client/src/components/AppsTab.tsx
Normal file
|
|
@ -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<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => Promise<CompatibilityCallToolResult>;
|
||||
prefilledToolCall?: {
|
||||
id: number;
|
||||
toolName: string;
|
||||
params: Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
try {
|
||||
return structuredClone(source);
|
||||
} catch {
|
||||
return { ...source };
|
||||
}
|
||||
};
|
||||
|
||||
const AppsTab = ({
|
||||
sandboxPath,
|
||||
tools,
|
||||
listTools,
|
||||
callTool,
|
||||
prefilledToolCall,
|
||||
onPrefilledToolCallConsumed,
|
||||
error,
|
||||
mcpClient,
|
||||
onNotification,
|
||||
}: AppsTabProps) => {
|
||||
const [appTools, setAppTools] = useState<Tool[]>([]);
|
||||
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
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<string, unknown> | undefined
|
||||
>(undefined);
|
||||
const [submittedToolResult, setSubmittedToolResult] =
|
||||
useState<CompatibilityCallToolResult | null>(null);
|
||||
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
|
||||
const openAppRunIdRef = useRef(0);
|
||||
const prefillingParamsRef = useRef<Record<string, unknown> | null>(null);
|
||||
const consumedPrefilledCallIdRef = useRef<number | null>(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<string, unknown>) => {
|
||||
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 (
|
||||
<TabsContent value="apps" className="space-y-4">
|
||||
<div
|
||||
className={
|
||||
isMaximized
|
||||
? "grid grid-cols-1 gap-4"
|
||||
: "grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
}
|
||||
>
|
||||
{!isMaximized && (
|
||||
<ListPane
|
||||
items={appTools}
|
||||
listItems={handleRefresh}
|
||||
setSelectedItem={handleSelectTool}
|
||||
renderItem={(tool) => {
|
||||
return (
|
||||
<div className="flex items-start w-full gap-2">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<IconDisplay icons={(tool as WithIcons).icons} size="sm" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="truncate font-semibold">{tool.name}</span>
|
||||
{tool.description && (
|
||||
<span className="text-sm text-gray-500 text-left line-clamp-2">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400 mt-1" />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
title="MCP Apps"
|
||||
buttonText="Refresh Apps"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-card border border-border rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedTool && (
|
||||
<IconDisplay
|
||||
icons={(selectedTool as WithIcons).icons}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
<h3 className="font-semibold">
|
||||
{selectedTool ? selectedTool.name : "Select an app"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedTool && isAppOpen && (
|
||||
<Button
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={isMaximized ? "Minimize" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedTool && (
|
||||
<Button
|
||||
onClick={handleDeselectTool}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Close app"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{selectedTool ? (
|
||||
(() => {
|
||||
const hasFields =
|
||||
selectedTool.inputSchema.properties &&
|
||||
Object.keys(selectedTool.inputSchema.properties).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!isAppOpen ? (
|
||||
<div className="space-y-4">
|
||||
{selectedTool.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 border rounded-lg p-4 bg-muted/30">
|
||||
<h4 className="font-medium text-sm">App Input</h4>
|
||||
{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 (
|
||||
<div key={key} className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{key}
|
||||
{required && (
|
||||
<span className="text-red-500 ml-1">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{prop.nullable ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${key}-null`}
|
||||
checked={params[key] === null}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: checked
|
||||
? null
|
||||
: prop.type === "array"
|
||||
? undefined
|
||||
: prop.default !== null
|
||||
? prop.default
|
||||
: prop.type === "boolean"
|
||||
? false
|
||||
: prop.type === "string"
|
||||
? ""
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`${key}-null`}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
null
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${prop.nullable && params[key] === null ? "pointer-events-none opacity-50" : ""}`}
|
||||
>
|
||||
{prop.type === "boolean" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={key}
|
||||
checked={!!params[key]}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{prop.description ||
|
||||
"Toggle this option"}
|
||||
</label>
|
||||
</div>
|
||||
) : prop.type === "string" && prop.enum ? (
|
||||
<Select
|
||||
value={
|
||||
params[key] === undefined
|
||||
? ""
|
||||
: String(params[key])
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]:
|
||||
value === "" ? undefined : value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={key}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
prop.description ||
|
||||
"Select an option"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{prop.enum.map((option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : prop.type === "string" ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
placeholder={prop.description}
|
||||
value={
|
||||
params[key] === undefined
|
||||
? ""
|
||||
: String(params[key])
|
||||
}
|
||||
onChange={(e) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]:
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : prop.type === "object" ||
|
||||
prop.type === "array" ? (
|
||||
<DynamicJsonForm
|
||||
ref={(ref) =>
|
||||
(formRefs.current[key] = ref)
|
||||
}
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={
|
||||
(params[key] as JsonValue) ??
|
||||
generateDefaultValue(prop)
|
||||
}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
setTimeout(checkValidationErrors, 100);
|
||||
}}
|
||||
/>
|
||||
) : prop.type === "number" ||
|
||||
prop.type === "integer" ? (
|
||||
<Input
|
||||
type="number"
|
||||
id={key}
|
||||
placeholder={prop.description}
|
||||
value={
|
||||
params[key] === undefined
|
||||
? ""
|
||||
: String(params[key])
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: undefined,
|
||||
});
|
||||
} else {
|
||||
const num = Number(value);
|
||||
setParams({
|
||||
...params,
|
||||
[key]: isNaN(num) ? value : num,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DynamicJsonForm
|
||||
ref={(ref) =>
|
||||
(formRefs.current[key] = ref)
|
||||
}
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={params[key] as JsonValue}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
setTimeout(checkValidationErrors, 100);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
onClick={() => void handleOpenApp()}
|
||||
className="w-full"
|
||||
disabled={hasValidationErrors || isOpeningApp}
|
||||
>
|
||||
{isOpeningApp ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isOpeningApp ? "Opening App..." : "Open App"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{hasFields && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCloseApp}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Back to Input
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[600px]">
|
||||
<AppRenderer
|
||||
sandboxPath={sandboxPath}
|
||||
tool={selectedTool}
|
||||
mcpClient={mcpClient}
|
||||
toolInput={submittedParams}
|
||||
toolResult={submittedToolResult}
|
||||
onNotification={onNotification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground space-y-4">
|
||||
<AlertCircle className="w-12 h-12 opacity-20" />
|
||||
<p>Select an app from the list to get started</p>
|
||||
{appTools.length === 0 && (
|
||||
<p className="text-xs text-center max-w-[200px]">
|
||||
No MCP Apps available. Apps are tools that include a{" "}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
_meta.ui.resourceUri
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsTab;
|
||||
|
|
@ -125,6 +125,9 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
const [rawJsonValue, setRawJsonValue] = useState<string>(
|
||||
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||
);
|
||||
const [numericInputDrafts, setNumericInputDrafts] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
// Use a ref to manage debouncing timeouts to avoid parsing JSON
|
||||
// on every keystroke which would be inefficient and error-prone
|
||||
|
|
@ -134,6 +137,37 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
return !!jsonError;
|
||||
};
|
||||
|
||||
const getPathKey = (path: string[]) =>
|
||||
path.length === 0 ? "$root" : path.join(".");
|
||||
|
||||
const getNumericDisplayValue = (
|
||||
path: string[],
|
||||
currentValue: JsonValue,
|
||||
): string => {
|
||||
const pathKey = getPathKey(path);
|
||||
if (Object.prototype.hasOwnProperty.call(numericInputDrafts, pathKey)) {
|
||||
return numericInputDrafts[pathKey];
|
||||
}
|
||||
return typeof currentValue === "number" ? currentValue.toString() : "";
|
||||
};
|
||||
|
||||
const updateNumericDraft = (path: string[], draftValue: string) => {
|
||||
const pathKey = getPathKey(path);
|
||||
setNumericInputDrafts((prev) => ({ ...prev, [pathKey]: draftValue }));
|
||||
};
|
||||
|
||||
const clearNumericDraft = (path: string[]) => {
|
||||
const pathKey = getPathKey(path);
|
||||
setNumericInputDrafts((prev) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(prev, pathKey)) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[pathKey];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Debounce JSON parsing and parent updates to handle typing gracefully
|
||||
const debouncedUpdateParent = useCallback(
|
||||
(jsonString: string) => {
|
||||
|
|
@ -429,9 +463,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={(currentValue as number)?.toString() ?? ""}
|
||||
value={getNumericDisplayValue(path, currentValue)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
updateNumericDraft(path, val);
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
|
|
@ -441,6 +476,19 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
}
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val) {
|
||||
clearNumericDraft(path);
|
||||
return;
|
||||
}
|
||||
|
||||
const num = Number(val);
|
||||
if (!isNaN(num)) {
|
||||
handleFieldChange(path, num);
|
||||
}
|
||||
clearNumericDraft(path);
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
min={propSchema.minimum}
|
||||
|
|
@ -453,9 +501,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={(currentValue as number)?.toString() ?? ""}
|
||||
value={getNumericDisplayValue(path, currentValue)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
updateNumericDraft(path, val);
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
|
|
@ -465,6 +514,19 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
}
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val) {
|
||||
clearNumericDraft(path);
|
||||
return;
|
||||
}
|
||||
|
||||
const num = Number(val);
|
||||
if (!isNaN(num) && Number.isInteger(num)) {
|
||||
handleFieldChange(path, num);
|
||||
}
|
||||
clearNumericDraft(path);
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
min={propSchema.minimum}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useState, useMemo, useRef } from "react";
|
|||
type ListPaneProps<T> = {
|
||||
items: T[];
|
||||
listItems: () => void;
|
||||
clearItems: () => void;
|
||||
clearItems?: () => void;
|
||||
setSelectedItem: (item: T) => void;
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
title: string;
|
||||
|
|
@ -101,14 +101,16 @@ const ListPane = <T extends object>({
|
|||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-4"
|
||||
onClick={clearItems}
|
||||
disabled={items.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
{clearItems && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-4"
|
||||
onClick={clearItems}
|
||||
disabled={items.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<div className="space-y-2 overflow-y-auto max-h-96">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue