mcp-upstream.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. /** Real upstream MCP browser checks against private loopback pages and disposable Chromium. */
  2. import { spawn } from 'node:child_process'
  3. import { once } from 'node:events'
  4. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  5. import { createServer } from 'node:http'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import { pathToFileURL } from 'node:url'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import type { Plugin } from '@deepseek-ai/cordis'
  11. import Loader from '@deepseek-ai/cordis-plugin-loader'
  12. import Include from '@deepseek-ai/cordis-plugin-include'
  13. import BrowserUse from '@deepseek-ai/dsh-browser-use'
  14. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  15. import Tools from '@deepseek-ai/dsh-tools'
  16. import Llm from '@deepseek-ai/dsh-llm'
  17. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  18. import Sessions, { SessionId } from '@deepseek-ai/dsh-session'
  19. import Agents from '@deepseek-ai/dsh-agent'
  20. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  21. import Projections from '@deepseek-ai/dsh-session-projection'
  22. import { expect, vi } from 'vitest'
  23. import type { BrowserMcpConfig } from '../src/mcp.ts'
  24. export async function verifyMcpBrowser(
  25. provider: Pick<Plugin.Object<BrowserMcpConfig>, 'apply'>,
  26. namespace: string,
  27. navigate: { name: string; arguments(url: string): Record<string, unknown> },
  28. mode: 'launch' | 'attach',
  29. ): Promise<void> {
  30. const executable = process.env.DSH_BROWSER_EXECUTABLE!
  31. const root = await mkdtemp(join(tmpdir(), 'dsh-browser-upstream-'))
  32. const observed = new Set<string>()
  33. const server = createServer((request, response) => {
  34. observed.add(request.url!)
  35. response.writeHead(200, { 'Content-Type': 'text/html' })
  36. response.end('<!doctype html><title>DSH browser fixture</title><h1>Browser integration works</h1>')
  37. })
  38. const ctx = new Context()
  39. let external: ReturnType<typeof spawn> | undefined
  40. let exited: Promise<unknown> | undefined
  41. try {
  42. server.listen(0, '127.0.0.1')
  43. await once(server, 'listening')
  44. const address = server.address()
  45. if (address === null || typeof address === 'string') throw new Error('Fixture has no TCP listener')
  46. const url = `http://127.0.0.1:${address.port}/${namespace}-${mode}`
  47. let browserConfig: BrowserMcpConfig = { mode: 'launch', headless: true, executablePath: executable }
  48. if (mode === 'attach') {
  49. const profile = join(root, 'external-profile')
  50. external = spawn(executable, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', 'about:blank'], { stdio: 'ignore' })
  51. exited = once(external, 'exit')
  52. const portFile = join(profile, 'DevToolsActivePort')
  53. let port: string | undefined
  54. await vi.waitFor(async () => { port = (await readFile(portFile, 'utf8')).split('\n')[0]; expect(Number(port)).toBeGreaterThan(0) }, { timeout: 20000 })
  55. browserConfig = { mode: 'attach', endpoint: `http://127.0.0.1:${port}` }
  56. }
  57. const modules = new Map<string, unknown>([
  58. ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['llm', Llm],
  59. ['sessions', Sessions], ['agents', Agents], ['loop', AgentLoop], ['projections', Projections], ['browser', provider],
  60. ])
  61. const configPath = join(root, 'cordis.yml')
  62. await writeFile(configPath, JSON.stringify([...modules.keys()].map(name => ({ id: name, name, config: name === 'loop' ? { agents: [] } : name === 'browser' ? browserConfig : {} }))))
  63. ctx.baseUrl = pathToFileURL(root).href + '/'
  64. await ctx.plugin(Loader)
  65. ctx.loader.builtins.include = Include
  66. ctx.loader.internal = {
  67. version: 'v2',
  68. async import(specifier: string) {
  69. if (!modules.has(specifier)) throw new Error(`Unexpected smoke module ${specifier}`)
  70. return modules.get(specifier)
  71. },
  72. } as unknown as NonNullable<typeof ctx.loader.internal>
  73. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  74. await ctx.loader.await()
  75. const owner = await ctx.agents.create({ sessionId: SessionId('browser-upstream'), meta: { cwd: root } })
  76. await owner.agent.whenIdle()
  77. await ctx.systemPrompt.assemble({ agent: owner.agent, scope: owner.agent, signal: new AbortController().signal })
  78. const schemas = ctx.tools.schemas(owner.agent)
  79. expect(schemas.length).toBeGreaterThan(5)
  80. expect(schemas.every(tool => tool.name.startsWith(`mcp__${namespace}__`))).toBe(true)
  81. const result = await ctx.tools.execute({ agent: owner.agent, name: `mcp__${namespace}__${navigate.name}`, arguments: navigate.arguments(url), callId: ToolCallId('navigate'), signal: new AbortController().signal })
  82. expect(result.isError, JSON.stringify(result.content)).toBe(false)
  83. expect(observed.has(`/${namespace}-${mode}`)).toBe(true)
  84. expect(result.content.length).toBeGreaterThan(0)
  85. await owner.dispose()
  86. expect(ctx.tools.schemas(owner.agent)).toEqual([])
  87. if (browserConfig.mode === 'attach') {
  88. expect(external?.exitCode).toBeNull()
  89. const response = await fetch(`${browserConfig.endpoint}/json/list`)
  90. const pages = await response.json() as { url: string }[]
  91. expect(pages.some(page => page.url === url)).toBe(true)
  92. }
  93. } finally {
  94. await ctx.fiber.dispose()
  95. if (external !== undefined && external.exitCode === null) external.kill('SIGTERM')
  96. if (exited !== undefined) await exited
  97. server.closeAllConnections()
  98. await new Promise<void>((resolve, reject) => {
  99. server.close((error) => {
  100. if (error === undefined) resolve()
  101. else reject(error)
  102. })
  103. })
  104. await rm(root, { recursive: true, force: true })
  105. }
  106. }