返回源码地图

packages/experimental/browser-use-runtime/src/mcp.ts

main snapshot · da00f7f5358f · 正文引用章节 12;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/** Session-owned MCP browser processes and provider catalog activation. @module */
2
3import type { Context } from '@deepseek-ai/cordis'
4import type { Agent } from '@deepseek-ai/dsh-agent'
5import Schema from '@deepseek-ai/schemastery'
6import { BrowserUseProviderName } from '@deepseek-ai/dsh-browser-use/brand'
7import * as McpClient from '@deepseek-ai/dsh-mcp-client'
8import { createScope } from '@deepseek-ai/dsh-scope'
9import type { Scope } from '@deepseek-ai/dsh-scope'
10import { SessionResources } from './index.ts'
11import type {} from '@deepseek-ai/dsh-browser-use'
12import type {} from '@deepseek-ai/dsh-tools'
13import type {} from '@deepseek-ai/dsh-system-prompt'
14
15/** Browser launch settings shared by the MCP integrations. */
16export interface BrowserMcpLaunchConfig {
17 /** Launch a new isolated Chromium browser for each live Session. */
18 mode: 'launch'
19 /** Whether Chromium runs without a visible window; defaults to true. */
20 headless: boolean
21 /** Chromium executable; omission uses the upstream server's installation discovery. */
22 executablePath?: string
23 /** Per-call timeout override in milliseconds; omission uses the MCP client default. */
24 toolCallTimeoutMs?: number
25}
26
27/** Attachment to an externally owned Chromium browser. */
28export interface BrowserMcpAttachConfig {
29 /** Exclusively attach one live Session to the configured browser. */
30 mode: 'attach'
31 /** HTTP(S) debugging URL or WS(S) browser debugging endpoint. */
32 endpoint: string
33 /** Per-call timeout override in milliseconds; omission uses the MCP client default. */
34 toolCallTimeoutMs?: number
35}
36
37/** Fixed launch or attachment choice for one MCP browser provider. */
38export type BrowserMcpConfig = BrowserMcpLaunchConfig | BrowserMcpAttachConfig
39
40/** Validate the browser mode before the provider reserves browser use. */
41export const BrowserMcpConfig: Schema<BrowserMcpAttachConfig | (Omit<BrowserMcpLaunchConfig, 'headless'> & { headless?: boolean }), BrowserMcpConfig> = Schema.union([
42 Schema.object({
43 mode: Schema.const('launch').required(),
44 headless: Schema.boolean().default(true),
45 executablePath: Schema.string().pattern(/\S/u),
46 toolCallTimeoutMs: Schema.number().min(1),
47 }),
48 Schema.object({
49 mode: Schema.const('attach').required(),
50 endpoint: Schema.string().pattern(/^https?:\/\/[^\s/]+|^wss?:\/\/[^\s/]+/u).required(),
51 toolCallTimeoutMs: Schema.number().min(1),
52 }),
53])
54
55/**
56 * Reject an invalid debugging endpoint before acquiring provider or browser resources.
57 * @param config - schema-validated browser selection.
58 */
59export function validateBrowserMcpConfig(config: BrowserMcpConfig): void {
60 if (config.mode !== 'attach') return
61 let endpoint: URL
62 try {
63 endpoint = new URL(config.endpoint)
64 } catch (error) {
65 throw new Error('browser endpoint must be a valid HTTP(S) or WS(S) URL', { cause: error })
66 }
67 if (!['http:', 'https:', 'ws:', 'wss:'].includes(endpoint.protocol) || /\s/u.test(config.endpoint)) {
68 throw new Error('browser endpoint must be a valid HTTP(S) or WS(S) URL without whitespace')
69 }
70}
71
72/** Provider-owned connection options for one live Session. */
73export interface SessionMcpOptions {
74 /** Provider identity and MCP tool namespace. */
75 name: string
76 /** Whether another live Session must wait for the attached browser to be released. */
77 exclusive: boolean
78 /** Executable used to start the installed MCP server. */
79 command: string
80 /** Arguments passed directly without a shell. */
81 args: string[]
82 /** Explicit overrides merged into the MCP client's scrubbed child environment. */
83 env?: Record<string, string>
84 /** Per-call timeout override; omission retains the MCP client default. */
85 toolCallTimeoutMs?: number
86}
87
88interface ClientState {
89 status: 'ready' | 'blocked'
90 mask?: Scope
91}
92
93/**
94 * Await one MCP client during each future Agent's creation.
95 * A busy attachment leaves that activation without browser tools; its other turns continue.
96 * Calls are serialized per Session; unload closes every server before releasing registration.
97 * @param ctx - provider context supplying browser use, Agents, tools, and prompt assembly.
98 * @param options - provider identity, attachment exclusivity, and executable configuration.
99 */
100export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void {
101 let resources!: SessionResources<Scope>
102 const clients = new Map<Agent, ClientState>()
103 const toolPrefix = `mcp__${options.name}__`
104 const resourceTools = new Set(['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'])
105 let stopping = false
106 let refreshingMasks = false
107
108 const refreshBlockedMasks = (): void => {
109 if (stopping || refreshingMasks) return
110 refreshingMasks = true
111 try {
112 for (const [agent, state] of clients) {
113 if (state.status !== 'blocked') continue
114 const inherited = ctx.tools.schemas(agent).filter(tool => tool.name.startsWith(toolPrefix))
115 if (inherited.length === 0) continue
116 state.mask ??= createScope(ctx, agent)
117 state.mask.ctx.tools.restrict({ deny: inherited.map(tool => tool.name) })
118 }
119 } finally {
120 refreshingMasks = false
121 }
122 }
123
124 ctx.effect(function* () {
125 yield ctx.browserUse.register(BrowserUseProviderName(options.name))
126 resources = new SessionResources(ctx, {
127 label: options.name,
128 exclusive: options.exclusive,
129 async open(agent, signal) {
130 const scope = createScope(ctx, agent)
131 let cancellation: Promise<void> | undefined
132 const cancel = (): void => { cancellation = scope.dispose() }
133 signal.addEventListener('abort', cancel, { once: true })
134 try {
135 signal.throwIfAborted()
136 scope.ctx.on('tools/execute', async (exec, next) => {
137 if (!exec.name.startsWith(toolPrefix)) return next()
138 if (exec.agent !== agent) {
139 if (ctx.tools.get(exec.name, exec.agent) !== ctx.tools.get(exec.name, agent)) return next()
140 throw new Error(`${options.name}: browser tool belongs to another Session`)
141 }
142 return next()
143 })
144 await scope.ctx.plugin(McpClient, McpClient.Config({
145 transport: 'stdio',
146 serverName: options.name,
147 command: options.command,
148 args: options.args,
149 ...options.env === undefined ? {} : { env: options.env },
150 ...agent.session.header.cwd === undefined ? {} : { cwd: agent.session.header.cwd },
151 ...options.toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs: options.toolCallTimeoutMs },
152 failOnStartupError: true,
153 reconnect: { enabled: false },
154 }))
155 signal.throwIfAborted()
156 return {
157 value: scope,
158 close() {
159 clients.delete(agent)
160 return scope.dispose()
161 },
162 }
163 } catch (error) {
164 await (cancellation ?? scope.dispose())
165 throw error
166 } finally {
167 signal.removeEventListener('abort', cancel)
168 }
169 },
170 })
171 yield async () => {
172 stopping = true
173 await resources.dispose()
174 clients.clear()
175 }
176 }, `${options.name}.sessions`)
177 ctx.on('agent/created', async ({ agent, signal }) => {
178 const state: ClientState = { status: resources.available(agent) ? 'ready' : 'blocked' }
179 agent.ctx.effect(() => async () => {
180 clients.delete(agent)
181 await state.mask?.dispose()
182 }, `${options.name}.activation`)
183 if (state.status === 'blocked') {
184 clients.set(agent, state)
185 refreshBlockedMasks()
186 return
187 }
188 await resources.get(agent, signal)
189 clients.set(agent, state)
190 }, { prepend: true })
191 ctx.on('tools/change', refreshBlockedMasks)
192 ctx.on('tools/execute', async (exec, next) => {
193 const ownResource = resourceTools.has(exec.name)
194 && typeof exec.arguments === 'object' && exec.arguments !== null
195 && (exec.arguments as { server?: unknown }).server === options.name
196 if (!exec.name.startsWith(toolPrefix) && !ownResource) return next()
197 const agent = exec.agent
198 if (agent === undefined || clients.get(agent)?.status !== 'ready') {
199 throw new Error(`${options.name}: browser tool belongs to another Session`)
200 }
201 return resources.run(agent, exec.signal, async (_scope, combined) => {
202 const original = exec.signal
203 exec.signal = combined
204 try {
205 return await next()
206 } finally {
207 exec.signal = original
208 }
209 })
210 })
211 ctx.on('system-prompt/assemble', async (_assembly, { agent }, next) => {
212 const assembly = await next()
213 if (agent === undefined || clients.get(agent)?.status === 'ready') return assembly
214 return { ...assembly, sections: assembly.sections.filter(section => section.name !== `mcp:${options.name}`) }
215 })
216}