Merge branch 'main' into jesselumarie/test-SEP-974
This commit is contained in:
commit
35286a24c6
32 changed files with 3294 additions and 705 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**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|
||||||
|
**Version Consideration**
|
||||||
|
|
||||||
|
Inspector V2 is under development to address architectural and UX improvements. See [CONTRIBUTING.md](CONTRIBUTING.md) for information about V2 development.
|
||||||
|
|
|
||||||
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. See [CONTRIBUTING.md](CONTRIBUTING.md) for information about V2 development.
|
||||||
|
|
||||||
|
## 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 -->
|
||||||
2
.github/workflows/e2e_tests.yml
vendored
2
.github/workflows/e2e_tests.yml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
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
|
timeout-minutes: 15
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ client/tsconfig.node.tsbuildinfo
|
||||||
cli/build
|
cli/build
|
||||||
test-output
|
test-output
|
||||||
tool-test-output
|
tool-test-output
|
||||||
|
metadata-test-output
|
||||||
# symlinked by `npm run link:sdk`:
|
# symlinked by `npm run link:sdk`:
|
||||||
sdk
|
sdk
|
||||||
client/playwright-report/
|
client/playwright-report/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# MCP Inspector Development Guide
|
# MCP Inspector Development Guide
|
||||||
|
|
||||||
|
> **Note:** Inspector V2 is under development to address architectural and UX improvements. These guidelines apply to the current version (V1). See [CONTRIBUTING.md](CONTRIBUTING.md) for information about V2 development.
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
- Build all: `npm run build`
|
- Build all: `npm run build`
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,17 @@ 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
|
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
|
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:**
|
||||||
|
|
||||||
|
- Critical issues are actively maintained
|
||||||
|
- Small UX improvements are welcome
|
||||||
|
- Documentation updates are always appreciated
|
||||||
|
- Major changes will be directed to V2 development
|
||||||
|
|
||||||
## Development Process & Pull Requests
|
## Development Process & Pull Requests
|
||||||
|
|
||||||
1. Create a new branch for your changes
|
1. Create a new branch for your changes
|
||||||
|
|
@ -30,7 +41,7 @@ If you find a security vulnerability, please refer to our [Security Policy](SECU
|
||||||
|
|
||||||
## Questions?
|
## 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/mcp-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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "0.17.1",
|
"version": "0.17.4",
|
||||||
"description": "CLI for the Model Context Protocol inspector",
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {},
|
"devDependencies": {},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"spawn-rx": "^5.1.2"
|
"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 };
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
// List available prompts
|
// List available prompts
|
||||||
export async function listPrompts(client: Client): Promise<McpResponse> {
|
export async function listPrompts(
|
||||||
|
client: Client,
|
||||||
|
metadata?: Record<string, string>,
|
||||||
|
): Promise<McpResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await client.listPrompts();
|
const params =
|
||||||
|
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
|
||||||
|
const response = await client.listPrompts(params);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -28,6 +33,7 @@ export async function getPrompt(
|
||||||
client: Client,
|
client: Client,
|
||||||
name: string,
|
name: string,
|
||||||
args?: Record<string, JsonValue>,
|
args?: Record<string, JsonValue>,
|
||||||
|
metadata?: Record<string, string>,
|
||||||
): Promise<McpResponse> {
|
): Promise<McpResponse> {
|
||||||
try {
|
try {
|
||||||
// Convert all arguments to strings for prompt arguments
|
// 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,
|
name,
|
||||||
arguments: stringArgs,
|
arguments: stringArgs,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (metadata && Object.keys(metadata).length > 0) {
|
||||||
|
params._meta = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.getPrompt(params);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { McpResponse } from "./types.js";
|
import { McpResponse } from "./types.js";
|
||||||
|
|
||||||
// List available resources
|
// List available resources
|
||||||
export async function listResources(client: Client): Promise<McpResponse> {
|
export async function listResources(
|
||||||
|
client: Client,
|
||||||
|
metadata?: Record<string, string>,
|
||||||
|
): Promise<McpResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await client.listResources();
|
const params =
|
||||||
|
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
|
||||||
|
const response = await client.listResources(params);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -17,9 +22,14 @@ export async function listResources(client: Client): Promise<McpResponse> {
|
||||||
export async function readResource(
|
export async function readResource(
|
||||||
client: Client,
|
client: Client,
|
||||||
uri: string,
|
uri: string,
|
||||||
|
metadata?: Record<string, string>,
|
||||||
): Promise<McpResponse> {
|
): Promise<McpResponse> {
|
||||||
try {
|
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;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -31,9 +41,12 @@ export async function readResource(
|
||||||
// List resource templates
|
// List resource templates
|
||||||
export async function listResourceTemplates(
|
export async function listResourceTemplates(
|
||||||
client: Client,
|
client: Client,
|
||||||
|
metadata?: Record<string, string>,
|
||||||
): Promise<McpResponse> {
|
): Promise<McpResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await client.listResourceTemplates();
|
const params =
|
||||||
|
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
|
||||||
|
const response = await client.listResourceTemplates(params);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,14 @@ type JsonSchemaType = {
|
||||||
items?: JsonSchemaType;
|
items?: JsonSchemaType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listTools(client: Client): Promise<McpResponse> {
|
export async function listTools(
|
||||||
|
client: Client,
|
||||||
|
metadata?: Record<string, string>,
|
||||||
|
): Promise<McpResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await client.listTools();
|
const params =
|
||||||
|
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
|
||||||
|
const response = await client.listTools(params);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -82,9 +87,11 @@ export async function callTool(
|
||||||
client: Client,
|
client: Client,
|
||||||
name: string,
|
name: string,
|
||||||
args: Record<string, JsonValue>,
|
args: Record<string, JsonValue>,
|
||||||
|
generalMetadata?: Record<string, string>,
|
||||||
|
toolSpecificMetadata?: Record<string, string>,
|
||||||
): Promise<McpResponse> {
|
): Promise<McpResponse> {
|
||||||
try {
|
try {
|
||||||
const toolsResponse = await listTools(client);
|
const toolsResponse = await listTools(client, generalMetadata);
|
||||||
const tools = toolsResponse.tools as Tool[];
|
const tools = toolsResponse.tools as Tool[];
|
||||||
const tool = tools.find((t) => t.name === name);
|
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({
|
const response = await client.callTool({
|
||||||
name: name,
|
name: name,
|
||||||
arguments: convertedArgs,
|
arguments: convertedArgs,
|
||||||
|
_meta:
|
||||||
|
mergedMetadata && Object.keys(mergedMetadata).length > 0
|
||||||
|
? mergedMetadata
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,10 @@ type Args = {
|
||||||
logLevel?: LogLevel;
|
logLevel?: LogLevel;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
toolArg?: Record<string, JsonValue>;
|
toolArg?: Record<string, JsonValue>;
|
||||||
|
toolMeta?: Record<string, string>;
|
||||||
transport?: "sse" | "stdio" | "http";
|
transport?: "sse" | "stdio" | "http";
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTransportOptions(
|
function createTransportOptions(
|
||||||
|
|
@ -129,7 +131,7 @@ async function callMethod(args: Args): Promise<void> {
|
||||||
|
|
||||||
// Tools methods
|
// Tools methods
|
||||||
if (args.method === "tools/list") {
|
if (args.method === "tools/list") {
|
||||||
result = await listTools(client);
|
result = await listTools(client, args.metadata);
|
||||||
} else if (args.method === "tools/call") {
|
} else if (args.method === "tools/call") {
|
||||||
if (!args.toolName) {
|
if (!args.toolName) {
|
||||||
throw new Error(
|
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
|
// Resources methods
|
||||||
else if (args.method === "resources/list") {
|
else if (args.method === "resources/list") {
|
||||||
result = await listResources(client);
|
result = await listResources(client, args.metadata);
|
||||||
} else if (args.method === "resources/read") {
|
} else if (args.method === "resources/read") {
|
||||||
if (!args.uri) {
|
if (!args.uri) {
|
||||||
throw new Error(
|
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") {
|
} else if (args.method === "resources/templates/list") {
|
||||||
result = await listResourceTemplates(client);
|
result = await listResourceTemplates(client, args.metadata);
|
||||||
}
|
}
|
||||||
// Prompts methods
|
// Prompts methods
|
||||||
else if (args.method === "prompts/list") {
|
else if (args.method === "prompts/list") {
|
||||||
result = await listPrompts(client);
|
result = await listPrompts(client, args.metadata);
|
||||||
} else if (args.method === "prompts/get") {
|
} else if (args.method === "prompts/get") {
|
||||||
if (!args.promptName) {
|
if (!args.promptName) {
|
||||||
throw new Error(
|
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
|
// Logging methods
|
||||||
else if (args.method === "logging/setLevel") {
|
else if (args.method === "logging/setLevel") {
|
||||||
|
|
@ -328,6 +341,21 @@ function parseArgs(): Args {
|
||||||
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
|
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
|
||||||
parseHeaderPair,
|
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 --
|
// Parse only the arguments before --
|
||||||
|
|
@ -335,6 +363,8 @@ function parseArgs(): Args {
|
||||||
|
|
||||||
const options = program.opts() as Omit<Args, "target"> & {
|
const options = program.opts() as Omit<Args, "target"> & {
|
||||||
header?: Record<string, string>;
|
header?: Record<string, string>;
|
||||||
|
metadata?: Record<string, JsonValue>;
|
||||||
|
toolMetadata?: Record<string, JsonValue>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let remainingArgs = program.args;
|
let remainingArgs = program.args;
|
||||||
|
|
@ -352,6 +382,22 @@ function parseArgs(): Args {
|
||||||
target: finalArgs,
|
target: finalArgs,
|
||||||
...options,
|
...options,
|
||||||
headers: options.header, // commander.js uses 'header' field, map to 'headers'
|
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",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.17.1",
|
"version": "0.17.4",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"cleanup:e2e": "node e2e/global-teardown.js"
|
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/serve-handler": "^6.1.4",
|
"@types/serve-handler": "^6.1.4",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"co": "^4.6.0",
|
"co": "^4.6.0",
|
||||||
"eslint": "^9.11.1",
|
"eslint": "^9.11.1",
|
||||||
|
|
@ -77,6 +77,6 @@
|
||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^6.3.5"
|
"vite": "^7.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ import {
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||||
|
import {
|
||||||
|
hasValidMetaName,
|
||||||
|
hasValidMetaPrefix,
|
||||||
|
isReservedMetaKey,
|
||||||
|
} from "@/utils/metaUtils";
|
||||||
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
||||||
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
||||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||||
|
|
@ -47,6 +52,7 @@ import {
|
||||||
Hash,
|
Hash,
|
||||||
Key,
|
Key,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -80,9 +86,28 @@ import {
|
||||||
CustomHeaders,
|
CustomHeaders,
|
||||||
migrateFromLegacyAuth,
|
migrateFromLegacyAuth,
|
||||||
} from "./lib/types/customHeaders";
|
} from "./lib/types/customHeaders";
|
||||||
|
import MetadataTab from "./components/MetadataTab";
|
||||||
|
|
||||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
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 App = () => {
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [resourceTemplates, setResourceTemplates] = useState<
|
const [resourceTemplates, setResourceTemplates] = useState<
|
||||||
|
|
@ -199,9 +224,31 @@ const App = () => {
|
||||||
const [authState, setAuthState] =
|
const [authState, setAuthState] =
|
||||||
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
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>) => {
|
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||||
setAuthState((prev) => ({ ...prev, ...updates }));
|
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 nextRequestId = useRef(0);
|
||||||
const rootsRef = useRef<Root[]>([]);
|
const rootsRef = useRef<Root[]>([]);
|
||||||
|
|
||||||
|
|
@ -305,6 +352,7 @@ const App = () => {
|
||||||
},
|
},
|
||||||
getRoots: () => rootsRef.current,
|
getRoots: () => rootsRef.current,
|
||||||
defaultLoggingLevel: logLevel,
|
defaultLoggingLevel: logLevel,
|
||||||
|
metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -785,7 +833,11 @@ const App = () => {
|
||||||
cacheToolOutputSchemas(response.tools);
|
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;
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -795,15 +847,21 @@ const App = () => {
|
||||||
? cleanParams(params, tool.inputSchema as JsonSchemaType)
|
? cleanParams(params, tool.inputSchema as JsonSchemaType)
|
||||||
: params;
|
: 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(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "tools/call" as const,
|
method: "tools/call" as const,
|
||||||
params: {
|
params: {
|
||||||
name,
|
name,
|
||||||
arguments: cleanedParams,
|
arguments: cleanedParams,
|
||||||
_meta: {
|
_meta: mergedMetadata,
|
||||||
progressToken: progressTokenRef.current++,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CompatibilityCallToolResultSchema,
|
CompatibilityCallToolResultSchema,
|
||||||
|
|
@ -1002,6 +1060,10 @@ const App = () => {
|
||||||
<Key className="w-4 h-4 mr-2" />
|
<Key className="w-4 h-4 mr-2" />
|
||||||
Auth
|
Auth
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="metadata">
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Metadata
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|
@ -1112,10 +1174,14 @@ const App = () => {
|
||||||
setNextToolCursor(undefined);
|
setNextToolCursor(undefined);
|
||||||
cacheToolOutputSchemas([]);
|
cacheToolOutputSchemas([]);
|
||||||
}}
|
}}
|
||||||
callTool={async (name, params) => {
|
callTool={async (
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
clearError("tools");
|
clearError("tools");
|
||||||
setToolResult(null);
|
setToolResult(null);
|
||||||
await callTool(name, params);
|
await callTool(name, params, metadata);
|
||||||
}}
|
}}
|
||||||
selectedTool={selectedTool}
|
selectedTool={selectedTool}
|
||||||
setSelectedTool={(tool) => {
|
setSelectedTool={(tool) => {
|
||||||
|
|
@ -1158,6 +1224,10 @@ const App = () => {
|
||||||
onRootsChange={handleRootsChange}
|
onRootsChange={handleRootsChange}
|
||||||
/>
|
/>
|
||||||
<AuthDebuggerWrapper />
|
<AuthDebuggerWrapper />
|
||||||
|
<MetadataTab
|
||||||
|
metadata={metadata}
|
||||||
|
onMetadataChange={handleMetadataChange}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,8 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||||
setJsonError(errorMessage);
|
setJsonError(errorMessage);
|
||||||
|
|
||||||
// Reset to default for clearly invalid JSON (not just incomplete typing)
|
// Reset to default for clearly invalid JSON (not just incomplete typing)
|
||||||
const trimmed = jsonString.trim();
|
const trimmed = jsonString?.trim();
|
||||||
if (trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
|
if (trimmed && trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
|
||||||
onChange(generateDefaultValue(schema));
|
onChange(generateDefaultValue(schema));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +155,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||||
|
|
||||||
const formatJson = () => {
|
const formatJson = () => {
|
||||||
try {
|
try {
|
||||||
const jsonStr = rawJsonValue.trim();
|
const jsonStr = rawJsonValue?.trim();
|
||||||
if (!jsonStr) {
|
if (!jsonStr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +171,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||||
const validateJson = () => {
|
const validateJson = () => {
|
||||||
if (!isJsonMode) return { isValid: true, error: null };
|
if (!isJsonMode) return { isValid: true, error: null };
|
||||||
try {
|
try {
|
||||||
const jsonStr = rawJsonValue.trim();
|
const jsonStr = rawJsonValue?.trim();
|
||||||
if (!jsonStr) return { isValid: true, error: null };
|
if (!jsonStr) return { isValid: true, error: null };
|
||||||
const parsed = JSON.parse(jsonStr);
|
const parsed = JSON.parse(jsonStr);
|
||||||
// Clear any pending debounced update and immediately update parent
|
// Clear any pending debounced update and immediately update parent
|
||||||
|
|
|
||||||
170
client/src/components/MetadataTab.tsx
Normal file
170
client/src/components/MetadataTab.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Trash2, Plus } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
META_NAME_RULES_MESSAGE,
|
||||||
|
META_PREFIX_RULES_MESSAGE,
|
||||||
|
RESERVED_NAMESPACE_MESSAGE,
|
||||||
|
hasValidMetaName,
|
||||||
|
hasValidMetaPrefix,
|
||||||
|
isReservedMetaKey,
|
||||||
|
} from "@/utils/metaUtils";
|
||||||
|
|
||||||
|
interface MetadataEntry {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataTabProps {
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
onMetadataChange: (metadata: Record<string, string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetadataTab: React.FC<MetadataTabProps> = ({
|
||||||
|
metadata,
|
||||||
|
onMetadataChange,
|
||||||
|
}) => {
|
||||||
|
const [entries, setEntries] = useState<MetadataEntry[]>(() => {
|
||||||
|
return Object.entries(metadata).map(([key, value]) => ({ key, value }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const addEntry = () => {
|
||||||
|
setEntries([...entries, { key: "", value: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEntry = (index: number) => {
|
||||||
|
const newEntries = entries.filter((_, i) => i !== index);
|
||||||
|
setEntries(newEntries);
|
||||||
|
updateMetadata(newEntries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEntry = (
|
||||||
|
index: number,
|
||||||
|
field: "key" | "value",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const newEntries = [...entries];
|
||||||
|
newEntries[index][field] = value;
|
||||||
|
setEntries(newEntries);
|
||||||
|
updateMetadata(newEntries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMetadata = (newEntries: MetadataEntry[]) => {
|
||||||
|
const metadataObject: Record<string, string> = {};
|
||||||
|
newEntries.forEach(({ key, value }) => {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
if (
|
||||||
|
trimmedKey &&
|
||||||
|
value.trim() &&
|
||||||
|
hasValidMetaPrefix(trimmedKey) &&
|
||||||
|
!isReservedMetaKey(trimmedKey) &&
|
||||||
|
hasValidMetaName(trimmedKey)
|
||||||
|
) {
|
||||||
|
metadataObject[trimmedKey] = value.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onMetadataChange(metadataObject);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent value="metadata">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Metadata</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Key-value pairs that will be included in all MCP requests
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={addEntry} size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Entry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries.map((entry, index) => {
|
||||||
|
const trimmedKey = entry.key.trim();
|
||||||
|
const hasInvalidPrefix =
|
||||||
|
trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey);
|
||||||
|
const isReservedKey =
|
||||||
|
trimmedKey !== "" && isReservedMetaKey(trimmedKey);
|
||||||
|
const hasInvalidName =
|
||||||
|
trimmedKey !== "" && !hasValidMetaName(trimmedKey);
|
||||||
|
const validationMessage = hasInvalidPrefix
|
||||||
|
? META_PREFIX_RULES_MESSAGE
|
||||||
|
: isReservedKey
|
||||||
|
? RESERVED_NAMESPACE_MESSAGE
|
||||||
|
: hasInvalidName
|
||||||
|
? META_NAME_RULES_MESSAGE
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div key={index} className="space-y-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`key-${index}`} className="sr-only">
|
||||||
|
Key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`key-${index}`}
|
||||||
|
placeholder="Key"
|
||||||
|
value={entry.key}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateEntry(index, "key", e.target.value)
|
||||||
|
}
|
||||||
|
aria-invalid={Boolean(validationMessage)}
|
||||||
|
className={cn(
|
||||||
|
validationMessage &&
|
||||||
|
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-1",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`value-${index}`} className="sr-only">
|
||||||
|
Value
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`value-${index}`}
|
||||||
|
placeholder="Value"
|
||||||
|
value={entry.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateEntry(index, "value", e.target.value)
|
||||||
|
}
|
||||||
|
disabled={Boolean(validationMessage)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeEntry(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{validationMessage && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{validationMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
No metadata entries. Click "Add Entry" to add key-value pairs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetadataTab;
|
||||||
|
|
@ -205,7 +205,8 @@ const ToolResults = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.type === "resource" &&
|
{item.type === "resource" &&
|
||||||
(item.resource?.mimeType?.startsWith("audio/") ? (
|
(item.resource?.mimeType?.startsWith("audio/") &&
|
||||||
|
"blob" in item.resource ? (
|
||||||
<audio
|
<audio
|
||||||
controls
|
controls
|
||||||
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
generateDefaultValue,
|
generateDefaultValue,
|
||||||
isPropertyRequired,
|
isPropertyRequired,
|
||||||
normalizeUnionType,
|
normalizeUnionType,
|
||||||
|
resolveRef,
|
||||||
} from "@/utils/schemaUtils";
|
} from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
|
|
@ -40,6 +41,15 @@ import ToolResults from "./ToolResults";
|
||||||
import { useToast } from "@/lib/hooks/useToast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
import useCopy from "@/lib/hooks/useCopy";
|
import useCopy from "@/lib/hooks/useCopy";
|
||||||
import IconDisplay, { WithIcons } from "./IconDisplay";
|
import IconDisplay, { WithIcons } from "./IconDisplay";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
META_NAME_RULES_MESSAGE,
|
||||||
|
META_PREFIX_RULES_MESSAGE,
|
||||||
|
RESERVED_NAMESPACE_MESSAGE,
|
||||||
|
hasValidMetaName,
|
||||||
|
hasValidMetaPrefix,
|
||||||
|
isReservedMetaKey,
|
||||||
|
} from "@/utils/metaUtils";
|
||||||
|
|
||||||
// Type guard to safely detect the optional _meta field without using `any`
|
// Type guard to safely detect the optional _meta field without using `any`
|
||||||
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
|
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
|
||||||
|
|
@ -61,7 +71,11 @@ const ToolsTab = ({
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
listTools: () => void;
|
listTools: () => void;
|
||||||
clearTools: () => void;
|
clearTools: () => void;
|
||||||
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
|
callTool: (
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
selectedTool: Tool | null;
|
selectedTool: Tool | null;
|
||||||
setSelectedTool: (tool: Tool | null) => void;
|
setSelectedTool: (tool: Tool | null) => void;
|
||||||
toolResult: CompatibilityCallToolResult | null;
|
toolResult: CompatibilityCallToolResult | null;
|
||||||
|
|
@ -73,7 +87,10 @@ const ToolsTab = ({
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||||
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||||
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
|
const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
|
||||||
|
const [metadataEntries, setMetadataEntries] = useState<
|
||||||
|
{ id: string; key: string; value: string }[]
|
||||||
|
>([]);
|
||||||
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||||
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
|
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -91,14 +108,21 @@ const ToolsTab = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = Object.entries(
|
const params = Object.entries(
|
||||||
selectedTool?.inputSchema.properties ?? [],
|
selectedTool?.inputSchema.properties ?? [],
|
||||||
).map(([key, value]) => [
|
).map(([key, value]) => {
|
||||||
key,
|
// First resolve any $ref references
|
||||||
generateDefaultValue(
|
const resolvedValue = resolveRef(
|
||||||
value as JsonSchemaType,
|
value as JsonSchemaType,
|
||||||
key,
|
|
||||||
selectedTool?.inputSchema as JsonSchemaType,
|
selectedTool?.inputSchema as JsonSchemaType,
|
||||||
),
|
);
|
||||||
]);
|
return [
|
||||||
|
key,
|
||||||
|
generateDefaultValue(
|
||||||
|
resolvedValue,
|
||||||
|
key,
|
||||||
|
selectedTool?.inputSchema as JsonSchemaType,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
setParams(Object.fromEntries(params));
|
setParams(Object.fromEntries(params));
|
||||||
|
|
||||||
// Reset validation errors when switching tools
|
// Reset validation errors when switching tools
|
||||||
|
|
@ -108,6 +132,21 @@ const ToolsTab = ({
|
||||||
formRefs.current = {};
|
formRefs.current = {};
|
||||||
}, [selectedTool]);
|
}, [selectedTool]);
|
||||||
|
|
||||||
|
const hasReservedMetadataEntry = metadataEntries.some(({ key }) => {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
return trimmedKey !== "" && isReservedMetaKey(trimmedKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasInvalidMetaPrefixEntry = metadataEntries.some(({ key }) => {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
return trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasInvalidMetaNameEntry = metadataEntries.some(({ key }) => {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
return trimmedKey !== "" && !hasValidMetaName(trimmedKey);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent value="tools">
|
<TabsContent value="tools">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
@ -167,7 +206,12 @@ const ToolsTab = ({
|
||||||
</p>
|
</p>
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
([key, value]) => {
|
([key, value]) => {
|
||||||
const prop = normalizeUnionType(value as JsonSchemaType);
|
// First resolve any $ref references
|
||||||
|
const resolvedValue = resolveRef(
|
||||||
|
value as JsonSchemaType,
|
||||||
|
selectedTool.inputSchema as JsonSchemaType,
|
||||||
|
);
|
||||||
|
const prop = normalizeUnionType(resolvedValue);
|
||||||
const inputSchema =
|
const inputSchema =
|
||||||
selectedTool.inputSchema as JsonSchemaType;
|
selectedTool.inputSchema as JsonSchemaType;
|
||||||
const required = isPropertyRequired(key, inputSchema);
|
const required = isPropertyRequired(key, inputSchema);
|
||||||
|
|
@ -194,16 +238,18 @@ const ToolsTab = ({
|
||||||
...params,
|
...params,
|
||||||
[key]: checked
|
[key]: checked
|
||||||
? null
|
? null
|
||||||
: prop.default !== null
|
: prop.type === "array"
|
||||||
? prop.default
|
? undefined
|
||||||
: prop.type === "boolean"
|
: prop.default !== null
|
||||||
? false
|
? prop.default
|
||||||
: prop.type === "string"
|
: prop.type === "boolean"
|
||||||
? ""
|
? false
|
||||||
: prop.type === "number" ||
|
: prop.type === "string"
|
||||||
prop.type === "integer"
|
? ""
|
||||||
? undefined
|
: prop.type === "number" ||
|
||||||
: undefined,
|
prop.type === "integer"
|
||||||
|
? undefined
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -396,6 +442,138 @@ const ToolsTab = ({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
<div className="pb-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
Tool-specific Metadata:
|
||||||
|
</h4>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 px-2"
|
||||||
|
onClick={() =>
|
||||||
|
setMetadataEntries((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id:
|
||||||
|
(
|
||||||
|
globalThis as unknown as {
|
||||||
|
crypto?: { randomUUID?: () => string };
|
||||||
|
}
|
||||||
|
).crypto?.randomUUID?.() ||
|
||||||
|
Math.random().toString(36).slice(2),
|
||||||
|
key: "",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add Pair
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{metadataEntries.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No metadata pairs.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{metadataEntries.map((entry, index) => {
|
||||||
|
const trimmedKey = entry.key.trim();
|
||||||
|
const hasInvalidPrefix =
|
||||||
|
trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey);
|
||||||
|
const isReservedKey =
|
||||||
|
trimmedKey !== "" && isReservedMetaKey(trimmedKey);
|
||||||
|
const hasInvalidName =
|
||||||
|
trimmedKey !== "" && !hasValidMetaName(trimmedKey);
|
||||||
|
const validationMessage = hasInvalidPrefix
|
||||||
|
? META_PREFIX_RULES_MESSAGE
|
||||||
|
: isReservedKey
|
||||||
|
? RESERVED_NAMESPACE_MESSAGE
|
||||||
|
: hasInvalidName
|
||||||
|
? META_NAME_RULES_MESSAGE
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<Label
|
||||||
|
htmlFor={`metadata-key-${entry.id}`}
|
||||||
|
className="text-xs shrink-0"
|
||||||
|
>
|
||||||
|
Key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`metadata-key-${entry.id}`}
|
||||||
|
value={entry.key}
|
||||||
|
placeholder="e.g. requestId"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setMetadataEntries((prev) =>
|
||||||
|
prev.map((m, i) =>
|
||||||
|
i === index ? { ...m, key: value } : m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"h-8 flex-1",
|
||||||
|
validationMessage &&
|
||||||
|
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-1",
|
||||||
|
)}
|
||||||
|
aria-invalid={Boolean(validationMessage)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`metadata-value-${entry.id}`}
|
||||||
|
className="text-xs shrink-0"
|
||||||
|
>
|
||||||
|
Value
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`metadata-value-${entry.id}`}
|
||||||
|
value={entry.value}
|
||||||
|
placeholder="e.g. 12345"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setMetadataEntries((prev) =>
|
||||||
|
prev.map((m, i) =>
|
||||||
|
i === index ? { ...m, value } : m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1"
|
||||||
|
disabled={Boolean(validationMessage)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 ml-auto shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
setMetadataEntries((prev) =>
|
||||||
|
prev.filter((_, i) => i !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label={`Remove meta pair ${index + 1}`}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{validationMessage && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{validationMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(hasReservedMetadataEntry ||
|
||||||
|
hasInvalidMetaPrefixEntry ||
|
||||||
|
hasInvalidMetaNameEntry) && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400">
|
||||||
|
Remove reserved or invalid metadata keys (prefix/name)
|
||||||
|
before running the tool.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{selectedTool.outputSchema && (
|
{selectedTool.outputSchema && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
|
@ -441,10 +619,12 @@ const ToolsTab = ({
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setIsMetaExpanded(!isMetaExpanded)}
|
onClick={() =>
|
||||||
|
setIsMetadataExpanded(!isMetadataExpanded)
|
||||||
|
}
|
||||||
className="h-6 px-2"
|
className="h-6 px-2"
|
||||||
>
|
>
|
||||||
{isMetaExpanded ? (
|
{isMetadataExpanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp className="h-3 w-3 mr-1" />
|
<ChevronUp className="h-3 w-3 mr-1" />
|
||||||
Collapse
|
Collapse
|
||||||
|
|
@ -459,40 +639,66 @@ const ToolsTab = ({
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`transition-all ${
|
className={`transition-all ${
|
||||||
isMetaExpanded ? "" : "max-h-[8rem] overflow-y-auto"
|
isMetadataExpanded
|
||||||
|
? ""
|
||||||
|
: "max-h-[8rem] overflow-y-auto"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<JsonView data={selectedTool._meta} />
|
<JsonView data={selectedTool._meta} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button
|
onClick={async () => {
|
||||||
onClick={async () => {
|
// Validate JSON inputs before calling tool
|
||||||
// Validate JSON inputs before calling tool
|
if (checkValidationErrors()) return;
|
||||||
if (checkValidationErrors()) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsToolRunning(true);
|
setIsToolRunning(true);
|
||||||
await callTool(selectedTool.name, params);
|
const metadata = metadataEntries.reduce<
|
||||||
} finally {
|
Record<string, unknown>
|
||||||
setIsToolRunning(false);
|
>((acc, { key, value }) => {
|
||||||
}
|
const trimmedKey = key.trim();
|
||||||
}}
|
if (
|
||||||
disabled={isToolRunning || hasValidationErrors}
|
trimmedKey !== "" &&
|
||||||
>
|
hasValidMetaPrefix(trimmedKey) &&
|
||||||
{isToolRunning ? (
|
!isReservedMetaKey(trimmedKey) &&
|
||||||
<>
|
hasValidMetaName(trimmedKey)
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
) {
|
||||||
Running...
|
acc[trimmedKey] = value;
|
||||||
</>
|
}
|
||||||
) : (
|
return acc;
|
||||||
<>
|
}, {});
|
||||||
<Send className="w-4 h-4 mr-2" />
|
await callTool(
|
||||||
Run Tool
|
selectedTool.name,
|
||||||
</>
|
params,
|
||||||
)}
|
Object.keys(metadata).length ? metadata : undefined,
|
||||||
</Button>
|
);
|
||||||
|
} finally {
|
||||||
|
setIsToolRunning(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
isToolRunning ||
|
||||||
|
hasValidationErrors ||
|
||||||
|
hasReservedMetadataEntry ||
|
||||||
|
hasInvalidMetaPrefixEntry ||
|
||||||
|
hasInvalidMetaNameEntry
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isToolRunning ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Running...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Run Tool
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ describe("HistoryAndNotifications", () => {
|
||||||
method: "notifications/message",
|
method: "notifications/message",
|
||||||
params: {
|
params: {
|
||||||
level: "info" as const,
|
level: "info" as const,
|
||||||
message: "First notification",
|
data: "First notification",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -36,7 +36,7 @@ describe("HistoryAndNotifications", () => {
|
||||||
params: {
|
params: {
|
||||||
progressToken: "test-token",
|
progressToken: "test-token",
|
||||||
progress: 50,
|
progress: 50,
|
||||||
message: "Second notification",
|
total: 100,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
593
client/src/components/__tests__/MetadataTab.test.tsx
Normal file
593
client/src/components/__tests__/MetadataTab.test.tsx
Normal file
|
|
@ -0,0 +1,593 @@
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import MetadataTab from "../MetadataTab";
|
||||||
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
META_NAME_RULES_MESSAGE,
|
||||||
|
META_PREFIX_RULES_MESSAGE,
|
||||||
|
RESERVED_NAMESPACE_MESSAGE,
|
||||||
|
} from "@/utils/metaUtils";
|
||||||
|
|
||||||
|
describe("MetadataTab", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
metadata: {},
|
||||||
|
onMetadataChange: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMetadataTab = (props = {}) => {
|
||||||
|
return render(
|
||||||
|
<Tabs defaultValue="metadata">
|
||||||
|
<MetadataTab {...defaultProps} {...props} />
|
||||||
|
</Tabs>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Initial Rendering", () => {
|
||||||
|
it("should render the metadata tab with title and description", () => {
|
||||||
|
renderMetadataTab();
|
||||||
|
|
||||||
|
expect(screen.getByText("Metadata")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Key-value pairs that will be included in all MCP requests",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render Add Entry button", () => {
|
||||||
|
renderMetadataTab();
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
expect(addButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show empty state message when no entries exist", () => {
|
||||||
|
renderMetadataTab();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'No metadata entries. Click "Add Entry" to add key-value pairs.',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not show empty state message when entries exist", () => {
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { key1: "value1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'No metadata entries. Click "Add Entry" to add key-value pairs.',
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Initial Data Handling", () => {
|
||||||
|
it("should initialize with existing metadata", () => {
|
||||||
|
const initialMetadata = {
|
||||||
|
API_KEY: "test-key",
|
||||||
|
VERSION: "1.0.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
renderMetadataTab({ metadata: initialMetadata });
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue("API_KEY")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("test-key")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("VERSION")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("1.0.0")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render multiple entries in correct order", () => {
|
||||||
|
const initialMetadata = {
|
||||||
|
FIRST: "first-value",
|
||||||
|
SECOND: "second-value",
|
||||||
|
THIRD: "third-value",
|
||||||
|
};
|
||||||
|
|
||||||
|
renderMetadataTab({ metadata: initialMetadata });
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
expect(keyInputs).toHaveLength(3);
|
||||||
|
expect(valueInputs).toHaveLength(3);
|
||||||
|
|
||||||
|
// Check that entries are rendered in the order they appear in the object
|
||||||
|
const entries = Object.entries(initialMetadata);
|
||||||
|
entries.forEach(([key, value], index) => {
|
||||||
|
expect(keyInputs[index]).toHaveValue(key);
|
||||||
|
expect(valueInputs[index]).toHaveValue(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Adding Entries", () => {
|
||||||
|
it("should add a new empty entry when Add Entry button is clicked", () => {
|
||||||
|
renderMetadataTab();
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
expect(keyInputs).toHaveLength(1);
|
||||||
|
expect(valueInputs).toHaveLength(1);
|
||||||
|
expect(keyInputs[0]).toHaveValue("");
|
||||||
|
expect(valueInputs[0]).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add multiple entries when Add Entry button is clicked multiple times", () => {
|
||||||
|
renderMetadataTab();
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
expect(keyInputs).toHaveLength(3);
|
||||||
|
expect(valueInputs).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide empty state message after adding first entry", () => {
|
||||||
|
renderMetadataTab();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'No metadata entries. Click "Add Entry" to add key-value pairs.',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'No metadata entries. Click "Add Entry" to add key-value pairs.',
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Removing Entries", () => {
|
||||||
|
it("should render remove button for each entry", () => {
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { key1: "value1", key2: "value2" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeButtons = screen.getAllByRole("button", { name: "" }); // Trash icon buttons have no text
|
||||||
|
expect(removeButtons).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove entry when remove button is clicked", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { key1: "value1", key2: "value2" },
|
||||||
|
onMetadataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeButtons = screen.getAllByRole("button", { name: "" });
|
||||||
|
fireEvent.click(removeButtons[0]);
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({ key2: "value2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove correct entry when multiple entries exist", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: {
|
||||||
|
FIRST: "first-value",
|
||||||
|
SECOND: "second-value",
|
||||||
|
THIRD: "third-value",
|
||||||
|
},
|
||||||
|
onMetadataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeButtons = screen.getAllByRole("button", { name: "" });
|
||||||
|
fireEvent.click(removeButtons[1]); // Remove second entry
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
FIRST: "first-value",
|
||||||
|
THIRD: "third-value",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show empty state message after removing all entries", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { key1: "value1" },
|
||||||
|
onMetadataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeButton = screen.getByRole("button", { name: "" });
|
||||||
|
fireEvent.click(removeButton);
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Editing Entries", () => {
|
||||||
|
it("should update key when key input is changed", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { oldKey: "value1" },
|
||||||
|
onMetadataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyInput = screen.getByDisplayValue("oldKey");
|
||||||
|
fireEvent.change(keyInput, { target: { value: "newKey" } });
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({ newKey: "value1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update value when value input is changed", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { key1: "oldValue" },
|
||||||
|
onMetadataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const valueInput = screen.getByDisplayValue("oldValue");
|
||||||
|
fireEvent.change(valueInput, { target: { value: "newValue" } });
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({ key1: "newValue" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle editing multiple entries independently", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: {
|
||||||
|
key1: "value1",
|
||||||
|
key2: "value2",
|
||||||
|
},
|
||||||
|
onMetadataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
// Edit first entry key
|
||||||
|
fireEvent.change(keyInputs[0], { target: { value: "newKey1" } });
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
newKey1: "value1",
|
||||||
|
key2: "value2",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear mock to test second edit independently
|
||||||
|
onMetadataChange.mockClear();
|
||||||
|
|
||||||
|
// Edit second entry value
|
||||||
|
fireEvent.change(valueInputs[1], { target: { value: "newValue2" } });
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
newKey1: "value1",
|
||||||
|
key2: "newValue2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Reserved Metadata Keys", () => {
|
||||||
|
test.each`
|
||||||
|
description | value | message | shouldDisableValue
|
||||||
|
${"reserved keys with prefix"} | ${"modelcontextprotocol.io/flip"} | ${RESERVED_NAMESPACE_MESSAGE} | ${true}
|
||||||
|
${"reserved root without slash"} | ${"modelcontextprotocol.io"} | ${RESERVED_NAMESPACE_MESSAGE} | ${true}
|
||||||
|
${"nested modelcontextprotocol domain"} | ${"api.modelcontextprotocol.org"} | ${RESERVED_NAMESPACE_MESSAGE} | ${false}
|
||||||
|
${"nested mcp domain"} | ${"tools.mcp.com/path"} | ${RESERVED_NAMESPACE_MESSAGE} | ${false}
|
||||||
|
${"invalid name segments"} | ${"custom/bad-"} | ${META_NAME_RULES_MESSAGE} | ${false}
|
||||||
|
${"invalid prefix labels"} | ${"1invalid-prefix/value"} | ${META_PREFIX_RULES_MESSAGE} | ${false}
|
||||||
|
`(
|
||||||
|
"should display an error for $description",
|
||||||
|
({ value, message, shouldDisableValue }) => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInput = screen.getByPlaceholderText("Key");
|
||||||
|
fireEvent.change(keyInput, { target: { value } });
|
||||||
|
|
||||||
|
const valueInput = screen.getByPlaceholderText("Value");
|
||||||
|
if (shouldDisableValue) {
|
||||||
|
expect(valueInput).toBeDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.getByText(message)).toBeInTheDocument();
|
||||||
|
expect(onMetadataChange).toHaveBeenLastCalledWith({});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Data Validation and Trimming", () => {
|
||||||
|
it("should trim whitespace from keys and values", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInput = screen.getByPlaceholderText("Key");
|
||||||
|
const valueInput = screen.getByPlaceholderText("Value");
|
||||||
|
|
||||||
|
fireEvent.change(keyInput, { target: { value: " trimmedKey " } });
|
||||||
|
fireEvent.change(valueInput, { target: { value: " trimmedValue " } });
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
trimmedKey: "trimmedValue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should exclude entries with empty keys or values after trimming", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
// First entry: valid key and value
|
||||||
|
fireEvent.change(keyInputs[0], { target: { value: "validKey" } });
|
||||||
|
fireEvent.change(valueInputs[0], { target: { value: "validValue" } });
|
||||||
|
|
||||||
|
// Second entry: empty key (should be excluded)
|
||||||
|
fireEvent.change(keyInputs[1], { target: { value: "" } });
|
||||||
|
fireEvent.change(valueInputs[1], { target: { value: "someValue" } });
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
validKey: "validValue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should exclude entries with whitespace-only keys or values", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
// First entry: valid key and value
|
||||||
|
fireEvent.change(keyInputs[0], { target: { value: "validKey" } });
|
||||||
|
fireEvent.change(valueInputs[0], { target: { value: "validValue" } });
|
||||||
|
|
||||||
|
// Second entry: whitespace-only key (should be excluded)
|
||||||
|
fireEvent.change(keyInputs[1], { target: { value: " " } });
|
||||||
|
fireEvent.change(valueInputs[1], { target: { value: "someValue" } });
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
validKey: "validValue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed valid and invalid entries", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
// First entry: valid
|
||||||
|
fireEvent.change(keyInputs[0], { target: { value: "key1" } });
|
||||||
|
fireEvent.change(valueInputs[0], { target: { value: "value1" } });
|
||||||
|
|
||||||
|
// Second entry: empty key (invalid)
|
||||||
|
fireEvent.change(keyInputs[1], { target: { value: "" } });
|
||||||
|
fireEvent.change(valueInputs[1], { target: { value: "value2" } });
|
||||||
|
|
||||||
|
// Third entry: valid
|
||||||
|
fireEvent.change(keyInputs[2], { target: { value: "key3" } });
|
||||||
|
fireEvent.change(valueInputs[2], { target: { value: "value3" } });
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
key1: "value1",
|
||||||
|
key3: "value3",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Input Accessibility", () => {
|
||||||
|
it("should have proper labels for screen readers", () => {
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { key1: "value1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyLabel = screen.getByLabelText("Key", { selector: "input" });
|
||||||
|
const valueLabel = screen.getByLabelText("Value", { selector: "input" });
|
||||||
|
|
||||||
|
expect(keyLabel).toBeInTheDocument();
|
||||||
|
expect(valueLabel).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have unique IDs for each input pair", () => {
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: {
|
||||||
|
key1: "value1",
|
||||||
|
key2: "value2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
expect(keyInputs[0]).toHaveAttribute("id", "key-0");
|
||||||
|
expect(keyInputs[1]).toHaveAttribute("id", "key-1");
|
||||||
|
expect(valueInputs[0]).toHaveAttribute("id", "value-0");
|
||||||
|
expect(valueInputs[1]).toHaveAttribute("id", "value-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper placeholder text", () => {
|
||||||
|
renderMetadataTab();
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInput = screen.getByPlaceholderText("Key");
|
||||||
|
const valueInput = screen.getByPlaceholderText("Value");
|
||||||
|
|
||||||
|
expect(keyInput).toHaveAttribute("placeholder", "Key");
|
||||||
|
expect(valueInput).toHaveAttribute("placeholder", "Value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge Cases", () => {
|
||||||
|
it("should flag invalid names that contain unsupported characters", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInput = screen.getByPlaceholderText("Key");
|
||||||
|
const valueInput = screen.getByPlaceholderText("Value");
|
||||||
|
|
||||||
|
fireEvent.change(keyInput, {
|
||||||
|
target: { value: "key-with-special@chars!" },
|
||||||
|
});
|
||||||
|
fireEvent.change(valueInput, {
|
||||||
|
target: { value: "value with spaces & symbols $%^" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument();
|
||||||
|
expect(onMetadataChange).toHaveBeenLastCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject unicode names that do not start with an alphanumeric character", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInput = screen.getByPlaceholderText("Key");
|
||||||
|
const valueInput = screen.getByPlaceholderText("Value");
|
||||||
|
|
||||||
|
fireEvent.change(keyInput, { target: { value: "🔑_key" } });
|
||||||
|
fireEvent.change(valueInput, { target: { value: "值_value_🎯" } });
|
||||||
|
|
||||||
|
expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument();
|
||||||
|
expect(onMetadataChange).toHaveBeenLastCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle very long keys and values", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInput = screen.getByPlaceholderText("Key");
|
||||||
|
const valueInput = screen.getByPlaceholderText("Value");
|
||||||
|
|
||||||
|
const longKey = "A".repeat(100);
|
||||||
|
const longValue = "B".repeat(500);
|
||||||
|
|
||||||
|
fireEvent.change(keyInput, { target: { value: longKey } });
|
||||||
|
fireEvent.change(valueInput, { target: { value: longValue } });
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
[longKey]: longValue,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle duplicate keys by keeping the last one", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({ onMetadataChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: /add entry/i });
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||||
|
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||||
|
|
||||||
|
// Set same key for both entries
|
||||||
|
fireEvent.change(keyInputs[0], { target: { value: "duplicateKey" } });
|
||||||
|
fireEvent.change(valueInputs[0], { target: { value: "firstValue" } });
|
||||||
|
|
||||||
|
fireEvent.change(keyInputs[1], { target: { value: "duplicateKey" } });
|
||||||
|
fireEvent.change(valueInputs[1], { target: { value: "secondValue" } });
|
||||||
|
|
||||||
|
// The second value should overwrite the first
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({
|
||||||
|
duplicateKey: "secondValue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Integration with Parent Component", () => {
|
||||||
|
it("should not call onMetadataChange when component mounts with existing data", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { key1: "value1" },
|
||||||
|
onMetadataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onMetadataChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onMetadataChange only when user makes changes", () => {
|
||||||
|
const onMetadataChange = jest.fn();
|
||||||
|
renderMetadataTab({
|
||||||
|
metadata: { key1: "value1" },
|
||||||
|
onMetadataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not be called on mount
|
||||||
|
expect(onMetadataChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should be called when user changes value
|
||||||
|
const valueInput = screen.getByDisplayValue("value1");
|
||||||
|
fireEvent.change(valueInput, { target: { value: "newValue" } });
|
||||||
|
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onMetadataChange).toHaveBeenCalledWith({ key1: "newValue" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain internal state when props change (component doesn't sync with prop changes)", () => {
|
||||||
|
const { rerender } = renderMetadataTab({
|
||||||
|
metadata: { key1: "value1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue("key1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("value1")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Rerender with different props - component should maintain its internal state
|
||||||
|
// This is the intended behavior since useState initializer only runs once
|
||||||
|
rerender(
|
||||||
|
<Tabs defaultValue="metadata">
|
||||||
|
<MetadataTab
|
||||||
|
metadata={{ key2: "value2", key3: "value3" }}
|
||||||
|
onMetadataChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
</Tabs>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The component should still show the original values since it maintains internal state
|
||||||
|
expect(screen.getByDisplayValue("key1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("value1")).toBeInTheDocument();
|
||||||
|
// The new prop values should not be displayed
|
||||||
|
expect(screen.queryByDisplayValue("key2")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByDisplayValue("value2")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue