Merge branch 'main' into inspector/error-popup
This commit is contained in:
commit
8cb845798e
51 changed files with 7119 additions and 2488 deletions
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -34,3 +34,7 @@ If applicable, add screenshots to help explain your problem.
|
|||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Version Consideration**
|
||||
|
||||
Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](../../CONTRIBUTING.md) for more details.
|
||||
|
|
|
|||
57
.github/pull_request_template.md
vendored
Normal file
57
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
## Summary
|
||||
|
||||
<!-- Provide a brief description of what this PR does -->
|
||||
|
||||
> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](../CONTRIBUTING.md) for more details.
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!-- Mark the relevant option with an "x" -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Documentation update
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] Test updates
|
||||
- [ ] Build/CI improvements
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!-- Describe the changes in detail. Include screenshots/recordings if applicable -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link to related issues using #issue_number or "Fixes #issue_number" -->
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Describe how you tested these changes, where applicable -->
|
||||
|
||||
- [ ] Tested in UI mode
|
||||
- [ ] Tested in CLI mode
|
||||
- [ ] Tested with STDIO transport
|
||||
- [ ] Tested with SSE transport
|
||||
- [ ] Tested with Streamable HTTP transport
|
||||
- [ ] Added/updated automated tests
|
||||
- [ ] Manual testing performed
|
||||
|
||||
### Test Results and/or Instructions
|
||||
|
||||
<!-- Provide steps for reviewers to test your changes -->
|
||||
|
||||
Screenshots are encouraged to share your testing results for this change.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code follows the style guidelines (ran `npm run prettier-fix`)
|
||||
- [ ] Self-review completed
|
||||
- [ ] Code is commented where necessary
|
||||
- [ ] Documentation updated (README, comments, etc.)
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
<!-- If this is a breaking change, describe the impact and migration path -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Add any other context, screenshots, or information about the PR here -->
|
||||
49
.github/workflows/claude.yml
vendored
49
.github/workflows/claude.yml
vendored
|
|
@ -27,14 +27,48 @@ jobs:
|
|||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Get PR details
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request) ||
|
||||
github.event_name == 'pull_request_review_comment' ||
|
||||
github.event_name == 'pull_request_review'
|
||||
id: pr
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
let prNumber;
|
||||
if (context.eventName === 'issue_comment') {
|
||||
prNumber = context.issue.number;
|
||||
} else {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
}
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
core.setOutput('sha', pr.data.head.sha);
|
||||
core.setOutput('repo', pr.data.head.repo.full_name);
|
||||
|
||||
- name: Checkout PR branch
|
||||
if: steps.pr.outcome == 'success'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.pr.outcome != 'success'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
|
|
@ -45,9 +79,6 @@ jobs:
|
|||
# Trigger when assigned to an issue
|
||||
assignee_trigger: "claude"
|
||||
|
||||
# Allow Claude to run bash
|
||||
# This should be safe given the repo is already public
|
||||
allowed_tools: "Bash"
|
||||
|
||||
custom_instructions: |
|
||||
If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block.
|
||||
claude_args: |
|
||||
--allowedTools Bash
|
||||
--system-prompt "If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block."
|
||||
|
|
|
|||
2
.github/workflows/e2e_tests.yml
vendored
2
.github/workflows/e2e_tests.yml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
|||
|
||||
jobs:
|
||||
test:
|
||||
# Installing Playright dependencies can take quite awhile, and also depends on GitHub CI load.
|
||||
# Installing Playwright dependencies can take quite awhile, and also depends on GitHub CI load.
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ client/tsconfig.node.tsbuildinfo
|
|||
cli/build
|
||||
test-output
|
||||
tool-test-output
|
||||
metadata-test-output
|
||||
# symlinked by `npm run link:sdk`:
|
||||
sdk
|
||||
client/playwright-report/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# MCP Inspector Development Guide
|
||||
|
||||
> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
|
||||
|
||||
## Build Commands
|
||||
|
||||
- Build all: `npm run build`
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ Thanks for your interest in contributing! This guide explains how to get involve
|
|||
3. Run `npm run dev` to start both client and server in development mode
|
||||
4. Use the web UI at http://localhost:6274 to interact with the inspector
|
||||
|
||||
## Inspector V2 Development
|
||||
|
||||
We're actively developing **Inspector V2** to address architectural and UX improvements. We invite you to follow progress and participate in the Inspector V2 Working Group in [Discord](https://modelcontextprotocol.io/community/communication), [weekly meetings](https://meet.modelcontextprotocol.io/tag/inspector-v2-wg), and [GitHub Discussions](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/categories/meeting-notes-other) (where notes are posted after meetings).
|
||||
|
||||
**Current version (V1) contribution scope:**
|
||||
|
||||
- Bug fixes and MCP spec compliance are actively maintained
|
||||
- Documentation updates are always appreciated
|
||||
- Major changes will be directed to V2 development
|
||||
|
||||
## Development Process & Pull Requests
|
||||
|
||||
1. Create a new branch for your changes
|
||||
|
|
@ -30,7 +40,7 @@ If you find a security vulnerability, please refer to our [Security Policy](SECU
|
|||
|
||||
## Questions?
|
||||
|
||||
Feel free to [open an issue](https://github.com/modelcontextprotocol/mcp-inspector/issues) for questions or create a discussion for general topics.
|
||||
Feel free to [open an issue](https://github.com/modelcontextprotocol/inspector/issues) for questions or join the MCP Contributor [Discord server](https://modelcontextprotocol.io/community/communication). Also, please see notes above on Inspector V2 Development.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -34,7 +34,12 @@ The server will start up and the UI will be accessible at `http://localhost:6274
|
|||
You can also start it in a Docker container with the following command:
|
||||
|
||||
```bash
|
||||
docker run --rm --network host -p 6274:6274 -p 6277:6277 ghcr.io/modelcontextprotocol/inspector:latest
|
||||
docker run --rm \
|
||||
-p 127.0.0.1:6274:6274 \
|
||||
-p 127.0.0.1:6277:6277 \
|
||||
-e HOST=0.0.0.0 \
|
||||
-e MCP_AUTO_OPEN_ENABLED=false \
|
||||
ghcr.io/modelcontextprotocol/inspector:latest
|
||||
```
|
||||
|
||||
### From an MCP server repository
|
||||
|
|
@ -236,13 +241,15 @@ ALLOWED_ORIGINS=http://localhost:6274,http://localhost:8000 npm start
|
|||
|
||||
The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
|
||||
|
||||
| Setting | Description | Default |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
|
||||
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
|
||||
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
|
||||
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
|
||||
| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts (works with authentication enabled). Only as environment var, not configurable in browser. | true |
|
||||
| Setting | Description | Default |
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `MCP_SERVER_REQUEST_TIMEOUT` | Client-side timeout (ms) - Inspector will cancel the request if no response is received within this time. Note: servers may have their own timeouts | 300000 |
|
||||
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
|
||||
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
|
||||
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
|
||||
| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts (works with authentication enabled). Only as environment var, not configurable in browser. | true |
|
||||
|
||||
**Note on Timeouts:** The timeout settings above control when the Inspector (as an MCP client) will cancel requests. These are independent of any server-side timeouts. For example, if a server tool has a 10-minute timeout but the Inspector's timeout is set to 30 seconds, the Inspector will cancel the request after 30 seconds. Conversely, if the Inspector's timeout is 10 minutes but the server times out after 30 seconds, you'll receive the server's timeout error. For tools that require user interaction (like elicitation) or long-running operations, ensure the Inspector's timeout is set appropriately.
|
||||
|
||||
These settings can be adjusted in real-time through the UI and will persist across sessions.
|
||||
|
||||
|
|
@ -361,7 +368,7 @@ http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2
|
|||
You can also set initial config settings via query params, for example:
|
||||
|
||||
```
|
||||
http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=10000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577
|
||||
http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=60000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577
|
||||
```
|
||||
|
||||
Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence.
|
||||
|
|
@ -456,6 +463,17 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --me
|
|||
| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants |
|
||||
| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints |
|
||||
|
||||
## Tool Input Validation Guidelines
|
||||
|
||||
When implementing or modifying tool input parameter handling in the Inspector:
|
||||
|
||||
- **Omit optional fields with empty values** - When processing form inputs, omit empty strings or null values for optional parameters, UNLESS the field has an explicit default value in the schema that matches the current value
|
||||
- **Preserve explicit default values** - If a field schema contains an explicit default (e.g., `default: null`), and the current value matches that default, include it in the request. This is a meaningful value the tool expects
|
||||
- **Always include required fields** - Preserve required field values even when empty, allowing the MCP server to validate and return appropriate error messages
|
||||
- **Defer deep validation to the server** - Implement basic field presence checking in the Inspector client, but rely on the MCP server for parameter validation according to its schema
|
||||
|
||||
These guidelines maintain clean parameter passing and proper separation of concerns between the Inspector client and MCP servers.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License—see the [LICENSE](LICENSE) file for details.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.17.1",
|
||||
"version": "0.18.0",
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
}
|
||||
|
|
|
|||
676
cli/scripts/cli-metadata-tests.js
Executable file
676
cli/scripts/cli-metadata-tests.js
Executable file
|
|
@ -0,0 +1,676 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// Colors for output
|
||||
const colors = {
|
||||
GREEN: "\x1b[32m",
|
||||
YELLOW: "\x1b[33m",
|
||||
RED: "\x1b[31m",
|
||||
BLUE: "\x1b[34m",
|
||||
ORANGE: "\x1b[33m",
|
||||
NC: "\x1b[0m", // No Color
|
||||
};
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn } from "child_process";
|
||||
import os from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Get directory paths with ESM compatibility
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Track test results
|
||||
let PASSED_TESTS = 0;
|
||||
let FAILED_TESTS = 0;
|
||||
let SKIPPED_TESTS = 0;
|
||||
let TOTAL_TESTS = 0;
|
||||
|
||||
console.log(
|
||||
`${colors.YELLOW}=== MCP Inspector CLI Metadata Tests ===${colors.NC}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.BLUE}This script tests the MCP Inspector CLI's metadata functionality:${colors.NC}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.BLUE}- General metadata with --metadata option${colors.NC}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.BLUE}- Tool-specific metadata with --tool-metadata option${colors.NC}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.BLUE}- Metadata parsing with various data types${colors.NC}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.BLUE}- Metadata merging (tool-specific overrides general)${colors.NC}`,
|
||||
);
|
||||
console.log(
|
||||
`${colors.BLUE}- Metadata evaluation in different MCP methods${colors.NC}`,
|
||||
);
|
||||
console.log(`\n`);
|
||||
|
||||
// Get directory paths
|
||||
const SCRIPTS_DIR = __dirname;
|
||||
const PROJECT_ROOT = path.join(SCRIPTS_DIR, "../../");
|
||||
const BUILD_DIR = path.resolve(SCRIPTS_DIR, "../build");
|
||||
|
||||
// Define the test server command using npx
|
||||
const TEST_CMD = "npx";
|
||||
const TEST_ARGS = ["@modelcontextprotocol/server-everything"];
|
||||
|
||||
// Create output directory for test results
|
||||
const OUTPUT_DIR = path.join(SCRIPTS_DIR, "metadata-test-output");
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Create a temporary directory for test files
|
||||
const TEMP_DIR = path.join(os.tmpdir(), "mcp-inspector-metadata-tests");
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
|
||||
// Track servers for cleanup
|
||||
let runningServers = [];
|
||||
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`${colors.RED}Failed to remove temp directory: ${err.message}${colors.NC}`,
|
||||
);
|
||||
}
|
||||
|
||||
runningServers.forEach((server) => {
|
||||
try {
|
||||
process.kill(-server.pid);
|
||||
} catch (e) {}
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
runningServers.forEach((server) => {
|
||||
try {
|
||||
process.kill(-server.pid);
|
||||
} catch (e) {}
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Function to run a basic test
|
||||
async function runBasicTest(testName, ...args) {
|
||||
const outputFile = path.join(
|
||||
OUTPUT_DIR,
|
||||
`${testName.replace(/\//g, "_")}.log`,
|
||||
);
|
||||
|
||||
console.log(`\n${colors.YELLOW}Testing: ${testName}${colors.NC}`);
|
||||
TOTAL_TESTS++;
|
||||
|
||||
// Run the command and capture output
|
||||
console.log(
|
||||
`${colors.BLUE}Command: node ${BUILD_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Create a write stream for the output file
|
||||
const outputStream = fs.createWriteStream(outputFile);
|
||||
|
||||
// Spawn the process
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("node", [path.join(BUILD_DIR, "cli.js"), ...args], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(`${colors.YELLOW}Test timed out: ${testName}${colors.NC}`);
|
||||
child.kill();
|
||||
}, 15000);
|
||||
|
||||
// Pipe stdout and stderr to the output file
|
||||
child.stdout.pipe(outputStream);
|
||||
child.stderr.pipe(outputStream);
|
||||
|
||||
// Also capture output for display
|
||||
let output = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
child.stderr.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
outputStream.end();
|
||||
|
||||
// Check if we got valid JSON output (indicating success) even if process didn't exit cleanly
|
||||
const hasValidJsonOutput =
|
||||
output.includes('"tools"') ||
|
||||
output.includes('"resources"') ||
|
||||
output.includes('"prompts"') ||
|
||||
output.includes('"content"') ||
|
||||
output.includes('"messages"') ||
|
||||
output.includes('"contents"');
|
||||
|
||||
if (code === 0 || hasValidJsonOutput) {
|
||||
console.log(`${colors.GREEN}✓ Test passed: ${testName}${colors.NC}`);
|
||||
console.log(`${colors.BLUE}First few lines of output:${colors.NC}`);
|
||||
const firstFewLines = output
|
||||
.split("\n")
|
||||
.slice(0, 5)
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(firstFewLines);
|
||||
PASSED_TESTS++;
|
||||
resolve(true);
|
||||
} else {
|
||||
console.log(`${colors.RED}✗ Test failed: ${testName}${colors.NC}`);
|
||||
console.log(`${colors.RED}Error output:${colors.NC}`);
|
||||
console.log(
|
||||
output
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n"),
|
||||
);
|
||||
FAILED_TESTS++;
|
||||
|
||||
// Stop after any error is encountered
|
||||
console.log(
|
||||
`${colors.YELLOW}Stopping tests due to error. Please validate and fix before continuing.${colors.NC}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`${colors.RED}Error running test: ${error.message}${colors.NC}`,
|
||||
);
|
||||
FAILED_TESTS++;
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to run an error test (expected to fail)
|
||||
async function runErrorTest(testName, ...args) {
|
||||
const outputFile = path.join(
|
||||
OUTPUT_DIR,
|
||||
`${testName.replace(/\//g, "_")}.log`,
|
||||
);
|
||||
|
||||
console.log(`\n${colors.YELLOW}Testing error case: ${testName}${colors.NC}`);
|
||||
TOTAL_TESTS++;
|
||||
|
||||
// Run the command and capture output
|
||||
console.log(
|
||||
`${colors.BLUE}Command: node ${BUILD_DIR}/cli.js ${args.join(" ")}${colors.NC}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Create a write stream for the output file
|
||||
const outputStream = fs.createWriteStream(outputFile);
|
||||
|
||||
// Spawn the process
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("node", [path.join(BUILD_DIR, "cli.js"), ...args], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(
|
||||
`${colors.YELLOW}Error test timed out: ${testName}${colors.NC}`,
|
||||
);
|
||||
child.kill();
|
||||
}, 15000);
|
||||
|
||||
// Pipe stdout and stderr to the output file
|
||||
child.stdout.pipe(outputStream);
|
||||
child.stderr.pipe(outputStream);
|
||||
|
||||
// Also capture output for display
|
||||
let output = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
child.stderr.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
outputStream.end();
|
||||
|
||||
// For error tests, we expect a non-zero exit code
|
||||
if (code !== 0) {
|
||||
console.log(
|
||||
`${colors.GREEN}✓ Error test passed: ${testName}${colors.NC}`,
|
||||
);
|
||||
console.log(`${colors.BLUE}Error output (expected):${colors.NC}`);
|
||||
const firstFewLines = output
|
||||
.split("\n")
|
||||
.slice(0, 5)
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(firstFewLines);
|
||||
PASSED_TESTS++;
|
||||
resolve(true);
|
||||
} else {
|
||||
console.log(
|
||||
`${colors.RED}✗ Error test failed: ${testName} (expected error but got success)${colors.NC}`,
|
||||
);
|
||||
console.log(`${colors.RED}Output:${colors.NC}`);
|
||||
console.log(
|
||||
output
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n"),
|
||||
);
|
||||
FAILED_TESTS++;
|
||||
|
||||
// Stop after any error is encountered
|
||||
console.log(
|
||||
`${colors.YELLOW}Stopping tests due to error. Please validate and fix before continuing.${colors.NC}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`${colors.RED}Error running test: ${error.message}${colors.NC}`,
|
||||
);
|
||||
FAILED_TESTS++;
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
async function runTests() {
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running General Metadata Tests ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 1: General metadata with tools/list
|
||||
await runBasicTest(
|
||||
"metadata_tools_list",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
);
|
||||
|
||||
// Test 2: General metadata with resources/list
|
||||
await runBasicTest(
|
||||
"metadata_resources_list",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/list",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
);
|
||||
|
||||
// Test 3: General metadata with prompts/list
|
||||
await runBasicTest(
|
||||
"metadata_prompts_list",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/list",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
);
|
||||
|
||||
// Test 4: General metadata with resources/read
|
||||
await runBasicTest(
|
||||
"metadata_resources_read",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"test://static/resource/1",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
);
|
||||
|
||||
// Test 5: General metadata with prompts/get
|
||||
await runBasicTest(
|
||||
"metadata_prompts_get",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
"--prompt-name",
|
||||
"simple_prompt",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running Tool-Specific Metadata Tests ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 6: Tool-specific metadata with tools/call
|
||||
await runBasicTest(
|
||||
"metadata_tools_call_tool_meta",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=hello world",
|
||||
"--tool-metadata",
|
||||
"client=test-client",
|
||||
);
|
||||
|
||||
// Test 7: Tool-specific metadata with complex tool
|
||||
await runBasicTest(
|
||||
"metadata_tools_call_complex_tool_meta",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"add",
|
||||
"--tool-arg",
|
||||
"a=10",
|
||||
"b=20",
|
||||
"--tool-metadata",
|
||||
"client=test-client",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running Metadata Merging Tests ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 8: General metadata + tool-specific metadata (tool-specific should override)
|
||||
await runBasicTest(
|
||||
"metadata_merging_general_and_tool",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=hello world",
|
||||
"--metadata",
|
||||
"client=general-client",
|
||||
"--tool-metadata",
|
||||
"client=test-client",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running Metadata Parsing Tests ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 10: Metadata with numeric values (should be converted to strings)
|
||||
await runBasicTest(
|
||||
"metadata_parsing_numbers",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"integer_value=42",
|
||||
"decimal_value=3.14159",
|
||||
"negative_value=-10",
|
||||
);
|
||||
|
||||
// Test 11: Metadata with JSON values (should be converted to strings)
|
||||
await runBasicTest(
|
||||
"metadata_parsing_json",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
'json_object="{\\"key\\":\\"value\\"}"',
|
||||
'json_array="[1,2,3]"',
|
||||
'json_string="\\"quoted\\""',
|
||||
);
|
||||
|
||||
// Test 12: Metadata with special characters
|
||||
await runBasicTest(
|
||||
"metadata_parsing_special_chars",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"unicode=🚀🎉✨",
|
||||
"special_chars=!@#$%^&*()",
|
||||
"spaces=hello world with spaces",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running Metadata Edge Cases ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 13: Single metadata entry
|
||||
await runBasicTest(
|
||||
"metadata_single_entry",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"single_key=single_value",
|
||||
);
|
||||
|
||||
// Test 14: Many metadata entries
|
||||
await runBasicTest(
|
||||
"metadata_many_entries",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"key1=value1",
|
||||
"key2=value2",
|
||||
"key3=value3",
|
||||
"key4=value4",
|
||||
"key5=value5",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running Metadata Error Cases ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 15: Invalid metadata format (missing equals)
|
||||
await runErrorTest(
|
||||
"metadata_error_invalid_format",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"invalid_format_no_equals",
|
||||
);
|
||||
|
||||
// Test 16: Invalid tool-meta format (missing equals)
|
||||
await runErrorTest(
|
||||
"metadata_error_invalid_tool_meta_format",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=test",
|
||||
"--tool-metadata",
|
||||
"invalid_format_no_equals",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running Metadata Impact Tests ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 17: Test tool-specific metadata vs general metadata precedence
|
||||
await runBasicTest(
|
||||
"metadata_precedence_tool_overrides_general",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=precedence test",
|
||||
"--metadata",
|
||||
"client=general-client",
|
||||
"--tool-metadata",
|
||||
"client=tool-specific-client",
|
||||
);
|
||||
|
||||
// Test 18: Test metadata with resources methods
|
||||
await runBasicTest(
|
||||
"metadata_resources_methods",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/list",
|
||||
"--metadata",
|
||||
"resource_client=test-resource-client",
|
||||
);
|
||||
|
||||
// Test 19: Test metadata with prompts methods
|
||||
await runBasicTest(
|
||||
"metadata_prompts_methods",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
"--prompt-name",
|
||||
"simple_prompt",
|
||||
"--metadata",
|
||||
"prompt_client=test-prompt-client",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running Metadata Validation Tests ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 20: Test metadata with special characters in keys
|
||||
await runBasicTest(
|
||||
"metadata_special_key_characters",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=special keys test",
|
||||
"--metadata",
|
||||
"key-with-dashes=value1",
|
||||
"key_with_underscores=value2",
|
||||
"key.with.dots=value3",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n${colors.YELLOW}=== Running Metadata Integration Tests ===${colors.NC}`,
|
||||
);
|
||||
|
||||
// Test 21: Metadata with all MCP methods
|
||||
await runBasicTest(
|
||||
"metadata_integration_all_methods",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"integration_test=true",
|
||||
"test_phase=all_methods",
|
||||
);
|
||||
|
||||
// Test 22: Complex metadata scenario
|
||||
await runBasicTest(
|
||||
"metadata_complex_scenario",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=complex test",
|
||||
"--metadata",
|
||||
"session_id=12345",
|
||||
"user_id=67890",
|
||||
"timestamp=2024-01-01T00:00:00Z",
|
||||
"request_id=req-abc-123",
|
||||
"--tool-metadata",
|
||||
"tool_session=session-xyz-789",
|
||||
"execution_context=test",
|
||||
"priority=high",
|
||||
);
|
||||
|
||||
// Test 23: Metadata parsing validation test
|
||||
await runBasicTest(
|
||||
"metadata_parsing_validation",
|
||||
TEST_CMD,
|
||||
...TEST_ARGS,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=parsing validation test",
|
||||
"--metadata",
|
||||
"valid_key=valid_value",
|
||||
"numeric_key=123",
|
||||
"boolean_key=true",
|
||||
'json_key=\'{"test":"value"}\'',
|
||||
"special_key=!@#$%^&*()",
|
||||
"unicode_key=🚀🎉✨",
|
||||
);
|
||||
|
||||
// Print test summary
|
||||
console.log(`\n${colors.YELLOW}=== Test Summary ===${colors.NC}`);
|
||||
console.log(`${colors.GREEN}Passed: ${PASSED_TESTS}${colors.NC}`);
|
||||
console.log(`${colors.RED}Failed: ${FAILED_TESTS}${colors.NC}`);
|
||||
console.log(`${colors.ORANGE}Skipped: ${SKIPPED_TESTS}${colors.NC}`);
|
||||
console.log(`Total: ${TOTAL_TESTS}`);
|
||||
console.log(
|
||||
`${colors.BLUE}Detailed logs saved to: ${OUTPUT_DIR}${colors.NC}`,
|
||||
);
|
||||
|
||||
console.log(`\n${colors.GREEN}All metadata tests completed!${colors.NC}`);
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
runTests().catch((error) => {
|
||||
console.error(
|
||||
`${colors.RED}Tests failed with error: ${error.message}${colors.NC}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -12,9 +12,14 @@ type JsonValue =
|
|||
| { [key: string]: JsonValue };
|
||||
|
||||
// List available prompts
|
||||
export async function listPrompts(client: Client): Promise<McpResponse> {
|
||||
export async function listPrompts(
|
||||
client: Client,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.listPrompts();
|
||||
const params =
|
||||
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
|
||||
const response = await client.listPrompts(params);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
|
@ -28,6 +33,7 @@ export async function getPrompt(
|
|||
client: Client,
|
||||
name: string,
|
||||
args?: Record<string, JsonValue>,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
// Convert all arguments to strings for prompt arguments
|
||||
|
|
@ -44,10 +50,16 @@ export async function getPrompt(
|
|||
}
|
||||
}
|
||||
|
||||
const response = await client.getPrompt({
|
||||
const params: any = {
|
||||
name,
|
||||
arguments: stringArgs,
|
||||
});
|
||||
};
|
||||
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
params._meta = metadata;
|
||||
}
|
||||
|
||||
const response = await client.getPrompt(params);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|||
import { McpResponse } from "./types.js";
|
||||
|
||||
// List available resources
|
||||
export async function listResources(client: Client): Promise<McpResponse> {
|
||||
export async function listResources(
|
||||
client: Client,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.listResources();
|
||||
const params =
|
||||
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
|
||||
const response = await client.listResources(params);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
|
@ -17,9 +22,14 @@ export async function listResources(client: Client): Promise<McpResponse> {
|
|||
export async function readResource(
|
||||
client: Client,
|
||||
uri: string,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.readResource({ uri });
|
||||
const params: any = { uri };
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
params._meta = metadata;
|
||||
}
|
||||
const response = await client.readResource(params);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
|
@ -31,9 +41,12 @@ export async function readResource(
|
|||
// List resource templates
|
||||
export async function listResourceTemplates(
|
||||
client: Client,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.listResourceTemplates();
|
||||
const params =
|
||||
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
|
||||
const response = await client.listResourceTemplates(params);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -19,9 +19,14 @@ type JsonSchemaType = {
|
|||
items?: JsonSchemaType;
|
||||
};
|
||||
|
||||
export async function listTools(client: Client): Promise<McpResponse> {
|
||||
export async function listTools(
|
||||
client: Client,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const response = await client.listTools();
|
||||
const params =
|
||||
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
|
||||
const response = await client.listTools(params);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
|
@ -82,9 +87,11 @@ export async function callTool(
|
|||
client: Client,
|
||||
name: string,
|
||||
args: Record<string, JsonValue>,
|
||||
generalMetadata?: Record<string, string>,
|
||||
toolSpecificMetadata?: Record<string, string>,
|
||||
): Promise<McpResponse> {
|
||||
try {
|
||||
const toolsResponse = await listTools(client);
|
||||
const toolsResponse = await listTools(client, generalMetadata);
|
||||
const tools = toolsResponse.tools as Tool[];
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
|
||||
|
|
@ -106,9 +113,23 @@ export async function callTool(
|
|||
}
|
||||
}
|
||||
|
||||
// Merge general metadata with tool-specific metadata
|
||||
// Tool-specific metadata takes precedence over general metadata
|
||||
let mergedMetadata: Record<string, string> | undefined;
|
||||
if (generalMetadata || toolSpecificMetadata) {
|
||||
mergedMetadata = {
|
||||
...(generalMetadata || {}),
|
||||
...(toolSpecificMetadata || {}),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.callTool({
|
||||
name: name,
|
||||
arguments: convertedArgs,
|
||||
_meta:
|
||||
mergedMetadata && Object.keys(mergedMetadata).length > 0
|
||||
? mergedMetadata
|
||||
: undefined,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -41,8 +41,10 @@ type Args = {
|
|||
logLevel?: LogLevel;
|
||||
toolName?: string;
|
||||
toolArg?: Record<string, JsonValue>;
|
||||
toolMeta?: Record<string, string>;
|
||||
transport?: "sse" | "stdio" | "http";
|
||||
headers?: Record<string, string>;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
function createTransportOptions(
|
||||
|
|
@ -129,7 +131,7 @@ async function callMethod(args: Args): Promise<void> {
|
|||
|
||||
// Tools methods
|
||||
if (args.method === "tools/list") {
|
||||
result = await listTools(client);
|
||||
result = await listTools(client, args.metadata);
|
||||
} else if (args.method === "tools/call") {
|
||||
if (!args.toolName) {
|
||||
throw new Error(
|
||||
|
|
@ -137,11 +139,17 @@ async function callMethod(args: Args): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
result = await callTool(client, args.toolName, args.toolArg || {});
|
||||
result = await callTool(
|
||||
client,
|
||||
args.toolName,
|
||||
args.toolArg || {},
|
||||
args.metadata,
|
||||
args.toolMeta,
|
||||
);
|
||||
}
|
||||
// Resources methods
|
||||
else if (args.method === "resources/list") {
|
||||
result = await listResources(client);
|
||||
result = await listResources(client, args.metadata);
|
||||
} else if (args.method === "resources/read") {
|
||||
if (!args.uri) {
|
||||
throw new Error(
|
||||
|
|
@ -149,13 +157,13 @@ async function callMethod(args: Args): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
result = await readResource(client, args.uri);
|
||||
result = await readResource(client, args.uri, args.metadata);
|
||||
} else if (args.method === "resources/templates/list") {
|
||||
result = await listResourceTemplates(client);
|
||||
result = await listResourceTemplates(client, args.metadata);
|
||||
}
|
||||
// Prompts methods
|
||||
else if (args.method === "prompts/list") {
|
||||
result = await listPrompts(client);
|
||||
result = await listPrompts(client, args.metadata);
|
||||
} else if (args.method === "prompts/get") {
|
||||
if (!args.promptName) {
|
||||
throw new Error(
|
||||
|
|
@ -163,7 +171,12 @@ async function callMethod(args: Args): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
result = await getPrompt(client, args.promptName, args.promptArgs || {});
|
||||
result = await getPrompt(
|
||||
client,
|
||||
args.promptName,
|
||||
args.promptArgs || {},
|
||||
args.metadata,
|
||||
);
|
||||
}
|
||||
// Logging methods
|
||||
else if (args.method === "logging/setLevel") {
|
||||
|
|
@ -328,6 +341,21 @@ function parseArgs(): Args {
|
|||
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
|
||||
parseHeaderPair,
|
||||
{},
|
||||
)
|
||||
//
|
||||
// Metadata options
|
||||
//
|
||||
.option(
|
||||
"--metadata <pairs...>",
|
||||
"General metadata as key=value pairs (applied to all methods)",
|
||||
parseKeyValuePair,
|
||||
{},
|
||||
)
|
||||
.option(
|
||||
"--tool-metadata <pairs...>",
|
||||
"Tool-specific metadata as key=value pairs (for tools/call method only)",
|
||||
parseKeyValuePair,
|
||||
{},
|
||||
);
|
||||
|
||||
// Parse only the arguments before --
|
||||
|
|
@ -335,6 +363,8 @@ function parseArgs(): Args {
|
|||
|
||||
const options = program.opts() as Omit<Args, "target"> & {
|
||||
header?: Record<string, string>;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
toolMetadata?: Record<string, JsonValue>;
|
||||
};
|
||||
|
||||
let remainingArgs = program.args;
|
||||
|
|
@ -352,6 +382,22 @@ function parseArgs(): Args {
|
|||
target: finalArgs,
|
||||
...options,
|
||||
headers: options.header, // commander.js uses 'header' field, map to 'headers'
|
||||
metadata: options.metadata
|
||||
? Object.fromEntries(
|
||||
Object.entries(options.metadata).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
toolMeta: options.toolMetadata
|
||||
? Object.fromEntries(
|
||||
Object.entries(options.toolMetadata).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.17.1",
|
||||
"version": "0.18.0",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/serve-handler": "^6.1.4",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"co": "^4.6.0",
|
||||
"eslint": "^9.11.1",
|
||||
|
|
@ -77,6 +77,6 @@
|
|||
"ts-jest": "^29.4.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^7.1.11"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,16 @@ import {
|
|||
LoggingLevel,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import type {
|
||||
AnySchema,
|
||||
SchemaOutput,
|
||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||
import {
|
||||
hasValidMetaName,
|
||||
hasValidMetaPrefix,
|
||||
isReservedMetaKey,
|
||||
} from "@/utils/metaUtils";
|
||||
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
||||
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||
|
|
@ -47,6 +56,7 @@ import {
|
|||
Hash,
|
||||
Key,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
import { z } from "zod";
|
||||
|
|
@ -80,9 +90,28 @@ import {
|
|||
CustomHeaders,
|
||||
migrateFromLegacyAuth,
|
||||
} from "./lib/types/customHeaders";
|
||||
import MetadataTab from "./components/MetadataTab";
|
||||
|
||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||
|
||||
const filterReservedMetadata = (
|
||||
metadata: Record<string, string>,
|
||||
): Record<string, string> => {
|
||||
return Object.entries(metadata).reduce<Record<string, string>>(
|
||||
(acc, [key, value]) => {
|
||||
if (
|
||||
!isReservedMetaKey(key) &&
|
||||
hasValidMetaPrefix(key) &&
|
||||
hasValidMetaName(key)
|
||||
) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [resourceTemplates, setResourceTemplates] = useState<
|
||||
|
|
@ -199,9 +228,31 @@ const App = () => {
|
|||
const [authState, setAuthState] =
|
||||
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
||||
|
||||
// Metadata state - persisted in localStorage
|
||||
const [metadata, setMetadata] = useState<Record<string, string>>(() => {
|
||||
const savedMetadata = localStorage.getItem("lastMetadata");
|
||||
if (savedMetadata) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedMetadata);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return filterReservedMetadata(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse saved metadata:", error);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||
setAuthState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const handleMetadataChange = (newMetadata: Record<string, string>) => {
|
||||
const sanitizedMetadata = filterReservedMetadata(newMetadata);
|
||||
setMetadata(sanitizedMetadata);
|
||||
localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata));
|
||||
};
|
||||
const nextRequestId = useRef(0);
|
||||
const rootsRef = useRef<Root[]>([]);
|
||||
|
||||
|
|
@ -249,6 +300,7 @@ const App = () => {
|
|||
const {
|
||||
connectionStatus,
|
||||
serverCapabilities,
|
||||
serverImplementation,
|
||||
mcpClient,
|
||||
requestHistory,
|
||||
clearRequestHistory,
|
||||
|
|
@ -304,6 +356,7 @@ const App = () => {
|
|||
},
|
||||
getRoots: () => rootsRef.current,
|
||||
defaultLoggingLevel: logLevel,
|
||||
metadata,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -639,11 +692,11 @@ const App = () => {
|
|||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||
};
|
||||
|
||||
const sendMCPRequest = async <T extends z.ZodType>(
|
||||
const sendMCPRequest = async <T extends AnySchema>(
|
||||
request: ClientRequest,
|
||||
schema: T,
|
||||
tabKey?: keyof typeof errors,
|
||||
) => {
|
||||
): Promise<SchemaOutput<T>> => {
|
||||
try {
|
||||
const response = await makeRequest(request, schema);
|
||||
if (tabKey !== undefined) {
|
||||
|
|
@ -784,7 +837,11 @@ const App = () => {
|
|||
cacheToolOutputSchemas(response.tools);
|
||||
};
|
||||
|
||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||
const callTool = async (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
toolMetadata?: Record<string, unknown>,
|
||||
) => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
try {
|
||||
|
|
@ -794,15 +851,21 @@ const App = () => {
|
|||
? cleanParams(params, tool.inputSchema as JsonSchemaType)
|
||||
: params;
|
||||
|
||||
// Merge general metadata with tool-specific metadata
|
||||
// Tool-specific metadata takes precedence over general metadata
|
||||
const mergedMetadata = {
|
||||
...metadata, // General metadata
|
||||
progressToken: progressTokenRef.current++,
|
||||
...toolMetadata, // Tool-specific metadata
|
||||
};
|
||||
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "tools/call" as const,
|
||||
params: {
|
||||
name,
|
||||
arguments: cleanedParams,
|
||||
_meta: {
|
||||
progressToken: progressTokenRef.current++,
|
||||
},
|
||||
_meta: mergedMetadata,
|
||||
},
|
||||
},
|
||||
CompatibilityCallToolResultSchema,
|
||||
|
|
@ -920,6 +983,7 @@ const App = () => {
|
|||
loggingSupported={!!serverCapabilities?.logging || false}
|
||||
connectionType={connectionType}
|
||||
setConnectionType={setConnectionType}
|
||||
serverImplementation={serverImplementation}
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleSidebarDragStart}
|
||||
|
|
@ -1000,6 +1064,10 @@ const App = () => {
|
|||
<Key className="w-4 h-4 mr-2" />
|
||||
Auth
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="metadata">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Metadata
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="w-full">
|
||||
|
|
@ -1110,10 +1178,14 @@ const App = () => {
|
|||
setNextToolCursor(undefined);
|
||||
cacheToolOutputSchemas([]);
|
||||
}}
|
||||
callTool={async (name, params) => {
|
||||
callTool={async (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => {
|
||||
clearError("tools");
|
||||
setToolResult(null);
|
||||
await callTool(name, params);
|
||||
await callTool(name, params, metadata);
|
||||
}}
|
||||
selectedTool={selectedTool}
|
||||
setSelectedTool={(tool) => {
|
||||
|
|
@ -1156,6 +1228,10 @@ const App = () => {
|
|||
onRootsChange={handleRootsChange}
|
||||
/>
|
||||
<AuthDebuggerWrapper />
|
||||
<MetadataTab
|
||||
metadata={metadata}
|
||||
onMetadataChange={handleMetadataChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const disconnectedConnectionState = {
|
|||
completionsSupported: false,
|
||||
connect: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
serverImplementation: null,
|
||||
};
|
||||
|
||||
// Connected state for tests that need an active connection
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import { Input } from "@/components/ui/input";
|
|||
import JsonEditor from "./JsonEditor";
|
||||
import { updateValueAtPath } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import type {
|
||||
JsonValue,
|
||||
JsonSchemaType,
|
||||
JsonSchemaConst,
|
||||
} from "@/utils/jsonUtils";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
import { CheckCheck, Copy } from "lucide-react";
|
||||
|
||||
|
|
@ -76,7 +80,40 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
|
|||
|
||||
const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||
({ schema, value, onChange, maxDepth = 3 }, ref) => {
|
||||
const isOnlyJSON = !isSimpleObject(schema);
|
||||
// Determine if we can render a form at the top level.
|
||||
// This is more permissive than isSimpleObject():
|
||||
// - Objects with any properties are form-capable (individual complex fields may still fallback to JSON)
|
||||
// - Arrays with defined items are form-capable
|
||||
// - Primitive types are form-capable
|
||||
const canRenderTopLevelForm = (s: JsonSchemaType): boolean => {
|
||||
const primitiveTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
|
||||
const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type;
|
||||
if (!hasType) return false;
|
||||
|
||||
const includesType = (t: string) =>
|
||||
Array.isArray(s.type)
|
||||
? (s.type as ReadonlyArray<string>).includes(t)
|
||||
: s.type === t;
|
||||
|
||||
// Primitive at top-level
|
||||
if (primitiveTypes.some(includesType)) return true;
|
||||
|
||||
// Object with properties
|
||||
if (includesType("object")) {
|
||||
const keys = Object.keys(s.properties ?? {});
|
||||
return keys.length > 0;
|
||||
}
|
||||
|
||||
// Array with items
|
||||
if (includesType("array")) {
|
||||
return !!s.items;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isOnlyJSON = !canRenderTopLevelForm(schema);
|
||||
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
|
||||
const [jsonError, setJsonError] = useState<string>();
|
||||
const [copiedJson, setCopiedJson] = useState<boolean>(false);
|
||||
|
|
@ -113,8 +150,8 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
setJsonError(errorMessage);
|
||||
|
||||
// Reset to default for clearly invalid JSON (not just incomplete typing)
|
||||
const trimmed = jsonString.trim();
|
||||
if (trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
|
||||
const trimmed = jsonString?.trim();
|
||||
if (trimmed && trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
|
||||
onChange(generateDefaultValue(schema));
|
||||
}
|
||||
}
|
||||
|
|
@ -155,7 +192,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const jsonStr = rawJsonValue.trim();
|
||||
const jsonStr = rawJsonValue?.trim();
|
||||
if (!jsonStr) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -171,7 +208,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
const validateJson = () => {
|
||||
if (!isJsonMode) return { isValid: true, error: null };
|
||||
try {
|
||||
const jsonStr = rawJsonValue.trim();
|
||||
const jsonStr = rawJsonValue?.trim();
|
||||
if (!jsonStr) return { isValid: true, error: null };
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
// Clear any pending debounced update and immediately update parent
|
||||
|
|
@ -267,63 +304,81 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
|
||||
switch (fieldType) {
|
||||
case "string": {
|
||||
if (
|
||||
propSchema.oneOf &&
|
||||
propSchema.oneOf.every(
|
||||
(option) =>
|
||||
typeof option.const === "string" &&
|
||||
typeof option.title === "string",
|
||||
)
|
||||
) {
|
||||
// Titled single-select using oneOf/anyOf with const/title pairs
|
||||
const titledOptions = (
|
||||
(propSchema.oneOf ?? propSchema.anyOf) as
|
||||
| (JsonSchemaType | JsonSchemaConst)[]
|
||||
| undefined
|
||||
)?.filter((opt): opt is JsonSchemaConst => "const" in opt);
|
||||
|
||||
if (titledOptions && titledOptions.length > 0) {
|
||||
return (
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={isRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{propSchema.oneOf.map((option) => (
|
||||
<option
|
||||
key={option.const as string}
|
||||
value={option.const as string}
|
||||
>
|
||||
{option.title as string}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{propSchema.description}
|
||||
</p>
|
||||
)}
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={isRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{titledOptions.map((option) => (
|
||||
<option
|
||||
key={String(option.const)}
|
||||
value={String(option.const)}
|
||||
>
|
||||
{option.title ?? String(option.const)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Untitled single-select using enum (with optional legacy enumNames for labels)
|
||||
if (propSchema.enum) {
|
||||
const names = Array.isArray(propSchema.enumNames)
|
||||
? propSchema.enumNames
|
||||
: undefined;
|
||||
return (
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={isRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{propSchema.enum.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{propSchema.description}
|
||||
</p>
|
||||
)}
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={isRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{propSchema.enum.map((option, idx) => (
|
||||
<option key={option} value={option}>
|
||||
{names?.[idx] ?? option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -413,13 +468,20 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
|
||||
case "boolean":
|
||||
return (
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
required={isRequired}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{propSchema.description}
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
type="checkbox"
|
||||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
required={isRequired}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "null":
|
||||
return null;
|
||||
|
|
@ -449,7 +511,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{key}
|
||||
{(subSchema as JsonSchemaType).title ?? key}
|
||||
{propSchema.required?.includes(key) && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
|
|
@ -470,6 +532,72 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||
if (!propSchema.items) return null;
|
||||
|
||||
// Special handling: array of enums -> render multi-select control
|
||||
const itemSchema = propSchema.items as JsonSchemaType;
|
||||
let multiOptions: { value: string; label: string }[] | null = null;
|
||||
|
||||
const titledMulti = (
|
||||
(itemSchema.anyOf ?? itemSchema.oneOf) as
|
||||
| (JsonSchemaType | JsonSchemaConst)[]
|
||||
| undefined
|
||||
)?.filter((opt): opt is JsonSchemaConst => "const" in opt);
|
||||
|
||||
if (titledMulti && titledMulti.length > 0) {
|
||||
multiOptions = titledMulti.map((o) => ({
|
||||
value: String(o.const),
|
||||
label: o.title ?? String(o.const),
|
||||
}));
|
||||
} else if (itemSchema.enum) {
|
||||
const names = Array.isArray(itemSchema.enumNames)
|
||||
? itemSchema.enumNames
|
||||
: undefined;
|
||||
multiOptions = itemSchema.enum.map((v, i) => ({
|
||||
value: v,
|
||||
label: names?.[i] ?? v,
|
||||
}));
|
||||
}
|
||||
|
||||
if (multiOptions) {
|
||||
const selectSize = Math.min(Math.max(multiOptions.length, 3), 8);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{propSchema.description}
|
||||
</p>
|
||||
)}
|
||||
<select
|
||||
multiple
|
||||
size={selectSize}
|
||||
value={arrayValue as string[]}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(
|
||||
(e.target as HTMLSelectElement).selectedOptions,
|
||||
).map((o) => o.value);
|
||||
handleFieldChange(path, selected);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
{multiOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(propSchema.minItems || propSchema.maxItems) && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{propSchema.minItems
|
||||
? `Select at least ${propSchema.minItems}. `
|
||||
: ""}
|
||||
{propSchema.maxItems
|
||||
? `Select at most ${propSchema.maxItems}.`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If the array items are simple, render as form fields, otherwise use JSON editor
|
||||
if (isSimpleObject(propSchema.items)) {
|
||||
return (
|
||||
|
|
@ -629,6 +757,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
debouncedUpdateParent(newValue);
|
||||
}}
|
||||
error={jsonError}
|
||||
placeholder={schema.description}
|
||||
/>
|
||||
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
|
||||
// render a simple representation of the JSON data
|
||||
|
|
|
|||
58
client/src/components/IconDisplay.tsx
Normal file
58
client/src/components/IconDisplay.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Define Icon type locally since it might not be exported yet
|
||||
interface Icon {
|
||||
src: string;
|
||||
mimeType?: string;
|
||||
sizes?: string[];
|
||||
theme?: "light" | "dark";
|
||||
}
|
||||
|
||||
// Helper type for objects that may have icons
|
||||
export interface WithIcons {
|
||||
icons?: Icon[];
|
||||
}
|
||||
|
||||
interface IconDisplayProps {
|
||||
icons?: Icon[];
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const IconDisplay = ({
|
||||
icons,
|
||||
className = "",
|
||||
size = "md",
|
||||
}: IconDisplayProps) => {
|
||||
if (!icons || icons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
};
|
||||
|
||||
const sizeClass = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className={`flex gap-1 ${className}`}>
|
||||
{icons.map((icon, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={icon.src}
|
||||
alt=""
|
||||
className={`${sizeClass} object-contain flex-shrink-0`}
|
||||
style={{
|
||||
imageRendering: "auto",
|
||||
}}
|
||||
onError={(e) => {
|
||||
// Hide broken images
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconDisplay;
|
||||
|
|
@ -8,12 +8,14 @@ interface JsonEditorProps {
|
|||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const JsonEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
error: externalError,
|
||||
placeholder,
|
||||
}: JsonEditorProps) => {
|
||||
const [editorContent, setEditorContent] = useState(value || "");
|
||||
const [internalError, setInternalError] = useState<string | undefined>(
|
||||
|
|
@ -48,6 +50,7 @@ const JsonEditor = ({
|
|||
Prism.highlight(code, Prism.languages.json, "json")
|
||||
}
|
||||
padding={10}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
fontFamily: '"Fira code", "Fira Mono", monospace',
|
||||
fontSize: 14,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue