Merge branch 'main' into fix/enum-anyof-ref-resolution
This commit is contained in:
commit
442638f4c1
71 changed files with 16245 additions and 8744 deletions
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -34,3 +34,7 @@ If applicable, add screenshots to help explain your problem.
|
|||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Version Consideration**
|
||||
|
||||
Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](../../CONTRIBUTING.md) for more details.
|
||||
|
|
|
|||
57
.github/pull_request_template.md
vendored
Normal file
57
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
## Summary
|
||||
|
||||
<!-- Provide a brief description of what this PR does -->
|
||||
|
||||
> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](../CONTRIBUTING.md) for more details.
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!-- Mark the relevant option with an "x" -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Documentation update
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] Test updates
|
||||
- [ ] Build/CI improvements
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!-- Describe the changes in detail. Include screenshots/recordings if applicable -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link to related issues using #issue_number or "Fixes #issue_number" -->
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Describe how you tested these changes, where applicable -->
|
||||
|
||||
- [ ] Tested in UI mode
|
||||
- [ ] Tested in CLI mode
|
||||
- [ ] Tested with STDIO transport
|
||||
- [ ] Tested with SSE transport
|
||||
- [ ] Tested with Streamable HTTP transport
|
||||
- [ ] Added/updated automated tests
|
||||
- [ ] Manual testing performed
|
||||
|
||||
### Test Results and/or Instructions
|
||||
|
||||
<!-- Provide steps for reviewers to test your changes -->
|
||||
|
||||
Screenshots are encouraged to share your testing results for this change.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code follows the style guidelines (ran `npm run prettier-fix`)
|
||||
- [ ] Self-review completed
|
||||
- [ ] Code is commented where necessary
|
||||
- [ ] Documentation updated (README, comments, etc.)
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
<!-- If this is a breaking change, describe the impact and migration path -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Add any other context, screenshots, or information about the PR here -->
|
||||
50
.github/workflows/claude.yml
vendored
50
.github/workflows/claude.yml
vendored
|
|
@ -27,14 +27,48 @@ jobs:
|
|||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Get PR details
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request) ||
|
||||
github.event_name == 'pull_request_review_comment' ||
|
||||
github.event_name == 'pull_request_review'
|
||||
id: pr
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
let prNumber;
|
||||
if (context.eventName === 'issue_comment') {
|
||||
prNumber = context.issue.number;
|
||||
} else {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
}
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
core.setOutput('sha', pr.data.head.sha);
|
||||
core.setOutput('repo', pr.data.head.repo.full_name);
|
||||
|
||||
- name: Checkout PR branch
|
||||
if: steps.pr.outcome == 'success'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.pr.outcome != 'success'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
|
|
@ -45,9 +79,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."
|
||||
|
|
|
|||
3
.github/workflows/cli_tests.yml
vendored
3
.github/workflows/cli_tests.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/e2e_tests.yml
vendored
2
.github/workflows/e2e_tests.yml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
|||
|
||||
jobs:
|
||||
test:
|
||||
# Installing Playright dependencies can take quite awhile, and also depends on GitHub CI load.
|
||||
# Installing Playwright dependencies can take quite awhile, and also depends on GitHub CI load.
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ client/tsconfig.node.tsbuildinfo
|
|||
cli/build
|
||||
test-output
|
||||
tool-test-output
|
||||
metadata-test-output
|
||||
# symlinked by `npm run link:sdk`:
|
||||
sdk
|
||||
client/playwright-report/
|
||||
|
|
|
|||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"mcp-docs": {
|
||||
"type": "http",
|
||||
"url": "https://modelcontextprotocol.io/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
# MCP Inspector Development Guide
|
||||
|
||||
> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
|
||||
|
||||
## Build Commands
|
||||
|
||||
- Build all: `npm run build`
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ Thanks for your interest in contributing! This guide explains how to get involve
|
|||
3. Run `npm run dev` to start both client and server in development mode
|
||||
4. Use the web UI at http://localhost:6274 to interact with the inspector
|
||||
|
||||
## Inspector V2 Development
|
||||
|
||||
We're actively developing **Inspector V2** to address architectural and UX improvements. We invite you to follow progress and participate in the Inspector V2 Working Group in [Discord](https://modelcontextprotocol.io/community/communication), [weekly meetings](https://meet.modelcontextprotocol.io/tag/inspector-v2-wg), and [GitHub Discussions](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/categories/meeting-notes-other) (where notes are posted after meetings).
|
||||
|
||||
**Current version (V1) contribution scope:**
|
||||
|
||||
- Bug fixes and MCP spec compliance are actively maintained
|
||||
- Documentation updates are always appreciated
|
||||
- Major changes will be directed to V2 development
|
||||
|
||||
## Development Process & Pull Requests
|
||||
|
||||
1. Create a new branch for your changes
|
||||
|
|
@ -30,7 +40,7 @@ If you find a security vulnerability, please refer to our [Security Policy](SECU
|
|||
|
||||
## Questions?
|
||||
|
||||
Feel free to [open an issue](https://github.com/modelcontextprotocol/mcp-inspector/issues) for questions or create a discussion for general topics.
|
||||
Feel free to [open an issue](https://github.com/modelcontextprotocol/inspector/issues) for questions or join the MCP Contributor [Discord server](https://modelcontextprotocol.io/community/communication). Also, please see notes above on Inspector V2 Development.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
197
LICENSE
197
LICENSE
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
44
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
|
||||
871
cli/__tests__/cli.test.ts
Normal file
871
cli/__tests__/cli.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
210
cli/__tests__/headers.test.ts
Normal file
210
cli/__tests__/headers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
52
cli/__tests__/helpers/assertions.ts
Normal file
52
cli/__tests__/helpers/assertions.ts
Normal 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;
|
||||
}
|
||||
98
cli/__tests__/helpers/cli-runner.ts
Normal file
98
cli/__tests__/helpers/cli-runner.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
89
cli/__tests__/helpers/fixtures.ts
Normal file
89
cli/__tests__/helpers/fixtures.ts
Normal 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));
|
||||
}
|
||||
267
cli/__tests__/helpers/test-fixtures.ts
Normal file
267
cli/__tests__/helpers/test-fixtures.ts
Normal 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(),
|
||||
],
|
||||
};
|
||||
}
|
||||
443
cli/__tests__/helpers/test-server-http.ts
Normal file
443
cli/__tests__/helpers/test-server-http.ts
Normal 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);
|
||||
}
|
||||
241
cli/__tests__/helpers/test-server-stdio.ts
Normal file
241
cli/__tests__/helpers/test-server-stdio.ts
Normal 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
Loading…
Reference in a new issue