diff --git a/.gitignore b/.gitignore index 8024e99d..8683f711 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ mcp/dist .env.* !.env.example .claude/settings.local.json + +examples diff --git a/mcp/package.json b/mcp/package.json index 74891d28..ba55801e 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -27,6 +27,7 @@ ], "scripts": { "build": "tsup", + "test": "pnpm build && node --test test/*.test.cjs", "watch": "tsup --watch", "dev": "pnpm build && pnpm watch", "start": "pnpm build && node dist/cli.js server", diff --git a/mcp/src/server/http.ts b/mcp/src/server/http.ts index 734f3e13..8c2bb7da 100644 --- a/mcp/src/server/http.ts +++ b/mcp/src/server/http.ts @@ -4,13 +4,9 @@ */ import { createServer, type IncomingMessage, type ServerResponse } from "http"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { TOOLS, handleTool, error as toolError } from "./mcp.js"; +import { createAgentationMcpServer } from "./mcp.js"; import { createSession, getSession, @@ -66,33 +62,25 @@ const agentConnections = new Set(); // MCP HTTP Transport // ----------------------------------------------------------------------------- -// Store transports by session ID for stateful sessions -const mcpTransports = new Map(); +type McpSession = { + server: McpServer; + transport: StreamableHTTPServerTransport; +}; + +// Keep both objects alive for the lifetime of each stateful MCP session. +const mcpSessions = new Map(); /** * Initialize a new MCP server with HTTP transport for a session. */ -function createMcpSession(): { server: Server; transport: StreamableHTTPServerTransport } { +async function createMcpSession(): Promise { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), }); - const server = new Server( - { name: "agentation", version: "0.0.1" }, - { capabilities: { tools: {} } } - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); - server.setRequestHandler(CallToolRequestSchema, async (req) => { - try { - return await handleTool(req.params.name, req.params.arguments); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return toolError(message); - } - }); + const server = createAgentationMcpServer(); - server.connect(transport); + await server.connect(transport); return { server, transport }; } @@ -710,11 +698,11 @@ async function handleMcp(req: IncomingMessage, res: ServerResponse): Promise((resolve, reject) => { let data = ""; @@ -744,10 +732,10 @@ async function handleMcp(req: IncomingMessage, res: ServerResponse): Promise { + try { + return await handleTool(name, args); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return error(message); + } +} + +function getToolDescription(name: string): string { + const tool = TOOLS.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`Missing MCP tool definition: ${name}`); + return tool.description; +} + +/** Create a high-level MCP server with all Agentation tools registered. */ +export function createAgentationMcpServer(): McpServer { + const server = new McpServer({ + name: "agentation", + version: "0.0.1", + }); + + server.registerTool( + "agentation_list_sessions", + { description: getToolDescription("agentation_list_sessions") }, + () => executeTool("agentation_list_sessions", undefined), + ); + server.registerTool( + "agentation_get_session", + { + description: getToolDescription("agentation_get_session"), + inputSchema: GetSessionSchema.shape, + }, + (args) => executeTool("agentation_get_session", args), + ); + server.registerTool( + "agentation_get_pending", + { + description: getToolDescription("agentation_get_pending"), + inputSchema: GetPendingSchema.shape, + }, + (args) => executeTool("agentation_get_pending", args), + ); + server.registerTool( + "agentation_get_all_pending", + { description: getToolDescription("agentation_get_all_pending") }, + () => executeTool("agentation_get_all_pending", undefined), + ); + server.registerTool( + "agentation_acknowledge", + { + description: getToolDescription("agentation_acknowledge"), + inputSchema: AcknowledgeSchema.shape, + }, + (args) => executeTool("agentation_acknowledge", args), + ); + server.registerTool( + "agentation_resolve", + { + description: getToolDescription("agentation_resolve"), + inputSchema: ResolveSchema.shape, + }, + (args) => executeTool("agentation_resolve", args), + ); + server.registerTool( + "agentation_dismiss", + { + description: getToolDescription("agentation_dismiss"), + inputSchema: DismissSchema.shape, + }, + (args) => executeTool("agentation_dismiss", args), + ); + server.registerTool( + "agentation_reply", + { + description: getToolDescription("agentation_reply"), + inputSchema: ReplySchema.shape, + }, + (args) => executeTool("agentation_reply", args), + ); + server.registerTool( + "agentation_watch_annotations", + { + description: getToolDescription("agentation_watch_annotations"), + inputSchema: WatchAnnotationsSchema.shape, + }, + (args) => executeTool("agentation_watch_annotations", args), + ); + + return server; +} + /** * Create and start the MCP server on stdio. * @param baseUrl - Optional HTTP server URL to fetch from (default: http://localhost:4747) @@ -704,33 +795,7 @@ export async function startMcpServer(baseUrl?: string): Promise { setHttpBaseUrl(baseUrl); } - const server = new Server( - { - name: "agentation", - version: "0.0.1", - }, - { - capabilities: { - tools: {}, - }, - } - ); - - // List available tools - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { tools: TOOLS }; - }); - - // Handle tool calls - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - try { - return await handleTool(name, args); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return error(message); - } - }); + const server = createAgentationMcpServer(); // Connect via stdio const transport = new StdioServerTransport(); diff --git a/mcp/src/server/sqlite.ts b/mcp/src/server/sqlite.ts index 007c386a..8a3704f3 100644 --- a/mcp/src/server/sqlite.ts +++ b/mcp/src/server/sqlite.ts @@ -102,6 +102,7 @@ function initDatabase(db: Database.Database): void { is_multi_select INTEGER DEFAULT 0, is_fixed INTEGER DEFAULT 0, react_components TEXT, + source_file TEXT, url TEXT, intent TEXT, severity TEXT, @@ -215,6 +216,7 @@ function rowToAnnotation(row: Record): Annotation { isMultiSelect: Boolean(row.is_multi_select), isFixed: Boolean(row.is_fixed), reactComponents: row.react_components as string | undefined, + sourceFile: row.source_file as string | undefined, kind, ...(kind === "placement" && extra?.placement ? { placement: extra.placement } : {}), ...(kind === "rearrange" && extra?.rearrange ? { rearrange: extra.rearrange } : {}), @@ -243,6 +245,7 @@ export function createSQLiteStore(dbPath?: string): AFSStore { // Safe migrations for new columns (no-ops if already exist) try { db.exec("ALTER TABLE annotations ADD COLUMN kind TEXT DEFAULT 'feedback'"); } catch {} try { db.exec("ALTER TABLE annotations ADD COLUMN extra TEXT"); } catch {} + try { db.exec("ALTER TABLE annotations ADD COLUMN source_file TEXT"); } catch {} // Restore event sequence from last event const lastEvent = db.prepare("SELECT MAX(sequence) as seq FROM events").get() as { seq: number | null }; @@ -269,13 +272,13 @@ export function createSQLiteStore(dbPath?: string): AFSStore { id, session_id, x, y, comment, element, element_path, timestamp, selected_text, bounding_box, nearby_text, css_classes, nearby_elements, computed_styles, full_path, accessibility, is_multi_select, is_fixed, - react_components, url, intent, severity, status, thread, created_at, + react_components, source_file, url, intent, severity, status, thread, created_at, updated_at, resolved_at, resolved_by, author_id, kind, extra ) VALUES ( @id, @sessionId, @x, @y, @comment, @element, @elementPath, @timestamp, @selectedText, @boundingBox, @nearbyText, @cssClasses, @nearbyElements, @computedStyles, @fullPath, @accessibility, @isMultiSelect, @isFixed, - @reactComponents, @url, @intent, @severity, @status, @thread, @createdAt, + @reactComponents, @sourceFile, @url, @intent, @severity, @status, @thread, @createdAt, @updatedAt, @resolvedAt, @resolvedBy, @authorId, @kind, @extra ) `), @@ -430,6 +433,7 @@ export function createSQLiteStore(dbPath?: string): AFSStore { isMultiSelect: annotation.isMultiSelect ? 1 : 0, isFixed: annotation.isFixed ? 1 : 0, reactComponents: annotation.reactComponents ?? null, + sourceFile: annotation.sourceFile ?? null, url: annotation.url ?? null, intent: annotation.intent ?? null, severity: annotation.severity ?? null, @@ -612,6 +616,7 @@ export function createTenantStore(dbPath?: string): TenantStore { // Safe migrations for new columns (no-ops if already exist) try { db.exec("ALTER TABLE annotations ADD COLUMN kind TEXT DEFAULT 'feedback'"); } catch {} try { db.exec("ALTER TABLE annotations ADD COLUMN extra TEXT"); } catch {} + try { db.exec("ALTER TABLE annotations ADD COLUMN source_file TEXT"); } catch {} // Restore event sequence from last event const lastEvent = db.prepare("SELECT MAX(sequence) as seq FROM events").get() as { seq: number | null }; diff --git a/mcp/src/types.ts b/mcp/src/types.ts index b6ba3ac7..d50585eb 100644 --- a/mcp/src/types.ts +++ b/mcp/src/types.ts @@ -21,6 +21,7 @@ export type Annotation = { isMultiSelect?: boolean; // true if created via drag selection isFixed?: boolean; // true if element has fixed/sticky positioning (marker stays fixed) reactComponents?: string; // React component hierarchy (e.g. "