Merge branch 'main' into fix/enum-anyof-ref-resolution

This commit is contained in:
Ola Hungerford 2026-02-04 05:41:56 -07:00 • committed by GitHub
commit 442638f4c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 16245 additions and 8744 deletions

View file

@ -34,3 +34,7 @@ If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
**Version Consideration**
Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](../../CONTRIBUTING.md) for more details.

57
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,57 @@
## Summary
<!-- Provide a brief description of what this PR does -->
> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](../CONTRIBUTING.md) for more details.
## Type of Change
<!-- Mark the relevant option with an "x" -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] Test updates
- [ ] Build/CI improvements
## Changes Made
<!-- Describe the changes in detail. Include screenshots/recordings if applicable -->
## Related Issues
<!-- Link to related issues using #issue_number or "Fixes #issue_number" -->
## Testing
<!-- Describe how you tested these changes, where applicable -->
- [ ] Tested in UI mode
- [ ] Tested in CLI mode
- [ ] Tested with STDIO transport
- [ ] Tested with SSE transport
- [ ] Tested with Streamable HTTP transport
- [ ] Added/updated automated tests
- [ ] Manual testing performed
### Test Results and/or Instructions
<!-- Provide steps for reviewers to test your changes -->
Screenshots are encouraged to share your testing results for this change.
## Checklist
- [ ] Code follows the style guidelines (ran `npm run prettier-fix`)
- [ ] Self-review completed
- [ ] Code is commented where necessary
- [ ] Documentation updated (README, comments, etc.)
## Breaking Changes
<!-- If this is a breaking change, describe the impact and migration path -->
## Additional Context
<!-- Add any other context, screenshots, or information about the PR here -->

View file

@ -27,14 +27,48 @@ jobs:
id-token: write
actions: read
steps:
- name: Checkout repository
- name: Get PR details
if: |
(github.event_name == 'issue_comment' && github.event.issue.pull_request) ||
github.event_name == 'pull_request_review_comment' ||
github.event_name == 'pull_request_review'
id: pr
uses: actions/github-script@v7
with:
script: |
let prNumber;
if (context.eventName === 'issue_comment') {
prNumber = context.issue.number;
} else {
prNumber = context.payload.pull_request.number;
}
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
core.setOutput('sha', pr.data.head.sha);
core.setOutput('repo', pr.data.head.repo.full_name);
- name: Checkout PR branch
if: steps.pr.outcome == 'success'
uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ steps.pr.outputs.sha }}
repository: ${{ steps.pr.outputs.repo }}
fetch-depth: 0
- name: Checkout repository
if: steps.pr.outcome != 'success'
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
@ -45,9 +79,7 @@ jobs:
# Trigger when assigned to an issue
assignee_trigger: "claude"
# Allow Claude to run bash
# This should be safe given the repo is already public
allowed_tools: "Bash"
custom_instructions: |
If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block.
claude_args: |
--mcp-config .mcp.json
--allowedTools "Bash,mcp__mcp-docs"
--append-system-prompt "If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block. When working on MCP-related code or reviewing MCP-related changes, use the mcp-docs MCP server to look up the latest protocol documentation. For schema details, reference https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema which contains versioned schemas in JSON (schema.json) and TypeScript (schema.ts) formats."

View file

@ -31,9 +31,6 @@ jobs:
- name: Build CLI
run: npm run build
- name: Explicitly pre-install test dependencies
run: npx -y @modelcontextprotocol/server-everything --help || true
- name: Run tests
run: npm test
env:

View file

@ -8,7 +8,7 @@ on:
jobs:
test:
# Installing Playright dependencies can take quite awhile, and also depends on GitHub CI load.
# Installing Playwright dependencies can take quite awhile, and also depends on GitHub CI load.
timeout-minutes: 15
runs-on: ubuntu-latest

1
.gitignore vendored
View file

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

8
.mcp.json Normal file
View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"mcp-docs": {
"type": "http",
"url": "https://modelcontextprotocol.io/mcp"
}
}
}

View file

@ -1,5 +1,7 @@
# MCP Inspector Development Guide
> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
## Build Commands
- Build all: `npm run build`

View file

@ -9,6 +9,16 @@ Thanks for your interest in contributing! This guide explains how to get involve
3. Run `npm run dev` to start both client and server in development mode
4. Use the web UI at http://localhost:6274 to interact with the inspector
## Inspector V2 Development
We're actively developing **Inspector V2** to address architectural and UX improvements. We invite you to follow progress and participate in the Inspector V2 Working Group in [Discord](https://modelcontextprotocol.io/community/communication), [weekly meetings](https://meet.modelcontextprotocol.io/tag/inspector-v2-wg), and [GitHub Discussions](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/categories/meeting-notes-other) (where notes are posted after meetings).
**Current version (V1) contribution scope:**
- Bug fixes and MCP spec compliance are actively maintained
- Documentation updates are always appreciated
- Major changes will be directed to V2 development
## Development Process & Pull Requests
1. Create a new branch for your changes
@ -30,7 +40,7 @@ If you find a security vulnerability, please refer to our [Security Policy](SECU
## Questions?
Feel free to [open an issue](https://github.com/modelcontextprotocol/mcp-inspector/issues) for questions or create a discussion for general topics.
Feel free to [open an issue](https://github.com/modelcontextprotocol/inspector/issues) for questions or join the MCP Contributor [Discord server](https://modelcontextprotocol.io/community/communication). Also, please see notes above on Inspector V2 Development.
## License

197
LICENSE
View file

@ -1,6 +1,193 @@
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
No rights beyond those granted by the applicable original license are conveyed for such contributions.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
---
MIT License
Copyright (c) 2024 Anthropic, PBC
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -19,3 +206,11 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Creative Commons Attribution 4.0 International (CC-BY-4.0)
Documentation in this project (excluding specifications) is licensed under
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.

View file

@ -34,7 +34,12 @@ The server will start up and the UI will be accessible at `http://localhost:6274
You can also start it in a Docker container with the following command:
```bash
docker run --rm --network host -p 6274:6274 -p 6277:6277 ghcr.io/modelcontextprotocol/inspector:latest
docker run --rm \
-p 127.0.0.1:6274:6274 \
-p 127.0.0.1:6277:6277 \
-e HOST=0.0.0.0 \
-e MCP_AUTO_OPEN_ENABLED=false \
ghcr.io/modelcontextprotocol/inspector:latest
```
### From an MCP server repository

44
cli/__tests__/README.md Normal file
View 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

871
cli/__tests__/cli.test.ts Normal file
View file

@ -0,0 +1,871 @@
import { describe, it, beforeAll, afterAll, 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 } from "./helpers/test-server-stdio.js";
import { createTestServerHttp } from "./helpers/test-server-http.js";
import {
createEchoTool,
createTestServerInfo,
} from "./helpers/test-fixtures.js";
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: any) => 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 {
const port = await server.start("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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",
note: "Test SSE server",
},
},
});
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",
note: "Test HTTP server",
},
},
});
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("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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);
});
});
});

View file

@ -0,0 +1,210 @@
import { describe, it, expect } from "vitest";
import { runCli } from "./helpers/cli-runner.js";
import {
expectCliFailure,
expectOutputContains,
expectCliSuccess,
} from "./helpers/assertions.js";
import { createTestServerHttp } from "./helpers/test-server-http.js";
import {
createEchoTool,
createTestServerInfo,
} from "./helpers/test-fixtures.js";
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 {
const port = await server.start("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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 {
const port = await server.start("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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 {
const port = await server.start("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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 {
const port = await server.start("http");
const serverUrl = `${server.getUrl()}/mcp`;
const result = await runCli([
serverUrl,
"--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");
});
});
});

View file

@ -0,0 +1,52 @@
import { expect } from "vitest";
import type { CliResult } from "./cli-runner.js";
/**
* Assert that CLI command succeeded (exit code 0)
*/
export function expectCliSuccess(result: CliResult) {
expect(result.exitCode).toBe(0);
}
/**
* Assert that CLI command failed (non-zero exit code)
*/
export function expectCliFailure(result: CliResult) {
expect(result.exitCode).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;
}

View file

@ -0,0 +1,98 @@
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/cli.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,
// Kill child process tree on exit
detached: false,
});
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.kill("SIGTERM");
} else {
// On Unix, kill the process group
process.kill(-child.pid!, "SIGTERM");
}
} catch (e) {
// Process might already be dead, try direct kill
try {
child.kill("SIGKILL");
} catch (e2) {
// 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);
}
});
});
}

View file

@ -0,0 +1,89 @@
import fs from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import { getTestMcpServerCommand } from "./test-server-stdio.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 (err) {
// Ignore cleanup errors
}
}
/**
* Create a test config file
*/
export function createTestConfig(config: {
mcpServers: Record<string, any>;
}): 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));
}

View file

@ -0,0 +1,267 @@
/**
* Shared types and test fixtures for composable MCP test servers
*/
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Implementation } from "@modelcontextprotocol/sdk/types.js";
import * as z from "zod/v4";
import { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js";
type ToolInputSchema = ZodRawShapeCompat;
export interface ToolDefinition {
name: string;
description: string;
inputSchema?: ToolInputSchema;
handler: (params: Record<string, any>) => Promise<any>;
}
export interface ResourceDefinition {
uri: string;
name: string;
description?: string;
mimeType?: string;
text?: string;
}
type PromptArgsSchema = ZodRawShapeCompat;
export interface PromptDefinition {
name: string;
description?: string;
argsSchema?: PromptArgsSchema;
}
// This allows us to compose tests servers using the metadata and features we want in a given scenario
export interface ServerConfig {
serverInfo: Implementation; // Server metadata (name, version, etc.) - required
tools?: ToolDefinition[]; // Tools to register (optional, empty array means no tools, but tools capability is still advertised)
resources?: ResourceDefinition[]; // Resources to register (optional, empty array means no resources, but resources capability is still advertised)
prompts?: PromptDefinition[]; // Prompts to register (optional, empty array means no prompts, but prompts capability is still advertised)
logging?: boolean; // Whether to advertise logging capability (default: false)
}
/**
* Create an "echo" tool that echoes back the input message
*/
export function createEchoTool(): ToolDefinition {
return {
name: "echo",
description: "Echo back the input message",
inputSchema: {
message: z.string().describe("Message to echo back"),
},
handler: async (params: Record<string, any>) => {
return { message: `Echo: ${params.message as string}` };
},
};
}
/**
* Create an "add" tool that adds two numbers together
*/
export function createAddTool(): ToolDefinition {
return {
name: "add",
description: "Add two numbers together",
inputSchema: {
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
},
handler: async (params: Record<string, any>) => {
const a = params.a as number;
const b = params.b as number;
return { result: a + b };
},
};
}
/**
* Create a "get-sum" tool that returns the sum of two numbers (alias for add)
*/
export function createGetSumTool(): ToolDefinition {
return {
name: "get-sum",
description: "Get the sum of two numbers",
inputSchema: {
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
},
handler: async (params: Record<string, any>) => {
const a = params.a as number;
const b = params.b as number;
return { result: a + b };
},
};
}
/**
* Create a "get-annotated-message" tool that returns a message with optional image
*/
export function createGetAnnotatedMessageTool(): ToolDefinition {
return {
name: "get-annotated-message",
description: "Get an annotated message",
inputSchema: {
messageType: z
.enum(["success", "error", "warning", "info"])
.describe("Type of message"),
includeImage: z
.boolean()
.optional()
.describe("Whether to include an image"),
},
handler: async (params: Record<string, any>) => {
const messageType = params.messageType as string;
const includeImage = params.includeImage as boolean | undefined;
const message = `This is a ${messageType} message`;
const content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
> = [
{
type: "text",
text: message,
},
];
if (includeImage) {
content.push({
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", // 1x1 transparent PNG
mimeType: "image/png",
});
}
return { content };
},
};
}
/**
* Create a "simple-prompt" prompt definition
*/
export function createSimplePrompt(): PromptDefinition {
return {
name: "simple-prompt",
description: "A simple prompt for testing",
};
}
/**
* Create an "args-prompt" prompt that accepts arguments
*/
export function createArgsPrompt(): PromptDefinition {
return {
name: "args-prompt",
description: "A prompt that accepts arguments for testing",
argsSchema: {
city: z.string().describe("City name"),
state: z.string().describe("State name"),
},
};
}
/**
* Create an "architecture" resource definition
*/
export function createArchitectureResource(): ResourceDefinition {
return {
name: "architecture",
uri: "demo://resource/static/document/architecture.md",
description: "Architecture documentation",
mimeType: "text/markdown",
text: `# Architecture Documentation
This is a test resource for the MCP test server.
## Overview
This resource is used for testing resource reading functionality in the CLI.
## Sections
- Introduction
- Design
- Implementation
- Testing
## Notes
This is a static resource provided by the test MCP server.
`,
};
}
/**
* Create a "test-cwd" resource that exposes the current working directory (generally useful when testing with the stdio test server)
*/
export function createTestCwdResource(): ResourceDefinition {
return {
name: "test-cwd",
uri: "test://cwd",
description: "Current working directory of the test server",
mimeType: "text/plain",
text: process.cwd(),
};
}
/**
* Create a "test-env" resource that exposes environment variables (generally useful when testing with the stdio test server)
*/
export function createTestEnvResource(): ResourceDefinition {
return {
name: "test-env",
uri: "test://env",
description: "Environment variables available to the test server",
mimeType: "application/json",
text: JSON.stringify(process.env, null, 2),
};
}
/**
* Create a "test-argv" resource that exposes command-line arguments (generally useful when testing with the stdio test server)
*/
export function createTestArgvResource(): ResourceDefinition {
return {
name: "test-argv",
uri: "test://argv",
description: "Command-line arguments the test server was started with",
mimeType: "application/json",
text: JSON.stringify(process.argv, null, 2),
};
}
/**
* Create minimal server info for test servers
*/
export function createTestServerInfo(
name: string = "test-server",
version: string = "1.0.0",
): Implementation {
return {
name,
version,
};
}
/**
* Get default server config with common test tools, prompts, and resources
*/
export function getDefaultServerConfig(): ServerConfig {
return {
serverInfo: createTestServerInfo("test-mcp-server", "1.0.0"),
tools: [
createEchoTool(),
createGetSumTool(),
createGetAnnotatedMessageTool(),
],
prompts: [createSimplePrompt(), createArgsPrompt()],
resources: [
createArchitectureResource(),
createTestCwdResource(),
createTestEnvResource(),
createTestArgvResource(),
],
};
}

View file

@ -0,0 +1,443 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { SetLevelRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import type { Request, Response } from "express";
import express from "express";
import { createServer as createHttpServer, Server as HttpServer } from "http";
import { createServer as createNetServer } from "net";
import * as z from "zod/v4";
import type { ServerConfig } from "./test-fixtures.js";
export interface RecordedRequest {
method: string;
params?: any;
headers?: Record<string, string>;
metadata?: Record<string, string>;
response: any;
timestamp: number;
}
/**
* Find an available port starting from the given port
*/
async function findAvailablePort(startPort: number): Promise<number> {
return new Promise((resolve, reject) => {
const server = createNetServer();
server.listen(startPort, () => {
const port = (server.address() as { port: number })?.port;
server.close(() => resolve(port || startPort));
});
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
// Try next port
findAvailablePort(startPort + 1)
.then(resolve)
.catch(reject);
} else {
reject(err);
}
});
});
}
/**
* Extract headers from Express request
*/
function extractHeaders(req: Request): Record<string, string> {
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === "string") {
headers[key] = value;
} else if (Array.isArray(value) && value.length > 0) {
headers[key] = value[value.length - 1];
}
}
return headers;
}
// With this test server, your test can hold an instance and you can get the server's recorded message history at any time.
//
export class TestServerHttp {
private mcpServer: McpServer;
private config: ServerConfig;
private recordedRequests: RecordedRequest[] = [];
private httpServer?: HttpServer;
private transport?: StreamableHTTPServerTransport | SSEServerTransport;
private url?: string;
private currentRequestHeaders?: Record<string, string>;
private currentLogLevel: string | null = null;
constructor(config: ServerConfig) {
this.config = config;
const capabilities: {
tools?: {};
resources?: {};
prompts?: {};
logging?: {};
} = {};
// Only include capabilities for features that are present in config
if (config.tools !== undefined) {
capabilities.tools = {};
}
if (config.resources !== undefined) {
capabilities.resources = {};
}
if (config.prompts !== undefined) {
capabilities.prompts = {};
}
if (config.logging === true) {
capabilities.logging = {};
}
this.mcpServer = new McpServer(config.serverInfo, {
capabilities,
});
this.setupHandlers();
if (config.logging === true) {
this.setupLoggingHandler();
}
}
private setupHandlers() {
// Set up tools
if (this.config.tools && this.config.tools.length > 0) {
for (const tool of this.config.tools) {
this.mcpServer.registerTool(
tool.name,
{
description: tool.description,
inputSchema: tool.inputSchema,
},
async (args) => {
const result = await tool.handler(args as Record<string, any>);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
},
);
}
}
// Set up resources
if (this.config.resources && this.config.resources.length > 0) {
for (const resource of this.config.resources) {
this.mcpServer.registerResource(
resource.name,
resource.uri,
{
description: resource.description,
mimeType: resource.mimeType,
},
async () => {
return {
contents: [
{
uri: resource.uri,
mimeType: resource.mimeType || "text/plain",
text: resource.text || "",
},
],
};
},
);
}
}
// Set up prompts
if (this.config.prompts && this.config.prompts.length > 0) {
for (const prompt of this.config.prompts) {
this.mcpServer.registerPrompt(
prompt.name,
{
description: prompt.description,
argsSchema: prompt.argsSchema,
},
async (args) => {
// Return a simple prompt response
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Prompt: ${prompt.name}${args ? ` with args: ${JSON.stringify(args)}` : ""}`,
},
},
],
};
},
);
}
}
}
private setupLoggingHandler() {
// Intercept logging/setLevel requests to track the level
this.mcpServer.server.setRequestHandler(
SetLevelRequestSchema,
async (request) => {
this.currentLogLevel = request.params.level;
// Return empty result as per MCP spec
return {};
},
);
}
/**
* Start the server with the specified transport
*/
async start(
transport: "http" | "sse",
requestedPort?: number,
): Promise<number> {
const port = requestedPort
? await findAvailablePort(requestedPort)
: await findAvailablePort(transport === "http" ? 3001 : 3000);
this.url = `http://localhost:${port}`;
if (transport === "http") {
return this.startHttp(port);
} else {
return this.startSse(port);
}
}
private async startHttp(port: number): Promise<number> {
const app = express();
app.use(express.json());
// Create HTTP server
this.httpServer = createHttpServer(app);
// Create StreamableHTTP transport
this.transport = new StreamableHTTPServerTransport({});
// Set up Express route to handle MCP requests
app.post("/mcp", async (req: Request, res: Response) => {
// Capture headers for this request
this.currentRequestHeaders = extractHeaders(req);
try {
await (this.transport as StreamableHTTPServerTransport).handleRequest(
req,
res,
req.body,
);
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
});
// Intercept messages to record them
const originalOnMessage = this.transport.onmessage;
this.transport.onmessage = async (message) => {
const timestamp = Date.now();
const method =
"method" in message && typeof message.method === "string"
? message.method
: "unknown";
const params = "params" in message ? message.params : undefined;
try {
// Extract metadata from params if present
const metadata =
params && typeof params === "object" && "_meta" in params
? ((params as any)._meta as Record<string, string>)
: undefined;
// Let the server handle the message
if (originalOnMessage) {
await originalOnMessage.call(this.transport, message);
}
// Record successful request (response will be sent by transport)
// Note: We can't easily capture the response here, so we'll record
// that the request was processed
this.recordedRequests.push({
method,
params,
headers: { ...this.currentRequestHeaders },
metadata: metadata ? { ...metadata } : undefined,
response: { processed: true },
timestamp,
});
} catch (error) {
// Extract metadata from params if present
const metadata =
params && typeof params === "object" && "_meta" in params
? ((params as any)._meta as Record<string, string>)
: undefined;
// Record error
this.recordedRequests.push({
method,
params,
headers: { ...this.currentRequestHeaders },
metadata: metadata ? { ...metadata } : undefined,
response: {
error: error instanceof Error ? error.message : String(error),
},
timestamp,
});
throw error;
}
};
// Connect transport to server
await this.mcpServer.connect(this.transport);
// Start listening
return new Promise((resolve, reject) => {
this.httpServer!.listen(port, () => {
resolve(port);
});
this.httpServer!.on("error", reject);
});
}
private async startSse(port: number): Promise<number> {
const app = express();
app.use(express.json());
// Create HTTP server
this.httpServer = createHttpServer(app);
// For SSE, we need to set up an Express route that creates the transport per request
// This is a simplified version - SSE transport is created per connection
app.get("/mcp", async (req: Request, res: Response) => {
this.currentRequestHeaders = extractHeaders(req);
const sseTransport = new SSEServerTransport("/mcp", res);
// Intercept messages
const originalOnMessage = sseTransport.onmessage;
sseTransport.onmessage = async (message) => {
const timestamp = Date.now();
const method =
"method" in message && typeof message.method === "string"
? message.method
: "unknown";
const params = "params" in message ? message.params : undefined;
try {
// Extract metadata from params if present
const metadata =
params && typeof params === "object" && "_meta" in params
? ((params as any)._meta as Record<string, string>)
: undefined;
if (originalOnMessage) {
await originalOnMessage.call(sseTransport, message);
}
this.recordedRequests.push({
method,
params,
headers: { ...this.currentRequestHeaders },
metadata: metadata ? { ...metadata } : undefined,
response: { processed: true },
timestamp,
});
} catch (error) {
// Extract metadata from params if present
const metadata =
params && typeof params === "object" && "_meta" in params
? ((params as any)._meta as Record<string, string>)
: undefined;
this.recordedRequests.push({
method,
params,
headers: { ...this.currentRequestHeaders },
metadata: metadata ? { ...metadata } : undefined,
response: {
error: error instanceof Error ? error.message : String(error),
},
timestamp,
});
throw error;
}
};
await this.mcpServer.connect(sseTransport);
await sseTransport.start();
});
// Note: SSE transport is created per request, so we don't store a single instance
this.transport = undefined;
// Start listening
return new Promise((resolve, reject) => {
this.httpServer!.listen(port, () => {
resolve(port);
});
this.httpServer!.on("error", reject);
});
}
/**
* Stop the server
*/
async stop(): Promise<void> {
await this.mcpServer.close();
if (this.transport) {
await this.transport.close();
this.transport = undefined;
}
if (this.httpServer) {
return new Promise((resolve) => {
// Force close all connections
this.httpServer!.closeAllConnections?.();
this.httpServer!.close(() => {
this.httpServer = undefined;
resolve();
});
});
}
}
/**
* Get all recorded requests
*/
getRecordedRequests(): RecordedRequest[] {
return [...this.recordedRequests];
}
/**
* Clear recorded requests
*/
clearRecordings(): void {
this.recordedRequests = [];
}
/**
* Get the server URL
*/
getUrl(): string {
if (!this.url) {
throw new Error("Server not started");
}
return this.url;
}
/**
* Get the most recent log level that was set
*/
getCurrentLogLevel(): string | null {
return this.currentLogLevel;
}
}
/**
* Create an HTTP/SSE MCP test server
*/
export function createTestServerHttp(config: ServerConfig): TestServerHttp {
return new TestServerHttp(config);
}

View file

@ -0,0 +1,241 @@
#!/usr/bin/env node
/**
* Test MCP server for stdio transport testing
* Can be used programmatically or run as a standalone executable
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import * as z from "zod/v4";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import type {
ServerConfig,
ToolDefinition,
PromptDefinition,
ResourceDefinition,
} from "./test-fixtures.js";
import { getDefaultServerConfig } from "./test-fixtures.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export class TestServerStdio {
private mcpServer: McpServer;
private config: ServerConfig;
private transport?: StdioServerTransport;
constructor(config: ServerConfig) {
this.config = config;
const capabilities: {
tools?: {};
resources?: {};
prompts?: {};
logging?: {};
} = {};
// Only include capabilities for features that are present in config
if (config.tools !== undefined) {
capabilities.tools = {};
}
if (config.resources !== undefined) {
capabilities.resources = {};
}
if (config.prompts !== undefined) {
capabilities.prompts = {};
}
if (config.logging === true) {
capabilities.logging = {};
}
this.mcpServer = new McpServer(config.serverInfo, {
capabilities,
});
this.setupHandlers();
}
private setupHandlers() {
// Set up tools
if (this.config.tools && this.config.tools.length > 0) {
for (const tool of this.config.tools) {
this.mcpServer.registerTool(
tool.name,
{
description: tool.description,
inputSchema: tool.inputSchema,
},
async (args) => {
const result = await tool.handler(args as Record<string, any>);
// If handler returns content array directly (like get-annotated-message), use it
if (result && Array.isArray(result.content)) {
return { content: result.content };
}
// If handler returns message (like echo), format it
if (result && typeof result.message === "string") {
return {
content: [
{
type: "text",
text: result.message,
},
],
};
}
// Otherwise, stringify the result
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
};
},
);
}
}
// Set up resources
if (this.config.resources && this.config.resources.length > 0) {
for (const resource of this.config.resources) {
this.mcpServer.registerResource(
resource.name,
resource.uri,
{
description: resource.description,
mimeType: resource.mimeType,
},
async () => {
// For dynamic resources, get fresh text
let text = resource.text;
if (resource.name === "test-cwd") {
text = process.cwd();
} else if (resource.name === "test-env") {
text = JSON.stringify(process.env, null, 2);
} else if (resource.name === "test-argv") {
text = JSON.stringify(process.argv, null, 2);
}
return {
contents: [
{
uri: resource.uri,
mimeType: resource.mimeType || "text/plain",
text: text || "",
},
],
};
},
);
}
}
// Set up prompts
if (this.config.prompts && this.config.prompts.length > 0) {
for (const prompt of this.config.prompts) {
this.mcpServer.registerPrompt(
prompt.name,
{
description: prompt.description,
argsSchema: prompt.argsSchema,
},
async (args) => {
if (prompt.name === "args-prompt" && args) {
const city = (args as any).city as string;
const state = (args as any).state as string;
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `This is a prompt with arguments: city=${city}, state=${state}`,
},
},
],
};
} else {
return {
messages: [
{
role: "user",
content: {
type: "text",
text: "This is a simple prompt for testing purposes.",
},
},
],
};
}
},
);
}
}
}
/**
* Start the server with stdio transport
*/
async start(): Promise<void> {
this.transport = new StdioServerTransport();
await this.mcpServer.connect(this.transport);
}
/**
* Stop the server
*/
async stop(): Promise<void> {
await this.mcpServer.close();
if (this.transport) {
await this.transport.close();
this.transport = undefined;
}
}
}
/**
* Create a stdio MCP test server
*/
export function createTestServerStdio(config: ServerConfig): TestServerStdio {
return new TestServerStdio(config);
}
/**
* Get the path to the test MCP server script
*/
export function getTestMcpServerPath(): string {
return path.resolve(__dirname, "test-server-stdio.ts");
}
/**
* Get the command and args to run the test MCP server
*/
export function getTestMcpServerCommand(): { command: string; args: string[] } {
return {
command: "tsx",
args: [getTestMcpServerPath()],
};
}
// If run as a standalone script, start with default config
// Check if this file is being executed directly (not imported)
const isMainModule =
import.meta.url.endsWith(process.argv[1]) ||
process.argv[1]?.endsWith("test-server-stdio.ts") ||
process.argv[1]?.endsWith("test-server-stdio.js");
if (isMainModule) {
const server = new TestServerStdio(getDefaultServerConfig());
server
.start()
.then(() => {
// Server is now running and listening on stdio
// Keep the process alive
})
.catch((error) => {
console.error("Failed to start test MCP server:", error);
process.exit(1);
});
}

Some files were not shown because too many files have changed in this diff Show more