First pass at pulling the v1.5 CLI, TUI, and Launcher clients into v2.
This commit is contained in:
parent
d5f12b2a82
commit
467b01edd6
87 changed files with 24043 additions and 203 deletions
29
.github/workflows/main.yml
vendored
29
.github/workflows/main.yml
vendored
|
|
@ -39,6 +39,35 @@ jobs:
|
|||
working-directory: ./clients/web
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Install CLI client dependencies
|
||||
working-directory: ./clients/cli
|
||||
run: npm install
|
||||
|
||||
- name: Run CLI tests
|
||||
working-directory: ./clients/cli
|
||||
run: npm run test
|
||||
|
||||
- name: Install TUI client dependencies
|
||||
working-directory: ./clients/tui
|
||||
run: npm install
|
||||
|
||||
- name: Run TUI tests
|
||||
working-directory: ./clients/tui
|
||||
run: npm run test
|
||||
|
||||
- name: Install launcher dependencies
|
||||
working-directory: ./clients/launcher
|
||||
run: npm install
|
||||
|
||||
- name: Build TUI and launcher
|
||||
run: npm run build:tui && npm run build:launcher
|
||||
|
||||
- name: Smoke test launcher
|
||||
run: |
|
||||
node clients/launcher/build/index.js --help
|
||||
node clients/launcher/build/index.js --cli --help
|
||||
node clients/launcher/build/index.js --tui --help
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,7 +3,8 @@
|
|||
.DS_Store
|
||||
.claude/
|
||||
node_modules
|
||||
dist
|
||||
clients/web/dist
|
||||
clients/**/build
|
||||
test-servers/build
|
||||
.env
|
||||
/.playwright-mcp/
|
||||
|
|
|
|||
12
.npmignore
Normal file
12
.npmignore
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# npm pack uses .npmignore instead of .gitignore when this file exists.
|
||||
# Built artifacts are whitelisted via package.json "files".
|
||||
|
||||
node_modules
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
.claude/
|
||||
*.log
|
||||
coverage
|
||||
storybook-static
|
||||
|
|
@ -20,9 +20,9 @@ inspector/
|
|||
│ │ │ # quiet-config-warnings.mjs (--import hook for `npm run dev`: drops benign
|
||||
│ │ │ # node-only UNRESOLVED_IMPORT warnings Rolldown prints at config load)
|
||||
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
|
||||
│ ├── cli/ # CLI client
|
||||
│ ├── tui/ # TUI client
|
||||
│ ├── launcher/ # Shared launcher
|
||||
│ ├── cli/ # CLI client (tsup bundle, @inspector/core alias)
|
||||
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
||||
│ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs)
|
||||
├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias)
|
||||
│ ├── auth/ # OAuth: state machine, providers, discovery, storage
|
||||
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
||||
|
|
@ -110,6 +110,9 @@ All work should be driven by items on the project board.
|
|||
- Ensure test coverage for each file is at least 90%
|
||||
- In unit tests that expect error output, suppress it from the console
|
||||
- Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/`
|
||||
- Run CLI tests with `npm run test` from `clients/cli/` (builds test-servers + CLI bin first via `pretest`)
|
||||
- Run TUI tests with `npm run test` from `clients/tui/`
|
||||
- From repo root, `npm run test` runs web unit tests, then CLI and TUI tests
|
||||
- Run `npm run test:coverage` to verify the per-file gate: lines ≥ 90, statements ≥ 85, functions ≥ 80, branches ≥ 50 (CI enforces this gate). Branches is intentionally relaxed because Mantine portal/media-query branches are not exercisable under happy-dom; new business-logic branches should still be covered.
|
||||
- Run `npm run test:integration` (also from `clients/web/`) for the v1.5-ported InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI runs it as its own step after unit tests.
|
||||
- Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). v1.5-ported integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`.
|
||||
|
|
|
|||
107
clients/cli/README.md
Normal file
107
clients/cli/README.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# MCP Inspector CLI Client
|
||||
|
||||
The CLI mode enables programmatic interaction with MCP servers from the command line. It is ideal for scripting, automation, continuous integration, and establishing an efficient feedback loop with AI coding assistants.
|
||||
|
||||
## Running the CLI
|
||||
|
||||
You can run the CLI client directly via `npx`:
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js
|
||||
```
|
||||
|
||||
The CLI mode supports operations across tools, resources, and prompts, returning structured JSON output.
|
||||
|
||||
### Examples
|
||||
|
||||
**Basic usage**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js
|
||||
```
|
||||
|
||||
**With a configuration file**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli --config path/to/config.json --server myserver
|
||||
```
|
||||
|
||||
**List available tools**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
|
||||
```
|
||||
|
||||
**Call a specific tool**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2
|
||||
```
|
||||
|
||||
**Call a tool with JSON arguments**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg 'options={"format": "json", "max_tokens": 100}'
|
||||
```
|
||||
|
||||
**List available resources**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list
|
||||
```
|
||||
|
||||
**List available prompts**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list
|
||||
```
|
||||
|
||||
### Remote Servers
|
||||
|
||||
You can also connect to remote MCP servers using HTTP or SSE transports.
|
||||
|
||||
**Connect to a remote MCP server (default is SSE)**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
|
||||
```
|
||||
|
||||
**Connect with Streamable HTTP transport**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list
|
||||
```
|
||||
|
||||
**Pass custom headers**
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### MCP server (which server to connect to)
|
||||
|
||||
Options that specify the MCP server (config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--config`, `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`.
|
||||
|
||||
### CLI-specific (what to invoke)
|
||||
|
||||
| Option | Description |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `--method <method>` | MCP method to invoke (e.g. `tools/list`, `tools/call`, `resources/list`, `prompts/list`). |
|
||||
| `--tool-name <name>` | Tool name (for `tools/call`). |
|
||||
| `--tool-arg <key=value>` | Tool argument; repeat for multiple. Use `key='{"json":true}'` for JSON. |
|
||||
| `--uri <uri>` | Resource URI (for `resources/read`). |
|
||||
| `--prompt-name <name>` | Prompt name (for `prompts/get`). |
|
||||
| `--prompt-args <key=value>` | Prompt arguments; repeat for multiple. |
|
||||
| `--log-level <level>` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). |
|
||||
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
|
||||
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
|
||||
|
||||
## Why use the CLI?
|
||||
|
||||
While the Web Client provides a rich visual interface, the CLI is designed for:
|
||||
|
||||
- **Automation**: Ideal for CI/CD pipelines and batch processing.
|
||||
- **AI Coding Assistants**: Provides a direct, machine-readable interface (JSON) for tools like Cursor or Claude to verify changes immediately.
|
||||
- **Log Analysis**: Easier integration with command-line utilities (like `jq`) to process and analyze MCP server output.
|
||||
44
clients/cli/__tests__/README.md
Normal file
44
clients/cli/__tests__/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# CLI Tests
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run in watch mode (useful for test file changes; won't work on CLI source changes without rebuild)
|
||||
npm run test:watch
|
||||
|
||||
# Run specific test file
|
||||
npm run test:cli # cli.test.ts
|
||||
npm run test:cli-tools # tools.test.ts
|
||||
npm run test:cli-headers # headers.test.ts
|
||||
npm run test:cli-metadata # metadata.test.ts
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
- `cli.test.ts` - Basic CLI functionality: CLI mode, environment variables, config files, resources, prompts, logging, transport types
|
||||
- `tools.test.ts` - Tool-related tests: Tool discovery, JSON argument parsing, error handling, prompts
|
||||
- `headers.test.ts` - Header parsing and validation
|
||||
- `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation
|
||||
|
||||
## Helpers
|
||||
|
||||
The `helpers/` directory contains shared utilities:
|
||||
|
||||
- `cli-runner.ts` - Spawns CLI as subprocess and captures output
|
||||
- `test-mcp-server.ts` - Standalone stdio MCP server script for stdio transport testing
|
||||
- `instrumented-server.ts` - In-process MCP test server for HTTP/SSE transports with request recording
|
||||
- `assertions.ts` - Custom assertion helpers for CLI output validation
|
||||
- `fixtures.ts` - Test config file generators and temporary directory management
|
||||
|
||||
## Notes
|
||||
|
||||
- Tests run in parallel across files (Vitest default)
|
||||
- Tests within a file run sequentially (we have isolated config files and ports, so we could get more aggressive if desired)
|
||||
- Config files use `crypto.randomUUID()` for uniqueness in parallel execution
|
||||
- HTTP/SSE servers use dynamic port allocation to avoid conflicts
|
||||
- Coverage is not used because much of the code that we want to measure is run by a spawned process, so it can't be tracked by Vitest
|
||||
- /sample-config.json is no longer used by tests - not clear if this file serves some other purpose so leaving it for now
|
||||
- All tests now use built-in MCP test servers, there are no external dependencies on servers from a registry
|
||||
859
clients/cli/__tests__/cli.test.ts
Normal file
859
clients/cli/__tests__/cli.test.ts
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { runCli } from "./helpers/cli-runner.js";
|
||||
import {
|
||||
expectCliSuccess,
|
||||
expectCliFailure,
|
||||
expectValidJson,
|
||||
} from "./helpers/assertions.js";
|
||||
import {
|
||||
NO_SERVER_SENTINEL,
|
||||
createSampleTestConfig,
|
||||
createTestConfig,
|
||||
createInvalidConfig,
|
||||
deleteConfigFile,
|
||||
} from "./helpers/fixtures.js";
|
||||
import {
|
||||
getTestMcpServerCommand,
|
||||
createTestServerHttp,
|
||||
createEchoTool,
|
||||
createTestServerInfo,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
describe("CLI Tests", () => {
|
||||
describe("Basic CLI Mode", () => {
|
||||
it("should execute tools/list successfully", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
|
||||
// Validate expected tools from test-mcp-server
|
||||
const toolNames = json.tools.map((tool: { name: string }) => tool.name);
|
||||
expect(toolNames).toContain("echo");
|
||||
expect(toolNames).toContain("get_sum");
|
||||
expect(toolNames).toContain("get_annotated_message");
|
||||
});
|
||||
|
||||
it("should fail with nonexistent method", async () => {
|
||||
const result = await runCli([
|
||||
NO_SERVER_SENTINEL,
|
||||
"--cli",
|
||||
"--method",
|
||||
"nonexistent/method",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
|
||||
it("should fail without method", async () => {
|
||||
const result = await runCli([NO_SERVER_SENTINEL, "--cli"]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment Variables", () => {
|
||||
it("should accept environment variables", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"-e",
|
||||
"KEY1=value1",
|
||||
"-e",
|
||||
"KEY2=value2",
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"test://env",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("contents");
|
||||
expect(Array.isArray(json.contents)).toBe(true);
|
||||
expect(json.contents.length).toBeGreaterThan(0);
|
||||
|
||||
// Parse the env vars from the resource
|
||||
const envVars = JSON.parse(json.contents[0].text);
|
||||
expect(envVars.KEY1).toBe("value1");
|
||||
expect(envVars.KEY2).toBe("value2");
|
||||
});
|
||||
|
||||
it("should reject invalid environment variable format", async () => {
|
||||
const result = await runCli([
|
||||
NO_SERVER_SENTINEL,
|
||||
"-e",
|
||||
"INVALID_FORMAT",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
|
||||
it("should handle environment variable with equals sign in value", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"-e",
|
||||
"API_KEY=abc123=xyz789==",
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"test://env",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
const envVars = JSON.parse(json.contents[0].text);
|
||||
expect(envVars.API_KEY).toBe("abc123=xyz789==");
|
||||
});
|
||||
|
||||
it("should handle environment variable with base64-encoded value", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"-e",
|
||||
"JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"test://env",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
const envVars = JSON.parse(json.contents[0].text);
|
||||
expect(envVars.JWT_TOKEN).toBe(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config File", () => {
|
||||
it("should use config file with CLI mode", async () => {
|
||||
const configPath = createSampleTestConfig();
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-stdio",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
expect(json.tools.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when using config file without server name", async () => {
|
||||
const configPath = createSampleTestConfig();
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when using server name without config file", async () => {
|
||||
const result = await runCli([
|
||||
"--server",
|
||||
"test-stdio",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
|
||||
it("should fail with nonexistent config file", async () => {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
"./nonexistent-config.json",
|
||||
"--server",
|
||||
"test-stdio",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
|
||||
it("should fail with invalid config file format", async () => {
|
||||
// Create invalid config temporarily
|
||||
const invalidConfigPath = createInvalidConfig();
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
invalidConfigPath,
|
||||
"--server",
|
||||
"test-stdio",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
} finally {
|
||||
deleteConfigFile(invalidConfigPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail with nonexistent server in config", async () => {
|
||||
const configPath = createSampleTestConfig();
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"nonexistent",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Resource Options", () => {
|
||||
it("should read resource with URI", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"demo://resource/static/document/architecture.md",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("contents");
|
||||
expect(Array.isArray(json.contents)).toBe(true);
|
||||
expect(json.contents.length).toBeGreaterThan(0);
|
||||
expect(json.contents[0]).toHaveProperty(
|
||||
"uri",
|
||||
"demo://resource/static/document/architecture.md",
|
||||
);
|
||||
expect(json.contents[0]).toHaveProperty("mimeType", "text/markdown");
|
||||
expect(json.contents[0]).toHaveProperty("text");
|
||||
expect(json.contents[0].text).toContain("Architecture Documentation");
|
||||
});
|
||||
|
||||
it("should fail when reading resource without URI", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prompt Options", () => {
|
||||
it("should get prompt by name", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
"--prompt-name",
|
||||
"simple_prompt",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("messages");
|
||||
expect(Array.isArray(json.messages)).toBe(true);
|
||||
expect(json.messages.length).toBeGreaterThan(0);
|
||||
expect(json.messages[0]).toHaveProperty("role", "user");
|
||||
expect(json.messages[0]).toHaveProperty("content");
|
||||
expect(json.messages[0].content).toHaveProperty("type", "text");
|
||||
expect(json.messages[0].content.text).toBe(
|
||||
"This is a simple prompt for testing purposes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should get prompt with arguments", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
"--prompt-name",
|
||||
"args_prompt",
|
||||
"--prompt-args",
|
||||
"city=New York",
|
||||
"state=NY",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("messages");
|
||||
expect(Array.isArray(json.messages)).toBe(true);
|
||||
expect(json.messages.length).toBeGreaterThan(0);
|
||||
expect(json.messages[0]).toHaveProperty("role", "user");
|
||||
expect(json.messages[0]).toHaveProperty("content");
|
||||
expect(json.messages[0].content).toHaveProperty("type", "text");
|
||||
// Verify that the arguments were actually used in the response
|
||||
expect(json.messages[0].content.text).toContain("city=New York");
|
||||
expect(json.messages[0].content.text).toContain("state=NY");
|
||||
});
|
||||
|
||||
it("should fail when getting prompt without name", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Logging Options", () => {
|
||||
it("should set log level", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
logging: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"logging/setLevel",
|
||||
"--log-level",
|
||||
"debug",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
// Validate the response - logging/setLevel should return an empty result
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toEqual({});
|
||||
|
||||
// Validate that the server actually received and recorded the log level
|
||||
expect(server.getCurrentLogLevel()).toBe("debug");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject invalid log level", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"logging/setLevel",
|
||||
"--log-level",
|
||||
"invalid",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combined Options", () => {
|
||||
it("should handle config file with environment variables", async () => {
|
||||
const configPath = createSampleTestConfig();
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-stdio",
|
||||
"-e",
|
||||
"CLI_ENV_VAR=cli_value",
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"test://env",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("contents");
|
||||
expect(Array.isArray(json.contents)).toBe(true);
|
||||
expect(json.contents.length).toBeGreaterThan(0);
|
||||
|
||||
// Parse the env vars from the resource
|
||||
const envVars = JSON.parse(json.contents[0].text);
|
||||
expect(envVars).toHaveProperty("CLI_ENV_VAR");
|
||||
expect(envVars.CLI_ENV_VAR).toBe("cli_value");
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle all options together", async () => {
|
||||
const configPath = createSampleTestConfig();
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-stdio",
|
||||
"-e",
|
||||
"CLI_ENV_VAR=cli_value",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=Hello",
|
||||
"--log-level",
|
||||
"debug",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content.length).toBeGreaterThan(0);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
expect(json.content[0].text).toBe("Echo: Hello");
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Transport Types", () => {
|
||||
it("should work with stdio transport type", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
"test-stdio": {
|
||||
type: "stdio",
|
||||
command,
|
||||
args,
|
||||
env: {
|
||||
TEST_ENV: "test-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
// First validate tools/list works
|
||||
const toolsResult = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-stdio",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(toolsResult);
|
||||
const toolsJson = expectValidJson(toolsResult);
|
||||
expect(toolsJson).toHaveProperty("tools");
|
||||
expect(Array.isArray(toolsJson.tools)).toBe(true);
|
||||
expect(toolsJson.tools.length).toBeGreaterThan(0);
|
||||
|
||||
// Then validate env vars from config are passed to server
|
||||
const envResult = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-stdio",
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"test://env",
|
||||
]);
|
||||
|
||||
expectCliSuccess(envResult);
|
||||
const envJson = expectValidJson(envResult);
|
||||
const envVars = JSON.parse(envJson.contents[0].text);
|
||||
expect(envVars).toHaveProperty("TEST_ENV");
|
||||
expect(envVars.TEST_ENV).toBe("test-value");
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail with SSE transport type in CLI mode (connection error)", async () => {
|
||||
const configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
"test-sse": {
|
||||
type: "sse",
|
||||
url: "http://localhost:3000/sse",
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-sse",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail with HTTP transport type in CLI mode (connection error)", async () => {
|
||||
const configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
"test-http": {
|
||||
type: "streamable-http",
|
||||
url: "http://localhost:3001/mcp",
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-http",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with legacy config without type field", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
"test-legacy": {
|
||||
command,
|
||||
args,
|
||||
env: {
|
||||
LEGACY_ENV: "legacy-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
// First validate tools/list works
|
||||
const toolsResult = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-legacy",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(toolsResult);
|
||||
const toolsJson = expectValidJson(toolsResult);
|
||||
expect(toolsJson).toHaveProperty("tools");
|
||||
expect(Array.isArray(toolsJson.tools)).toBe(true);
|
||||
expect(toolsJson.tools.length).toBeGreaterThan(0);
|
||||
|
||||
// Then validate env vars from config are passed to server
|
||||
const envResult = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"test-legacy",
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"test://env",
|
||||
]);
|
||||
|
||||
expectCliSuccess(envResult);
|
||||
const envJson = expectValidJson(envResult);
|
||||
const envVars = JSON.parse(envJson.contents[0].text);
|
||||
expect(envVars).toHaveProperty("LEGACY_ENV");
|
||||
expect(envVars.LEGACY_ENV).toBe("legacy-value");
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default Server Selection", () => {
|
||||
it("should auto-select single server", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
"only-server": {
|
||||
command,
|
||||
args,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
expect(json.tools.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should require explicit server selection even with default-server key (multiple servers)", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
"default-server": {
|
||||
command,
|
||||
args,
|
||||
},
|
||||
"other-server": {
|
||||
command: "node",
|
||||
args: ["other.js"],
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("should require explicit server selection with multiple servers", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
server1: {
|
||||
command,
|
||||
args,
|
||||
},
|
||||
server2: {
|
||||
command: "node",
|
||||
args: ["other.js"],
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
} finally {
|
||||
deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP Transport", () => {
|
||||
it("should infer HTTP transport from URL ending with /mcp", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
expect(json.tools.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with explicit --transport http flag", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--transport",
|
||||
"http",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
expect(json.tools.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with explicit transport flag and URL suffix", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--transport",
|
||||
"http",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
expect(json.tools.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when SSE transport is given to HTTP server", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--transport",
|
||||
"sse",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when HTTP transport is specified without URL", async () => {
|
||||
const result = await runCli([
|
||||
"--transport",
|
||||
"http",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
|
||||
it("should fail when SSE transport is specified without URL", async () => {
|
||||
const result = await runCli([
|
||||
"--transport",
|
||||
"sse",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
202
clients/cli/__tests__/headers.test.ts
Normal file
202
clients/cli/__tests__/headers.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { runCli } from "./helpers/cli-runner.js";
|
||||
import {
|
||||
expectCliFailure,
|
||||
expectOutputContains,
|
||||
expectCliSuccess,
|
||||
} from "./helpers/assertions.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
createEchoTool,
|
||||
createTestServerInfo,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
describe("Header Parsing and Validation", () => {
|
||||
describe("Valid Headers", () => {
|
||||
it("should parse valid single header and send it to server", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--transport",
|
||||
"http",
|
||||
"--header",
|
||||
"Authorization: Bearer token123",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Check that the server received the request with the correct headers
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
expect(recordedRequests.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the tools/list request (should be the last one)
|
||||
const toolsListRequest = recordedRequests[recordedRequests.length - 1];
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest.method).toBe("tools/list");
|
||||
|
||||
// Express normalizes headers to lowercase
|
||||
expect(toolsListRequest.headers).toHaveProperty("authorization");
|
||||
expect(toolsListRequest.headers?.authorization).toBe("Bearer token123");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should parse multiple headers", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--transport",
|
||||
"http",
|
||||
"--header",
|
||||
"Authorization: Bearer token123",
|
||||
"--header",
|
||||
"X-API-Key: secret123",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests[recordedRequests.length - 1];
|
||||
expect(toolsListRequest.method).toBe("tools/list");
|
||||
expect(toolsListRequest.headers?.authorization).toBe("Bearer token123");
|
||||
expect(toolsListRequest.headers?.["x-api-key"]).toBe("secret123");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle header with colons in value", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--transport",
|
||||
"http",
|
||||
"--header",
|
||||
"X-Time: 2023:12:25:10:30:45",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests[recordedRequests.length - 1];
|
||||
expect(toolsListRequest.method).toBe("tools/list");
|
||||
expect(toolsListRequest.headers?.["x-time"]).toBe(
|
||||
"2023:12:25:10:30:45",
|
||||
);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle whitespace in headers", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--transport",
|
||||
"http",
|
||||
"--header",
|
||||
" X-Header : value with spaces ",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests[recordedRequests.length - 1];
|
||||
expect(toolsListRequest.method).toBe("tools/list");
|
||||
// Header values should be trimmed by the CLI parser
|
||||
expect(toolsListRequest.headers?.["x-header"]).toBe(
|
||||
"value with spaces",
|
||||
);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Invalid Header Formats", () => {
|
||||
it("should reject header format without colon", async () => {
|
||||
const result = await runCli([
|
||||
"https://example.com",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--transport",
|
||||
"http",
|
||||
"--header",
|
||||
"InvalidHeader",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
expectOutputContains(result, "Invalid header format");
|
||||
});
|
||||
|
||||
it("should reject header format with empty name", async () => {
|
||||
const result = await runCli([
|
||||
"https://example.com",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--transport",
|
||||
"http",
|
||||
"--header",
|
||||
": value",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
expectOutputContains(result, "Invalid header format");
|
||||
});
|
||||
|
||||
it("should reject header format with empty value", async () => {
|
||||
const result = await runCli([
|
||||
"https://example.com",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--transport",
|
||||
"http",
|
||||
"--header",
|
||||
"Header:",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
expectOutputContains(result, "Invalid header format");
|
||||
});
|
||||
});
|
||||
});
|
||||
42
clients/cli/__tests__/helpers/assertions.test.ts
Normal file
42
clients/cli/__tests__/helpers/assertions.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { expectCliSuccess, expectCliFailure } from "./assertions.js";
|
||||
import type { CliResult } from "./cli-runner.js";
|
||||
|
||||
function assertionMessage(fn: () => void): string {
|
||||
try {
|
||||
fn();
|
||||
return "";
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
describe("CLI assertion helpers", () => {
|
||||
it("expectCliSuccess includes stdout and stderr on failure", () => {
|
||||
const result: CliResult = {
|
||||
exitCode: 1,
|
||||
stdout: "tool output",
|
||||
stderr: "connect error",
|
||||
output: "tool outputconnect error",
|
||||
};
|
||||
|
||||
const message = assertionMessage(() => expectCliSuccess(result));
|
||||
expect(message).toContain("CLI exited with code 1");
|
||||
expect(message).toContain("stdout: tool output");
|
||||
expect(message).toContain("stderr: connect error");
|
||||
});
|
||||
|
||||
it("expectCliFailure includes stdout and stderr on unexpected success", () => {
|
||||
const result: CliResult = {
|
||||
exitCode: 0,
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
output: "ok",
|
||||
};
|
||||
|
||||
const message = assertionMessage(() => expectCliFailure(result));
|
||||
expect(message).toContain("CLI unexpectedly exited with code 0");
|
||||
expect(message).toContain("stdout: ok");
|
||||
expect(message).toContain("stderr: (empty)");
|
||||
});
|
||||
});
|
||||
64
clients/cli/__tests__/helpers/assertions.ts
Normal file
64
clients/cli/__tests__/helpers/assertions.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { expect } from "vitest";
|
||||
import type { CliResult } from "./cli-runner.js";
|
||||
|
||||
function formatCliOutput(result: CliResult): string {
|
||||
const out = result.stdout?.trim() || "(empty)";
|
||||
const err = result.stderr?.trim() || "(empty)";
|
||||
return `stdout: ${out}\nstderr: ${err}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that CLI command succeeded (exit code 0)
|
||||
*/
|
||||
export function expectCliSuccess(result: CliResult) {
|
||||
expect(
|
||||
result.exitCode,
|
||||
`CLI exited with code ${result.exitCode}. ${formatCliOutput(result)}`,
|
||||
).toBe(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that CLI command failed (non-zero exit code)
|
||||
*/
|
||||
export function expectCliFailure(result: CliResult) {
|
||||
expect(
|
||||
result.exitCode,
|
||||
`CLI unexpectedly exited with code ${result.exitCode}. ${formatCliOutput(result)}`,
|
||||
).not.toBe(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that output contains expected text
|
||||
*/
|
||||
export function expectOutputContains(result: CliResult, text: string) {
|
||||
expect(result.output).toContain(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that output contains valid JSON
|
||||
* Uses stdout (not stderr) since JSON is written to stdout and warnings go to stderr
|
||||
*/
|
||||
export function expectValidJson(result: CliResult) {
|
||||
expect(() => JSON.parse(result.stdout)).not.toThrow();
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that output contains JSON with error flag
|
||||
*/
|
||||
export function expectJsonError(result: CliResult) {
|
||||
const json = expectValidJson(result);
|
||||
expect(json.isError).toBe(true);
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that output contains expected JSON structure
|
||||
*/
|
||||
export function expectJsonStructure(result: CliResult, expectedKeys: string[]) {
|
||||
const json = expectValidJson(result);
|
||||
expectedKeys.forEach((key) => {
|
||||
expect(json).toHaveProperty(key);
|
||||
});
|
||||
return json;
|
||||
}
|
||||
97
clients/cli/__tests__/helpers/cli-runner.ts
Normal file
97
clients/cli/__tests__/helpers/cli-runner.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { spawn } from "child_process";
|
||||
import { resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI_PATH = resolve(__dirname, "../../build/index.js");
|
||||
|
||||
export interface CliResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
output: string; // Combined stdout + stderr
|
||||
}
|
||||
|
||||
export interface CliOptions {
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the CLI with given arguments and capture output
|
||||
*/
|
||||
export async function runCli(
|
||||
args: string[],
|
||||
options: CliOptions = {},
|
||||
): Promise<CliResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("node", [CLI_PATH, ...args], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...options.env },
|
||||
signal: options.signal,
|
||||
// Own process group on Unix so timeout can kill CLI + stdio grandchildren.
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let resolved = false;
|
||||
|
||||
// Default timeout of 10 seconds (less than vitest's 15s)
|
||||
const timeoutMs = options.timeout ?? 10000;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
// Kill the process and all its children
|
||||
try {
|
||||
if (process.platform !== "win32" && child.pid != null) {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
} catch {
|
||||
// Process might already be dead, try direct kill
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// Process is definitely dead
|
||||
}
|
||||
}
|
||||
reject(new Error(`CLI command timed out after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
exitCode: code,
|
||||
stdout,
|
||||
stderr,
|
||||
output: stdout + stderr,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
90
clients/cli/__tests__/helpers/fixtures.ts
Normal file
90
clients/cli/__tests__/helpers/fixtures.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as crypto from "crypto";
|
||||
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
|
||||
import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js";
|
||||
|
||||
/**
|
||||
* Sentinel value for tests that don't need a real server
|
||||
* (tests that expect failure before connecting)
|
||||
*/
|
||||
export const NO_SERVER_SENTINEL = "invalid-command-that-does-not-exist";
|
||||
|
||||
/**
|
||||
* Create a sample test config with test-stdio and test-http servers
|
||||
* Returns a temporary config file path that should be cleaned up with deleteConfigFile()
|
||||
* @param httpUrl - Optional full URL (including /mcp path) for test-http server.
|
||||
* If not provided, uses a placeholder URL. The test-http server exists
|
||||
* to test server selection logic and may not actually be used.
|
||||
*/
|
||||
export function createSampleTestConfig(httpUrl?: string): string {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
return createTestConfig({
|
||||
mcpServers: {
|
||||
"test-stdio": {
|
||||
type: "stdio",
|
||||
command,
|
||||
args,
|
||||
env: {
|
||||
HELLO: "Hello MCP!",
|
||||
},
|
||||
},
|
||||
"test-http": {
|
||||
type: "streamable-http",
|
||||
url: httpUrl || "http://localhost:3001/mcp",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary directory for test files
|
||||
* Uses crypto.randomUUID() to ensure uniqueness even when called in parallel
|
||||
*/
|
||||
function createTempDir(prefix: string = "mcp-inspector-test-"): string {
|
||||
const uniqueId = crypto.randomUUID();
|
||||
const tempDir = path.join(os.tmpdir(), `${prefix}${uniqueId}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary directory
|
||||
*/
|
||||
function cleanupTempDir(dir: string) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test config file
|
||||
*/
|
||||
export function createTestConfig(config: {
|
||||
mcpServers: Record<string, MCPServerConfig>;
|
||||
}): string {
|
||||
const tempDir = createTempDir("mcp-inspector-config-");
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invalid config file (malformed JSON)
|
||||
*/
|
||||
export function createInvalidConfig(): string {
|
||||
const tempDir = createTempDir("mcp-inspector-config-");
|
||||
const configPath = path.join(tempDir, "invalid-config.json");
|
||||
fs.writeFileSync(configPath, '{\n "mcpServers": {\n "invalid": {');
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a config file and its containing directory
|
||||
*/
|
||||
export function deleteConfigFile(configPath: string): void {
|
||||
cleanupTempDir(path.dirname(configPath));
|
||||
}
|
||||
934
clients/cli/__tests__/metadata.test.ts
Normal file
934
clients/cli/__tests__/metadata.test.ts
Normal file
|
|
@ -0,0 +1,934 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { runCli } from "./helpers/cli-runner.js";
|
||||
import {
|
||||
expectCliSuccess,
|
||||
expectCliFailure,
|
||||
expectValidJson,
|
||||
} from "./helpers/assertions.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
createEchoTool,
|
||||
createAddTool,
|
||||
createTestServerInfo,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { NO_SERVER_SENTINEL } from "./helpers/fixtures.js";
|
||||
|
||||
describe("Metadata Tests", () => {
|
||||
describe("General Metadata", () => {
|
||||
it("should work with tools/list", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({ client: "test-client" });
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with resources/list", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
resources: [
|
||||
{
|
||||
uri: "test://resource",
|
||||
name: "test-resource",
|
||||
text: "test content",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/list",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("resources");
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const resourcesListRequest = recordedRequests.find(
|
||||
(r) => r.method === "resources/list",
|
||||
);
|
||||
expect(resourcesListRequest).toBeDefined();
|
||||
expect(resourcesListRequest?.metadata).toEqual({
|
||||
client: "test-client",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with prompts/list", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
prompts: [
|
||||
{
|
||||
name: "test-prompt",
|
||||
description: "A test prompt",
|
||||
promptString: "test prompt",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/list",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("prompts");
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const promptsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "prompts/list",
|
||||
);
|
||||
expect(promptsListRequest).toBeDefined();
|
||||
expect(promptsListRequest?.metadata).toEqual({
|
||||
client: "test-client",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with resources/read", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
resources: [
|
||||
{
|
||||
uri: "test://resource",
|
||||
name: "test-resource",
|
||||
text: "test content",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/read",
|
||||
"--uri",
|
||||
"test://resource",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("contents");
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const readRequest = recordedRequests.find(
|
||||
(r) => r.method === "resources/read",
|
||||
);
|
||||
expect(readRequest).toBeDefined();
|
||||
expect(readRequest?.metadata).toEqual({ client: "test-client" });
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with prompts/get", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
prompts: [
|
||||
{
|
||||
name: "test-prompt",
|
||||
description: "A test prompt",
|
||||
promptString: "test prompt",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
"--prompt-name",
|
||||
"test-prompt",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("messages");
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const getPromptRequest = recordedRequests.find(
|
||||
(r) => r.method === "prompts/get",
|
||||
);
|
||||
expect(getPromptRequest).toBeDefined();
|
||||
expect(getPromptRequest?.metadata).toEqual({ client: "test-client" });
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tool-Specific Metadata", () => {
|
||||
it("should work with tools/call", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=hello world",
|
||||
"--tool-metadata",
|
||||
"client=test-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolCallRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/call",
|
||||
);
|
||||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({ client: "test-client" });
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with complex tool", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createAddTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"add",
|
||||
"--tool-arg",
|
||||
"a=10",
|
||||
"b=20",
|
||||
"--tool-metadata",
|
||||
"client=test-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolCallRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/call",
|
||||
);
|
||||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({ client: "test-client" });
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata Merging", () => {
|
||||
it("should merge general and tool-specific metadata (tool-specific overrides)", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=hello world",
|
||||
"--metadata",
|
||||
"client=general-client",
|
||||
"shared_key=shared_value",
|
||||
"--tool-metadata",
|
||||
"client=tool-specific-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate metadata was merged correctly (tool-specific overrides general)
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolCallRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/call",
|
||||
);
|
||||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({
|
||||
client: "tool-specific-client", // Tool-specific overrides general
|
||||
shared_key: "shared_value", // General metadata is preserved
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata Parsing", () => {
|
||||
it("should handle numeric values", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"integer_value=42",
|
||||
"decimal_value=3.14159",
|
||||
"negative_value=-10",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate metadata values are sent as strings
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
integer_value: "42",
|
||||
decimal_value: "3.14159",
|
||||
negative_value: "-10",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle JSON values", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
'json_object="{\\"key\\":\\"value\\"}"',
|
||||
'json_array="[1,2,3]"',
|
||||
'json_string="\\"quoted\\""',
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate JSON values are sent as strings
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
json_object: '{"key":"value"}',
|
||||
json_array: "[1,2,3]",
|
||||
json_string: '"quoted"',
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle special characters", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"unicode=🚀🎉✨",
|
||||
"special_chars=!@#$%^&*()",
|
||||
"spaces=hello world with spaces",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate special characters are preserved
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
unicode: "🚀🎉✨",
|
||||
special_chars: "!@#$%^&*()",
|
||||
spaces: "hello world with spaces",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata Edge Cases", () => {
|
||||
it("should handle single metadata entry", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"single_key=single_value",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate single metadata entry
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
single_key: "single_value",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle many metadata entries", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"key1=value1",
|
||||
"key2=value2",
|
||||
"key3=value3",
|
||||
"key4=value4",
|
||||
"key5=value5",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate all metadata entries
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
key1: "value1",
|
||||
key2: "value2",
|
||||
key3: "value3",
|
||||
key4: "value4",
|
||||
key5: "value5",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata Error Cases", () => {
|
||||
it("should fail with invalid metadata format (missing equals)", async () => {
|
||||
const result = await runCli([
|
||||
NO_SERVER_SENTINEL,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"invalid_format_no_equals",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
|
||||
it("should fail with invalid tool-metadata format (missing equals)", async () => {
|
||||
const result = await runCli([
|
||||
NO_SERVER_SENTINEL,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=test",
|
||||
"--tool-metadata",
|
||||
"invalid_format_no_equals",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata Impact", () => {
|
||||
it("should handle tool-specific metadata precedence over general", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=precedence test",
|
||||
"--metadata",
|
||||
"client=general-client",
|
||||
"--tool-metadata",
|
||||
"client=tool-specific-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate tool-specific metadata overrides general
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolCallRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/call",
|
||||
);
|
||||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({
|
||||
client: "tool-specific-client",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with resources methods", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
resources: [
|
||||
{
|
||||
uri: "test://resource",
|
||||
name: "test-resource",
|
||||
text: "test content",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"resources/list",
|
||||
"--metadata",
|
||||
"resource_client=test-resource-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const resourcesListRequest = recordedRequests.find(
|
||||
(r) => r.method === "resources/list",
|
||||
);
|
||||
expect(resourcesListRequest).toBeDefined();
|
||||
expect(resourcesListRequest?.metadata).toEqual({
|
||||
resource_client: "test-resource-client",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with prompts methods", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
prompts: [
|
||||
{
|
||||
name: "test-prompt",
|
||||
description: "A test prompt",
|
||||
promptString: "test prompt",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
"--prompt-name",
|
||||
"test-prompt",
|
||||
"--metadata",
|
||||
"prompt_client=test-prompt-client",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const getPromptRequest = recordedRequests.find(
|
||||
(r) => r.method === "prompts/get",
|
||||
);
|
||||
expect(getPromptRequest).toBeDefined();
|
||||
expect(getPromptRequest?.metadata).toEqual({
|
||||
prompt_client: "test-prompt-client",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata Validation", () => {
|
||||
it("should handle special characters in keys", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--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",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate special characters in keys are preserved
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolCallRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/call",
|
||||
);
|
||||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({
|
||||
"key-with-dashes": "value1",
|
||||
key_with_underscores: "value2",
|
||||
"key.with.dots": "value3",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata Integration", () => {
|
||||
it("should work with all MCP methods", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"integration_test=true",
|
||||
"test_phase=all_methods",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
integration_test: "true",
|
||||
test_phase: "all_methods",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle complex metadata scenario", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--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",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate complex metadata merging
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolCallRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/call",
|
||||
);
|
||||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({
|
||||
session_id: "12345",
|
||||
user_id: "67890",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
request_id: "req-abc-123",
|
||||
tool_session: "session-xyz-789",
|
||||
execution_context: "test",
|
||||
priority: "high",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle metadata parsing validation", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--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=🚀🎉✨",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate all value types are sent as strings
|
||||
// Note: The CLI parses metadata values, so single-quoted JSON strings
|
||||
// are preserved with their quotes
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolCallRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/call",
|
||||
);
|
||||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({
|
||||
valid_key: "valid_value",
|
||||
numeric_key: "123",
|
||||
boolean_key: "true",
|
||||
json_key: '\'{"test":"value"}\'', // Single quotes are preserved
|
||||
special_key: "!@#$%^&*()",
|
||||
unicode_key: "🚀🎉✨",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE Transport Tests", () => {
|
||||
it("should work with tools/list using SSE transport", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverType: "sse",
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
"client=test-client",
|
||||
"--transport",
|
||||
"sse",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
|
||||
// Validate metadata was sent
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({ client: "test-client" });
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
527
clients/cli/__tests__/tools.test.ts
Normal file
527
clients/cli/__tests__/tools.test.ts
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { runCli } from "./helpers/cli-runner.js";
|
||||
import {
|
||||
expectCliSuccess,
|
||||
expectCliFailure,
|
||||
expectValidJson,
|
||||
expectJsonError,
|
||||
} from "./helpers/assertions.js";
|
||||
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
describe("Tool Tests", () => {
|
||||
describe("Tool Discovery", () => {
|
||||
it("should list available tools", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
expect(json.tools.length).toBeGreaterThan(0);
|
||||
// Validate that tools have required properties
|
||||
expect(json.tools[0]).toHaveProperty("name");
|
||||
expect(json.tools[0]).toHaveProperty("description");
|
||||
// Validate expected tools from test-mcp-server
|
||||
const toolNames = json.tools.map((tool: { name: string }) => tool.name);
|
||||
expect(toolNames).toContain("echo");
|
||||
expect(toolNames).toContain("get_sum");
|
||||
expect(toolNames).toContain("get_annotated_message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON Argument Parsing", () => {
|
||||
it("should handle string arguments (backward compatibility)", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=hello world",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content.length).toBeGreaterThan(0);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
expect(json.content[0].text).toBe("Echo: hello world");
|
||||
});
|
||||
|
||||
it("should handle integer number arguments", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"get_sum",
|
||||
"--tool-arg",
|
||||
"a=42",
|
||||
"b=58",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content.length).toBeGreaterThan(0);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// test-mcp-server returns JSON with {result: a+b}
|
||||
const resultData = JSON.parse(json.content[0].text);
|
||||
expect(resultData.result).toBe(100);
|
||||
});
|
||||
|
||||
it("should handle decimal number arguments", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"get_sum",
|
||||
"--tool-arg",
|
||||
"a=19.99",
|
||||
"b=20.01",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content.length).toBeGreaterThan(0);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// test-mcp-server returns JSON with {result: a+b}
|
||||
const resultData = JSON.parse(json.content[0].text);
|
||||
expect(resultData.result).toBeCloseTo(40.0, 2);
|
||||
});
|
||||
|
||||
it("should handle boolean arguments - true", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"get_annotated_message",
|
||||
"--tool-arg",
|
||||
"messageType=success",
|
||||
"includeImage=true",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
// Should have both text and image content
|
||||
expect(json.content.length).toBeGreaterThan(1);
|
||||
const hasImage = json.content.some(
|
||||
(item: { type?: string }) => item.type === "image",
|
||||
);
|
||||
expect(hasImage).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle boolean arguments - false", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"get_annotated_message",
|
||||
"--tool-arg",
|
||||
"messageType=error",
|
||||
"includeImage=false",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
// Should only have text content, no image
|
||||
const hasImage = json.content.some(
|
||||
(item: { type?: string }) => item.type === "image",
|
||||
);
|
||||
expect(hasImage).toBe(false);
|
||||
// test-mcp-server returns "This is a {messageType} message"
|
||||
expect(json.content[0].text.toLowerCase()).toContain("error");
|
||||
});
|
||||
|
||||
it("should handle null arguments", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
'message="null"',
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// The string "null" should be passed through
|
||||
expect(json.content[0].text).toBe("Echo: null");
|
||||
});
|
||||
|
||||
it("should handle multiple arguments with mixed types", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"get_sum",
|
||||
"--tool-arg",
|
||||
"a=42.5",
|
||||
"b=57.5",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content.length).toBeGreaterThan(0);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// test-mcp-server returns JSON with {result: a+b}
|
||||
const resultData = JSON.parse(json.content[0].text);
|
||||
expect(resultData.result).toBeCloseTo(100.0, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON Parsing Edge Cases", () => {
|
||||
it("should fall back to string for invalid JSON", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message={invalid json}",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// Should treat invalid JSON as a string
|
||||
expect(json.content[0].text).toBe("Echo: {invalid json}");
|
||||
});
|
||||
|
||||
it("should handle empty string value", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
'message=""',
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// Empty string should be preserved
|
||||
expect(json.content[0].text).toBe("Echo: ");
|
||||
});
|
||||
|
||||
it("should handle special characters in strings", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
'message="C:\\\\Users\\\\test"',
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// Special characters should be preserved
|
||||
expect(json.content[0].text).toContain("C:");
|
||||
expect(json.content[0].text).toContain("Users");
|
||||
expect(json.content[0].text).toContain("test");
|
||||
});
|
||||
|
||||
it("should handle unicode characters", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
'message="🚀🎉✨"',
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// Unicode characters should be preserved
|
||||
expect(json.content[0].text).toContain("🚀");
|
||||
expect(json.content[0].text).toContain("🎉");
|
||||
expect(json.content[0].text).toContain("✨");
|
||||
});
|
||||
|
||||
it("should handle arguments with equals signs in values", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=2+2=4",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// Equals signs in values should be preserved
|
||||
expect(json.content[0].text).toBe("Echo: 2+2=4");
|
||||
});
|
||||
|
||||
it("should handle base64-like strings", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const base64String =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=";
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
`message=${base64String}`,
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// Base64-like strings should be preserved
|
||||
expect(json.content[0].text).toBe(`Echo: ${base64String}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tool Error Handling", () => {
|
||||
it("should fail with nonexistent tool", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"nonexistent_tool",
|
||||
"--tool-arg",
|
||||
"message=test",
|
||||
]);
|
||||
|
||||
// CLI returns exit code 0 but includes isError: true in JSON (server returns error)
|
||||
expectJsonError(result);
|
||||
});
|
||||
|
||||
it("should fail when tool name is missing", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-arg",
|
||||
"message=test",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
|
||||
it("should fail with invalid tool argument format", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"invalid_format_no_equals",
|
||||
]);
|
||||
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prompt JSON Arguments", () => {
|
||||
it("should handle prompt with JSON arguments", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
"--prompt-name",
|
||||
"args_prompt",
|
||||
"--prompt-args",
|
||||
"city=New York",
|
||||
"state=NY",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("messages");
|
||||
expect(Array.isArray(json.messages)).toBe(true);
|
||||
expect(json.messages.length).toBeGreaterThan(0);
|
||||
expect(json.messages[0]).toHaveProperty("content");
|
||||
expect(json.messages[0].content).toHaveProperty("type", "text");
|
||||
// Validate that the arguments were actually used in the response
|
||||
// test-mcp-server formats it as "This is a prompt with arguments: city={city}, state={state}"
|
||||
expect(json.messages[0].content.text).toContain("city=New York");
|
||||
expect(json.messages[0].content.text).toContain("state=NY");
|
||||
});
|
||||
|
||||
it("should handle prompt with simple arguments", async () => {
|
||||
// Note: simple_prompt doesn't accept arguments, but the CLI should still
|
||||
// accept the command and the server should ignore the arguments
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"prompts/get",
|
||||
"--prompt-name",
|
||||
"simple_prompt",
|
||||
"--prompt-args",
|
||||
"name=test",
|
||||
"count=5",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("messages");
|
||||
expect(Array.isArray(json.messages)).toBe(true);
|
||||
expect(json.messages.length).toBeGreaterThan(0);
|
||||
expect(json.messages[0]).toHaveProperty("content");
|
||||
expect(json.messages[0].content).toHaveProperty("type", "text");
|
||||
// test-mcp-server's simple_prompt returns standard message (ignoring args)
|
||||
expect(json.messages[0].content.text).toBe(
|
||||
"This is a simple prompt for testing purposes.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Backward Compatibility", () => {
|
||||
it("should support existing string-only usage", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=hello",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
expect(json.content[0].text).toBe("Echo: hello");
|
||||
});
|
||||
|
||||
it("should support multiple string arguments", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"get_sum",
|
||||
"--tool-arg",
|
||||
"a=10",
|
||||
"b=20",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const json = expectValidJson(result);
|
||||
expect(json).toHaveProperty("content");
|
||||
expect(Array.isArray(json.content)).toBe(true);
|
||||
expect(json.content.length).toBeGreaterThan(0);
|
||||
expect(json.content[0]).toHaveProperty("type", "text");
|
||||
// test-mcp-server returns JSON with {result: a+b}
|
||||
const resultData = JSON.parse(json.content[0].text);
|
||||
expect(resultData.result).toBe(30);
|
||||
});
|
||||
});
|
||||
});
|
||||
3954
clients/cli/package-lock.json
generated
Normal file
3954
clients/cli/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
clients/cli/package.json
Normal file
44
clients/cli/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"exports": {
|
||||
".": "./build/index.js"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-inspector-cli": "./build/index.js"
|
||||
},
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"pretest": "npm run test-servers:build && npm run build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test-servers:build": "tsc -p ../../test-servers --noCheck",
|
||||
"test:cli": "vitest run cli.test.ts",
|
||||
"test:cli-tools": "vitest run tools.test.ts",
|
||||
"test:cli-headers": "vitest run headers.test.ts",
|
||||
"test:cli-metadata": "vitest run metadata.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"commander": "^13.1.0",
|
||||
"pino": "^9.14.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.4",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
460
clients/cli/src/cli.ts
Normal file
460
clients/cli/src/cli.ts
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
import { dirname, join } from "path";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
import { Command } from "commander";
|
||||
type McpResponse = Record<string, unknown>;
|
||||
import { handleError } from "./error-handler.js";
|
||||
import { awaitableLog } from "./utils/awaitable-log.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { DEFAULT_TASK_TTL_MS } from "@inspector/core/mcp/types.js";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import {
|
||||
ManagedToolsState,
|
||||
ManagedResourcesState,
|
||||
ManagedResourceTemplatesState,
|
||||
ManagedPromptsState,
|
||||
} from "@inspector/core/mcp/state/index.js";
|
||||
import {
|
||||
createTransportNode,
|
||||
resolveServerConfigs,
|
||||
parseKeyValuePair as parseEnvPair,
|
||||
parseHeaderPair,
|
||||
} from "@inspector/core/mcp/node/index.js";
|
||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
import {
|
||||
LoggingLevelSchema,
|
||||
type LoggingLevel,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export const validLogLevels: LoggingLevel[] = Object.values(
|
||||
LoggingLevelSchema.enum,
|
||||
);
|
||||
|
||||
type MethodArgs = {
|
||||
method?: string;
|
||||
promptName?: string;
|
||||
promptArgs?: Record<string, JsonValue>;
|
||||
uri?: string;
|
||||
logLevel?: LoggingLevel;
|
||||
toolName?: string;
|
||||
toolArg?: Record<string, JsonValue>;
|
||||
toolMeta?: Record<string, string>;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
function headersToServerSettings(
|
||||
headers?: Record<string, string>,
|
||||
): InspectorServerSettings | undefined {
|
||||
if (!headers || Object.keys(headers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
autoRefreshOnListChanged: false,
|
||||
roots: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function callMethod(
|
||||
serverConfig: MCPServerConfig,
|
||||
serverSettings: InspectorServerSettings | undefined,
|
||||
args: MethodArgs & { method: string },
|
||||
): Promise<void> {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = join(__dirname, "../package.json");
|
||||
const packageJsonData = await import(pathToFileURL(packageJsonPath).href, {
|
||||
with: { type: "json" },
|
||||
});
|
||||
const packageJson = packageJsonData.default as {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const [, name = packageJson.name] = packageJson.name.split("/");
|
||||
const version = packageJson.version;
|
||||
const clientIdentity = { name, version };
|
||||
|
||||
const inspectorClient = new InspectorClient(serverConfig, {
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
},
|
||||
clientIdentity,
|
||||
initialLoggingLevel: "debug",
|
||||
progress: false,
|
||||
sample: false,
|
||||
elicit: false,
|
||||
serverSettings,
|
||||
});
|
||||
|
||||
let managedToolsState: ManagedToolsState | null = null;
|
||||
let managedResourcesState: ManagedResourcesState | null = null;
|
||||
let managedResourceTemplatesState: ManagedResourceTemplatesState | null =
|
||||
null;
|
||||
let managedPromptsState: ManagedPromptsState | null = null;
|
||||
|
||||
try {
|
||||
await inspectorClient.connect();
|
||||
|
||||
let result: McpResponse;
|
||||
|
||||
if (args.method === "tools/list" || args.method === "tools/call") {
|
||||
managedToolsState = new ManagedToolsState(inspectorClient);
|
||||
managedToolsState.setMetadata(args.metadata);
|
||||
await managedToolsState.refresh();
|
||||
}
|
||||
|
||||
if (args.method === "resources/list") {
|
||||
managedResourcesState = new ManagedResourcesState(inspectorClient);
|
||||
managedResourcesState.setMetadata(args.metadata);
|
||||
await managedResourcesState.refresh();
|
||||
} else if (args.method === "resources/templates/list") {
|
||||
managedResourceTemplatesState = new ManagedResourceTemplatesState(
|
||||
inspectorClient,
|
||||
);
|
||||
managedResourceTemplatesState.setMetadata(args.metadata);
|
||||
await managedResourceTemplatesState.refresh();
|
||||
} else if (args.method === "prompts/list") {
|
||||
managedPromptsState = new ManagedPromptsState(inspectorClient);
|
||||
managedPromptsState.setMetadata(args.metadata);
|
||||
await managedPromptsState.refresh();
|
||||
}
|
||||
|
||||
if (args.method === "tools/list") {
|
||||
result = { tools: managedToolsState!.getTools() };
|
||||
} else if (args.method === "tools/call") {
|
||||
if (!args.toolName) {
|
||||
throw new Error(
|
||||
"Tool name is required for tools/call method. Use --tool-name to specify the tool name.",
|
||||
);
|
||||
}
|
||||
|
||||
const tool = managedToolsState!
|
||||
.getTools()
|
||||
.find((t) => t.name === args.toolName);
|
||||
if (!tool) {
|
||||
result = {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Tool '${args.toolName}' not found.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
} else {
|
||||
const invocation = await inspectorClient.callTool(
|
||||
tool,
|
||||
args.toolArg || {},
|
||||
args.metadata,
|
||||
args.toolMeta,
|
||||
);
|
||||
if (invocation.result !== null) {
|
||||
result = invocation.result;
|
||||
} else {
|
||||
result = {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: invocation.error || "Tool call failed",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (args.method === "resources/list") {
|
||||
result = {
|
||||
resources: managedResourcesState!.getResources(),
|
||||
};
|
||||
} else if (args.method === "resources/read") {
|
||||
if (!args.uri) {
|
||||
throw new Error(
|
||||
"URI is required for resources/read method. Use --uri to specify the resource URI.",
|
||||
);
|
||||
}
|
||||
|
||||
const invocation = await inspectorClient.readResource(
|
||||
args.uri,
|
||||
args.metadata,
|
||||
);
|
||||
result = invocation.result;
|
||||
} else if (args.method === "resources/templates/list") {
|
||||
result = {
|
||||
resourceTemplates:
|
||||
managedResourceTemplatesState!.getResourceTemplates(),
|
||||
};
|
||||
} else if (args.method === "prompts/list") {
|
||||
result = { prompts: managedPromptsState!.getPrompts() };
|
||||
} else if (args.method === "prompts/get") {
|
||||
if (!args.promptName) {
|
||||
throw new Error(
|
||||
"Prompt name is required for prompts/get method. Use --prompt-name to specify the prompt name.",
|
||||
);
|
||||
}
|
||||
|
||||
const invocation = await inspectorClient.getPrompt(
|
||||
args.promptName,
|
||||
args.promptArgs || {},
|
||||
args.metadata,
|
||||
);
|
||||
result = invocation.result;
|
||||
} else if (args.method === "logging/setLevel") {
|
||||
if (!args.logLevel) {
|
||||
throw new Error(
|
||||
"Log level is required for logging/setLevel method. Use --log-level to specify the log level.",
|
||||
);
|
||||
}
|
||||
|
||||
await inspectorClient.setLoggingLevel(args.logLevel);
|
||||
result = {};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`,
|
||||
);
|
||||
}
|
||||
|
||||
await awaitableLog(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
managedToolsState?.destroy();
|
||||
managedResourcesState?.destroy();
|
||||
managedResourceTemplatesState?.destroy();
|
||||
managedPromptsState?.destroy();
|
||||
await inspectorClient.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeyValuePair(
|
||||
value: string,
|
||||
previous: Record<string, JsonValue> = {},
|
||||
): Record<string, JsonValue> {
|
||||
const parts = value.split("=");
|
||||
const key = parts[0];
|
||||
const val = parts.slice(1).join("=");
|
||||
|
||||
if (!key || val === undefined || val === "") {
|
||||
throw new Error(
|
||||
`Invalid parameter format: ${value}. Use key=value format.`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsedValue: JsonValue;
|
||||
try {
|
||||
parsedValue = JSON.parse(val) as JsonValue;
|
||||
} catch {
|
||||
parsedValue = val;
|
||||
}
|
||||
|
||||
return { ...previous, [key as string]: parsedValue };
|
||||
}
|
||||
|
||||
function parseArgs(argv?: string[]): {
|
||||
serverConfig: MCPServerConfig;
|
||||
serverSettings: InspectorServerSettings | undefined;
|
||||
methodArgs: MethodArgs & { method: string };
|
||||
} {
|
||||
const program = new Command();
|
||||
const rawArgs = argv ?? process.argv;
|
||||
const scriptArgs = rawArgs.slice(2);
|
||||
const dashDashIndex = scriptArgs.indexOf("--");
|
||||
let targetArgs: string[] = [];
|
||||
let optionArgs: string[] = [];
|
||||
if (dashDashIndex >= 0) {
|
||||
targetArgs = scriptArgs.slice(0, dashDashIndex);
|
||||
optionArgs = scriptArgs.slice(dashDashIndex + 1);
|
||||
} else {
|
||||
let i = 0;
|
||||
while (i < scriptArgs.length && !scriptArgs[i]!.startsWith("-")) {
|
||||
targetArgs.push(scriptArgs[i]!);
|
||||
i++;
|
||||
}
|
||||
optionArgs = scriptArgs.slice(i);
|
||||
}
|
||||
const preArgs: string[] = [
|
||||
rawArgs[0] ?? "node",
|
||||
rawArgs[1] ?? "inspector-cli",
|
||||
...optionArgs,
|
||||
];
|
||||
|
||||
program
|
||||
.name("inspector-cli")
|
||||
.allowUnknownOption()
|
||||
.argument(
|
||||
"[target...]",
|
||||
"Command and arguments or URL of the MCP server (or use --config and --server)",
|
||||
)
|
||||
.option("--config <path>", "Config file path")
|
||||
.option("--server <name>", "Server name from config file")
|
||||
.option(
|
||||
"-e <env>",
|
||||
"Environment variables for the server (KEY=VALUE)",
|
||||
parseEnvPair,
|
||||
{},
|
||||
)
|
||||
.option("--method <method>", "Method to invoke")
|
||||
.option("--tool-name <toolName>", "Tool name (for tools/call method)")
|
||||
.option(
|
||||
"--tool-arg <pairs...>",
|
||||
"Tool argument as key=value pair",
|
||||
parseKeyValuePair,
|
||||
{},
|
||||
)
|
||||
.option("--uri <uri>", "URI of the resource (for resources/read method)")
|
||||
.option(
|
||||
"--prompt-name <promptName>",
|
||||
"Name of the prompt (for prompts/get method)",
|
||||
)
|
||||
.option(
|
||||
"--prompt-args <pairs...>",
|
||||
"Prompt arguments as key=value pairs",
|
||||
parseKeyValuePair,
|
||||
{},
|
||||
)
|
||||
.option(
|
||||
"--log-level <level>",
|
||||
"Logging level (for logging/setLevel method)",
|
||||
(value: string) => {
|
||||
if (!validLogLevels.includes(value as LoggingLevel)) {
|
||||
throw new Error(
|
||||
`Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return value as LoggingLevel;
|
||||
},
|
||||
)
|
||||
.option("--cwd <path>", "Working directory for stdio server process")
|
||||
.option(
|
||||
"--transport <type>",
|
||||
"Transport type (sse, http, or stdio). Auto-detected from URL: /mcp → http, /sse → sse, commands → stdio",
|
||||
(value: string) => {
|
||||
const validTransports = ["sse", "http", "stdio"];
|
||||
if (!validTransports.includes(value)) {
|
||||
throw new Error(
|
||||
`Invalid transport type: ${value}. Valid types are: ${validTransports.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return value as "sse" | "http" | "stdio";
|
||||
},
|
||||
)
|
||||
.option("--server-url <url>", "Server URL for SSE/HTTP transport")
|
||||
.option(
|
||||
"--header <headers...>",
|
||||
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
|
||||
parseHeaderPair,
|
||||
{},
|
||||
)
|
||||
.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,
|
||||
{},
|
||||
);
|
||||
|
||||
program.parse(preArgs);
|
||||
|
||||
const options = program.opts() as {
|
||||
config?: string;
|
||||
server?: string;
|
||||
e?: Record<string, string>;
|
||||
method?: string;
|
||||
toolName?: string;
|
||||
toolArg?: Record<string, JsonValue>;
|
||||
uri?: string;
|
||||
promptName?: string;
|
||||
promptArgs?: Record<string, JsonValue>;
|
||||
logLevel?: LoggingLevel;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
toolMetadata?: Record<string, JsonValue>;
|
||||
cwd?: string;
|
||||
transport?: "sse" | "http" | "stdio";
|
||||
serverUrl?: string;
|
||||
header?: Record<string, string>;
|
||||
};
|
||||
|
||||
const serverOptions = {
|
||||
configPath: options.config,
|
||||
serverName: options.server,
|
||||
target: targetArgs.length > 0 ? targetArgs : undefined,
|
||||
transport: options.transport,
|
||||
serverUrl: options.serverUrl,
|
||||
cwd: options.cwd,
|
||||
env: options.e,
|
||||
};
|
||||
|
||||
const configs = resolveServerConfigs(serverOptions, "single");
|
||||
const serverConfig = configs[0];
|
||||
if (!serverConfig) {
|
||||
throw new Error(
|
||||
"Could not resolve server config. Specify a URL or command, or use --config and --server.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.method) {
|
||||
throw new Error(
|
||||
"Method is required. Use --method to specify the method to invoke.",
|
||||
);
|
||||
}
|
||||
|
||||
const methodArgs: MethodArgs & { method: string } = {
|
||||
method: options.method,
|
||||
toolName: options.toolName,
|
||||
toolArg: options.toolArg,
|
||||
uri: options.uri,
|
||||
promptName: options.promptName,
|
||||
promptArgs: options.promptArgs,
|
||||
logLevel: options.logLevel,
|
||||
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,
|
||||
};
|
||||
|
||||
return {
|
||||
serverConfig,
|
||||
serverSettings: headersToServerSettings(options.header),
|
||||
methodArgs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCli(argv?: string[]): Promise<void> {
|
||||
const { serverConfig, serverSettings, methodArgs } = parseArgs(
|
||||
argv ?? process.argv,
|
||||
);
|
||||
await callMethod(serverConfig, serverSettings, methodArgs);
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
process.on("uncaughtException", (error) => {
|
||||
handleError(error);
|
||||
});
|
||||
|
||||
try {
|
||||
await runCli();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
20
clients/cli/src/error-handler.ts
Normal file
20
clients/cli/src/error-handler.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
function formatError(error: unknown): string {
|
||||
let message: string;
|
||||
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else if (typeof error === "string") {
|
||||
message = error;
|
||||
} else {
|
||||
message = "Unknown error";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function handleError(error: unknown): never {
|
||||
const errorMessage = formatError(error);
|
||||
console.error(errorMessage);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
19
clients/cli/src/index.ts
Normal file
19
clients/cli/src/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { runCli, validLogLevels } from "./cli.js";
|
||||
import { handleError } from "./error-handler.js";
|
||||
|
||||
export { runCli, validLogLevels };
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const isMain =
|
||||
process.argv[1] !== undefined &&
|
||||
resolve(process.argv[1]) === resolve(__filename);
|
||||
|
||||
if (isMain) {
|
||||
runCli(process.argv)
|
||||
.then(() => process.exit(0))
|
||||
.catch(handleError);
|
||||
}
|
||||
7
clients/cli/src/utils/awaitable-log.ts
Normal file
7
clients/cli/src/utils/awaitable-log.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function awaitableLog(logValue: string): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
process.stdout.write(logValue, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue