openpencil/tests/engine/mcp/webmcp.test.ts
Danila Poyarkov ea4b8e4b97 feat(settings): unify preferences and integration editors
Unify Settings navigation, credential drafts, translated form validation, and appearance controls. Add explicit WebMCP access modes and searchable MCP tool permissions while preserving execution and credential safeguards.
2026-09-15 16:34:05 +03:00

169 lines
5 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import type { WebMCP } from 'webmcp-types'
import { registerWebMCPTools } from '@/app/automation/webmcp/registration'
function host() {
const tools = new Map<string, WebMCP.ModelContextTool>()
return {
tools,
async registerTool(
tool: WebMCP.ModelContextTool,
options?: WebMCP.ModelContextRegisterToolOptions
) {
tools.set(tool.name, tool)
options?.signal?.addEventListener('abort', () => tools.delete(tool.name), { once: true })
}
}
}
function requiredTool(tools: Map<string, WebMCP.ModelContextTool>, name: string) {
const tool = tools.get(name)
if (!tool) throw new Error(`Missing tool ${name}`)
return tool
}
const options = () => ({ signal: new AbortController().signal })
describe('WebMCP registration', () => {
test('unsupported browsers do nothing', async () => {
const registration = registerWebMCPTools(
undefined,
() => {
throw new Error('Must not resolve an editor')
},
'edit'
)
await registration.ready
registration.dispose()
})
test('registers only reviewed tools and unregisters on disposal', async () => {
const context = host()
const registration = registerWebMCPTools(context, () => ({ execute: async () => ({}) }), 'edit')
await registration.ready
expect(context.tools.has('get_node')).toBe(true)
for (const name of [
'eval',
'render',
'save_file',
'delete_node',
'create_variable',
'set_image_fill'
]) {
expect(context.tools.has(name)).toBe(false)
}
expect(context.tools.get('get_node')?.annotations?.readOnlyHint).toBe(true)
expect(context.tools.get('set_fill')?.annotations?.readOnlyHint).toBe(false)
for (const tool of context.tools.values()) {
expect(tool.annotations?.untrustedContentHint).toBe(true)
expect(tool.inputSchema).toMatchObject({ type: 'object' })
}
registration.dispose()
expect(context.tools.size).toBe(0)
})
test('validates before execution and captures the target for each call', async () => {
const context = host()
let active = 'first'
let calls = 0
const registration = registerWebMCPTools(
context,
() => {
const captured = active
return {
execute: async () => {
calls++
await Promise.resolve()
return { document: captured }
}
}
},
'edit'
)
await registration.ready
try {
const tool = requiredTool(context.tools, 'get_node')
await expect(tool.execute({}, options())).rejects.toThrow()
expect(calls).toBe(0)
const pending = tool.execute({ id: 'node' }, options())
active = 'second'
expect(await pending).toBe('{"document":"first"}')
expect(await tool.execute({ id: 'node' }, options())).toBe('{"document":"second"}')
} finally {
registration.dispose()
}
})
test('rejects cancelled calls and excessive results', async () => {
const context = host()
const registration = registerWebMCPTools(
context,
() => ({
execute: async () => 'x'.repeat(300_000)
}),
'edit'
)
await registration.ready
try {
const tool = requiredTool(context.tools, 'get_node')
const cancelled = new AbortController()
cancelled.abort()
await expect(tool.execute({ id: 'node' }, { signal: cancelled.signal })).rejects.toThrow()
await expect(tool.execute({ id: 'node' }, options())).rejects.toThrow('Result too large')
} finally {
registration.dispose()
}
})
test('reports committed edits even when their result exceeds the size limit', async () => {
const context = host()
let content = ''
const registration = registerWebMCPTools(
context,
() => ({
execute: async (_def, args) => {
content = String(args.text)
return { id: args.id, text: content }
}
}),
'edit'
)
await registration.ready
try {
const text = 'x'.repeat(300_000)
const result = await requiredTool(context.tools, 'set_text').execute(
{ id: 'node', text },
options()
)
expect(content).toBe(text)
expect(result).toBe(
JSON.stringify({
ok: true,
resultOmitted: true,
message: 'Edit committed. Result exceeds the size limit; inspect a smaller selection.'
})
)
} finally {
registration.dispose()
}
})
test('cleans partial registration after browser rejection', async () => {
const context = host()
const registration = registerWebMCPTools(
{
async registerTool(tool, options) {
if (context.tools.size === 2) throw new Error('Registration denied')
await context.registerTool(tool, options)
}
},
() => ({ execute: async () => ({}) }),
'edit'
)
await expect(registration.ready).rejects.toThrow('Registration denied')
expect(context.tools.size).toBe(0)
})
})