Compare commits

..
Author SHA1 Message Date
GitHub Actions
9a96ea6e59 Automatic update of packages
Some checks failed
Python / detect-packages (push) Has been cancelled
TypeScript / detect-packages (push) Has been cancelled
Python / Test (push) Has been cancelled
Python / Build (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
2026-07-10 03:30:18 +00:00
50 changed files with 773 additions and 2180 deletions

View file

@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'weekly'

View file

@ -36,10 +36,10 @@ jobs:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v3
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
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@v7
uses: astral-sh/setup-uv@v3
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version-file: "src/${{ matrix.package }}/.python-version"
@ -93,7 +93,7 @@ jobs:
run: uv build
- name: Upload artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: dist-${{ matrix.package }}
path: src/${{ matrix.package }}/dist/

View file

@ -1,10 +1,7 @@
name: README PR Check
on:
# 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:
pull_request:
types: [opened]
paths:
- 'README.md'
@ -13,7 +10,7 @@ on:
jobs:
check-readme-only:
if: github.event_name == 'pull_request_target'
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read

View file

@ -25,7 +25,7 @@ jobs:
echo "Using last release hash: ${HASH}"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
- name: Create version name
id: create-version
@ -41,7 +41,7 @@ jobs:
cat RELEASE_NOTES.md
- name: Release notes
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: release-notes
path: RELEASE_NOTES.md
@ -77,7 +77,7 @@ jobs:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
- name: Update packages
run: |
@ -121,10 +121,10 @@ jobs:
ref: ${{ needs.create-metadata.outputs.version }}
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
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@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
@ -228,7 +228,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download release notes
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: release-notes

View file

@ -34,7 +34,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
@ -57,7 +57,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm

View file

@ -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/brave-search-mcp-server`](https://www.npmjs.com/package/@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).
- **[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.

1004
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -113,7 +113,7 @@ npm run start:streamableHttp
### Install
```shell
npm install -g @modelcontextprotocol/server-everything@latest
```
````
### Run the default (stdio) server
```shell

View file

@ -54,7 +54,7 @@
- `prompts: {}`
- `resources: { subscribe: true }`
- **Server Instructions**
- Loaded from the docs folder (`instructions.md`).
- Loaded from the docs folder (`server-instructions.md`).
- **Registrations**
- Registers **tools** via `registerTools(server)`.
- Registers **resources** via `registerResources(server)`.

View file

@ -85,19 +85,9 @@ src/everything
### `docs/`
- `architecture.md`
- 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.
- This document.
- `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/`

View file

@ -30,7 +30,7 @@
"test": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@modelcontextprotocol/sdk": "^1.29.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.4.0",
"shx": "^0.3.4",
"typescript": "^5.6.2",
"vitest": "^4.1.8"
}

View file

@ -99,9 +99,10 @@ export const setSubscriptionHandlers = (server: McpServer) => {
/**
* Sends simulated resource update notifications to the subscribed client.
*
* Iterates the URIs in `subscriptions` and emits a
* `notifications/resources/updated` notification for each URI the given
* sessionId is subscribed to.
* 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.
*
* @param {McpServer} server - The server instance used to send notifications.
* @param {string | undefined} sessionId - The session ID of the client to check for subscriptions.
@ -121,6 +122,8 @@ const sendSimulatedResourceUpdates = async (
method: "notifications/resources/updated",
params: { uri },
});
} else {
subscribers.delete(sessionId); // subscriber has disconnected
}
}
};

View file

@ -127,26 +127,31 @@ export const blobResourceUri = (resourceId: number) =>
new URL(`${blobUriBase}/${resourceId}`);
/**
* Parses the resource identifier from the provided variables and validates
* that it is a positive integer.
* 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.
*
* 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.
* @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.
*/
const parseResourceId = (uri: URL, variables: Record<string, unknown>) => {
const idxStr = String((variables as any).resourceId ?? "");
const idx = Number(idxStr);
if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) {
return idx;
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);
}
}
throw new Error(`Unknown resource: ${uri.toString()}`);
};
/**

View file

@ -11,8 +11,9 @@ 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
@ -26,10 +27,6 @@ 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

View file

@ -6,8 +6,6 @@ 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.

View file

@ -1,6 +1,6 @@
[project]
name = "mcp-server-fetch"
version = "0.6.3"
version = "2026.7.10"
description = "A Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs"
readme = "README.md"
requires-python = ">=3.10"
@ -18,7 +18,7 @@ classifiers = [
dependencies = [
"httpx>=0.27",
"markdownify>=0.13.1",
"mcp>=1.29.0,<2",
"mcp>=1.1.3",
"protego>=0.3.1",
"pydantic>=2.0.0",
"readabilipy>=0.2.0",

22
src/fetch/uv.lock generated
View file

@ -1,12 +1,6 @@
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"
@ -539,7 +533,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.29.0"
version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -557,14 +551,14 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
name = "mcp-server-fetch"
version = "0.6.3"
version = "2026.7.10"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@ -588,7 +582,7 @@ dev = [
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "markdownify", specifier = ">=0.13.1" },
{ name = "mcp", specifier = ">=1.29.0,<2" },
{ name = "mcp", specifier = ">=1.1.3" },
{ name = "protego", specifier = ">=0.3.1" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "readabilipy", specifier = ">=0.2.0" },
@ -1185,11 +1179,11 @@ wheels = [
[[package]]
name = "soupsieve"
version = "2.8.4"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]

View file

@ -1,57 +0,0 @@
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);
});
});

View file

@ -14,7 +14,6 @@ import {
getFileStats,
readFileContent,
writeFileContent,
moveFile,
// Search & filtering functions
searchFilesWithValidation,
// File editing functions
@ -27,23 +26,6 @@ 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();
@ -83,10 +65,8 @@ describe('Lib Functions', () => {
});
it('handles negative numbers', () => {
// 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');
// Negative numbers will result in NaN for the log calculation
expect(formatSize(-1024)).toContain('NaN');
expect(formatSize(-0)).toBe('0 B');
});
@ -173,13 +153,6 @@ 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'];
@ -215,34 +188,19 @@ describe('Lib Functions', () => {
expect(result).toBe(path.resolve(newFilePath));
});
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 () => {
it('rejects when parent directory does not exist', async () => {
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt';
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);
// 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);
await expect(validatePath(newFilePath))
.rejects.toThrow('Parent directory does not exist');
});
@ -350,56 +308,6 @@ 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();
});
});
});
@ -508,8 +416,6 @@ 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 () => {
@ -604,30 +510,6 @@ 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' }
@ -761,23 +643,6 @@ 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);
@ -834,20 +699,6 @@ 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(),

View file

@ -1,35 +0,0 @@
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