fix: avoid CSP-unsafe MCP SDK usage in settings (#423)

* fix: avoid CSP-unsafe MCP SDK usage in settings

* fix: handle paginated MCP tool listings

* fix: restore MCP SDK tool listing for settings
This commit is contained in:
Nikhil
2026-03-05 16:40:57 -08:00
committed by GitHub
parent cc075e7770
commit c56186111e
2 changed files with 386 additions and 13 deletions

View File

@@ -0,0 +1,299 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { createServer, type IncomingMessage, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { fetchMcpTools } from './client'
function readBody(req: IncomingMessage): Promise<string> {
const chunks: Uint8Array[] = []
return new Promise((resolve, reject) => {
req.on('data', (chunk) => {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
})
req.on('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'))
})
req.on('error', reject)
})
}
function listen(server: Server): Promise<void> {
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', resolve)
})
}
function close(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
describe('fetchMcpTools', () => {
let originalFunction: FunctionConstructor
beforeEach(() => {
originalFunction = globalThis.Function
})
afterEach(() => {
globalThis.Function = originalFunction
})
it('lists tools without invoking Function-based schema compilation', async () => {
const requests: string[] = []
let evalCalls = 0
let getRequests = 0
let initialized = false
const server = createServer(async (req, res) => {
if (req.method === 'GET') {
getRequests += 1
res.writeHead(405)
res.end()
return
}
const message = JSON.parse(await readBody(req)) as {
id?: string | number
method: string
}
requests.push(message.method)
if (message.method === 'initialize') {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': 'test-session',
})
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: '2025-03-26',
capabilities: {
tools: {},
},
serverInfo: {
name: 'test-server',
version: '1.0.0',
},
},
}),
)
return
}
if (message.method === 'notifications/initialized') {
initialized = true
res.writeHead(202)
res.end()
return
}
if (message.method === 'tools/list') {
res.writeHead(200, {
'Content-Type': 'application/json',
})
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
tools: [
{
name: 'browser_list_tabs',
description: 'List tabs',
inputSchema: {
type: 'object',
},
outputSchema: {
type: 'object',
properties: {
ok: {
type: 'boolean',
},
},
},
},
],
},
}),
)
return
}
res.writeHead(500)
res.end()
})
await listen(server)
try {
globalThis.Function = new Proxy(originalFunction, {
apply(target, thisArg, argArray) {
evalCalls += 1
return Reflect.apply(target, thisArg, argArray)
},
construct(target, argArray, newTarget) {
evalCalls += 1
return Reflect.construct(target, argArray, newTarget)
},
}) as unknown as FunctionConstructor
const { port } = server.address() as AddressInfo
const tools = await fetchMcpTools(`http://127.0.0.1:${port}/mcp`)
expect(tools).toEqual([
{
name: 'browser_list_tabs',
description: 'List tabs',
},
])
expect(evalCalls).toBe(0)
expect(getRequests).toBe(1)
expect(initialized).toBe(true)
expect(requests).toEqual([
'initialize',
'notifications/initialized',
'tools/list',
])
} finally {
globalThis.Function = originalFunction
await close(server)
}
})
it('follows paginated tools/list responses', async () => {
const cursors: Array<string | undefined> = []
let listRequests = 0
const server = createServer(async (req, res) => {
if (req.method === 'GET') {
res.writeHead(405)
res.end()
return
}
const message = JSON.parse(await readBody(req)) as {
id?: string | number
method: string
params?: {
cursor?: string
}
}
if (message.method === 'initialize') {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': 'test-session',
})
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: '2025-03-26',
capabilities: {
tools: {},
},
serverInfo: {
name: 'test-server',
version: '1.0.0',
},
},
}),
)
return
}
if (message.method === 'notifications/initialized') {
res.writeHead(202)
res.end()
return
}
if (message.method === 'tools/list') {
listRequests += 1
cursors.push(message.params?.cursor)
res.writeHead(200, {
'Content-Type': 'application/json',
})
if (message.params?.cursor === 'cursor-1') {
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
tools: [
{
name: 'browser_get_page_content',
description: 'Get page content',
inputSchema: {
type: 'object',
},
},
],
},
}),
)
return
}
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
nextCursor: 'cursor-1',
tools: [
{
name: 'browser_list_tabs',
description: 'List tabs',
inputSchema: {
type: 'object',
},
},
],
},
}),
)
return
}
res.writeHead(500)
res.end()
})
await listen(server)
try {
const { port } = server.address() as AddressInfo
const tools = await fetchMcpTools(`http://127.0.0.1:${port}/mcp`)
expect(tools).toEqual([
{
name: 'browser_list_tabs',
description: 'List tabs',
},
{
name: 'browser_get_page_content',
description: 'Get page content',
},
])
expect(listRequests).toBe(2)
expect(cursors).toEqual([undefined, 'cursor-1'])
} finally {
await close(server)
}
})
})

