1
/** Session-owned MCP browser processes and provider catalog activation. @module */3
import type { Context } from '@deepseek-ai/cordis'4
import type { Agent } from '@deepseek-ai/dsh-agent'5
import Schema from '@deepseek-ai/schemastery'6
import { BrowserUseProviderName } from '@deepseek-ai/dsh-browser-use/brand'7
import * as McpClient from '@deepseek-ai/dsh-mcp-client'8
import { createScope } from '@deepseek-ai/dsh-scope'9
import type { Scope } from '@deepseek-ai/dsh-scope'10
import { SessionResources } from './index.ts'11
import type {} from '@deepseek-ai/dsh-browser-use'12
import type {} from '@deepseek-ai/dsh-tools'13
import type {} from '@deepseek-ai/dsh-system-prompt'15
/** Browser launch settings shared by the MCP integrations. */16
export 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: boolean21
/** Chromium executable; omission uses the upstream server's installation discovery. */22
executablePath?: string23
/** Per-call timeout override in milliseconds; omission uses the MCP client default. */24
toolCallTimeoutMs?: number25
}27
/** Attachment to an externally owned Chromium browser. */28
export 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: string33
/** Per-call timeout override in milliseconds; omission uses the MCP client default. */34
toolCallTimeoutMs?: number35
}37
/** Fixed launch or attachment choice for one MCP browser provider. */38
export type BrowserMcpConfig = BrowserMcpLaunchConfig | BrowserMcpAttachConfig40
/** Validate the browser mode before the provider reserves browser use. */41
export 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
])55
/**56
* Reject an invalid debugging endpoint before acquiring provider or browser resources.57
* @param config - schema-validated browser selection.58
*/59
export function validateBrowserMcpConfig(config: BrowserMcpConfig): void {60
if (config.mode !== 'attach') return61
let endpoint: URL62
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
}72
/** Provider-owned connection options for one live Session. */73
export interface SessionMcpOptions {74
/** Provider identity and MCP tool namespace. */75
name: string76
/** Whether another live Session must wait for the attached browser to be released. */77
exclusive: boolean78
/** Executable used to start the installed MCP server. */79
command: string80
/** 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?: number86
}88
interface ClientState {89
status: 'ready' | 'blocked'90
mask?: Scope91
}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
*/100
export 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 = false106
let refreshingMasks = false108
const refreshBlockedMasks = (): void => {109
if (stopping || refreshingMasks) return110
refreshingMasks = true111
try {112
for (const [agent, state] of clients) {113
if (state.status !== 'blocked') continue114
const inherited = ctx.tools.schemas(agent).filter(tool => tool.name.startsWith(toolPrefix))115
if (inherited.length === 0) continue116
state.mask ??= createScope(ctx, agent)117
state.mask.ctx.tools.restrict({ deny: inherited.map(tool => tool.name) })118
}119
} finally {120
refreshingMasks = false121
}122
}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> | undefined132
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 error166
} finally {167
signal.removeEventListener('abort', cancel)168
}169
},170
})171
yield async () => {172
stopping = true173
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
return187
}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 !== null195
&& (exec.arguments as { server?: unknown }).server === options.name196
if (!exec.name.startsWith(toolPrefix) && !ownResource) return next()197
const agent = exec.agent198
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.signal203
exec.signal = combined204
try {205
return await next()206
} finally {207
exec.signal = original208
}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 assembly214
return { ...assembly, sections: assembly.sections.filter(section => section.name !== `mcp:${options.name}`) }215
})216
}