diff --git a/src/McpContext.ts b/src/McpContext.ts index b4fecf363..0b8107693 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -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'; @@ -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 { diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 12d2c640b..487e59561 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -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'; @@ -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'}, ]; @@ -235,8 +244,8 @@ 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/); }); }); @@ -244,15 +253,19 @@ describe('McpContext', () => { 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/, ); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index ba04fafb3..10e0c2ffe 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -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'; @@ -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'), }, }); diff --git a/tests/roots.test.ts b/tests/roots.test.ts new file mode 100644 index 000000000..ee7a0667b --- /dev/null +++ b/tests/roots.test.ts @@ -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/); + }); + }); +});