From c35dcc8228c2c5ed845ba63ba4704dc613c58eab Mon Sep 17 00:00:00 2001 From: rrader26 Date: Sun, 10 May 2026 12:11:02 -0400 Subject: [PATCH] feat(mcp): MCP server for the entire AgentMark library (v0.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps every public surface — web browser, PDF documents, AcroForm filling, OCR — as a Model Context Protocol server. Any MCP client (Claude Desktop, Cursor, Claude Code, custom agents) can use AgentMark through one configuration entry. No SDK install, no language commitment. Configure in any MCP client: { "mcpServers": { "agentmark": { "command": "npx", "args": ["-y", "@thinkfleet/agentmark", "agentmark-mcp"] } } } 15 tools exposed - Browser (3): browser_open / browser_close / browser_save_session - Page (5): page_open / page_navigate / page_snapshot / page_execute / page_close - PDF (6): pdf_open (file path or data: URI) / pdf_close / pdf_snapshot / pdf_execute / pdf_save (with optional flatten) / pdf_reset - Meta (1): list_sessions for debugging stuck connections Architecture - src/mcp/tool-defs.ts: declarative tool catalog with full JSON Schema input descriptions - src/mcp/dispatcher.ts: pure dispatch function — name + args → result. Stateless except for the session registries it receives. Tested directly without transport. - src/mcp/server.ts: MCP Server wiring + StdioServerTransport binding + SIGINT/SIGTERM cleanup - src/mcp/cli.ts: Bin entry (#!/usr/bin/env node) — what npm installs as `agentmark-mcp` on PATH Stateful session model - Long-lived browsers + pages + opened PDF documents are held server-side, keyed by short opaque IDs returned from _open calls. Agents can drive multiple parallel surfaces from one MCP connection. - All resources auto-released on disposeAll() — invoked on shutdown via SIGINT/SIGTERM and exposed for tests. Programmatic API - createMcpServer() / startMcpServer() exported for embedding the server in larger applications. - dispatch() exported for tests / custom transports. Optional peer dependency - @modelcontextprotocol/sdk@^1 added as optional peer. Library callers who don't run the MCP server pay no install cost. bin entry - package.json now has "bin": { "agentmark-mcp": "./dist/src/mcp/cli.js" } - npm sets executable bit on install; shebang preserved through tsc Tests (213 unit + 10 real-Chromium = 223 total, was 199) - 14 dispatcher tests: - tool registry shape (uniqueness, naming prefix, schema validity) - error semantics (unknown tool, missing args, unknown session ID, AgentMark error code prefix surfacing) - PDF round trip (open → snapshot → execute → save with file IO) - reset clears pending values - data-URI PDF source (base64 in-memory loading) - list_sessions (empty + populated) - disposeAll closes everything - 4 wire-level handshake tests via InMemoryTransport — exercises the full MCP protocol (handshake, ListTools, CallTool, error responses) without spawning a subprocess Distribution unlocked After npm publish, anyone can configure AgentMark in any MCP client with the snippet above. The full SDK (web + PDF + OCR + AcroForm fill/save) becomes available as 15 tools any agent can call. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 54 +++++ README.md | 38 +++ package.json | 12 +- src/mcp/cli.ts | 35 +++ src/mcp/dispatcher.ts | 386 ++++++++++++++++++++++++++++++ src/mcp/index.ts | 18 ++ src/mcp/server.ts | 98 ++++++++ src/mcp/tool-defs.ts | 278 +++++++++++++++++++++ src/mcp/types.ts | 33 +++ test/mcp/dispatcher.test.ts | 266 ++++++++++++++++++++ test/mcp/server-handshake.test.ts | 102 ++++++++ 11 files changed, 1318 insertions(+), 2 deletions(-) create mode 100644 src/mcp/cli.ts create mode 100644 src/mcp/dispatcher.ts create mode 100644 src/mcp/index.ts create mode 100644 src/mcp/server.ts create mode 100644 src/mcp/tool-defs.ts create mode 100644 src/mcp/types.ts create mode 100644 test/mcp/dispatcher.test.ts create mode 100644 test/mcp/server-handshake.test.ts 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/) + }) +})