Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
*/

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {fileURLToPath, pathToFileURL} from 'node:url';

import type {TargetUniverse} from './DevtoolsUtils.js';
import {UniverseManager} from './DevtoolsUtils.js';
Expand Down Expand Up @@ -158,7 +159,16 @@ export class McpContext implements Context {
}

roots(): Root[] | undefined {
return this.#roots;
if (this.#roots === undefined) {
return undefined;
}
return [
...this.#roots,
{
uri: pathToFileURL(os.tmpdir()).href,
name: 'temp',
},
];
}

setRoots(roots: Root[] | undefined): void {
Expand Down
27 changes: 20 additions & 7 deletions tests/McpContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import assert from 'node:assert';
import os from 'node:os';
import path from 'node:path';
import {afterEach, describe, it} from 'node:test';
import {pathToFileURL} from 'node:url';
Expand Down Expand Up @@ -220,13 +221,21 @@ describe('McpContext', () => {
await withMcpContext(async (_response, context) => {
const roots = [{uri: 'file:///test', name: 'test'}];
context.setRoots(roots);
assert.deepEqual(context.roots(), roots);
const actualRoots = context.roots();
assert.ok(
actualRoots?.some(r => r.name === 'test'),
'Should contain the set root',
);
assert.ok(
actualRoots?.some(r => r.name === 'temp'),
'Should contain the temp root',
);
});
});

it('validatePath allows paths within roots', async () => {
await withMcpContext(async (_response, context) => {
const workspacePath = path.resolve('/tmp/workspace');
const workspacePath = path.resolve(os.homedir(), 'workspace-test');
const roots = [
{uri: pathToFileURL(workspacePath).href, name: 'workspace'},
];
Expand All @@ -235,24 +244,28 @@ describe('McpContext', () => {
context.validatePath(path.join(workspacePath, 'test.txt'));
context.validatePath(workspacePath);

// Invalid path outside root
const outsidePath = path.resolve('/tmp/outside.txt');
// Invalid path outside root and outside temp dir
const outsidePath = path.resolve(os.homedir(), 'outside-test.txt');
assert.throws(() => context.validatePath(outsidePath), /Access denied/);
});
});

it('validatePath allows all paths if roots are undefined (legacy)', async () => {
await withMcpContext(async (_response, context) => {
context.setRoots(undefined);
context.validatePath(path.resolve('/tmp/anywhere.txt'));
context.validatePath(path.resolve(os.homedir(), 'anywhere.txt'));
});
});

it('validatePath denies all paths if roots list is empty', async () => {
it('validatePath denies paths outside os.tmpdir() if roots list is empty', async () => {
await withMcpContext(async (_response, context) => {
context.setRoots([]);
// Should allow temp dir
context.validatePath(path.join(os.tmpdir(), 'test.txt'));

// Should deny outside temp dir
assert.throws(
() => context.validatePath(path.resolve('/tmp/anywhere.txt')),
() => context.validatePath(path.resolve(os.homedir(), 'anywhere.txt')),
/Access denied/,
);
});
Expand Down
4 changes: 3 additions & 1 deletion tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {describe, it} from 'node:test';

import {Client} from '@modelcontextprotocol/sdk/client/index.js';
Expand Down Expand Up @@ -208,7 +210,7 @@ describe('e2e', () => {
const result = await client.callTool({
name: 'take_screenshot',
arguments: {
filePath: '/tmp/test.png',
filePath: path.resolve(os.homedir(), 'test.png'),
},
});

Expand Down
49 changes: 49 additions & 0 deletions tests/roots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';
import os from 'node:os';
import path from 'node:path';
import {describe, it} from 'node:test';
import {pathToFileURL} from 'node:url';

import {withMcpContext} from './utils.js';

describe('McpContext Roots', () => {
it('should allow access to os.tmpdir() even if roots are empty', async () => {
await withMcpContext(async (_response, context) => {
context.setRoots([]);
const tmpPath = path.join(os.tmpdir(), 'test-file.txt');
// This should not throw
context.validatePath(tmpPath);
});
});

it('should allow access to os.tmpdir() when other roots are set', async () => {
await withMcpContext(async (_response, context) => {
const otherRoot = path.resolve(
os.tmpdir(),
'..',
'other_workspace_root_for_test',
);
context.setRoots([{uri: pathToFileURL(otherRoot).href, name: 'other'}]);

const tmpPath = path.join(os.tmpdir(), 'test-file.txt');
// This should not throw.
context.validatePath(tmpPath);

// Other root should also be allowed.
context.validatePath(path.join(otherRoot, 'file.txt'));

// Outside should still be denied. Use a path that is definitely not a root or temp dir.
const outsidePath = path.resolve(
os.homedir(),
'a_very_unlikely_path_name_12345',
);
assert.throws(() => context.validatePath(outsidePath), /Access denied/);
});
});
});
Loading