mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
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:
299
apps/agent/lib/mcp/client.test.ts
Normal file
299
apps/agent/lib/mcp/client.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user