Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73f99efbf |
||
|
|
1ec570c27d |
||
|
|
f41b666edf |
||
|
|
c3d8e43d1f |
||
|
|
abbbddb2e3 |
||
|
|
649af5856e |
||
|
|
242751e8ee |
||
|
|
ba167cef5b |
||
|
|
48723c95dc |
||
|
|
96c49c7364 |
||
|
|
cce08de417 |
||
|
|
2e3e4c7ab7 |
||
|
|
3aa19f5574 |
||
|
|
579c3903f3 |
||
|
|
a6cdbf4dea |
||
|
|
ebaccfd623 |
||
|
|
cda92bdaac |
||
|
|
d6402efee6 |
||
|
|
40a8de87ed |
||
|
|
11845d51cb |
||
|
|
cf05b37694 |
||
|
|
642a9112e7 |
||
|
|
18ce197639 |
||
|
|
562feeb281 |
||
|
|
71e3cfbd1e |
||
|
|
781a93db14 |
||
|
|
8102fbe27a |
||
|
|
599dafc105 |
||
|
|
d811ba11f1 | ||
|
|
53dbe83b7a | ||
|
|
76d64c822f |
||
|
|
1cd1096d29 |
||
|
|
d8cff7f016 |
||
|
|
7112282ce2 |
||
|
|
76639ead0c |
||
|
|
3bcfedd8e4 |
||
|
|
fd8248e56b |
||
|
|
ced1408d3a |
||
|
|
20b5783d3b |
||
|
|
4852bb3fc4 |
||
|
|
10962d3114 |
||
|
|
7bdcdb6a85 |
||
|
|
fab6abed58 |
||
|
|
2e15194d4e |
||
|
|
42a1d825b1 |
||
|
|
40ce2f784e |
||
|
|
31bd12967f |
||
|
|
86b9438e0e |
||
|
|
63df72cb15 | ||
|
|
e73b9a2f9a |
50 changed files with 2174 additions and 767 deletions
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
10
.github/workflows/python.yml
vendored
10
.github/workflows/python.yml
vendored
|
|
@ -36,10 +36,10 @@ jobs:
|
|||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version-file: "src/${{ matrix.package }}/.python-version"
|
||||
|
||||
|
|
@ -73,10 +73,10 @@ jobs:
|
|||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version-file: "src/${{ matrix.package }}/.python-version"
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
run: uv build
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist-${{ matrix.package }}
|
||||
path: src/${{ matrix.package }}/dist/
|
||||
|
|
|
|||
7
.github/workflows/readme-pr-check.yml
vendored
7
.github/workflows/readme-pr-check.yml
vendored
|
|
@ -1,7 +1,10 @@
|
|||
name: README PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# pull_request_target, not pull_request: fork PRs get a read-only token under
|
||||
# pull_request, so the label and comment calls fail with 403. This job only
|
||||
# calls the API and never checks out PR code, so the write token is safe.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
paths:
|
||||
- 'README.md'
|
||||
|
|
@ -10,7 +13,7 @@ on:
|
|||
|
||||
jobs:
|
||||
check-readme-only:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
echo "Using last release hash: ${HASH}"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Create version name
|
||||
id: create-version
|
||||
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
cat RELEASE_NOTES.md
|
||||
|
||||
- name: Release notes
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-notes
|
||||
path: RELEASE_NOTES.md
|
||||
|
|
@ -77,7 +77,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Update packages
|
||||
run: |
|
||||
|
|
@ -121,10 +121,10 @@ jobs:
|
|||
ref: ${{ needs.create-metadata.outputs.version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version-file: "src/${{ matrix.package }}/.python-version"
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ jobs:
|
|||
with:
|
||||
ref: ${{ needs.create-metadata.outputs.version }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
@ -228,7 +228,7 @@ jobs:
|
|||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download release notes
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-notes
|
||||
|
||||
|
|
|
|||
4
.github/workflows/typescript.yml
vendored
4
.github/workflows/typescript.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ These servers aim to demonstrate MCP features and the official SDKs.
|
|||
The following reference servers are now archived and can be found at [servers-archived](https://github.com/modelcontextprotocol/servers-archived).
|
||||
|
||||
- **[AWS KB Retrieval](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/aws-kb-retrieval-server)** - Retrieval from AWS Knowledge Base using Bedrock Agent Runtime.
|
||||
- **[Brave Search](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search)** - Web and local search using Brave's Search API. Has been replaced by the [official server](https://github.com/brave/brave-search-mcp-server).
|
||||
- **[Brave Search](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search)** - Web and local search using Brave's Search API. Has been replaced by the [official server](https://github.com/brave/brave-search-mcp-server) ([`@brave/brave-search-mcp-server`](https://www.npmjs.com/package/@brave/brave-search-mcp-server)).
|
||||
- **[EverArt](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/everart)** - AI image generation using various models.
|
||||
- **[GitHub](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github)** - Repository management, file operations, and GitHub API integration.
|
||||
- **[GitLab](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/gitlab)** - GitLab API, enabling project management.
|
||||
|
|
|
|||
1012
package-lock.json
generated
1012
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -113,7 +113,7 @@ npm run start:streamableHttp
|
|||
### Install
|
||||
```shell
|
||||
npm install -g @modelcontextprotocol/server-everything@latest
|
||||
````
|
||||
```
|
||||
|
||||
### Run the default (stdio) server
|
||||
```shell
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
- `prompts: {}`
|
||||
- `resources: { subscribe: true }`
|
||||
- **Server Instructions**
|
||||
- Loaded from the docs folder (`server-instructions.md`).
|
||||
- Loaded from the docs folder (`instructions.md`).
|
||||
- **Registrations**
|
||||
- Registers **tools** via `registerTools(server)`.
|
||||
- Registers **resources** via `registerResources(server)`.
|
||||
|
|
|
|||
|
|
@ -85,9 +85,19 @@ src/everything
|
|||
### `docs/`
|
||||
|
||||
- `architecture.md`
|
||||
- This document.
|
||||
- Describes the server architecture, including transport layers, session initialization, and primitive registration.
|
||||
- `extension.md`
|
||||
- Explains how to extend the server with new tools, prompts, resources, or transports.
|
||||
- `features.md`
|
||||
- A comprehensive reference listing all implemented prompts, resources, tools, and protocol behaviors.
|
||||
- `how-it-works.md`
|
||||
- Explains how SSE and Streamable HTTP transports are set up and how sessions are managed.
|
||||
- `instructions.md`
|
||||
- Human‑readable instructions intended to be passed to the client/LLM as guidance on server use. Loaded by the server at startup and returned in the initialize exchange.
|
||||
- `startup.md`
|
||||
- Describes the server startup sequence, environment checks, and initialization.
|
||||
- `structure.md`
|
||||
- This document.
|
||||
|
||||
### `prompts/`
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
"@types/express": "^5.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"prettier": "^2.8.8",
|
||||
"shx": "^0.3.4",
|
||||
"shx": "^0.4.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,10 +99,9 @@ export const setSubscriptionHandlers = (server: McpServer) => {
|
|||
/**
|
||||
* Sends simulated resource update notifications to the subscribed client.
|
||||
*
|
||||
* This function iterates through all resource URIs stored in the subscriptions
|
||||
* and checks if the specified session ID is subscribed to them. If so, it sends
|
||||
* a notification through the provided server. If the session ID is no longer valid
|
||||
* (disconnected), it removes the session ID from the list of subscribers.
|
||||
* Iterates the URIs in `subscriptions` and emits a
|
||||
* `notifications/resources/updated` notification for each URI the given
|
||||
* sessionId is subscribed to.
|
||||
*
|
||||
* @param {McpServer} server - The server instance used to send notifications.
|
||||
* @param {string | undefined} sessionId - The session ID of the client to check for subscriptions.
|
||||
|
|
@ -122,8 +121,6 @@ const sendSimulatedResourceUpdates = async (
|
|||
method: "notifications/resources/updated",
|
||||
params: { uri },
|
||||
});
|
||||
} else {
|
||||
subscribers.delete(sessionId); // subscriber has disconnected
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -127,31 +127,26 @@ export const blobResourceUri = (resourceId: number) =>
|
|||
new URL(`${blobUriBase}/${resourceId}`);
|
||||
|
||||
/**
|
||||
* Parses the resource identifier from the provided URI and validates it
|
||||
* against the given variables. Throws an error if the URI corresponds
|
||||
* to an unknown resource or if the resource identifier is invalid.
|
||||
* Parses the resource identifier from the provided variables and validates
|
||||
* that it is a positive integer.
|
||||
*
|
||||
* @param {URL} uri - The URI of the resource to be parsed.
|
||||
* @param {Record<string, unknown>} variables - A record containing context-specific variables that include the resourceId.
|
||||
* @returns {number} The parsed and validated resource identifier as an integer.
|
||||
* @throws {Error} Throws an error if the URI matches unsupported base URIs or if the resourceId is invalid.
|
||||
* The SDK only routes URIs that match the registered template to the
|
||||
* resource handler, so by the time we get here the URI prefix is already
|
||||
* known to be one of `textUriBase` / `blobUriBase`. Only the resourceId
|
||||
* variable still needs validating.
|
||||
*
|
||||
* @param {URL} uri - The URI of the resource (used in the error message).
|
||||
* @param {Record<string, unknown>} variables - Context variables including resourceId.
|
||||
* @returns {number} The parsed and validated resource identifier as a positive integer.
|
||||
* @throws {Error} If the resourceId is not a finite positive integer.
|
||||
*/
|
||||
const parseResourceId = (uri: URL, variables: Record<string, unknown>) => {
|
||||
const uriError = `Unknown resource: ${uri.toString()}`;
|
||||
if (
|
||||
uri.toString().startsWith(textUriBase) &&
|
||||
uri.toString().startsWith(blobUriBase)
|
||||
) {
|
||||
throw new Error(uriError);
|
||||
} else {
|
||||
const idxStr = String((variables as any).resourceId ?? "");
|
||||
const idx = Number(idxStr);
|
||||
if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) {
|
||||
return idx;
|
||||
} else {
|
||||
throw new Error(uriError);
|
||||
}
|
||||
const idxStr = String((variables as any).resourceId ?? "");
|
||||
const idx = Number(idxStr);
|
||||
if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) {
|
||||
return idx;
|
||||
}
|
||||
throw new Error(`Unknown resource: ${uri.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ ENV UV_COMPILE_BYTECODE=1
|
|||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Install the project's dependencies using the lockfile and settings
|
||||
COPY uv.lock pyproject.toml ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev --no-editable
|
||||
|
||||
# Then, add the rest of the project source code and install it
|
||||
|
|
@ -27,6 +26,10 @@ FROM python:3.12-slim-bookworm
|
|||
WORKDIR /app
|
||||
|
||||
COPY --from=uv /root/.local /root/.local
|
||||
|
||||
RUN if ! id -u app >/dev/null 2>&1; then \
|
||||
useradd -rUM -s /usr/sbin/nologin app; \
|
||||
fi
|
||||
COPY --from=uv --chown=app:app /app/.venv /app/.venv
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ A Model Context Protocol server that provides web content fetching capabilities.
|
|||
|
||||
Source: https://github.com/modelcontextprotocol/servers/tree/main/src/fetch
|
||||
|
||||
Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server uses. The port to v2 is in progress.
|
||||
|
||||
> [!CAUTION]
|
||||
> This server can access local/internal IP addresses and may represent a security risk. Exercise caution when using this MCP server to ensure this does not expose any sensitive data.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ classifiers = [
|
|||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
"markdownify>=0.13.1",
|
||||
"mcp>=1.1.3",
|
||||
"mcp>=1.29.0,<2",
|
||||
"protego>=0.3.1",
|
||||
"pydantic>=2.0.0",
|
||||
"readabilipy>=0.2.0",
|
||||
|
|
|
|||
20
src/fetch/uv.lock
generated
20
src/fetch/uv.lock
generated
|
|
@ -1,6 +1,12 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version < '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.14' and sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
|
|
@ -533,7 +539,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.23.0"
|
||||
version = "1.29.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -551,9 +557,9 @@ dependencies = [
|
|||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/1a/9c8a5362e3448d585081d6c7aa95898a64e0ac59d3e26169ae6c3ca5feaf/mcp-1.23.0.tar.gz", hash = "sha256:84e0c29316d0a8cf0affd196fd000487ac512aa3f771b63b2ea864e22961772b", size = 596506, upload-time = "2025-12-02T13:40:02.558Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/b2/28739ce409f98159c0121eab56e69ad71546c4f34ac8b42e58c03f57dccc/mcp-1.23.0-py3-none-any.whl", hash = "sha256:5a645cf111ed329f4619f2629a3f15d9aabd7adc2ea09d600d31467b51ecb64f", size = 231427, upload-time = "2025-12-02T13:40:00.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -582,7 +588,7 @@ dev = [
|
|||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "markdownify", specifier = ">=0.13.1" },
|
||||
{ name = "mcp", specifier = ">=1.1.3" },
|
||||
{ name = "mcp", specifier = ">=1.29.0,<2" },
|
||||
{ name = "protego", specifier = ">=0.3.1" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "readabilipy", specifier = ">=0.2.0" },
|
||||
|
|
@ -1179,11 +1185,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.6"
|
||||
version = "2.8.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569, upload-time = "2024-08-13T13:39:12.166Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186, upload-time = "2024-08-13T13:39:10.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
57
src/filesystem/__tests__/directory-tree.mcp-sdk.test.ts
Normal file
57
src/filesystem/__tests__/directory-tree.mcp-sdk.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
describe('directory_tree MCP SDK regression', () => {
|
||||
let client: Client;
|
||||
let transport: StdioClientTransport;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-tree-'));
|
||||
testDir = await fs.realpath(tmp);
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'root.txt'), 'root');
|
||||
await fs.mkdir(path.join(testDir, 'nested'));
|
||||
await fs.writeFile(path.join(testDir, 'nested', 'child.txt'), 'child');
|
||||
|
||||
const serverPath = path.resolve(__dirname, '../dist/index.js');
|
||||
transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [serverPath, testDir],
|
||||
});
|
||||
|
||||
client = new Client(
|
||||
{ name: 'directory-tree-regression-test', version: '1.0.0' },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client?.close();
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns structuredContent.content as a string (not an array) when called via MCP SDK', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'directory_tree',
|
||||
arguments: { path: testDir },
|
||||
});
|
||||
|
||||
// Regression test for issues where structuredContent was returned as an array,
|
||||
// which causes MCP SDK validation to throw -32602 (invalid structured content).
|
||||
const structured = result.structuredContent as { content: unknown };
|
||||
expect(structured).toBeDefined();
|
||||
expect(typeof structured.content).toBe('string');
|
||||
expect(Array.isArray(structured.content)).toBe(false);
|
||||
|
||||
const parsed = JSON.parse(structured.content as string);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
getFileStats,
|
||||
readFileContent,
|
||||
writeFileContent,
|
||||
moveFile,
|
||||
// Search & filtering functions
|
||||
searchFilesWithValidation,
|
||||
// File editing functions
|
||||
|
|
@ -26,6 +27,23 @@ import {
|
|||
vi.mock('fs/promises');
|
||||
const mockFs = fs as any;
|
||||
|
||||
function createMockFileHandle(content: Buffer) {
|
||||
return {
|
||||
read: vi.fn(
|
||||
async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const bytesRead = content.copy(
|
||||
buffer,
|
||||
offset,
|
||||
position,
|
||||
Math.min(position + length, content.length),
|
||||
);
|
||||
return { bytesRead, buffer };
|
||||
},
|
||||
),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Lib Functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -65,8 +83,10 @@ describe('Lib Functions', () => {
|
|||
});
|
||||
|
||||
it('handles negative numbers', () => {
|
||||
// Negative numbers will result in NaN for the log calculation
|
||||
expect(formatSize(-1024)).toContain('NaN');
|
||||
// Negative numbers should return '0 B' as file sizes cannot be negative
|
||||
expect(formatSize(-1)).toBe('0 B');
|
||||
expect(formatSize(-1024)).toBe('0 B');
|
||||
expect(formatSize(-1000000)).toBe('0 B');
|
||||
expect(formatSize(-0)).toBe('0 B');
|
||||
});
|
||||
|
||||
|
|
@ -153,6 +173,13 @@ describe('Lib Functions', () => {
|
|||
|
||||
describe('Security & Validation Functions', () => {
|
||||
describe('validatePath', () => {
|
||||
it('rejects Windows drive paths on POSIX hosts', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
await expect(validatePath('C:\\Users\\me\\notes\\file.md'))
|
||||
.rejects.toThrow('Windows-style path received on a POSIX host');
|
||||
});
|
||||
|
||||
// Use Windows-compatible paths for testing
|
||||
const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp'] : ['/home/user', '/tmp'];
|
||||
|
||||
|
|
@ -188,19 +215,34 @@ describe('Lib Functions', () => {
|
|||
expect(result).toBe(path.resolve(newFilePath));
|
||||
});
|
||||
|
||||
it('rejects when parent directory does not exist', async () => {
|
||||
it('walks past multiple missing ancestors to an existing allowed directory', async () => {
|
||||
// e.g. create_directory('/home/user/nonexistent/nested/newfile.txt') when
|
||||
// neither 'nonexistent' nor 'nonexistent/nested' exist yet, but '/home/user'
|
||||
// (an allowed directory) does. Regression test for #4629.
|
||||
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\nested\\newfile.txt' : '/home/user/nonexistent/nested/newfile.txt';
|
||||
|
||||
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
// The path itself is missing. The resolver then realpaths the allowed
|
||||
// directory ('/home/user'), which falls through to the beforeEach base
|
||||
// implementation and echoes the path back as-is. readdir is mocked to
|
||||
// undefined, so every component below it counts as missing.
|
||||
mockFs.realpath.mockRejectedValueOnce(enoentError);
|
||||
|
||||
const result = await validatePath(newFilePath);
|
||||
expect(result).toBe(path.resolve(newFilePath));
|
||||
});
|
||||
|
||||
it('rejects when no ancestor directory exists at all', async () => {
|
||||
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt';
|
||||
|
||||
// Create errors with the ENOENT code
|
||||
const enoentError1 = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError1.code = 'ENOENT';
|
||||
const enoentError2 = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError2.code = 'ENOENT';
|
||||
|
||||
mockFs.realpath
|
||||
.mockRejectedValueOnce(enoentError1)
|
||||
.mockRejectedValueOnce(enoentError2);
|
||||
|
||||
|
||||
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
// Every ancestor, all the way up to the filesystem root, is missing.
|
||||
mockFs.realpath.mockRejectedValue(enoentError);
|
||||
|
||||
await expect(validatePath(newFilePath))
|
||||
.rejects.toThrow('Parent directory does not exist');
|
||||
});
|
||||
|
|
@ -308,6 +350,56 @@ describe('Lib Functions', () => {
|
|||
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' });
|
||||
});
|
||||
|
||||
it('preserves file permissions when overwriting existing file', async () => {
|
||||
// First writeFile call with 'wx' flag fails because file exists
|
||||
mockFs.writeFile.mockRejectedValueOnce(Object.assign(new Error('EEXIST'), { code: 'EEXIST' }));
|
||||
// stat returns executable permissions
|
||||
mockFs.stat.mockResolvedValueOnce({ mode: 0o100755 });
|
||||
// Second writeFile (to temp) succeeds
|
||||
mockFs.writeFile.mockResolvedValueOnce(undefined);
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
mockFs.chmod.mockResolvedValueOnce(undefined);
|
||||
|
||||
await writeFileContent('/test/script.sh', 'new content');
|
||||
|
||||
expect(mockFs.stat).toHaveBeenCalledWith('/test/script.sh');
|
||||
expect(mockFs.chmod).toHaveBeenCalledWith('/test/script.sh', 0o755);
|
||||
});
|
||||
|
||||
it('does not fail the write when chmod fails', async () => {
|
||||
mockFs.writeFile.mockRejectedValueOnce(Object.assign(new Error('EEXIST'), { code: 'EEXIST' }));
|
||||
mockFs.stat.mockResolvedValueOnce({ mode: 0o100755 });
|
||||
mockFs.writeFile.mockResolvedValueOnce(undefined);
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
mockFs.chmod.mockRejectedValueOnce(Object.assign(new Error('EPERM'), { code: 'EPERM' }));
|
||||
|
||||
await expect(writeFileContent('/test/script.sh', 'new content')).resolves.toBeUndefined();
|
||||
expect(mockFs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveFile', () => {
|
||||
it('moves the file when the destination does not exist', async () => {
|
||||
const enoent = Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
mockFs.lstat.mockRejectedValueOnce(enoent);
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
await moveFile('/test/source.txt', '/test/dest.txt');
|
||||
|
||||
expect(mockFs.rename).toHaveBeenCalledWith('/test/source.txt', '/test/dest.txt');
|
||||
});
|
||||
|
||||
it('fails without overwriting when the destination already exists', async () => {
|
||||
// lstat resolving means the destination is occupied.
|
||||
mockFs.lstat.mockResolvedValueOnce({} as any);
|
||||
|
||||
await expect(moveFile('/test/source.txt', '/test/dest.txt')).rejects.toThrow(
|
||||
'Destination already exists'
|
||||
);
|
||||
|
||||
expect(mockFs.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -416,6 +508,8 @@ describe('Lib Functions', () => {
|
|||
beforeEach(() => {
|
||||
mockFs.readFile.mockResolvedValue('line1\nline2\nline3\n');
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.stat.mockResolvedValue({ mode: 0o100644 });
|
||||
mockFs.chmod.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('applies simple text replacement', async () => {
|
||||
|
|
@ -510,6 +604,30 @@ describe('Lib Functions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('preserves file permissions after applying edits', async () => {
|
||||
mockFs.stat.mockResolvedValue({ mode: 0o100755 });
|
||||
const edits = [
|
||||
{ oldText: 'line2', newText: 'modified line2' }
|
||||
];
|
||||
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
await applyFileEdits('/test/script.sh', edits, false);
|
||||
|
||||
expect(mockFs.stat).toHaveBeenCalledWith('/test/script.sh');
|
||||
expect(mockFs.chmod).toHaveBeenCalledWith('/test/script.sh', 0o755);
|
||||
});
|
||||
|
||||
it('does not restore permissions in dry run mode', async () => {
|
||||
const edits = [
|
||||
{ oldText: 'line2', newText: 'modified line2' }
|
||||
];
|
||||
|
||||
await applyFileEdits('/test/file.txt', edits, true);
|
||||
|
||||
expect(mockFs.chmod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws error for non-matching edits', async () => {
|
||||
const edits = [
|
||||
{ oldText: 'nonexistent line', newText: 'replacement' }
|
||||
|
|
@ -643,6 +761,23 @@ describe('Lib Functions', () => {
|
|||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves UTF-8 characters split across chunk boundaries', async () => {
|
||||
const content = Buffer.concat([
|
||||
Buffer.from('discard\n'),
|
||||
Buffer.from('界'),
|
||||
Buffer.alloc(1017, 'a'),
|
||||
Buffer.from('\nlast'),
|
||||
]);
|
||||
const mockFileHandle = createMockFileHandle(content);
|
||||
|
||||
mockFs.stat.mockResolvedValue({ size: content.length } as any);
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
const result = await tailFile('/test/file.txt', 2);
|
||||
|
||||
expect(result).toBe(`界${'a'.repeat(1017)}\nlast`);
|
||||
});
|
||||
|
||||
it('handles read errors gracefully', async () => {
|
||||
mockFs.stat.mockResolvedValue({ size: 100 } as any);
|
||||
|
||||
|
|
@ -699,6 +834,20 @@ describe('Lib Functions', () => {
|
|||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves UTF-8 characters split across chunk boundaries', async () => {
|
||||
const content = Buffer.concat([
|
||||
Buffer.alloc(1023, 'a'),
|
||||
Buffer.from('界\nsecond'),
|
||||
]);
|
||||
const mockFileHandle = createMockFileHandle(content);
|
||||
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
const result = await headFile('/test/file.txt', 1);
|
||||
|
||||
expect(result).toBe(`${'a'.repeat(1023)}界`);
|
||||
});
|
||||
|
||||
it('handles files with leftover content', async () => {
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
|
|
|
|||
35
src/filesystem/__tests__/nested-parents.test.ts
Normal file
35
src/filesystem/__tests__/nested-parents.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { setAllowedDirectories, validatePath } from '../lib.js';
|
||||
|
||||
// Regression coverage for #4629: validatePath must accept a path whose
|
||||
// ancestors are missing several levels deep, so create_directory can mkdir -p.
|
||||
describe('validatePath with multiple missing ancestors', () => {
|
||||
let allowedDir: string;
|
||||
let outsideDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
allowedDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-nested-allowed-')));
|
||||
outsideDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-nested-outside-')));
|
||||
setAllowedDirectories([allowedDir]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setAllowedDirectories([]);
|
||||
await fs.rm(allowedDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the full path when several ancestors do not exist', async () => {
|
||||
const requested = path.join(allowedDir, 'a', 'b', 'c');
|
||||
await expect(validatePath(requested)).resolves.toBe(requested);
|
||||
});
|
||||
|
||||
it('rejects when the nearest existing ancestor is a symlink out of the allowed tree', async () => {
|
||||
await fs.symlink(outsideDir, path.join(allowedDir, 'link'), 'junction');
|
||||
await expect(validatePath(path.join(allowedDir, 'link', 'a', 'b')))
|
||||
.rejects.toThrow('Access denied');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue