diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5eae5..cabf743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,59 @@ All notable changes to `@thinkfleet/agentmark` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] — 2026-05-10 + +MCP server. The entire AgentMark library is now drivable from any MCP +client (Claude Desktop, Cursor, Claude Code, custom agents) through a +single config entry. + +### Added + +- **`agentmark-mcp` CLI** — bin entry in package.json. Configure any + MCP client with one line: + ```json + { + "mcpServers": { + "agentmark": { + "command": "npx", + "args": ["-y", "@thinkfleet/agentmark", "agentmark-mcp"] + } + } + } + ``` +- **15 MCP tools** covering every public surface: + - Browser: `browser_open` / `browser_close` / `browser_save_session` + - Page: `page_open` / `page_navigate` / `page_snapshot` / `page_execute` / `page_close` + - PDF: `pdf_open` (file path or `data:` URI) / `pdf_close` / `pdf_snapshot` / `pdf_execute` / `pdf_save` / `pdf_reset` + - Meta: `list_sessions` for debugging stuck connections +- **Stateful session model.** The server holds long-lived browsers + open + PDFs keyed by IDs returned from `_open` calls, so one MCP connection + can drive multiple parallel agents. +- **Programmatic access.** `createMcpServer()` + `startMcpServer()` + + `dispatch()` exported for embedding the server in other applications + or testing without spinning up stdio. +- **Graceful shutdown.** SIGINT / SIGTERM disposes all browsers, + Tesseract workers, and PDF handles before exit. +- **`@modelcontextprotocol/sdk` as optional peer dependency.** Library + callers who don't run the MCP server pay no install cost; surface a + clean error if the SDK is missing. + +### Tests + +- 14 new dispatcher tests (PDF round-trip, error semantics, data-URI + loading, session listing, dispose-all) +- 4 new wire-level handshake tests using `InMemoryTransport` (full + MCP protocol — handshake, ListTools, CallTool, error responses) — + proves real MCP clients can connect without spawning a subprocess. +- Total: 213 unit + 10 real-Chromium integration = 223 (was 199). + +### Distribution unlocked + +After `npm publish`, anyone can configure AgentMark in any MCP client +with the snippet above. No code, no language, no setup beyond the +config file. The full SDK (web + PDF + OCR + form fill/save) becomes +available as ~15 tools any agent can call. + ## [0.6.0] — 2026-05-10 PDF form support. AcroForm fields become AgentMark actions; the new @@ -270,6 +323,7 @@ Initial release of `@thinkfleet/agentmark`. - In-memory action binding - 90 tests, npm provenance auto-publish +[0.7.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.7.0 [0.6.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.6.0 [0.5.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.5.0 [0.4.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.4.0 diff --git a/README.md b/README.md index 2a049ed..53bf106 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,44 @@ OCR modes: - `'always'` — OCR every page (overrides any extracted text). - `'never'` — disable OCR. Same as omitting `ocr` from `convertPdf`. +## MCP server (v0.7+) + +AgentMark ships a Model Context Protocol server so any MCP client (Claude Desktop, Cursor, Claude Code, custom agents) can use the entire library — web, PDF, OCR, AcroForm — through one configuration entry. No SDK install, no language commitment. + +**Configure once in your MCP client:** + +```json +{ + "mcpServers": { + "agentmark": { + "command": "npx", + "args": ["-y", "@thinkfleet/agentmark", "agentmark-mcp"] + } + } +} +``` + +The server exposes ~15 tools, prefixed `agentmark_*`: + +| Surface | Tools | +|---|---| +| Browser | `agentmark_browser_open`, `agentmark_browser_close`, `agentmark_browser_save_session` | +| Page | `agentmark_page_open`, `agentmark_page_navigate`, `agentmark_page_snapshot`, `agentmark_page_execute`, `agentmark_page_close` | +| PDF | `agentmark_pdf_open`, `agentmark_pdf_close`, `agentmark_pdf_snapshot`, `agentmark_pdf_execute`, `agentmark_pdf_save`, `agentmark_pdf_reset` | +| Meta | `agentmark_list_sessions` | + +Each tool is documented in-line via the MCP `list_tools` response — clients see usage hints, JSON schemas, and parameter descriptions automatically. + +The server holds long-lived state per connection (browsers, opened PDFs) keyed by IDs returned from `_open` calls — agents can drive multiple parallel surfaces from one connection. Resources auto-release on shutdown via SIGINT/SIGTERM cleanup. + +MCP support is opt-in via the optional peer dependency: + +```bash +npm install @modelcontextprotocol/sdk +``` + +Library callers who don't run the MCP server pay no install cost. + ## Lower-level APIs For callers who want direct control over conversion or want to feed AgentMark diff --git a/package.json b/package.json index 3607d40..f314d6c 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "@thinkfleet/agentmark", - "version": "0.6.0", - "description": "AI browser + document + form library — convert any web page or PDF (text, scanned, printed, or fillable AcroForm) into a compact AgentMark snapshot, then drive it via clean primitives any AI can call.", + "version": "0.7.0", + "description": "AI browser + document + form library + MCP server — convert any web page or PDF into a compact AgentMark snapshot, then drive it from any MCP client (Claude Desktop, Cursor, Claude Code) or directly via the SDK.", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", + "bin": { + "agentmark-mcp": "./dist/src/mcp/cli.js" + }, "license": "MIT", "repository": { "type": "git", @@ -40,6 +43,7 @@ "tslib": "2.6.2" }, "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.10.38", "playwright-core": ">=1.40.0", @@ -57,9 +61,13 @@ }, "pdf-lib": { "optional": true + }, + "@modelcontextprotocol/sdk": { + "optional": true } }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@types/js-yaml": "4.0.9", "@types/node": "20.19.9", "pdf-lib": "^1.17.1", diff --git a/src/mcp/cli.ts b/src/mcp/cli.ts new file mode 100644 index 0000000..c143039 --- /dev/null +++ b/src/mcp/cli.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +/** + * `agentmark-mcp` CLI — the bin entry referenced by package.json. + * + * Configure in any MCP client to expose the entire AgentMark library: + * + * { + * "mcpServers": { + * "agentmark": { + * "command": "npx", + * "args": ["-y", "@thinkfleet/agentmark", "agentmark-mcp"] + * } + * } + * } + * + * (Or just `npx -y @thinkfleet/agentmark` once the bin name resolves on $PATH.) + */ + +import { startMcpServer } from './server' + +async function main(): Promise { + await startMcpServer({ + name: 'agentmark', + // Version is read from package.json at build time; for now hardcoded. + version: '0.7.0', + }) + // Stay alive — the MCP transport keeps the event loop busy via stdio. +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('Failed to start AgentMark MCP server:', err) + process.exit(1) +}) diff --git a/src/mcp/dispatcher.ts b/src/mcp/dispatcher.ts new file mode 100644 index 0000000..29022c7 --- /dev/null +++ b/src/mcp/dispatcher.ts @@ -0,0 +1,386 @@ +/** + * AgentMark MCP tool dispatcher — pure function that maps a tool name + + * arguments to an AgentMark operation. Stateless except for the session + * registries it receives. + * + * Kept separate from the MCP transport layer so tests can drive it + * directly without spinning up stdio + jsonrpc. + */ + +import { readFile, writeFile } from 'node:fs/promises' +import * as path from 'node:path' +import { pathToFileURL } from 'node:url' +import { + createBrowser, + openPdfDocument, + isAgentMarkError, + type Browser, + type Page, + type PdfDocument, +} from '../index' +import { generateSessionId, type BrowserSession, type PdfSession } from './types' + +export interface DispatcherState { + browsers: Map + pages: Map + pdfs: Map +} + +export function createDispatcherState(): DispatcherState { + return { + browsers: new Map(), + pages: new Map(), + pdfs: new Map(), + } +} + +export interface DispatchResult { + /** Plain-text content returned to the MCP client. */ + text: string + /** True when the operation reports a user-facing error (vs success). */ + isError?: boolean +} + +export async function dispatch( + state: DispatcherState, + name: string, + args: Record, +): Promise { + try { + switch (name) { + // ── Web browser ────────────────────────────────────────────── + case 'agentmark_browser_open': + return await openBrowser(state, args) + case 'agentmark_browser_close': + return await closeBrowser(state, args) + case 'agentmark_browser_save_session': + return await saveBrowserSession(state, args) + case 'agentmark_page_open': + return await openPage(state, args) + case 'agentmark_page_navigate': + return await pageNavigate(state, args) + case 'agentmark_page_snapshot': + return await pageSnapshot(state, args) + case 'agentmark_page_execute': + return await pageExecute(state, args) + case 'agentmark_page_close': + return await closePage(state, args) + + // ── PDF document ───────────────────────────────────────────── + case 'agentmark_pdf_open': + return await openPdf(state, args) + case 'agentmark_pdf_close': + return await closePdf(state, args) + case 'agentmark_pdf_snapshot': + return await pdfSnapshot(state, args) + case 'agentmark_pdf_execute': + return await pdfExecute(state, args) + case 'agentmark_pdf_save': + return await pdfSave(state, args) + case 'agentmark_pdf_reset': + return await pdfReset(state, args) + + // ── Meta ───────────────────────────────────────────────────── + case 'agentmark_list_sessions': + return listSessions(state) + + default: + return { text: `Unknown tool: ${name}`, isError: true } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const code = isAgentMarkError(err) ? `[${err.code}] ` : '' + return { text: `${code}${message}`, isError: true } + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Web tool handlers +// ────────────────────────────────────────────────────────────────────────── + +async function openBrowser(state: DispatcherState, args: Record): Promise { + const headless = args.headless !== false + const sessionPath = typeof args.session_path === 'string' ? args.session_path : undefined + + const browser = await createBrowser({ + launch: { headless }, + sessionPath, + }) + const id = generateSessionId('br') + state.browsers.set(id, { + id, + browser, + pages: new Map(), + createdAt: new Date(), + }) + return { text: JSON.stringify({ browser_id: id }, null, 2) } +} + +async function closeBrowser(state: DispatcherState, args: Record): Promise { + const id = requireString(args, 'browser_id') + const session = state.browsers.get(id) + if (!session) return { text: `Unknown browser_id: ${id}`, isError: true } + // Remove all pages owned by this browser. + for (const [pageId, info] of state.pages) { + if (info.browserId === id) state.pages.delete(pageId) + } + await session.browser.close() + state.browsers.delete(id) + return { text: `Browser ${id} closed.` } +} + +async function saveBrowserSession(state: DispatcherState, args: Record): Promise { + const id = requireString(args, 'browser_id') + const targetPath = path.resolve(requireString(args, 'path')) + const session = state.browsers.get(id) + if (!session) return { text: `Unknown browser_id: ${id}`, isError: true } + await session.browser.saveSession(targetPath) + return { text: `Session saved to ${targetPath}` } +} + +async function openPage(state: DispatcherState, args: Record): Promise { + const browserId = requireString(args, 'browser_id') + const session = state.browsers.get(browserId) + if (!session) return { text: `Unknown browser_id: ${browserId}`, isError: true } + const page = await session.browser.newPage() + const pageId = generateSessionId('pg') + state.pages.set(pageId, { browserId, page }) + session.pages.set(pageId, page) + return { text: JSON.stringify({ page_id: pageId, browser_id: browserId }, null, 2) } +} + +async function pageNavigate(state: DispatcherState, args: Record): Promise { + const pageId = requireString(args, 'page_id') + const url = requireString(args, 'url') + const page = requirePage(state, pageId) + const waitUntil = args.wait_until as 'load' | 'domcontentloaded' | 'networkidle' | 'commit' | undefined + const timeout = typeof args.timeout === 'number' ? args.timeout : undefined + const response = await page.goto(url, { waitUntil, timeout }) + return { + text: JSON.stringify( + { + final_url: page.url(), + status: response?.status() ?? null, + }, + null, + 2, + ), + } +} + +async function pageSnapshot(state: DispatcherState, args: Record): Promise { + const pageId = requireString(args, 'page_id') + const page = requirePage(state, pageId) + const snap = await page.snapshot() + return { text: snap.agentmark } +} + +async function pageExecute(state: DispatcherState, args: Record): Promise { + const pageId = requireString(args, 'page_id') + const actionId = requireString(args, 'action_id') + const page = requirePage(state, pageId) + const result = await page.execute(actionId, args.value) + return { + text: JSON.stringify( + { + action_id: result.actionId, + action_type: result.actionType, + duration_ms: result.durationMs, + }, + null, + 2, + ), + } +} + +async function closePage(state: DispatcherState, args: Record): Promise { + const pageId = requireString(args, 'page_id') + const info = state.pages.get(pageId) + if (!info) return { text: `Unknown page_id: ${pageId}`, isError: true } + await info.page.close() + state.pages.delete(pageId) + state.browsers.get(info.browserId)?.pages.delete(pageId) + return { text: `Page ${pageId} closed.` } +} + +// ────────────────────────────────────────────────────────────────────────── +// PDF tool handlers +// ────────────────────────────────────────────────────────────────────────── + +async function openPdf(state: DispatcherState, args: Record): Promise { + const source = requireString(args, 'source') + const data = await loadPdfBytes(source) + const sourceUrl = + typeof args.source_url === 'string' + ? args.source_url + : source.startsWith('data:') + ? source.slice(0, 80) + '...' + : pathToFileURL(path.resolve(source)).toString() + const title = typeof args.title === 'string' ? args.title : undefined + const password = typeof args.password === 'string' ? args.password : undefined + + const document = await openPdfDocument({ data, sourceUrl, title, password }) + const id = generateSessionId('pdf') + state.pdfs.set(id, { id, document, createdAt: new Date() }) + return { + text: JSON.stringify( + { + doc_id: id, + source_url: sourceUrl, + field_count: document.fields.size, + }, + null, + 2, + ), + } +} + +async function closePdf(state: DispatcherState, args: Record): Promise { + const id = requireString(args, 'doc_id') + const session = state.pdfs.get(id) + if (!session) return { text: `Unknown doc_id: ${id}`, isError: true } + await session.document.close() + state.pdfs.delete(id) + return { text: `PDF ${id} closed.` } +} + +async function pdfSnapshot(state: DispatcherState, args: Record): Promise { + const id = requireString(args, 'doc_id') + const doc = requirePdf(state, id) + const snap = await doc.snapshot() + return { text: snap.agentmark } +} + +async function pdfExecute(state: DispatcherState, args: Record): Promise { + const id = requireString(args, 'doc_id') + const actionId = requireString(args, 'action_id') + const doc = requirePdf(state, id) + await doc.execute(actionId, args.value) + return { + text: JSON.stringify( + { + action_id: actionId, + pending_count: doc.pending.size, + }, + null, + 2, + ), + } +} + +async function pdfSave(state: DispatcherState, args: Record): Promise { + const id = requireString(args, 'doc_id') + const outputPath = path.resolve(requireString(args, 'output_path')) + const flatten = args.flatten === true + const doc = requirePdf(state, id) + const bytes = await doc.save({ flatten }) + await writeFile(outputPath, bytes) + return { + text: JSON.stringify( + { + output_path: outputPath, + bytes: bytes.length, + flattened: flatten, + }, + null, + 2, + ), + } +} + +async function pdfReset(state: DispatcherState, args: Record): Promise { + const id = requireString(args, 'doc_id') + const doc = requirePdf(state, id) + doc.reset() + return { text: `PDF ${id} pending values cleared.` } +} + +// ────────────────────────────────────────────────────────────────────────── +// Meta +// ────────────────────────────────────────────────────────────────────────── + +function listSessions(state: DispatcherState): DispatchResult { + return { + text: JSON.stringify( + { + browsers: Array.from(state.browsers.values()).map((s) => ({ + browser_id: s.id, + page_ids: Array.from(s.pages.keys()), + created_at: s.createdAt.toISOString(), + })), + pdfs: Array.from(state.pdfs.values()).map((s) => ({ + doc_id: s.id, + field_count: s.document.fields.size, + pending: s.document.pending.size, + created_at: s.createdAt.toISOString(), + })), + }, + null, + 2, + ), + } +} + +/** + * Dispose of every active resource — called on server shutdown. + */ +export async function disposeAll(state: DispatcherState): Promise { + const closers: Promise[] = [] + for (const session of state.browsers.values()) { + closers.push(session.browser.close().catch(() => {})) + } + for (const session of state.pdfs.values()) { + closers.push(session.document.close().catch(() => {})) + } + await Promise.allSettled(closers) + state.browsers.clear() + state.pages.clear() + state.pdfs.clear() +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +function requireString(args: Record, key: string): string { + const v = args[key] + if (typeof v !== 'string' || v.length === 0) { + throw new Error(`Missing required argument: ${key}`) + } + return v +} + +function requirePage(state: DispatcherState, pageId: string): Page { + const info = state.pages.get(pageId) + if (!info) throw new Error(`Unknown page_id: ${pageId}`) + return info.page +} + +function requirePdf(state: DispatcherState, docId: string): PdfDocument { + const session = state.pdfs.get(docId) + if (!session) throw new Error(`Unknown doc_id: ${docId}`) + return session.document +} + +/** + * Load PDF bytes from either a file path OR a data URL. Data URLs are + * useful for clients that have the PDF in memory and don't want to write + * a temp file. + */ +async function loadPdfBytes(source: string): Promise { + if (source.startsWith('data:')) { + const commaAt = source.indexOf(',') + if (commaAt === -1) throw new Error('Malformed data URI') + const header = source.slice(5, commaAt) + const payload = source.slice(commaAt + 1) + if (header.includes(';base64')) { + return new Uint8Array(Buffer.from(payload, 'base64')) + } + return new Uint8Array(Buffer.from(decodeURIComponent(payload), 'utf8')) + } + const buf = await readFile(path.resolve(source)) + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) +} + +// Re-export so unrelated callers don't need to reach into types.ts. +export { type DispatcherState as McpDispatcherState } diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..5351f30 --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,18 @@ +/** + * Public entry points for the AgentMark MCP server. + * + * Most users just want the `agentmark-mcp` bin (no code import). Programmatic + * access is provided here for testing and embedding the server in a larger + * application. + */ + +export { startMcpServer, createMcpServer } from './server' +export type { AgentMarkMcpServerOptions } from './server' +export { + dispatch, + createDispatcherState, + disposeAll, +} from './dispatcher' +export type { DispatcherState, DispatchResult } from './dispatcher' +export { ALL_TOOLS } from './tool-defs' +export type { McpToolDef } from './tool-defs' diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..3cc045f --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,98 @@ +/** + * AgentMark MCP server. + * + * Wraps the entire AgentMark library (web + PDF + form + OCR) as a Model + * Context Protocol server so any MCP client (Claude Desktop, Cursor, + * Claude Code, custom agents) can drive it through a single configuration + * entry — no SDK install, no language commitment. + * + * Transport: stdio (the most common MCP transport for desktop and CLI + * clients). HTTP/SSE transports can be added later if needed. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' +import { ALL_TOOLS } from './tool-defs' +import { + createDispatcherState, + dispatch, + disposeAll, + type DispatcherState, +} from './dispatcher' + +export interface AgentMarkMcpServerOptions { + /** Server name reported on the MCP handshake. */ + name?: string + /** Server version reported on the MCP handshake. */ + version?: string +} + +/** + * Construct the MCP server (without connecting it). Used by tests that + * inject custom transports or want to wire the dispatcher directly. + */ +export function createMcpServer(options: AgentMarkMcpServerOptions = {}): { + server: Server + state: DispatcherState +} { + const server = new Server( + { + name: options.name ?? 'agentmark', + version: options.version ?? '0.7.0', + }, + { + capabilities: { + tools: {}, + }, + }, + ) + + const state = createDispatcherState() + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: ALL_TOOLS, + })) + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + const result = await dispatch(state, name, args ?? {}) + return { + content: [{ type: 'text', text: result.text }], + isError: result.isError === true, + } + }) + + return { server, state } +} + +/** + * Start the AgentMark MCP server on stdio. Returns a stop() function that + * disposes all resources and closes the transport. + */ +export async function startMcpServer( + options: AgentMarkMcpServerOptions = {}, +): Promise<{ stop: () => Promise }> { + const { server, state } = createMcpServer(options) + const transport = new StdioServerTransport() + await server.connect(transport) + + const stop = async () => { + await disposeAll(state) + await server.close().catch(() => {}) + } + + // Best-effort cleanup on process termination signals. The MCP client + // typically tears the connection down explicitly, but ctrl-C / SIGTERM + // need to release Playwright + Tesseract workers + open PDFs. + const onShutdown = () => { + stop().finally(() => process.exit(0)) + } + process.once('SIGINT', onShutdown) + process.once('SIGTERM', onShutdown) + + return { stop } +} diff --git a/src/mcp/tool-defs.ts b/src/mcp/tool-defs.ts new file mode 100644 index 0000000..b12ca57 --- /dev/null +++ b/src/mcp/tool-defs.ts @@ -0,0 +1,278 @@ +/** + * MCP tool definitions for AgentMark. + * + * Each tool corresponds to one operation in the AgentMark SDK; agents drive + * the library by calling these. Names follow `agentmark__`. + * + * Schema follows the JSON Schema flavor MCP expects. + */ + +export interface McpToolDef { + name: string + description: string + inputSchema: { + type: 'object' + properties: Record + required?: string[] + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Web browser tools +// ────────────────────────────────────────────────────────────────────────── + +const WEB_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_browser_open', + description: + 'Launch a Chromium browser and return a browser_id. The browser ' + + 'lives for the duration of the MCP session unless explicitly ' + + 'closed. Optional: load a previously saved session file to resume ' + + 'an authenticated state.', + inputSchema: { + type: 'object', + properties: { + headless: { + type: 'boolean', + description: 'Run Chromium in headless mode (default: true).', + }, + session_path: { + type: 'string', + description: 'Path to a session file produced by browser_save_session.', + }, + }, + }, + }, + { + name: 'agentmark_browser_close', + description: 'Close a browser and all its pages.', + inputSchema: { + type: 'object', + properties: { + browser_id: { type: 'string' }, + }, + required: ['browser_id'], + }, + }, + { + name: 'agentmark_browser_save_session', + description: + 'Persist the browser\'s cookies + storage to a file path so a ' + + 'future agentmark_browser_open call can resume the same session.', + inputSchema: { + type: 'object', + properties: { + browser_id: { type: 'string' }, + path: { type: 'string', description: 'Output file path.' }, + }, + required: ['browser_id', 'path'], + }, + }, + { + name: 'agentmark_page_open', + description: 'Open a new page in a browser. Returns a page_id.', + inputSchema: { + type: 'object', + properties: { + browser_id: { type: 'string' }, + }, + required: ['browser_id'], + }, + }, + { + name: 'agentmark_page_navigate', + description: + 'Navigate a page to a URL. Invalidates any cached snapshot. ' + + 'Returns once the wait condition is met (default: load).', + inputSchema: { + type: 'object', + properties: { + page_id: { type: 'string' }, + url: { type: 'string' }, + wait_until: { + type: 'string', + enum: ['load', 'domcontentloaded', 'networkidle', 'commit'], + }, + timeout: { type: 'number', description: 'Timeout in milliseconds.' }, + }, + required: ['page_id', 'url'], + }, + }, + { + name: 'agentmark_page_snapshot', + description: + 'Capture an AgentMark snapshot of the current page state. Returns ' + + 'the YAML+markdown wire format. The result is cached on the page ' + + 'so subsequent agentmark_page_execute calls can resolve action IDs.', + inputSchema: { + type: 'object', + properties: { + page_id: { type: 'string' }, + }, + required: ['page_id'], + }, + }, + { + name: 'agentmark_page_execute', + description: + 'Execute an action by ID against the most recent snapshot. Pass ' + + '`value` for actions that take input (type, select, check, etc).', + inputSchema: { + type: 'object', + properties: { + page_id: { type: 'string' }, + action_id: { type: 'string' }, + value: { + description: + 'Value for actions that take input. Omit for click/hover/etc.', + }, + }, + required: ['page_id', 'action_id'], + }, + }, + { + name: 'agentmark_page_close', + description: 'Close a single page.', + inputSchema: { + type: 'object', + properties: { + page_id: { type: 'string' }, + }, + required: ['page_id'], + }, + }, +] + +// ────────────────────────────────────────────────────────────────────────── +// PDF / document tools +// ────────────────────────────────────────────────────────────────────────── + +const PDF_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_pdf_open', + description: + 'Open a PDF document for reading + form interaction. Returns a ' + + 'doc_id. Source can be a local file path OR a base64-encoded ' + + 'data URI (e.g. "data:application/pdf;base64,JVBERi0..."). ' + + 'The document is held in memory until agentmark_pdf_close.', + inputSchema: { + type: 'object', + properties: { + source: { + type: 'string', + description: 'File path OR `data:application/pdf;base64,...` URI.', + }, + source_url: { + type: 'string', + description: 'Optional URI to record as the snapshot\'s `url` field.', + }, + title: { + type: 'string', + description: 'Override the document title.', + }, + password: { + type: 'string', + description: 'Password for encrypted PDFs.', + }, + }, + required: ['source'], + }, + }, + { + name: 'agentmark_pdf_close', + description: 'Close an opened PDF document and release its resources.', + inputSchema: { + type: 'object', + properties: { + doc_id: { type: 'string' }, + }, + required: ['doc_id'], + }, + }, + { + name: 'agentmark_pdf_snapshot', + description: + 'Capture an AgentMark snapshot of the PDF. PDFs with form fields ' + + 'will have `kind: "form"` with all fields exposed as actions; ' + + 'plain documents have `kind: "document"`. Returns the YAML+markdown ' + + 'wire format.', + inputSchema: { + type: 'object', + properties: { + doc_id: { type: 'string' }, + }, + required: ['doc_id'], + }, + }, + { + name: 'agentmark_pdf_execute', + description: + 'Fill a form field by action ID. Value type depends on the action ' + + 'type: string for text/select/radio, boolean for checkbox, ' + + 'string[] for multi_select. Changes are buffered until ' + + 'agentmark_pdf_save is called.', + inputSchema: { + type: 'object', + properties: { + doc_id: { type: 'string' }, + action_id: { type: 'string' }, + value: { + description: 'Value for the field. Type depends on action type.', + }, + }, + required: ['doc_id', 'action_id'], + }, + }, + { + name: 'agentmark_pdf_save', + description: + 'Write the PDF (with all queued field values applied) to a file. ' + + 'Returns the absolute path. If `flatten` is true, field values ' + + 'are baked into the page content and the PDF is no longer fillable.', + inputSchema: { + type: 'object', + properties: { + doc_id: { type: 'string' }, + output_path: { + type: 'string', + description: 'Where to write the filled PDF.', + }, + flatten: { + type: 'boolean', + description: 'Bake values into page content (default: false).', + }, + }, + required: ['doc_id', 'output_path'], + }, + }, + { + name: 'agentmark_pdf_reset', + description: 'Discard all queued field values without saving.', + inputSchema: { + type: 'object', + properties: { + doc_id: { type: 'string' }, + }, + required: ['doc_id'], + }, + }, +] + +// ────────────────────────────────────────────────────────────────────────── +// Session inspection +// ────────────────────────────────────────────────────────────────────────── + +const META_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_list_sessions', + description: + 'List all currently open browsers, pages, and PDF documents ' + + 'with their IDs. Useful for debugging or recovering a stuck session.', + inputSchema: { + type: 'object', + properties: {}, + }, + }, +] + +export const ALL_TOOLS: McpToolDef[] = [...WEB_TOOLS, ...PDF_TOOLS, ...META_TOOLS] diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..a39b330 --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,33 @@ +/** + * Shared types + helpers for the MCP server. The server holds long-lived + * resources (browsers, opened PDF documents) keyed by session ID, so any + * MCP client can drive multiple parallel agents from one connection. + */ + +import type { Browser, Page, PdfDocument } from '../index' + +export interface BrowserSession { + id: string + browser: Browser + pages: Map // pageId → Page + createdAt: Date +} + +export interface PdfSession { + id: string + document: PdfDocument + createdAt: Date +} + +/** + * Generates a short unique ID. Uses crypto.randomUUID() if available, + * otherwise a Math.random fallback. The IDs are opaque to clients — + * they're returned by `_open` tools and passed back on subsequent calls. + */ +export function generateSessionId(prefix: 'br' | 'pdf' | 'pg'): string { + const r = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID().replace(/-/g, '').slice(0, 12) + : Math.random().toString(36).slice(2, 14) + return `${prefix}_${r}` +} diff --git a/test/mcp/dispatcher.test.ts b/test/mcp/dispatcher.test.ts new file mode 100644 index 0000000..7fa03ed --- /dev/null +++ b/test/mcp/dispatcher.test.ts @@ -0,0 +1,266 @@ +/** + * Unit tests for the MCP tool dispatcher. + * + * Drives `dispatch()` directly — no MCP transport, no JSON-RPC. The + * dispatcher is the meaty logic; transport is a thin pass-through tested + * separately. + * + * PDF + form tests use programmatically generated PDFs (no real browsers, + * no API keys) so the suite is deterministic and fast. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { PDFDocument, StandardFonts } from 'pdf-lib' +import { + dispatch, + createDispatcherState, + disposeAll, + type DispatcherState, +} from '../../src/mcp/dispatcher' +import { ALL_TOOLS } from '../../src/mcp/tool-defs' + +let state: DispatcherState +const tmpFiles: string[] = [] + +function tmpPath(suffix = '.pdf'): string { + const p = path.join( + os.tmpdir(), + `agentmark-mcp-test-${process.pid}-${Date.now()}-${Math.random()}${suffix}`, + ) + tmpFiles.push(p) + return p +} + +async function writeFormPdf(targetPath: string): Promise { + const doc = await PDFDocument.create() + doc.setTitle('MCP Test Form') + const page = doc.addPage([595, 842]) + const font = await doc.embedFont(StandardFonts.Helvetica) + const form = doc.getForm() + + const tf = form.createTextField('company') + tf.addToPage(page, { x: 50, y: 700, width: 200, height: 18, font }) + + const cb = form.createCheckBox('agree') + cb.addToPage(page, { x: 50, y: 650, width: 12, height: 12 }) + + const bytes = await doc.save() + await fs.writeFile(targetPath, bytes) +} + +beforeEach(() => { + state = createDispatcherState() +}) + +afterEach(async () => { + await disposeAll(state) + for (const p of tmpFiles.splice(0)) { + await fs.unlink(p).catch(() => {}) + } +}) + +describe('Tool registry', () => { + it('exports a non-empty list of unique tool definitions', () => { + expect(ALL_TOOLS.length).toBeGreaterThan(10) + const names = ALL_TOOLS.map((t) => t.name) + expect(new Set(names).size).toBe(names.length) + }) + + it('every tool has the AgentMark naming prefix', () => { + for (const t of ALL_TOOLS) { + expect(t.name).toMatch(/^agentmark_/) + } + }) + + it('every tool has a non-trivial description', () => { + for (const t of ALL_TOOLS) { + // Min 15 chars — short enough to allow concise tools like + // "Close a single page." while still catching empty/missing. + expect(t.description.length).toBeGreaterThanOrEqual(15) + } + }) + + it('every tool has a JSON-Schema-shaped inputSchema', () => { + for (const t of ALL_TOOLS) { + expect(t.inputSchema.type).toBe('object') + expect(typeof t.inputSchema.properties).toBe('object') + } + }) +}) + +describe('dispatch — error semantics', () => { + it('returns isError for unknown tool names', async () => { + const r = await dispatch(state, 'agentmark_nonexistent', {}) + expect(r.isError).toBe(true) + expect(r.text).toContain('Unknown tool') + }) + + it('returns isError when a required argument is missing', async () => { + const r = await dispatch(state, 'agentmark_browser_close', {}) + expect(r.isError).toBe(true) + expect(r.text).toMatch(/browser_id/) + }) + + it('returns isError when referencing an unknown session ID', async () => { + const r = await dispatch(state, 'agentmark_pdf_snapshot', { doc_id: 'bogus' }) + expect(r.isError).toBe(true) + expect(r.text).toMatch(/Unknown doc_id/) + }) + + it('errors include the AgentMark error code prefix when present', async () => { + const pdf = tmpPath() + await writeFormPdf(pdf) + const open = await dispatch(state, 'agentmark_pdf_open', { source: pdf }) + const docId = JSON.parse(open.text).doc_id + + const r = await dispatch(state, 'agentmark_pdf_execute', { + doc_id: docId, + action_id: 'act_does_not_exist', + }) + expect(r.isError).toBe(true) + expect(r.text).toMatch(/\[action_not_found\]/) + }) +}) + +describe('dispatch — pdf flow end-to-end', () => { + it('open → snapshot → execute → save round trip', async () => { + const pdfPath = tmpPath() + await writeFormPdf(pdfPath) + + // open + const open = await dispatch(state, 'agentmark_pdf_open', { source: pdfPath }) + expect(open.isError).not.toBe(true) + const opened = JSON.parse(open.text) + const docId = opened.doc_id as string + expect(opened.field_count).toBe(2) + + // snapshot + const snap = await dispatch(state, 'agentmark_pdf_snapshot', { doc_id: docId }) + expect(snap.isError).not.toBe(true) + expect(snap.text).toMatch(/^---/) + expect(snap.text).toContain('kind: form') + + // Find action IDs by parsing the snapshot's `actions:` block. + // (Cheaper than parsing YAML; we just need a couple action IDs.) + const ids = (snap.text.match(/^\s+(act_field_\d+):/gm) ?? []).map((m) => + m.trim().replace(':', ''), + ) + expect(ids.length).toBe(2) + + // execute (queue values) + const exec1 = await dispatch(state, 'agentmark_pdf_execute', { + doc_id: docId, + action_id: ids[0], + value: 'Acme Inc.', + }) + expect(exec1.isError).not.toBe(true) + const exec2 = await dispatch(state, 'agentmark_pdf_execute', { + doc_id: docId, + action_id: ids[1], + value: true, + }) + expect(exec2.isError).not.toBe(true) + + // save + const outPath = tmpPath('-filled.pdf') + const save = await dispatch(state, 'agentmark_pdf_save', { + doc_id: docId, + output_path: outPath, + }) + expect(save.isError).not.toBe(true) + const saved = JSON.parse(save.text) + expect(saved.output_path).toBe(path.resolve(outPath)) + expect(saved.bytes).toBeGreaterThan(100) + + // file actually exists with content + const stat = await fs.stat(outPath) + expect(stat.size).toBe(saved.bytes) + + // close + const close = await dispatch(state, 'agentmark_pdf_close', { doc_id: docId }) + expect(close.isError).not.toBe(true) + const reopen = await dispatch(state, 'agentmark_pdf_snapshot', { doc_id: docId }) + expect(reopen.isError).toBe(true) + }) + + it('reset clears pending values', async () => { + const pdfPath = tmpPath() + await writeFormPdf(pdfPath) + const open = await dispatch(state, 'agentmark_pdf_open', { source: pdfPath }) + const docId = JSON.parse(open.text).doc_id + + const snap = await dispatch(state, 'agentmark_pdf_snapshot', { doc_id: docId }) + const ids = (snap.text.match(/^\s+(act_field_\d+):/gm) ?? []).map((m) => + m.trim().replace(':', ''), + ) + await dispatch(state, 'agentmark_pdf_execute', { + doc_id: docId, + action_id: ids[0], + value: 'Will be reset', + }) + await dispatch(state, 'agentmark_pdf_reset', { doc_id: docId }) + + const list = await dispatch(state, 'agentmark_list_sessions', {}) + const json = JSON.parse(list.text) + expect(json.pdfs[0].pending).toBe(0) + }) + + it('open accepts a base64 data URI', async () => { + const pdfPath = tmpPath() + await writeFormPdf(pdfPath) + const bytes = await fs.readFile(pdfPath) + const dataUrl = `data:application/pdf;base64,${bytes.toString('base64')}` + const open = await dispatch(state, 'agentmark_pdf_open', { + source: dataUrl, + source_url: 'memory://test.pdf', + }) + expect(open.isError).not.toBe(true) + const opened = JSON.parse(open.text) + expect(opened.field_count).toBe(2) + expect(opened.source_url).toBe('memory://test.pdf') + }) +}) + +describe('dispatch — list_sessions', () => { + it('returns empty arrays when nothing is open', async () => { + const r = await dispatch(state, 'agentmark_list_sessions', {}) + const json = JSON.parse(r.text) + expect(json.browsers).toEqual([]) + expect(json.pdfs).toEqual([]) + }) + + it('lists open PDFs with their field counts', async () => { + const pdf1 = tmpPath() + const pdf2 = tmpPath() + await writeFormPdf(pdf1) + await writeFormPdf(pdf2) + await dispatch(state, 'agentmark_pdf_open', { source: pdf1 }) + await dispatch(state, 'agentmark_pdf_open', { source: pdf2 }) + + const r = await dispatch(state, 'agentmark_list_sessions', {}) + const json = JSON.parse(r.text) + expect(json.pdfs.length).toBe(2) + for (const pdf of json.pdfs) { + expect(pdf.field_count).toBe(2) + expect(pdf.pending).toBe(0) + } + }) +}) + +describe('disposeAll', () => { + it('closes every active resource and clears state', async () => { + const pdfPath = tmpPath() + await writeFormPdf(pdfPath) + await dispatch(state, 'agentmark_pdf_open', { source: pdfPath }) + await dispatch(state, 'agentmark_pdf_open', { source: pdfPath }) + + expect(state.pdfs.size).toBe(2) + await disposeAll(state) + expect(state.pdfs.size).toBe(0) + expect(state.browsers.size).toBe(0) + expect(state.pages.size).toBe(0) + }) +}) diff --git a/test/mcp/server-handshake.test.ts b/test/mcp/server-handshake.test.ts new file mode 100644 index 0000000..885b751 --- /dev/null +++ b/test/mcp/server-handshake.test.ts @@ -0,0 +1,102 @@ +/** + * Wire-level test for the MCP server. Connects an in-memory `Client` to an + * in-memory transport pair so we exercise the full handshake → ListTools → + * CallTool flow without spawning a subprocess. + * + * Verifies that the server: + * 1. Negotiates the MCP handshake correctly + * 2. Returns the full tool catalog on tools/list + * 3. Routes tool calls through the dispatcher and returns content blocks + * 4. Surfaces errors via isError on the response + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { createMcpServer } from '../../src/mcp/server' +import { disposeAll } from '../../src/mcp/dispatcher' + +let cleanup: Array<() => Promise> = [] + +afterEach(async () => { + for (const c of cleanup.splice(0)) { + await c().catch(() => {}) + } +}) + +async function connect() { + const { server, state } = createMcpServer({ name: 'agentmark-test', version: '0.7.0-test' }) + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + + const client = new Client( + { name: 'agentmark-test-client', version: '0.0.0' }, + { capabilities: {} }, + ) + await client.connect(clientTransport) + + cleanup.push(async () => { + await client.close().catch(() => {}) + await disposeAll(state) + await server.close().catch(() => {}) + }) + + return { client, server, state } +} + +describe('AgentMark MCP server (in-memory transport)', () => { + it('lists every defined AgentMark tool', async () => { + const { client } = await connect() + const result = await client.listTools() + const names = result.tools.map((t) => t.name) + expect(names.length).toBeGreaterThan(10) + expect(names).toEqual(expect.arrayContaining([ + 'agentmark_pdf_open', + 'agentmark_pdf_snapshot', + 'agentmark_pdf_execute', + 'agentmark_pdf_save', + 'agentmark_browser_open', + 'agentmark_page_navigate', + 'agentmark_page_snapshot', + 'agentmark_page_execute', + 'agentmark_list_sessions', + ])) + }) + + it('routes tool calls through the dispatcher and returns text content', async () => { + const { client } = await connect() + const result = await client.callTool({ + name: 'agentmark_list_sessions', + arguments: {}, + }) + expect(Array.isArray(result.content)).toBe(true) + const content = result.content as Array<{ type: string; text: string }> + expect(content.length).toBe(1) + expect(content[0].type).toBe('text') + const json = JSON.parse(content[0].text) + expect(json.browsers).toEqual([]) + expect(json.pdfs).toEqual([]) + }) + + it('surfaces dispatcher errors via isError on the response', async () => { + const { client } = await connect() + const result = await client.callTool({ + name: 'agentmark_pdf_snapshot', + arguments: { doc_id: 'bogus' }, + }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string; text: string }> + expect(content[0].text).toMatch(/Unknown doc_id/) + }) + + it('rejects calls to unknown tools', async () => { + const { client } = await connect() + const result = await client.callTool({ + name: 'agentmark_nope', + arguments: {}, + }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string; text: string }> + expect(content[0].text).toMatch(/Unknown tool/) + }) +})