View File

@@ -1,5 +1,5 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
import * as z from 'zod/v4'
/** @public */
export interface McpTool {
@@ -7,26 +7,100 @@ export interface McpTool {
description?: string
}
const MCP_CLIENT_INFO = {
name: 'browseros-settings',
version: '1.0.0',
} as const
type McpClientConstructor =
typeof import('@modelcontextprotocol/sdk/client/index.js').Client
type McpListToolsResultSchema =
typeof import('@modelcontextprotocol/sdk/types.js').ListToolsResultSchema
type McpTransportConstructor =
typeof import('@modelcontextprotocol/sdk/client/streamableHttp.js').StreamableHTTPClientTransport
interface McpSdk {
Client: McpClientConstructor
ListToolsResultSchema: McpListToolsResultSchema
StreamableHTTPClientTransport: McpTransportConstructor
}
let mcpSdkPromise: Promise<McpSdk> | undefined
async function loadMcpSdk(): Promise<McpSdk> {
if (!mcpSdkPromise) {
mcpSdkPromise = (async () => {
const previousJitless = z.config().jitless
// Zod v4 captures JIT settings when schemas are constructed, so this has
// to be set before the SDK modules create their schemas.
z.config({ jitless: true })
try {
const [clientModule, transportModule, typesModule] = await Promise.all([
import('@modelcontextprotocol/sdk/client/index.js'),
import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
import('@modelcontextprotocol/sdk/types.js'),
])
return {
Client: clientModule.Client,
StreamableHTTPClientTransport:
transportModule.StreamableHTTPClientTransport,
ListToolsResultSchema: typesModule.ListToolsResultSchema,
}
} finally {
z.config({ jitless: previousJitless })
}
})()
}
return mcpSdkPromise
}
function normalizeTools(tools: ListToolsResult['tools']): McpTool[] {
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
}))
}
async function listTools(
client: InstanceType<McpClientConstructor>,
listToolsResultSchema: McpListToolsResultSchema,
): Promise<McpTool[]> {
const tools: McpTool[] = []
let cursor: string | undefined
do {
const response: ListToolsResult = await client.request(
{
method: 'tools/list',
...(cursor ? { params: { cursor } } : {}),
},
listToolsResultSchema,
)
tools.push(...normalizeTools(response.tools))
cursor = response.nextCursor
} while (cursor)
return tools
}
/**
* Fetches available tools from an MCP server
* @public
*/
export async function fetchMcpTools(serverUrl: string): Promise<McpTool[]> {
const client = new Client({
name: 'browseros-settings',
version: '1.0.0',
})
const { Client, StreamableHTTPClientTransport, ListToolsResultSchema } =
await loadMcpSdk()
const client = new Client(MCP_CLIENT_INFO)
const transport = new StreamableHTTPClientTransport(new URL(serverUrl))
try {
await client.connect(transport)
const response = await client.listTools()
return response.tools.map((tool) => ({
name: tool.name,
description: tool.description,
}))
return await listTools(client, ListToolsResultSchema)
} finally {
await client.close()
}