diff --git a/src/mcp/dispatcher.ts b/src/mcp/dispatcher.ts index 3c52e88..dca2e5e 100644 --- a/src/mcp/dispatcher.ts +++ b/src/mcp/dispatcher.ts @@ -1,662 +1,87 @@ /** - * AgentMark MCP tool dispatcher — pure function that maps a tool name + - * arguments to an AgentMark operation. Stateless except for the session - * registries it receives. + * Backward-compatible facade for the legacy `dispatch()` / `createDispatcherState()` + * functional API. The real work happens in the plugin registry + * (`./plugin.ts`) and the per-capability plugins under `./plugins/`. * - * Kept separate from the MCP transport layer so tests can drive it - * directly without spinning up stdio + jsonrpc. + * Kept for two reasons: + * 1. The existing test suite drives `dispatch(state, name, args)` directly. + * 2. Third-party code that imports these functions shouldn't break across + * this refactor. + * + * Prefer the new API for new code: + * import { Dispatcher, createWebPlugin, createPdfPlugin, ... } from '@thinkfleet/agentmark' */ -import { readFile, writeFile } from 'node:fs/promises' -import * as path from 'node:path' -import { pathToFileURL } from 'node:url' -import { - createBrowser, - convertDesktop, - FixtureBackend, - MacosAxapiBackend, - WindowsUiaBackend, - openPdfDocument, - isAgentMarkError, - parseSnapshot, - PopplerRenderBackend, - TesseractOcrBackend, - type Browser, - type DesktopCaptureBackend, - type DesktopTarget, - type ExecuteDesktopAction, - type KeyModifier, - type Page, - type PdfDocument, - type OcrPipelineOptions, -} from '../index' -import { generateSessionId, type BrowserSession, type DesktopSession, type PdfSession } from './types' +import { Dispatcher, type AgentMarkPlugin, type DispatchResult } from './plugin' +import { createWebPlugin, type WebPlugin } from './plugins/web' +import { createPdfPlugin, type PdfPlugin } from './plugins/pdf' +import { createDesktopPlugin, type DesktopPlugin } from './plugins/desktop' +import { createMetaPlugin } from './plugins/meta' +import type { BrowserSession, DesktopSession, PdfSession } from './types' +import type { Page } from '../index' + +export type { DispatchResult } from './plugin' +/** + * Aggregate state container exposed by `createDispatcherState()`. The + * per-capability maps (`browsers`, `pages`, `pdfs`, `desktops`) are + * preserved for backward compatibility with code that reached into the + * dispatcher state directly. New code should use the `dispatcher` field + * (a `Dispatcher` instance) instead. + */ export interface DispatcherState { browsers: Map pages: Map pdfs: Map desktops: Map + /** The plugin registry that owns the handler routing. */ + dispatcher: Dispatcher + /** Plugins registered with this state, in registration order. */ + plugins: ReadonlyArray } +/** + * Build the default first-party plugin set (web + pdf + desktop + meta) + * and return a `DispatcherState` that exposes both the new dispatcher + * and the legacy per-capability maps. + */ export function createDispatcherState(): DispatcherState { + const web: WebPlugin = createWebPlugin() + const pdf: PdfPlugin = createPdfPlugin() + const desktop: DesktopPlugin = createDesktopPlugin() + const meta = createMetaPlugin([web, pdf, desktop]) + const plugins: AgentMarkPlugin[] = [web, pdf, desktop, meta] + const dispatcher = new Dispatcher(plugins) + return { - browsers: new Map(), - pages: new Map(), - pdfs: new Map(), - desktops: new Map(), + browsers: web.browsers, + pages: web.pages, + pdfs: pdf.pdfs, + desktops: desktop.desktops, + dispatcher, + plugins, } } -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 -} - +/** + * Route a tool invocation through the legacy state's dispatcher. + * Equivalent to `state.dispatcher.dispatch(name, args)`. + */ 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) - - // ── Desktop ────────────────────────────────────────────────── - case 'agentmark_desktop_open': - return await openDesktop(state, args) - case 'agentmark_desktop_close': - return await closeDesktop(state, args) - case 'agentmark_desktop_list_targets': - return await desktopListTargets(state, args) - case 'agentmark_desktop_snapshot': - return await desktopSnapshot(state, args) - case 'agentmark_desktop_execute': - return await desktopExecute(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 - - let ocr: OcrPipelineOptions | undefined - if (args.enable_ocr === true) { - const language = typeof args.ocr_language === 'string' ? args.ocr_language : 'eng' - const dpi = typeof args.ocr_dpi === 'number' ? args.ocr_dpi : 200 - ocr = { - render: new PopplerRenderBackend(), - ocr: new TesseractOcrBackend({ language }), - mode: 'auto', - dpi, - language, - } - } - - const document = await openPdfDocument({ data, sourceUrl, title, password, ocr }) - 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, - ocr_enabled: args.enable_ocr === true, - }, - 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.` } -} - -// ────────────────────────────────────────────────────────────────────────── -// Desktop tool handlers -// ────────────────────────────────────────────────────────────────────────── - -async function openDesktop(state: DispatcherState, args: Record): Promise { - const requested = typeof args.backend === 'string' ? args.backend : 'fixture' - const bridgePath = typeof args.bridge_path === 'string' ? args.bridge_path : undefined - - let backend: DesktopCaptureBackend - try { - switch (requested) { - case 'fixture': - backend = new FixtureBackend() - break - case 'windows_uia': - if (process.platform !== 'win32') { - return { - text: - `Backend "windows_uia" requires Windows (process.platform=='win32'). ` - + `Current platform: ${process.platform}. Use backend="fixture" for ` - + `in-memory testing, or run agentmark on a Windows host.`, - isError: true, - } - } - backend = new WindowsUiaBackend({ bridgePath }) - break - case 'macos_axapi': - if (process.platform !== 'darwin') { - return { - text: - `Backend "macos_axapi" requires macOS (process.platform=='darwin'). ` - + `Current platform: ${process.platform}. Use backend="fixture" for ` - + `in-memory testing, or run agentmark on a Mac host.`, - isError: true, - } - } - backend = new MacosAxapiBackend({ bridgePath }) - break - default: - return { text: `Unknown desktop backend: ${requested}`, isError: true } - } - } catch (err) { - return { - text: `Failed to initialise backend "${requested}": ${(err as Error).message}`, - isError: true, - } - } - - const id = generateSessionId('dt') - state.desktops.set(id, { - id, - backend, - createdAt: new Date(), - }) - return { - text: JSON.stringify({ desktop_id: id, backend: requested }, null, 2), - } -} - -async function closeDesktop(state: DispatcherState, args: Record): Promise { - const id = requireString(args, 'desktop_id') - const session = state.desktops.get(id) - if (!session) return { text: `Unknown desktop_id: ${id}`, isError: true } - await session.backend.close?.() - state.desktops.delete(id) - return { text: `Desktop session ${id} closed.` } -} - -async function desktopListTargets(state: DispatcherState, args: Record): Promise { - const id = requireString(args, 'desktop_id') - const session = requireDesktop(state, id) - const windows = await session.backend.listTargets() - return { - text: JSON.stringify({ windows }, null, 2), - } -} - -async function desktopSnapshot(state: DispatcherState, args: Record): Promise { - const id = requireString(args, 'desktop_id') - const session = requireDesktop(state, id) - - const target = parseTarget(args.target) - const maxDepth = typeof args.max_depth === 'number' ? args.max_depth : undefined - const includeHidden = args.include_hidden === true - const timeoutMs = typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined - - const { agentmark, binding } = await convertDesktop({ - backend: session.backend, - target, - maxDepth, - includeHidden, - timeoutMs, - }) - - // Cache binding + action types so subsequent _execute can resolve. - session.lastTarget = target - session.lastBinding = binding - const snap = parseSnapshot(agentmark) - session.lastActionTypes = new Map( - Object.entries(snap.actions ?? {}).map(([k, def]) => [k, def.type]), - ) - - return { text: agentmark } -} - -async function desktopExecute(state: DispatcherState, args: Record): Promise { - const id = requireString(args, 'desktop_id') - const actionId = requireString(args, 'action_id') - const session = requireDesktop(state, id) - - if (!session.lastBinding || !session.lastActionTypes) { - return { - text: - `No cached snapshot for desktop_id ${id}. Call ` - + `agentmark_desktop_snapshot first so the action_id can be resolved.`, - isError: true, - } - } - - const elementId = session.lastBinding.get(actionId) - if (!elementId) { - return { text: `Unknown action_id: ${actionId}`, isError: true } - } - - const actionType = session.lastActionTypes.get(actionId) ?? 'click' - const value = args.value - const modifiers = parseModifiers(args.modifiers) - const clearFirst = args.clear_first === true - - const action = buildExecuteAction(actionType, elementId, value, modifiers, clearFirst) - const result = await session.backend.execute({ - target: session.lastTarget, - action, - }) - - return { - text: JSON.stringify( - { - action_id: actionId, - action_type: actionType, - element_id: elementId, - ok: result.ok, - ...(result.message !== undefined ? { message: result.message } : {}), - ...(result.new_value !== undefined ? { new_value: result.new_value } : {}), - }, - null, - 2, - ), - isError: !result.ok, - } -} - -function parseTarget(input: unknown): DesktopTarget | undefined { - if (!input || typeof input !== 'object') return undefined - const t = input as Record - const out: DesktopTarget = {} - if (typeof t.process_name === 'string') out.process_name = t.process_name - if (typeof t.process_id === 'number') out.process_id = t.process_id - if (typeof t.window_title === 'string') out.window_title = t.window_title - if (typeof t.window_id === 'string') out.window_id = t.window_id - return Object.keys(out).length > 0 ? out : undefined -} - -function parseModifiers(input: unknown): KeyModifier[] | undefined { - if (!Array.isArray(input)) return undefined - const allowed: ReadonlySet = new Set(['ctrl', 'alt', 'shift', 'meta', 'win']) - const out: KeyModifier[] = [] - for (const m of input) { - if (typeof m === 'string' && allowed.has(m as KeyModifier)) out.push(m as KeyModifier) - } - return out.length > 0 ? out : undefined -} - -function buildExecuteAction( - actionType: string, - elementId: string, - value: unknown, - modifiers: KeyModifier[] | undefined, - clearFirst: boolean, -): ExecuteDesktopAction { - switch (actionType) { - case 'type': - return { - type: 'type', - element_id: elementId, - text: typeof value === 'string' ? value : String(value ?? ''), - clear_first: clearFirst, - } - case 'check': - return { - type: 'check', - element_id: elementId, - checked: value === true || value === 'true', - } - case 'select': - case 'multi_select': - return { - type: 'select', - element_id: elementId, - value: typeof value === 'string' ? value : String(value ?? ''), - } - case 'range': - return { - type: 'type', - element_id: elementId, - text: typeof value === 'number' ? String(value) : String(value ?? ''), - } - case 'key': - return { - type: 'key', - element_id: elementId, - key: typeof value === 'string' ? value : String(value ?? ''), - modifiers, - } - case 'scroll_to': - return { type: 'scroll_to', element_id: elementId } - default: - return { type: 'click', element_id: elementId } - } -} - -// ────────────────────────────────────────────────────────────────────────── -// 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(), - })), - desktops: Array.from(state.desktops.values()).map((s) => ({ - desktop_id: s.id, - backend: s.backend.name, - has_snapshot: s.lastBinding !== undefined, - created_at: s.createdAt.toISOString(), - })), - }, - null, - 2, - ), - } + return state.dispatcher.dispatch(name, args) } /** - * Dispose of every active resource — called on server shutdown. + * Dispose every resource held by the dispatcher's plugins. Equivalent to + * `state.dispatcher.dispose()`. Safe to call multiple times — plugins' + * own dispose hooks should be idempotent. */ 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(() => {})) - } - for (const session of state.desktops.values()) { - if (session.backend.close) closers.push(session.backend.close().catch(() => {})) - } - await Promise.allSettled(closers) - state.browsers.clear() - state.pages.clear() - state.pdfs.clear() - state.desktops.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 -} - -function requireDesktop(state: DispatcherState, desktopId: string): DesktopSession { - const session = state.desktops.get(desktopId) - if (!session) throw new Error(`Unknown desktop_id: ${desktopId}`) - return session -} - -/** - * 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) + await state.dispatcher.dispose() } // Re-export so unrelated callers don't need to reach into types.ts. diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 5351f30..65b89c3 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -3,16 +3,37 @@ * * 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. + * application, and for building third-party plugin packs (Microsoft, + * Insurance, etc.) against the AgentMarkPlugin contract. */ +// Server entry points export { startMcpServer, createMcpServer } from './server' export type { AgentMarkMcpServerOptions } from './server' + +// Legacy functional dispatcher API (kept for backward compatibility) export { dispatch, createDispatcherState, disposeAll, } from './dispatcher' -export type { DispatcherState, DispatchResult } from './dispatcher' +export type { DispatcherState } from './dispatcher' + +// Plugin contract — use this when authoring a new plugin pack. +export { Dispatcher } from './plugin' +export type { + AgentMarkPlugin, + DispatchResult, + ToolHandler, +} from './plugin' + +// First-party plugin factories — invoke these to assemble a custom plugin +// array (e.g. to add your own pack alongside the defaults). +export { createWebPlugin, type WebPlugin } from './plugins/web' +export { createPdfPlugin, type PdfPlugin } from './plugins/pdf' +export { createDesktopPlugin, type DesktopPlugin } from './plugins/desktop' +export { createMetaPlugin } from './plugins/meta' + +// Tool definition shape + the aggregated default list. export { ALL_TOOLS } from './tool-defs' export type { McpToolDef } from './tool-defs' diff --git a/src/mcp/plugin.ts b/src/mcp/plugin.ts new file mode 100644 index 0000000..78ac2eb --- /dev/null +++ b/src/mcp/plugin.ts @@ -0,0 +1,140 @@ +/** + * AgentMark MCP plugin contract. + * + * A plugin is a self-contained bundle of MCP tools that can be registered + * against an AgentMark MCP server. First-party capabilities (Web, PDF, + * Desktop, Meta) and third-party packs (Microsoft Workflows, Insurance, + * etc.) use this same shape — there is no special-casing for built-ins. + * + * Registration semantics: + * - Every tool name in `tools` must have a corresponding handler in + * `handlers`. Mismatched plugins are rejected at registration time. + * - No two plugins may declare the same tool name. Conflicts throw. + * - `dispose()` is invoked on server shutdown, in parallel across plugins. + * - `describeSessions()` contributes to `agentmark_list_sessions` output; + * keys from different plugins are merged into a single JSON object. + */ +import { isAgentMarkError } from '../errors' +import type { McpToolDef } from './tool-defs' + +export type ToolHandler = (args: Record) => Promise + +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 interface AgentMarkPlugin { + /** Unique plugin id. Used in diagnostics and conflict messages. */ + name: string + /** Optional plugin version, surfaced in diagnostics. */ + version?: string + /** Tool definitions this plugin contributes. */ + tools: McpToolDef[] + /** Handler for each tool name in `tools`. Keys must match exactly. */ + handlers: Record + /** Optional shutdown hook; called on server stop. */ + dispose?: () => Promise + /** + * Optional session-introspection hook. The returned object is merged + * into the `agentmark_list_sessions` payload. Use a top-level key + * scoped to this plugin (e.g. `browsers`, `pdfs`) to avoid collisions + * with other plugins. + */ + describeSessions?: () => Record +} + +/** + * Registry + router for MCP tools. Constructed from an ordered list of + * plugins. Stateless beyond the registration metadata it holds; per-tool + * state lives inside each plugin's closure. + */ +export class Dispatcher { + private readonly plugins: ReadonlyArray + private readonly handlerMap: Map + private readonly toolList: ReadonlyArray + + constructor(plugins: AgentMarkPlugin[]) { + const handlers = new Map() + const tools: McpToolDef[] = [] + const seen = new Map() // tool name -> plugin name + + for (const plugin of plugins) { + for (const def of plugin.tools) { + const handler = plugin.handlers[def.name] + if (!handler) { + throw new Error( + `Plugin "${plugin.name}" declares tool "${def.name}" ` + + `but provides no handler for it.`, + ) + } + const prev = seen.get(def.name) + if (prev) { + throw new Error( + `Tool name conflict: "${def.name}" is declared by both ` + + `plugin "${prev}" and plugin "${plugin.name}".`, + ) + } + seen.set(def.name, plugin.name) + handlers.set(def.name, handler) + tools.push(def) + } + } + + this.plugins = plugins + this.handlerMap = handlers + this.toolList = tools + } + + /** All tool definitions, in plugin-registration order. */ + get tools(): ReadonlyArray { + return this.toolList + } + + /** Set of registered tool names; useful for diagnostics. */ + get toolNames(): string[] { + return Array.from(this.handlerMap.keys()) + } + + /** + * Route a tool invocation to the owning plugin's handler. Unknown tools + * and thrown errors are wrapped as `isError` responses rather than + * propagated, matching the MCP server's expectations. + */ + async dispatch(name: string, args: Record): Promise { + const handler = this.handlerMap.get(name) + if (!handler) { + return { text: `Unknown tool: ${name}`, isError: true } + } + try { + return await handler(args) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const code = isAgentMarkError(err) ? `[${err.code}] ` : '' + return { text: `${code}${message}`, isError: true } + } + } + + /** + * Merged describeSessions output from every plugin that implements it. + * Last writer wins on key collisions — plugins should namespace their + * keys (`browsers`, `pdfs`, `desktops`, etc.) to avoid this. + */ + describeSessions(): Record { + const merged: Record = {} + for (const plugin of this.plugins) { + if (!plugin.describeSessions) continue + Object.assign(merged, plugin.describeSessions()) + } + return merged + } + + /** Run every plugin's dispose hook in parallel. */ + async dispose(): Promise { + await Promise.allSettled( + this.plugins.map((p) => (p.dispose ? p.dispose() : Promise.resolve())), + ) + } +} diff --git a/src/mcp/plugins/desktop.ts b/src/mcp/plugins/desktop.ts new file mode 100644 index 0000000..323293a --- /dev/null +++ b/src/mcp/plugins/desktop.ts @@ -0,0 +1,396 @@ +/** + * Desktop (AXAPI / UIA / fixture) MCP plugin. + * + * Wraps the AgentMark DesktopCaptureBackend protocol as MCP tools and + * owns its own DesktopSession map. + */ +import { + convertDesktop, + FixtureBackend, + MacosAxapiBackend, + WindowsUiaBackend, + parseSnapshot, + type DesktopCaptureBackend, + type DesktopTarget, + type ExecuteDesktopAction, + type KeyModifier, +} from '../../index' +import { generateSessionId, type DesktopSession } from '../types' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../plugin' +import type { McpToolDef } from '../tool-defs' + +const DESKTOP_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_desktop_open', + description: + 'Attach to a desktop accessibility-tree backend and return a ' + + 'desktop_id. Backends:\n' + + ' - "fixture" (default): pre-baked Excel + NowCerts trees, ' + + 'works on any OS — useful for testing without a real bridge ' + + 'installed.\n' + + ' - "windows_uia": connects to the Windows FlaUI sidecar ' + + 'process (requires the bridge running on the same machine).\n' + + ' - "macos_axapi": connects to the macOS AXAPI sidecar ' + + '(requires the bridge + Accessibility permission granted).\n' + + '\nThe session lives for the duration of the MCP connection ' + + 'unless explicitly closed.', + inputSchema: { + type: 'object', + properties: { + backend: { + type: 'string', + enum: ['fixture', 'windows_uia', 'macos_axapi'], + description: 'Which backend to use. Default: "fixture".', + }, + bridge_url: { + type: 'string', + description: + 'Override the bridge WebSocket URL (for non-fixture ' + + 'backends). Default: ws://127.0.0.1:9325/agentmark-bridge.', + }, + }, + }, + }, + { + name: 'agentmark_desktop_close', + description: 'Close a desktop session and release the bridge connection.', + inputSchema: { + type: 'object', + properties: { desktop_id: { type: 'string' } }, + required: ['desktop_id'], + }, + }, + { + name: 'agentmark_desktop_list_targets', + description: + 'Enumerate top-level windows the backend can see. Returns one ' + + 'lightweight summary per window (process_name, process_id, ' + + 'window_title, window_class, window_id, has_focus) without ' + + 'walking the full element tree. Use this to let the user (or ' + + 'agent) pick a window before calling agentmark_desktop_snapshot ' + + 'with that specific window_id.', + inputSchema: { + type: 'object', + properties: { desktop_id: { type: 'string' } }, + required: ['desktop_id'], + }, + }, + { + name: 'agentmark_desktop_snapshot', + description: + 'Capture an AgentMark snapshot of a desktop window. If `target` ' + + 'is omitted, the currently focused window is captured. The ' + + 'result is cached on the session so subsequent ' + + 'agentmark_desktop_execute calls can resolve action IDs back ' + + 'to native accessibility element IDs.', + inputSchema: { + type: 'object', + properties: { + desktop_id: { type: 'string' }, + target: { + type: 'object', + description: + 'Which window to capture. Provide any combination; ' + + 'the backend resolves whichever it can. Omit to ' + + 'capture the focused window.', + properties: { + process_name: { type: 'string' }, + process_id: { type: 'number' }, + window_title: { type: 'string' }, + window_id: { + type: 'string', + description: + 'Backend-defined opaque handle returned by a ' + + 'previous snapshot. Most precise targeting.', + }, + }, + }, + max_depth: { type: 'number', description: 'Maximum accessibility-tree depth to traverse. Default: 12.' }, + include_hidden: { type: 'boolean', description: 'Include off-screen / invisible elements. Default: false.' }, + timeout_ms: { type: 'number', description: 'Per-capture timeout in milliseconds. Default: 5000.' }, + }, + required: ['desktop_id'], + }, + }, + { + name: 'agentmark_desktop_execute', + description: + 'Execute an action against the most recent snapshot of a ' + + 'desktop session. `action_id` is one of the keys from the ' + + 'snapshot\'s `actions` map (e.g. `act_btn_save`). The MCP ' + + 'server resolves it to the underlying element via the ' + + 'ActionBinding captured at snapshot time.\n' + + '\nValue semantics by action type:\n' + + ' - click / focus / scroll_to: omit `value`\n' + + ' - type: string (text to enter)\n' + + ' - check: boolean (target state)\n' + + ' - select: string (option value or label)\n' + + ' - key: string (key name, e.g. "Enter", "F5") + optional ' + + '`modifiers` array', + inputSchema: { + type: 'object', + properties: { + desktop_id: { type: 'string' }, + action_id: { type: 'string' }, + value: { description: 'Value for input-style actions. Type depends on the action: string, boolean, etc.' }, + modifiers: { + type: 'array', + items: { type: 'string', enum: ['ctrl', 'alt', 'shift', 'meta', 'win'] }, + description: 'Key modifiers for `key` actions (or holding modifiers during a click).', + }, + clear_first: { + type: 'boolean', + description: 'For `type` actions, clear the existing value before typing. Default: false.', + }, + }, + required: ['desktop_id', 'action_id'], + }, + }, +] + +export interface DesktopPlugin extends AgentMarkPlugin { + readonly desktops: Map +} + +export function createDesktopPlugin(): DesktopPlugin { + const desktops = new Map() + + const requireDesktop = (id: string): DesktopSession => { + const s = desktops.get(id) + if (!s) throw new Error(`Unknown desktop_id: ${id}`) + return s + } + + const handlers: Record = { + agentmark_desktop_open: async (args): Promise => { + const requested = typeof args.backend === 'string' ? args.backend : 'fixture' + const bridgePath = typeof args.bridge_path === 'string' ? args.bridge_path : undefined + + let backend: DesktopCaptureBackend + try { + switch (requested) { + case 'fixture': + backend = new FixtureBackend() + break + case 'windows_uia': + if (process.platform !== 'win32') { + return { + text: + `Backend "windows_uia" requires Windows (process.platform=='win32'). ` + + `Current platform: ${process.platform}. Use backend="fixture" for ` + + `in-memory testing, or run agentmark on a Windows host.`, + isError: true, + } + } + backend = new WindowsUiaBackend({ bridgePath }) + break + case 'macos_axapi': + if (process.platform !== 'darwin') { + return { + text: + `Backend "macos_axapi" requires macOS (process.platform=='darwin'). ` + + `Current platform: ${process.platform}. Use backend="fixture" for ` + + `in-memory testing, or run agentmark on a Mac host.`, + isError: true, + } + } + backend = new MacosAxapiBackend({ bridgePath }) + break + default: + return { text: `Unknown desktop backend: ${requested}`, isError: true } + } + } catch (err) { + return { + text: `Failed to initialise backend "${requested}": ${(err as Error).message}`, + isError: true, + } + } + + const id = generateSessionId('dt') + desktops.set(id, { id, backend, createdAt: new Date() }) + return { text: JSON.stringify({ desktop_id: id, backend: requested }, null, 2) } + }, + + agentmark_desktop_close: async (args): Promise => { + const id = requireString(args, 'desktop_id') + const session = desktops.get(id) + if (!session) return { text: `Unknown desktop_id: ${id}`, isError: true } + await session.backend.close?.() + desktops.delete(id) + return { text: `Desktop session ${id} closed.` } + }, + + agentmark_desktop_list_targets: async (args): Promise => { + const id = requireString(args, 'desktop_id') + const session = requireDesktop(id) + const windows = await session.backend.listTargets() + return { text: JSON.stringify({ windows }, null, 2) } + }, + + agentmark_desktop_snapshot: async (args): Promise => { + const id = requireString(args, 'desktop_id') + const session = requireDesktop(id) + + const target = parseTarget(args.target) + const maxDepth = typeof args.max_depth === 'number' ? args.max_depth : undefined + const includeHidden = args.include_hidden === true + const timeoutMs = typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined + + const { agentmark, binding } = await convertDesktop({ + backend: session.backend, + target, + maxDepth, + includeHidden, + timeoutMs, + }) + + session.lastTarget = target + session.lastBinding = binding + const snap = parseSnapshot(agentmark) + session.lastActionTypes = new Map( + Object.entries(snap.actions ?? {}).map(([k, def]) => [k, def.type]), + ) + + return { text: agentmark } + }, + + agentmark_desktop_execute: async (args): Promise => { + const id = requireString(args, 'desktop_id') + const actionId = requireString(args, 'action_id') + const session = requireDesktop(id) + + if (!session.lastBinding || !session.lastActionTypes) { + return { + text: + `No cached snapshot for desktop_id ${id}. Call ` + + `agentmark_desktop_snapshot first so the action_id can be resolved.`, + isError: true, + } + } + + const elementId = session.lastBinding.get(actionId) + if (!elementId) { + return { text: `Unknown action_id: ${actionId}`, isError: true } + } + + const actionType = session.lastActionTypes.get(actionId) ?? 'click' + const modifiers = parseModifiers(args.modifiers) + const clearFirst = args.clear_first === true + const action = buildExecuteAction(actionType, elementId, args.value, modifiers, clearFirst) + + const result = await session.backend.execute({ target: session.lastTarget, action }) + + return { + text: JSON.stringify({ + action_id: actionId, + action_type: actionType, + element_id: elementId, + ok: result.ok, + ...(result.message !== undefined ? { message: result.message } : {}), + ...(result.new_value !== undefined ? { new_value: result.new_value } : {}), + }, null, 2), + isError: !result.ok, + } + }, + } + + return { + name: 'desktop', + tools: DESKTOP_TOOLS, + handlers, + desktops, + dispose: async () => { + await Promise.allSettled( + Array.from(desktops.values()) + .filter((s) => s.backend.close) + .map((s) => s.backend.close!()), + ) + desktops.clear() + }, + describeSessions: () => ({ + desktops: Array.from(desktops.values()).map((s) => ({ + desktop_id: s.id, + backend: s.backend.name, + has_snapshot: s.lastBinding !== undefined, + created_at: s.createdAt.toISOString(), + })), + }), + } +} + +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 parseTarget(input: unknown): DesktopTarget | undefined { + if (!input || typeof input !== 'object') return undefined + const t = input as Record + const out: DesktopTarget = {} + if (typeof t.process_name === 'string') out.process_name = t.process_name + if (typeof t.process_id === 'number') out.process_id = t.process_id + if (typeof t.window_title === 'string') out.window_title = t.window_title + if (typeof t.window_id === 'string') out.window_id = t.window_id + return Object.keys(out).length > 0 ? out : undefined +} + +function parseModifiers(input: unknown): KeyModifier[] | undefined { + if (!Array.isArray(input)) return undefined + const allowed: ReadonlySet = new Set(['ctrl', 'alt', 'shift', 'meta', 'win']) + const out: KeyModifier[] = [] + for (const m of input) { + if (typeof m === 'string' && allowed.has(m as KeyModifier)) out.push(m as KeyModifier) + } + return out.length > 0 ? out : undefined +} + +function buildExecuteAction( + actionType: string, + elementId: string, + value: unknown, + modifiers: KeyModifier[] | undefined, + clearFirst: boolean, +): ExecuteDesktopAction { + switch (actionType) { + case 'type': + return { + type: 'type', + element_id: elementId, + text: typeof value === 'string' ? value : String(value ?? ''), + clear_first: clearFirst, + } + case 'check': + return { + type: 'check', + element_id: elementId, + checked: value === true || value === 'true', + } + case 'select': + case 'multi_select': + return { + type: 'select', + element_id: elementId, + value: typeof value === 'string' ? value : String(value ?? ''), + } + case 'range': + return { + type: 'type', + element_id: elementId, + text: typeof value === 'number' ? String(value) : String(value ?? ''), + } + case 'key': + return { + type: 'key', + element_id: elementId, + key: typeof value === 'string' ? value : String(value ?? ''), + modifiers, + } + case 'scroll_to': + return { type: 'scroll_to', element_id: elementId } + default: + return { type: 'click', element_id: elementId } + } +} diff --git a/src/mcp/plugins/meta.ts b/src/mcp/plugins/meta.ts new file mode 100644 index 0000000..69da2cb --- /dev/null +++ b/src/mcp/plugins/meta.ts @@ -0,0 +1,46 @@ +/** + * Meta MCP plugin. + * + * Provides `agentmark_list_sessions`, which surfaces the merged session + * descriptors from every other plugin. The plugin must be registered AFTER + * the plugins it introspects so its handler can ask them for descriptors. + */ +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../plugin' +import type { McpToolDef } from '../tool-defs' + +const META_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_list_sessions', + description: + 'List all currently open browsers, pages, PDF documents, and ' + + 'desktop sessions with their IDs. Useful for debugging or ' + + 'recovering a stuck session.', + inputSchema: { + type: 'object', + properties: {}, + }, + }, +] + +/** + * Create the meta plugin. Takes a `peers` array used to gather session + * descriptors at call time (not at construction time, so late-registered + * plugins are reflected). + */ +export function createMetaPlugin(peers: ReadonlyArray): AgentMarkPlugin { + const handlers: Record = { + agentmark_list_sessions: async (): Promise => { + const merged: Record = {} + for (const peer of peers) { + if (peer.describeSessions) Object.assign(merged, peer.describeSessions()) + } + return { text: JSON.stringify(merged, null, 2) } + }, + } + + return { + name: 'meta', + tools: META_TOOLS, + handlers, + } +} diff --git a/src/mcp/plugins/pdf.ts b/src/mcp/plugins/pdf.ts new file mode 100644 index 0000000..2093b37 --- /dev/null +++ b/src/mcp/plugins/pdf.ts @@ -0,0 +1,262 @@ +/** + * PDF MCP plugin. + * + * Wraps the AgentMark PDF document API (open / snapshot / execute / save / + * reset / close) as MCP tools. Owns its own PdfSession map. + */ +import { readFile, writeFile } from 'node:fs/promises' +import * as path from 'node:path' +import { pathToFileURL } from 'node:url' +import { + openPdfDocument, + PopplerRenderBackend, + TesseractOcrBackend, + type PdfDocument, + type OcrPipelineOptions, +} from '../../index' +import { generateSessionId, type PdfSession } from '../types' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../plugin' +import type { McpToolDef } from '../tool-defs' + +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. ' + + 'Set enable_ocr=true for scanned PDFs or "Microsoft Print To PDF" ' + + 'output where text extraction yields nothing.', + 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.' }, + enable_ocr: { + type: 'boolean', + description: + 'Run OCR (Tesseract + Poppler) on pages with no extractable ' + + 'text. Requires `pdftoppm` on the worker host (macOS: ' + + '`brew install poppler`). Default: false.', + }, + ocr_language: { type: 'string', description: 'BCP-47 language hint for OCR. Default: eng.' }, + ocr_dpi: { type: 'number', description: 'DPI for OCR rasterization. Default: 200.' }, + }, + 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'], + }, + }, +] + +export interface PdfPlugin extends AgentMarkPlugin { + readonly pdfs: Map +} + +export function createPdfPlugin(): PdfPlugin { + const pdfs = new Map() + + const requirePdf = (id: string): PdfDocument => { + const s = pdfs.get(id) + if (!s) throw new Error(`Unknown doc_id: ${id}`) + return s.document + } + + const handlers: Record = { + agentmark_pdf_open: async (args): 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 + + let ocr: OcrPipelineOptions | undefined + if (args.enable_ocr === true) { + const language = typeof args.ocr_language === 'string' ? args.ocr_language : 'eng' + const dpi = typeof args.ocr_dpi === 'number' ? args.ocr_dpi : 200 + ocr = { + render: new PopplerRenderBackend(), + ocr: new TesseractOcrBackend({ language }), + mode: 'auto', + dpi, + language, + } + } + + const document = await openPdfDocument({ data, sourceUrl, title, password, ocr }) + const id = generateSessionId('pdf') + pdfs.set(id, { id, document, createdAt: new Date() }) + return { + text: JSON.stringify({ + doc_id: id, + source_url: sourceUrl, + field_count: document.fields.size, + ocr_enabled: args.enable_ocr === true, + }, null, 2), + } + }, + + agentmark_pdf_close: async (args): Promise => { + const id = requireString(args, 'doc_id') + const session = pdfs.get(id) + if (!session) return { text: `Unknown doc_id: ${id}`, isError: true } + await session.document.close() + pdfs.delete(id) + return { text: `PDF ${id} closed.` } + }, + + agentmark_pdf_snapshot: async (args): Promise => { + const id = requireString(args, 'doc_id') + const snap = await requirePdf(id).snapshot() + return { text: snap.agentmark } + }, + + agentmark_pdf_execute: async (args): Promise => { + const id = requireString(args, 'doc_id') + const actionId = requireString(args, 'action_id') + const doc = requirePdf(id) + await doc.execute(actionId, args.value) + return { + text: JSON.stringify({ + action_id: actionId, + pending_count: doc.pending.size, + }, null, 2), + } + }, + + agentmark_pdf_save: async (args): Promise => { + const id = requireString(args, 'doc_id') + const outputPath = path.resolve(requireString(args, 'output_path')) + const flatten = args.flatten === true + const bytes = await requirePdf(id).save({ flatten }) + await writeFile(outputPath, bytes) + return { + text: JSON.stringify({ + output_path: outputPath, + bytes: bytes.length, + flattened: flatten, + }, null, 2), + } + }, + + agentmark_pdf_reset: async (args): Promise => { + const id = requireString(args, 'doc_id') + requirePdf(id).reset() + return { text: `PDF ${id} pending values cleared.` } + }, + } + + return { + name: 'pdf', + tools: PDF_TOOLS, + handlers, + pdfs, + dispose: async () => { + await Promise.allSettled( + Array.from(pdfs.values()).map((s) => s.document.close()), + ) + pdfs.clear() + }, + describeSessions: () => ({ + pdfs: Array.from(pdfs.values()).map((s) => ({ + doc_id: s.id, + field_count: s.document.fields.size, + pending: s.document.pending.size, + created_at: s.createdAt.toISOString(), + })), + }), + } +} + +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 +} + +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) +} diff --git a/src/mcp/plugins/web.ts b/src/mcp/plugins/web.ts new file mode 100644 index 0000000..51a37dd --- /dev/null +++ b/src/mcp/plugins/web.ts @@ -0,0 +1,249 @@ +/** + * Web (Playwright/Chromium) MCP plugin. + * + * Wraps the AgentMark Browser/Page API as MCP tools. Owns its own + * browser + page session maps and disposes them on shutdown. + */ +import { createBrowser, type Browser, type Page } from '../../index' +import { generateSessionId, type BrowserSession } from '../types' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../plugin' +import type { McpToolDef } from '../tool-defs' + +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. Returns the resolved URL + HTTP status.', + inputSchema: { + type: 'object', + properties: { + page_id: { type: 'string' }, + url: { type: 'string' }, + wait_until: { + type: 'string', + enum: ['load', 'domcontentloaded', 'networkidle', 'commit'], + description: 'Playwright waitUntil semantics. Default: load.', + }, + timeout: { type: 'number', description: 'Per-navigation timeout in ms.' }, + }, + required: ['page_id', 'url'], + }, + }, + { + name: 'agentmark_page_snapshot', + description: + 'Capture an AgentMark snapshot of the page (YAML+markdown wire format). ' + + 'Returns interactive elements as `actions` the agent can call via ' + + 'agentmark_page_execute.', + inputSchema: { + type: 'object', + properties: { page_id: { type: 'string' } }, + required: ['page_id'], + }, + }, + { + name: 'agentmark_page_execute', + description: + 'Execute a snapshot action by its action_id. Optional `value` for ' + + 'input-style actions (string for text/select, boolean for checkbox).', + inputSchema: { + type: 'object', + properties: { + page_id: { type: 'string' }, + action_id: { type: 'string' }, + value: { description: 'Value for input actions; type varies by action.' }, + }, + required: ['page_id', 'action_id'], + }, + }, + { + name: 'agentmark_page_close', + description: 'Close a single page (keeps the browser alive).', + inputSchema: { + type: 'object', + properties: { page_id: { type: 'string' } }, + required: ['page_id'], + }, + }, +] + +export interface WebPlugin extends AgentMarkPlugin { + readonly browsers: Map + readonly pages: Map +} + +export function createWebPlugin(): WebPlugin { + const browsers = new Map() + const pages = new Map() + + const requirePage = (pageId: string): Page => { + const info = pages.get(pageId) + if (!info) throw new Error(`Unknown page_id: ${pageId}`) + return info.page + } + + const handlers: Record = { + agentmark_browser_open: async (args): 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') + browsers.set(id, { id, browser, pages: new Map(), createdAt: new Date() }) + return { text: JSON.stringify({ browser_id: id }, null, 2) } + }, + + agentmark_browser_close: async (args): Promise => { + const id = requireString(args, 'browser_id') + const session = browsers.get(id) + if (!session) return { text: `Unknown browser_id: ${id}`, isError: true } + for (const [pageId, info] of pages) { + if (info.browserId === id) pages.delete(pageId) + } + await session.browser.close() + browsers.delete(id) + return { text: `Browser ${id} closed.` } + }, + + agentmark_browser_save_session: async (args): Promise => { + const id = requireString(args, 'browser_id') + const targetPath = resolvePath(requireString(args, 'path')) + const session = browsers.get(id) + if (!session) return { text: `Unknown browser_id: ${id}`, isError: true } + await session.browser.saveSession(targetPath) + return { text: `Session saved to ${targetPath}` } + }, + + agentmark_page_open: async (args): Promise => { + const browserId = requireString(args, 'browser_id') + const session = browsers.get(browserId) + if (!session) return { text: `Unknown browser_id: ${browserId}`, isError: true } + const page = await session.browser.newPage() + const pageId = generateSessionId('pg') + pages.set(pageId, { browserId, page }) + session.pages.set(pageId, page) + return { text: JSON.stringify({ page_id: pageId, browser_id: browserId }, null, 2) } + }, + + agentmark_page_navigate: async (args): Promise => { + const pageId = requireString(args, 'page_id') + const url = requireString(args, 'url') + const page = requirePage(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) } + }, + + agentmark_page_snapshot: async (args): Promise => { + const pageId = requireString(args, 'page_id') + const snap = await requirePage(pageId).snapshot() + return { text: snap.agentmark } + }, + + agentmark_page_execute: async (args): Promise => { + const pageId = requireString(args, 'page_id') + const actionId = requireString(args, 'action_id') + const result = await requirePage(pageId).execute(actionId, args.value) + return { + text: JSON.stringify({ + action_id: result.actionId, + action_type: result.actionType, + duration_ms: result.durationMs, + }, null, 2), + } + }, + + agentmark_page_close: async (args): Promise => { + const pageId = requireString(args, 'page_id') + const info = pages.get(pageId) + if (!info) return { text: `Unknown page_id: ${pageId}`, isError: true } + await info.page.close() + pages.delete(pageId) + browsers.get(info.browserId)?.pages.delete(pageId) + return { text: `Page ${pageId} closed.` } + }, + } + + return { + name: 'web', + tools: WEB_TOOLS, + handlers, + browsers, + pages, + dispose: async () => { + await Promise.allSettled( + Array.from(browsers.values()).map((s) => s.browser.close()), + ) + browsers.clear() + pages.clear() + }, + describeSessions: () => ({ + browsers: Array.from(browsers.values()).map((s) => ({ + browser_id: s.id, + page_ids: Array.from(s.pages.keys()), + created_at: s.createdAt.toISOString(), + })), + }), + } +} + +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 resolvePath(p: string): string { + // Lazy import to avoid pulling node:path into bundlers when unused. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require('node:path') as typeof import('node:path') + return path.resolve(p) +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4205777..7bdc5a6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,10 +1,14 @@ /** * 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. + * Wraps the entire AgentMark library (web + PDF + form + OCR + desktop) + * 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. + * + * Plugin model: capabilities are registered as `AgentMarkPlugin`s. The + * default plugin set (web + pdf + desktop + meta) is loaded automatically + * unless the caller provides their own array. * * Transport: stdio (the most common MCP transport for desktop and CLI * clients). HTTP/SSE transports can be added later if needed. @@ -16,24 +20,30 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' -import { ALL_TOOLS } from './tool-defs' -import { - createDispatcherState, - dispatch, - disposeAll, - type DispatcherState, -} from './dispatcher' +import { createDispatcherState, type DispatcherState } from './dispatcher' +import { Dispatcher, type AgentMarkPlugin } from './plugin' export interface AgentMarkMcpServerOptions { /** Server name reported on the MCP handshake. */ name?: string /** Server version reported on the MCP handshake. */ version?: string + /** + * Override the default plugin set. When omitted, the first-party set + * (web + pdf + desktop + meta) is registered automatically. Pass an + * array to add your own plugins or to ship a subset. + */ + plugins?: AgentMarkPlugin[] } /** * Construct the MCP server (without connecting it). Used by tests that * inject custom transports or want to wire the dispatcher directly. + * + * Returns the `state` for backward compatibility. When the caller passes + * a custom `plugins` array, the legacy per-capability maps on `state` + * (`browsers`, `pdfs`, etc.) reflect only the first-party plugins that + * happen to be in the array; for new code, prefer `state.dispatcher`. */ export function createMcpServer(options: AgentMarkMcpServerOptions = {}): { server: Server @@ -51,15 +61,17 @@ export function createMcpServer(options: AgentMarkMcpServerOptions = {}): { }, ) - const state = createDispatcherState() + const state = options.plugins + ? buildCustomState(options.plugins) + : createDispatcherState() server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: ALL_TOOLS, + tools: Array.from(state.dispatcher.tools), })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params - const result = await dispatch(state, name, args ?? {}) + const result = await state.dispatcher.dispatch(name, args ?? {}) return { content: [{ type: 'text', text: result.text }], isError: result.isError === true, @@ -81,7 +93,7 @@ export async function startMcpServer( await server.connect(transport) const stop = async () => { - await disposeAll(state) + await state.dispatcher.dispose() await server.close().catch(() => {}) } @@ -101,3 +113,31 @@ export async function startMcpServer( return { stop } } + +/** + * Build a DispatcherState around a caller-supplied plugin array. The + * legacy per-capability maps are populated from any first-party plugin + * instances found in the array; if a category isn't represented, that + * map is empty. + */ +function buildCustomState(plugins: AgentMarkPlugin[]): DispatcherState { + const dispatcher = new Dispatcher(plugins) + return { + browsers: pickMap(plugins, 'web', 'browsers') as DispatcherState['browsers'], + pages: pickMap(plugins, 'web', 'pages') as DispatcherState['pages'], + pdfs: pickMap(plugins, 'pdf', 'pdfs') as DispatcherState['pdfs'], + desktops: pickMap(plugins, 'desktop', 'desktops') as DispatcherState['desktops'], + dispatcher, + plugins, + } +} + +function pickMap( + plugins: AgentMarkPlugin[], + name: string, + key: string, +): Map { + const found = plugins.find((p) => p.name === name) as Record | undefined + const candidate = found?.[key] + return candidate instanceof Map ? (candidate as Map) : new Map() +} diff --git a/src/mcp/tool-defs.ts b/src/mcp/tool-defs.ts index 70aa8a0..c169617 100644 --- a/src/mcp/tool-defs.ts +++ b/src/mcp/tool-defs.ts @@ -1,11 +1,17 @@ /** - * MCP tool definitions for AgentMark. + * MCP tool-definition shape and the aggregate `ALL_TOOLS` list. * - * 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. + * Each AgentMark capability lives in its own plugin under `./plugins/`. + * The tool definition arrays themselves are co-located with their handlers + * (so adding a new tool is a one-file change). `ALL_TOOLS` is the merged + * default set, exposed here for backward-compat with consumers that + * import it directly. */ +import { createWebPlugin } from './plugins/web' +import { createPdfPlugin } from './plugins/pdf' +import { createDesktopPlugin } from './plugins/desktop' +import { createMetaPlugin } from './plugins/meta' +import type { AgentMarkPlugin } from './plugin' export interface McpToolDef { name: string @@ -17,440 +23,26 @@ export interface McpToolDef { } } -// ────────────────────────────────────────────────────────────────────────── -// 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. ' - + 'Set enable_ocr=true for scanned PDFs or "Microsoft Print To PDF" ' - + 'output where text extraction yields nothing.', - 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.', - }, - enable_ocr: { - type: 'boolean', - description: - 'Run OCR (Tesseract + Poppler) on pages with no extractable ' - + 'text. Requires `pdftoppm` on the worker host (macOS: ' - + '`brew install poppler`). Default: false.', - }, - ocr_language: { - type: 'string', - description: 'BCP-47 language hint for OCR. Default: eng.', - }, - ocr_dpi: { - type: 'number', - description: 'DPI for OCR rasterization. Default: 200.', - }, - }, - 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'], - }, - }, -] - -// ────────────────────────────────────────────────────────────────────────── -// Desktop application tools (v0.4) -// ────────────────────────────────────────────────────────────────────────── - -const DESKTOP_TOOLS: McpToolDef[] = [ - { - name: 'agentmark_desktop_open', - description: - 'Attach to a desktop accessibility-tree backend and return a ' - + 'desktop_id. Backends:\n' - + ' - "fixture" (default): pre-baked Excel + NowCerts trees, ' - + 'works on any OS — useful for testing without a real bridge ' - + 'installed.\n' - + ' - "windows_uia": connects to the Windows FlaUI sidecar ' - + 'process (requires the bridge running on the same machine).\n' - + ' - "macos_axapi": connects to the macOS AXAPI sidecar ' - + '(requires the bridge + Accessibility permission granted).\n' - + '\nThe session lives for the duration of the MCP connection ' - + 'unless explicitly closed.', - inputSchema: { - type: 'object', - properties: { - backend: { - type: 'string', - enum: ['fixture', 'windows_uia', 'macos_axapi'], - description: 'Which backend to use. Default: "fixture".', - }, - bridge_url: { - type: 'string', - description: - 'Override the bridge WebSocket URL (for non-fixture ' - + 'backends). Default: ws://127.0.0.1:9325/agentmark-bridge.', - }, - }, - }, - }, - { - name: 'agentmark_desktop_close', - description: 'Close a desktop session and release the bridge connection.', - inputSchema: { - type: 'object', - properties: { - desktop_id: { type: 'string' }, - }, - required: ['desktop_id'], - }, - }, - { - name: 'agentmark_desktop_list_targets', - description: - 'Enumerate top-level windows the backend can see. Returns one ' - + 'lightweight summary per window (process_name, process_id, ' - + 'window_title, window_class, window_id, has_focus) without ' - + 'walking the full element tree. Use this to let the user (or ' - + 'agent) pick a window before calling agentmark_desktop_snapshot ' - + 'with that specific window_id.', - inputSchema: { - type: 'object', - properties: { - desktop_id: { type: 'string' }, - }, - required: ['desktop_id'], - }, - }, - { - name: 'agentmark_desktop_snapshot', - description: - 'Capture an AgentMark snapshot of a desktop window. If `target` ' - + 'is omitted, the currently focused window is captured. The ' - + 'result is cached on the session so subsequent ' - + 'agentmark_desktop_execute calls can resolve action IDs back ' - + 'to native accessibility element IDs.', - inputSchema: { - type: 'object', - properties: { - desktop_id: { type: 'string' }, - target: { - type: 'object', - description: - 'Which window to capture. Provide any combination; ' - + 'the backend resolves whichever it can. Omit to ' - + 'capture the focused window.', - properties: { - process_name: { type: 'string' }, - process_id: { type: 'number' }, - window_title: { type: 'string' }, - window_id: { - type: 'string', - description: - 'Backend-defined opaque handle returned by a ' - + 'previous snapshot. Most precise targeting.', - }, - }, - }, - max_depth: { - type: 'number', - description: - 'Maximum accessibility-tree depth to traverse. ' - + 'Default: 12.', - }, - include_hidden: { - type: 'boolean', - description: - 'Include off-screen / invisible elements. Default: false.', - }, - timeout_ms: { - type: 'number', - description: 'Per-capture timeout in milliseconds. Default: 5000.', - }, - }, - required: ['desktop_id'], - }, - }, - { - name: 'agentmark_desktop_execute', - description: - 'Execute an action against the most recent snapshot of a ' - + 'desktop session. `action_id` is one of the keys from the ' - + 'snapshot\'s `actions` map (e.g. `act_btn_save`). The MCP ' - + 'server resolves it to the underlying element via the ' - + 'ActionBinding captured at snapshot time.\n' - + '\nValue semantics by action type:\n' - + ' - click / focus / scroll_to: omit `value`\n' - + ' - type: string (text to enter)\n' - + ' - check: boolean (target state)\n' - + ' - select: string (option value or label)\n' - + ' - key: string (key name, e.g. "Enter", "F5") + optional ' - + '`modifiers` array', - inputSchema: { - type: 'object', - properties: { - desktop_id: { type: 'string' }, - action_id: { type: 'string' }, - value: { - description: - 'Value for input-style actions. Type depends on the ' - + 'action: string, boolean, etc.', - }, - modifiers: { - type: 'array', - items: { - type: 'string', - enum: ['ctrl', 'alt', 'shift', 'meta', 'win'], - }, - description: - 'Key modifiers for `key` actions (or holding modifiers ' - + 'during a click).', - }, - clear_first: { - type: 'boolean', - description: - 'For `type` actions, clear the existing value before ' - + 'typing. Default: false.', - }, - }, - required: ['desktop_id', 'action_id'], - }, - }, -] - -// ────────────────────────────────────────────────────────────────────────── -// Session inspection -// ────────────────────────────────────────────────────────────────────────── - -const META_TOOLS: McpToolDef[] = [ - { - name: 'agentmark_list_sessions', - description: - 'List all currently open browsers, pages, PDF documents, and ' - + 'desktop sessions with their IDs. Useful for debugging or ' - + 'recovering a stuck session.', - inputSchema: { - type: 'object', - properties: {}, - }, - }, -] +/** + * The default first-party plugin set, materialised purely to read off the + * union of tool definitions. The plugin instances themselves are thrown + * away — `ALL_TOOLS` is the only thing consumers see. + * + * If you're embedding AgentMark and want a custom plugin set, register + * `Dispatcher` with your own plugin array instead of relying on this. + */ +function buildDefaultToolList(): McpToolDef[] { + const web = createWebPlugin() + const pdf = createPdfPlugin() + const desktop = createDesktopPlugin() + const meta = createMetaPlugin([web, pdf, desktop]) + const plugins: AgentMarkPlugin[] = [web, pdf, desktop, meta] + const out: McpToolDef[] = [] + for (const p of plugins) out.push(...p.tools) + // Drop the placeholder plugin instances on the floor — they were only + // built to enumerate tool defs, not to hold runtime state. + void plugins + return out +} -export const ALL_TOOLS: McpToolDef[] = [...WEB_TOOLS, ...PDF_TOOLS, ...DESKTOP_TOOLS, ...META_TOOLS] +export const ALL_TOOLS: ReadonlyArray = buildDefaultToolList() diff --git a/test/mcp/plugin.test.ts b/test/mcp/plugin.test.ts new file mode 100644 index 0000000..7781323 --- /dev/null +++ b/test/mcp/plugin.test.ts @@ -0,0 +1,133 @@ +/** + * Smoke tests for the AgentMark MCP plugin contract. + * + * Covers the Dispatcher class directly (no MCP transport, no first-party + * plugins). Serves as the reference for third-party plugin authors. + */ + +import { describe, it, expect } from 'vitest' +import { + Dispatcher, + type AgentMarkPlugin, + type McpToolDef, +} from '../../src/mcp' + +function makePlugin( + name: string, + overrides: Partial = {}, +): AgentMarkPlugin { + const tool: McpToolDef = { + name: `${name}_ping`, + description: 'Demo tool.', + inputSchema: { type: 'object', properties: {} }, + } + return { + name, + tools: [tool], + handlers: { + [tool.name]: async () => ({ text: `pong from ${name}` }), + }, + ...overrides, + } +} + +describe('AgentMarkPlugin contract', () => { + it('dispatches a tool call to the owning plugin', async () => { + const dispatcher = new Dispatcher([makePlugin('alpha')]) + const result = await dispatcher.dispatch('alpha_ping', {}) + expect(result.isError).toBeFalsy() + expect(result.text).toBe('pong from alpha') + }) + + it('aggregates tools across plugins in registration order', () => { + const dispatcher = new Dispatcher([ + makePlugin('alpha'), + makePlugin('beta'), + makePlugin('gamma'), + ]) + expect(dispatcher.toolNames).toEqual(['alpha_ping', 'beta_ping', 'gamma_ping']) + }) + + it('rejects duplicate tool names across plugins', () => { + expect(() => + new Dispatcher([ + makePlugin('alpha'), + makePlugin('beta', { + tools: [{ + name: 'alpha_ping', // collision + description: 'x', + inputSchema: { type: 'object', properties: {} }, + }], + handlers: { alpha_ping: async () => ({ text: '' }) }, + }), + ]), + ).toThrow(/Tool name conflict.*alpha_ping/) + }) + + it('rejects a plugin that declares a tool without a handler', () => { + expect(() => + new Dispatcher([ + { + name: 'broken', + tools: [{ + name: 'broken_ping', + description: 'x', + inputSchema: { type: 'object', properties: {} }, + }], + handlers: {}, // intentionally empty + }, + ]), + ).toThrow(/declares tool "broken_ping" but provides no handler/) + }) + + it('returns isError when a tool is unknown', async () => { + const dispatcher = new Dispatcher([makePlugin('alpha')]) + const result = await dispatcher.dispatch('nope', {}) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/Unknown tool: nope/) + }) + + it('wraps thrown errors from handlers as isError responses', async () => { + const dispatcher = new Dispatcher([ + makePlugin('boom', { + handlers: { + boom_ping: async () => { throw new Error('kaboom') }, + }, + }), + ]) + const result = await dispatcher.dispatch('boom_ping', {}) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/kaboom/) + }) + + it('merges describeSessions output from every plugin', () => { + const dispatcher = new Dispatcher([ + makePlugin('alpha', { describeSessions: () => ({ alphas: [{ id: 'a1' }] }) }), + makePlugin('beta', { describeSessions: () => ({ betas: [{ id: 'b1' }] }) }), + ]) + const merged = dispatcher.describeSessions() + expect(merged).toEqual({ alphas: [{ id: 'a1' }], betas: [{ id: 'b1' }] }) + }) + + it('calls every plugin dispose hook on dispose()', async () => { + let alphaDisposed = false + let betaDisposed = false + const dispatcher = new Dispatcher([ + makePlugin('alpha', { dispose: async () => { alphaDisposed = true } }), + makePlugin('beta', { dispose: async () => { betaDisposed = true } }), + ]) + await dispatcher.dispose() + expect(alphaDisposed).toBe(true) + expect(betaDisposed).toBe(true) + }) + + it('continues disposing other plugins even if one throws', async () => { + let betaDisposed = false + const dispatcher = new Dispatcher([ + makePlugin('alpha', { dispose: async () => { throw new Error('alpha boom') } }), + makePlugin('beta', { dispose: async () => { betaDisposed = true } }), + ]) + await expect(dispatcher.dispose()).resolves.toBeUndefined() + expect(betaDisposed).toBe(true) + }) +})