Merge pull request #770 from ln-12/meta_properies_in_tools

Added UI to provide additional _meta values
This commit is contained in:
Cliff Hall 2025-11-18 14:50:28 -05:00 • committed by GitHub
commit dff91a8696
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2153 additions and 84 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ client/tsconfig.node.tsbuildinfo
cli/build
test-output
tool-test-output
metadata-test-output
# symlinked by `npm run link:sdk`:
sdk
client/playwright-report/

676
cli/scripts/cli-metadata-tests.js Executable file
View 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);
});

View file

@ -12,9 +12,14 @@ type JsonValue =
| { [key: string]: JsonValue };
// List available prompts
export async function listPrompts(client: Client): Promise<McpResponse> {
export async function listPrompts(
client: Client,
metadata?: Record<string, string>,
): Promise<McpResponse> {
try {
const response = await client.listPrompts();
const params =
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
const response = await client.listPrompts(params);
return response;
} catch (error) {
throw new Error(
@ -28,6 +33,7 @@ export async function getPrompt(
client: Client,
name: string,
args?: Record<string, JsonValue>,
metadata?: Record<string, string>,
): Promise<McpResponse> {
try {
// Convert all arguments to strings for prompt arguments
@ -44,10 +50,16 @@ export async function getPrompt(
}
}
const response = await client.getPrompt({
const params: any = {
name,
arguments: stringArgs,
});
};
if (metadata && Object.keys(metadata).length > 0) {
params._meta = metadata;
}
const response = await client.getPrompt(params);
return response;
} catch (error) {

View file

@ -2,9 +2,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpResponse } from "./types.js";
// List available resources
export async function listResources(client: Client): Promise<McpResponse> {
export async function listResources(
client: Client,
metadata?: Record<string, string>,
): Promise<McpResponse> {
try {
const response = await client.listResources();
const params =
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
const response = await client.listResources(params);
return response;
} catch (error) {
throw new Error(
@ -17,9 +22,14 @@ export async function listResources(client: Client): Promise<McpResponse> {
export async function readResource(
client: Client,
uri: string,
metadata?: Record<string, string>,
): Promise<McpResponse> {
try {
const response = await client.readResource({ uri });
const params: any = { uri };
if (metadata && Object.keys(metadata).length > 0) {
params._meta = metadata;
}
const response = await client.readResource(params);
return response;
} catch (error) {
throw new Error(
@ -31,9 +41,12 @@ export async function readResource(
// List resource templates
export async function listResourceTemplates(
client: Client,
metadata?: Record<string, string>,
): Promise<McpResponse> {
try {
const response = await client.listResourceTemplates();
const params =
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
const response = await client.listResourceTemplates(params);
return response;
} catch (error) {
throw new Error(

View file

@ -19,9 +19,14 @@ type JsonSchemaType = {
items?: JsonSchemaType;
};
export async function listTools(client: Client): Promise<McpResponse> {
export async function listTools(
client: Client,
metadata?: Record<string, string>,
): Promise<McpResponse> {
try {
const response = await client.listTools();
const params =
metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
const response = await client.listTools(params);
return response;
} catch (error) {
throw new Error(
@ -82,9 +87,11 @@ export async function callTool(
client: Client,
name: string,
args: Record<string, JsonValue>,
generalMetadata?: Record<string, string>,
toolSpecificMetadata?: Record<string, string>,
): Promise<McpResponse> {
try {
const toolsResponse = await listTools(client);
const toolsResponse = await listTools(client, generalMetadata);
const tools = toolsResponse.tools as Tool[];
const tool = tools.find((t) => t.name === name);
@ -106,9 +113,23 @@ export async function callTool(
}
}
// Merge general metadata with tool-specific metadata
// Tool-specific metadata takes precedence over general metadata
let mergedMetadata: Record<string, string> | undefined;
if (generalMetadata || toolSpecificMetadata) {
mergedMetadata = {
...(generalMetadata || {}),
...(toolSpecificMetadata || {}),
};
}
const response = await client.callTool({
name: name,
arguments: convertedArgs,
_meta:
mergedMetadata && Object.keys(mergedMetadata).length > 0
? mergedMetadata
: undefined,
});
return response;
} catch (error) {

View file

@ -41,8 +41,10 @@ type Args = {
logLevel?: LogLevel;
toolName?: string;
toolArg?: Record<string, JsonValue>;
toolMeta?: Record<string, string>;
transport?: "sse" | "stdio" | "http";
headers?: Record<string, string>;
metadata?: Record<string, string>;
};
function createTransportOptions(
@ -129,7 +131,7 @@ async function callMethod(args: Args): Promise<void> {
// Tools methods
if (args.method === "tools/list") {
result = await listTools(client);
result = await listTools(client, args.metadata);
} else if (args.method === "tools/call") {
if (!args.toolName) {
throw new Error(
@ -137,11 +139,17 @@ async function callMethod(args: Args): Promise<void> {
);
}
result = await callTool(client, args.toolName, args.toolArg || {});
result = await callTool(
client,
args.toolName,
args.toolArg || {},
args.metadata,
args.toolMeta,
);
}
// Resources methods
else if (args.method === "resources/list") {
result = await listResources(client);
result = await listResources(client, args.metadata);
} else if (args.method === "resources/read") {
if (!args.uri) {
throw new Error(
@ -149,13 +157,13 @@ async function callMethod(args: Args): Promise<void> {
);
}
result = await readResource(client, args.uri);
result = await readResource(client, args.uri, args.metadata);
} else if (args.method === "resources/templates/list") {
result = await listResourceTemplates(client);
result = await listResourceTemplates(client, args.metadata);
}
// Prompts methods
else if (args.method === "prompts/list") {
result = await listPrompts(client);
result = await listPrompts(client, args.metadata);
} else if (args.method === "prompts/get") {
if (!args.promptName) {
throw new Error(
@ -163,7 +171,12 @@ async function callMethod(args: Args): Promise<void> {
);
}
result = await getPrompt(client, args.promptName, args.promptArgs || {});
result = await getPrompt(
client,
args.promptName,
args.promptArgs || {},
args.metadata,
);
}
// Logging methods
else if (args.method === "logging/setLevel") {
@ -328,6 +341,21 @@ function parseArgs(): Args {
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
parseHeaderPair,
{},
)
//
// Metadata options
//
.option(
"--metadata <pairs...>",
"General metadata as key=value pairs (applied to all methods)",
parseKeyValuePair,
{},
)
.option(
"--tool-metadata <pairs...>",
"Tool-specific metadata as key=value pairs (for tools/call method only)",
parseKeyValuePair,
{},
);
// Parse only the arguments before --
@ -335,6 +363,8 @@ function parseArgs(): Args {
const options = program.opts() as Omit<Args, "target"> & {
header?: Record<string, string>;
metadata?: Record<string, JsonValue>;
toolMetadata?: Record<string, JsonValue>;
};
let remainingArgs = program.args;
@ -352,6 +382,22 @@ function parseArgs(): Args {
target: finalArgs,
...options,
headers: options.header, // commander.js uses 'header' field, map to 'headers'
metadata: options.metadata
? Object.fromEntries(
Object.entries(options.metadata).map(([key, value]) => [
key,
String(value),
]),
)
: undefined,
toolMeta: options.toolMetadata
? Object.fromEntries(
Object.entries(options.toolMetadata).map(([key, value]) => [
key,
String(value),
]),
)
: undefined,
};
}

View file

@ -19,6 +19,11 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import {
hasValidMetaName,
hasValidMetaPrefix,
isReservedMetaKey,
} from "@/utils/metaUtils";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
@ -47,6 +52,7 @@ import {
Hash,
Key,
MessageSquare,
Settings,
} from "lucide-react";
import { z } from "zod";
@ -80,9 +86,28 @@ import {
CustomHeaders,
migrateFromLegacyAuth,
} from "./lib/types/customHeaders";
import MetadataTab from "./components/MetadataTab";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const filterReservedMetadata = (
metadata: Record<string, string>,
): Record<string, string> => {
return Object.entries(metadata).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (
!isReservedMetaKey(key) &&
hasValidMetaPrefix(key) &&
hasValidMetaName(key)
) {
acc[key] = value;
}
return acc;
},
{},
);
};
const App = () => {
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
@ -199,9 +224,31 @@ const App = () => {
const [authState, setAuthState] =
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
// Metadata state - persisted in localStorage
const [metadata, setMetadata] = useState<Record<string, string>>(() => {
const savedMetadata = localStorage.getItem("lastMetadata");
if (savedMetadata) {
try {
const parsed = JSON.parse(savedMetadata);
if (parsed && typeof parsed === "object") {
return filterReservedMetadata(parsed);
}
} catch (error) {
console.warn("Failed to parse saved metadata:", error);
}
}
return {};
});
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
setAuthState((prev) => ({ ...prev, ...updates }));
};
const handleMetadataChange = (newMetadata: Record<string, string>) => {
const sanitizedMetadata = filterReservedMetadata(newMetadata);
setMetadata(sanitizedMetadata);
localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata));
};
const nextRequestId = useRef(0);
const rootsRef = useRef<Root[]>([]);
@ -304,6 +351,7 @@ const App = () => {
},
getRoots: () => rootsRef.current,
defaultLoggingLevel: logLevel,
metadata,
});
useEffect(() => {
@ -784,7 +832,11 @@ const App = () => {
cacheToolOutputSchemas(response.tools);
};
const callTool = async (name: string, params: Record<string, unknown>) => {
const callTool = async (
name: string,
params: Record<string, unknown>,
toolMetadata?: Record<string, unknown>,
) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
@ -794,15 +846,21 @@ const App = () => {
? cleanParams(params, tool.inputSchema as JsonSchemaType)
: params;
// Merge general metadata with tool-specific metadata
// Tool-specific metadata takes precedence over general metadata
const mergedMetadata = {
...metadata, // General metadata
progressToken: progressTokenRef.current++,
...toolMetadata, // Tool-specific metadata
};
const response = await sendMCPRequest(
{
method: "tools/call" as const,
params: {
name,
arguments: cleanedParams,
_meta: {
progressToken: progressTokenRef.current++,
},
_meta: mergedMetadata,
},
},
CompatibilityCallToolResultSchema,
@ -1000,6 +1058,10 @@ const App = () => {
<Key className="w-4 h-4 mr-2" />
Auth
</TabsTrigger>
<TabsTrigger value="metadata">
<Settings className="w-4 h-4 mr-2" />
Metadata
</TabsTrigger>
</TabsList>
<div className="w-full">
@ -1110,10 +1172,14 @@ const App = () => {
setNextToolCursor(undefined);
cacheToolOutputSchemas([]);
}}
callTool={async (name, params) => {
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
) => {
clearError("tools");
setToolResult(null);
await callTool(name, params);
await callTool(name, params, metadata);
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
@ -1156,6 +1222,10 @@ const App = () => {
onRootsChange={handleRootsChange}
/>
<AuthDebuggerWrapper />
<MetadataTab
metadata={metadata}
onMetadataChange={handleMetadataChange}
/>
</>
)}
</div>

View 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;

View file

@ -40,6 +40,15 @@ import JsonView from "./JsonView";
import ToolResults from "./ToolResults";
import { useToast } from "@/lib/hooks/useToast";
import useCopy from "@/lib/hooks/useCopy";
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`
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
@ -61,7 +70,11 @@ const ToolsTab = ({
tools: Tool[];
listTools: () => 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;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
@ -73,7 +86,10 @@ const ToolsTab = ({
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = 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 formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
const { toast } = useToast();
@ -115,6 +131,21 @@ const ToolsTab = ({
formRefs.current = {};
}, [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 (
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
@ -398,6 +429,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 && (
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
<div className="flex items-center justify-between mb-2">
@ -443,10 +606,12 @@ const ToolsTab = ({
<Button
size="sm"
variant="ghost"
onClick={() => setIsMetaExpanded(!isMetaExpanded)}
onClick={() =>
setIsMetadataExpanded(!isMetadataExpanded)
}
className="h-6 px-2"
>
{isMetaExpanded ? (
{isMetadataExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Collapse
@ -461,40 +626,66 @@ const ToolsTab = ({
</div>
<div
className={`transition-all ${
isMetaExpanded ? "" : "max-h-[8rem] overflow-y-auto"
isMetadataExpanded
? ""
: "max-h-[8rem] overflow-y-auto"
}`}
>
<JsonView data={selectedTool._meta} />
</div>
</div>
)}
<div className="flex gap-2">
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
if (checkValidationErrors()) return;
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
if (checkValidationErrors()) return;
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
} finally {
setIsToolRunning(false);
}
}}
disabled={isToolRunning || hasValidationErrors}
>
{isToolRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Run Tool
</>
)}
</Button>
try {
setIsToolRunning(true);
const metadata = metadataEntries.reduce<
Record<string, unknown>
>((acc, { key, value }) => {
const trimmedKey = key.trim();
if (
trimmedKey !== "" &&
hasValidMetaPrefix(trimmedKey) &&
!isReservedMetaKey(trimmedKey) &&
hasValidMetaName(trimmedKey)
) {
acc[trimmedKey] = value;
}
return acc;
}, {});
await callTool(
selectedTool.name,
params,
Object.keys(metadata).length ? metadata : undefined,
);
} 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
onClick={async () => {
try {

View 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();
});
});
});

View file

@ -6,6 +6,11 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
import { within } from "@testing-library/react";
import {
META_NAME_RULES_MESSAGE,
META_PREFIX_RULES_MESSAGE,
RESERVED_NAMESPACE_MESSAGE,
} from "@/utils/metaUtils";
describe("ToolsTab", () => {
beforeEach(() => {
@ -119,9 +124,13 @@ describe("ToolsTab", () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
count: 42,
});
expect(defaultProps.callTool).toHaveBeenCalledWith(
mockTools[1].name,
{
count: 42,
},
undefined,
);
});
it("should allow typing negative numbers", async () => {
@ -140,9 +149,13 @@ describe("ToolsTab", () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
num: -42,
});
expect(defaultProps.callTool).toHaveBeenCalledWith(
mockTools[0].name,
{
num: -42,
},
undefined,
);
});
it("should allow specifying null value", async () => {
@ -172,9 +185,13 @@ describe("ToolsTab", () => {
});
// Tool should have been called with null value
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableField.name, {
num: null,
});
expect(mockCallTool).toHaveBeenCalledWith(
toolWithNullableField.name,
{
num: null,
},
undefined,
);
});
it("should support tri-state nullable boolean (null -> false -> true -> null)", async () => {
@ -211,9 +228,13 @@ describe("ToolsTab", () => {
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableBoolean.name, {
optionalBoolean: null,
});
expect(mockCallTool).toHaveBeenCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: null,
},
undefined,
);
// State 2: Uncheck null checkbox -> should set value to false and enable input
await act(async () => {
@ -233,6 +254,7 @@ describe("ToolsTab", () => {
{
optionalBoolean: false,
},
undefined,
);
// State 3: Check boolean checkbox -> should set value to true
@ -254,6 +276,7 @@ describe("ToolsTab", () => {
{
optionalBoolean: true,
},
undefined,
);
// State 4: Check null checkbox again -> should set value back to null and disable input
@ -271,6 +294,7 @@ describe("ToolsTab", () => {
{
optionalBoolean: null,
},
undefined,
);
});
@ -697,10 +721,10 @@ describe("ToolsTab", () => {
});
});
describe("Meta Display", () => {
const toolWithMeta = {
describe("Metadata Display", () => {
const toolWithMetadata = {
name: "metaTool",
description: "Tool with meta",
description: "Tool with metadata",
inputSchema: {
type: "object" as const,
properties: {
@ -713,10 +737,10 @@ describe("ToolsTab", () => {
},
} as unknown as Tool;
it("should display meta section when tool has _meta", () => {
it("should display metadata section when tool has _meta", () => {
renderToolsTab({
tools: [toolWithMeta],
selectedTool: toolWithMeta,
tools: [toolWithMetadata],
selectedTool: toolWithMetadata,
});
expect(screen.getByText("Meta:")).toBeInTheDocument();
@ -725,10 +749,10 @@ describe("ToolsTab", () => {
).toBeInTheDocument();
});
it("should toggle meta expansion", () => {
it("should toggle metadata schema expansion", () => {
renderToolsTab({
tools: [toolWithMeta],
selectedTool: toolWithMeta,
tools: [toolWithMetadata],
selectedTool: toolWithMetadata,
});
// There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section
@ -758,19 +782,88 @@ describe("ToolsTab", () => {
});
});
describe("ToolResults Meta", () => {
it("should display meta information when present in toolResult", () => {
const resultWithMeta = {
describe("Metadata submission", () => {
it("should send metadata values when provided", async () => {
const callToolMock = jest.fn(async () => {});
renderToolsTab({ selectedTool: mockTools[0], callTool: callToolMock });
// Add a metadata key/value pair
const addPairButton = screen.getByRole("button", { name: /add pair/i });
await act(async () => {
fireEvent.click(addPairButton);
});
// Fill key and value
const keyInputs = screen.getAllByLabelText(/key/i);
const valueInputs = screen.getAllByLabelText(/value/i);
expect(keyInputs.length).toBeGreaterThan(0);
expect(valueInputs.length).toBeGreaterThan(0);
await act(async () => {
fireEvent.change(keyInputs[0], { target: { value: "requestId" } });
fireEvent.change(valueInputs[0], { target: { value: "abc123" } });
});
// Run tool
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
expect(callToolMock).toHaveBeenCalledTimes(1);
expect(callToolMock).toHaveBeenLastCalledWith(
mockTools[0].name,
expect.any(Object),
{ requestId: "abc123" },
);
});
});
describe("Reserved metadata keys", () => {
test.each`
description | value | message
${"reserved metadata prefix"} | ${"modelcontextprotocol.io/flip"} | ${RESERVED_NAMESPACE_MESSAGE}
${"reserved root without slash"} | ${"modelcontextprotocol.io"} | ${RESERVED_NAMESPACE_MESSAGE}
${"nested modelcontextprotocol domain"} | ${"api.modelcontextprotocol.org"} | ${RESERVED_NAMESPACE_MESSAGE}
${"nested mcp domain"} | ${"tools.mcp.com/resource"} | ${RESERVED_NAMESPACE_MESSAGE}
${"invalid name segment"} | ${"custom/bad-"} | ${META_NAME_RULES_MESSAGE}
${"invalid prefix label"} | ${"1invalid-prefix/value"} | ${META_PREFIX_RULES_MESSAGE}
`(
"should block execution when $description is provided",
async ({ value, message }) => {
renderToolsTab({ selectedTool: mockTools[0] });
const addPairButton = screen.getByRole("button", { name: /add pair/i });
await act(async () => {
fireEvent.click(addPairButton);
});
const keyInput = screen.getByPlaceholderText("e.g. requestId");
await act(async () => {
fireEvent.change(keyInput, { target: { value } });
});
const runButton = screen.getByRole("button", { name: /run tool/i });
expect(runButton).toBeDisabled();
expect(screen.getByText(message)).toBeInTheDocument();
},
);
});
describe("ToolResults Metadata", () => {
it("should display metadata information when present in toolResult", () => {
const resultWithMetadata = {
content: [],
_meta: { info: "details", version: 2 },
};
renderToolsTab({
selectedTool: mockTools[0],
toolResult: resultWithMeta,
toolResult: resultWithMetadata,
});
// Only ToolResults meta should be present since selectedTool has no _meta
// Only ToolResults metadata should be present since selectedTool has no _meta
expect(screen.getAllByText("Meta:")).toHaveLength(1);
expect(screen.getByText(/info/i)).toBeInTheDocument();
expect(screen.getByText(/version/i)).toBeInTheDocument();
@ -986,6 +1079,7 @@ describe("ToolsTab", () => {
message: "test message",
count: 5,
},
undefined,
);
});

View file

@ -83,6 +83,7 @@ interface UseConnectionOptions {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getRoots?: () => any[];
defaultLoggingLevel?: LoggingLevel;
metadata?: Record<string, string>;
}
export function useConnection({
@ -102,6 +103,7 @@ export function useConnection({
onElicitationRequest,
getRoots,
defaultLoggingLevel,
metadata = {},
}: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("disconnected");
@ -175,6 +177,20 @@ export function useConnection({
try {
const abortController = new AbortController();
// Add metadata to the request if available, but skip for tool calls
// as they handle metadata merging separately
const shouldAddGeneralMetadata =
request.method !== "tools/call" && Object.keys(metadata).length > 0;
const requestWithMetadata = shouldAddGeneralMetadata
? {
...request,
params: {
...request.params,
_meta: metadata,
},
}
: request;
// prepare MCP Client request options
const mcpRequestOptions: RequestOptions = {
signal: options?.signal ?? abortController.signal,
@ -203,13 +219,17 @@ export function useConnection({
let response;
try {
response = await mcpClient.request(request, schema, mcpRequestOptions);
response = await mcpClient.request(
requestWithMetadata,
schema,
mcpRequestOptions,
);
pushHistory(request, response);
pushHistory(requestWithMetadata, response);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
pushHistory(requestWithMetadata, { error: errorMessage });
throw error;
}

View file

@ -0,0 +1,162 @@
/**
* Metadata helpers aligned with the official MCP specification.
*
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
const META_PREFIX_LABEL_REGEX = /^[a-z](?:[a-z\d-]*[a-z\d])?$/i;
const META_NAME_REGEX = /^[a-z\d](?:[a-z\d._-]*[a-z\d])?$/i;
const RESERVED_NAMESPACE_LABELS = ["modelcontextprotocol", "mcp"];
export const RESERVED_NAMESPACE_MESSAGE =
'Keys using the "modelcontextprotocol.*" or "mcp.*" namespaces are reserved by MCP and cannot be used.';
export const META_NAME_RULES_MESSAGE =
"Names must begin and end with an alphanumeric character and may only contain alphanumerics, hyphens (-), underscores (_), or dots (.) in between.";
export const META_PREFIX_RULES_MESSAGE =
"Prefixes must be dot-separated labels that start with a letter and end with a letter or digit (e.g. example.domain/).";
/**
* Extracts the prefix portion (before the first slash) of a metadata key, if present.
*
* @param key - Raw metadata key entered by the user.
* @returns The prefix segment (without the trailing slash) or null when no prefix exists.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
const getPrefixSegment = (key: string): string | null => {
const trimmedKey = key.trim();
const slashIndex = trimmedKey.indexOf("/");
if (slashIndex === -1) {
return null;
}
return trimmedKey.slice(0, slashIndex);
};
/**
* Normalizes a potential prefix segment by trimming whitespace, removing schemes,
* and stripping trailing URL components so only the label portion remains.
*
* @param segment - The prefix segment extracted from the metadata key.
* @returns A normalized string suitable for label parsing, or null when empty.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
const normalizeSegment = (segment: string): string | null => {
if (!segment) return null;
let normalized = segment.trim().toLowerCase();
if (!normalized) return null;
const schemeIndex = normalized.indexOf("://");
if (schemeIndex !== -1) {
normalized = normalized.slice(schemeIndex + 3);
}
const stopChars = ["?", "#", ":"];
let endIndex = normalized.length;
stopChars.forEach((char) => {
const idx = normalized.indexOf(char);
if (idx !== -1 && idx < endIndex) {
endIndex = idx;
}
});
return normalized.slice(0, endIndex) || null;
};
/**
* Splits a normalized prefix into dot-separated labels and validates each label
* against the MCP prefix rules (start with letter, end with letter/digit, interior alphanumerics or hyphens).
*
* @param segment - Normalized prefix string.
* @returns Array of labels if valid, otherwise null.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
const splitLabels = (segment: string): string[] | null => {
const normalized = normalizeSegment(segment);
if (!normalized) return null;
const labels = normalized.split(".");
if (
labels.length === 0 ||
labels.some((label) => !label || !META_PREFIX_LABEL_REGEX.test(label))
) {
return null;
}
return labels;
};
/**
* Determines whether a metadata key is within the MCP-reserved namespace.
*
* @param key - Full metadata key entered by the user.
* @returns True if the key's prefix belongs to a reserved namespace.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
export const isReservedMetaKey = (key: string): boolean => {
const trimmedKey = key.trim();
if (!trimmedKey) {
return false;
}
const candidateSegment = getPrefixSegment(trimmedKey) ?? trimmedKey;
const labels = splitLabels(candidateSegment);
if (!labels || labels.length < 2) {
return false;
}
for (let i = 0; i < labels.length - 1; i += 1) {
const current = labels[i];
const next = labels[i + 1];
if (
RESERVED_NAMESPACE_LABELS.includes(current) &&
META_PREFIX_LABEL_REGEX.test(next)
) {
return true;
}
}
return false;
};
/**
* Validates the optional prefix portion of a metadata key.
*
* @param key - Full metadata key entered by the user.
* @returns True when the prefix is absent or satisfies the MCP label requirements.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
export const hasValidMetaPrefix = (key: string): boolean => {
const prefixSegment = getPrefixSegment(key);
if (prefixSegment === null) {
return true;
}
return splitLabels(prefixSegment) !== null;
};
const extractMetaName = (key: string): string => {
const trimmedKey = key.trim();
if (!trimmedKey) return "";
const slashIndex = trimmedKey.lastIndexOf("/");
if (slashIndex === -1) {
return trimmedKey;
}
return trimmedKey.slice(slashIndex + 1);
};
/**
* Validates the "name" portion of a metadata key, regardless of whether a prefix exists.
*
* @param key - Full metadata key entered by the user.
* @returns True if the name portion is valid per the MCP spec.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
export const hasValidMetaName = (key: string): boolean => {
const name = extractMetaName(key);
if (!name) return false;
return META_NAME_REGEX.test(name);
};