From 887ad3e5a40a8153b3c7882cee7ea2cfb636fae9 Mon Sep 17 00:00:00 2001 From: bendtherules Date: Tue, 14 Apr 2026 10:04:11 +0530 Subject: [PATCH] feat(mcp-server): Add MCP server with tool registrations Add MCP server implementation with support for: - ask262_search_spec_sections tool - ask262_get_section_content tool - ask262_evaluate_in_engine262 tool Includes proper error handling, input/output schemas, and structured content responses. --- AGENTS.md | 65 +++++ package.json | 3 + src/agent-tools/evaluateInEngine262.ts | 354 ++++++++++++++----------- src/agent-tools/getSectionContent.ts | 131 +++++---- src/agent-tools/index.ts | 29 +- src/agent-tools/searchSpecSections.ts | 77 ++++-- src/mcp-server.ts | 182 +++++++++++++ src/test/manual/test-mcp-server.ts | 139 ++++++++++ 8 files changed, 742 insertions(+), 238 deletions(-) create mode 100644 src/mcp-server.ts create mode 100644 src/test/manual/test-mcp-server.ts diff --git a/AGENTS.md b/AGENTS.md index 80308c3..979321a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ ask262/ │ ├── htmlAddInternalMethodLink.ts # Adds internal method links │ ├── text-splitters/ # Text chunking utilities │ └── utils/ # Formatting utilities +│ ├── mcp-server.ts # MCP server for external tool integration │ └── test/ # Manual verification tests │ └── manual/ │ ├── verify-db.ts # Verify database contents @@ -66,8 +67,72 @@ bun test # Run all tests bun run test-evaluate # Test evaluate in engine262 tool bun run test-search-spec-sections "query" # Test vector search tool with query bun run agent "Query" # Run agent with question +bun run mcp-server # Start MCP server (stdio transport) +bun run test-mcp-server # Test MCP server with all tools ``` +## MCP Server + +Ask262 can run as an MCP (Model Context Protocol) server, making its tools available to any MCP-compatible client (Claude Desktop, OpenCode, etc.). + +### Available MCP Tools + +| Tool | Description | +|------|-------------| +| `ask262_search_spec_sections` | Vector search ECMAScript spec for relevant sections | +| `ask262_get_section_content` | Retrieve full content from a spec section | +| `ask262_evaluate_in_engine262` | Execute JS in engine262 and capture spec sections | + +### Configuration + +Add to your MCP client configuration: + +**Claude Desktop (`claude_desktop_config.json`):** +```json +{ + "mcpServers": { + "ask262": { + "command": "bun", + "args": ["run", "/path/to/ask262/src/mcp-server.ts"], + "cwd": "/path/to/ask262" + } + } +} +``` + +**OpenCode (`.opencode/mcp.json`):** +```json +{ + "servers": { + "ask262": { + "command": "bun", + "args": ["run", "src/mcp-server.ts"] + } + } +} +``` + +### Testing + +Test the MCP server before configuring your client: + +```bash +# Run automated tests for all MCP tools +bun run test-mcp-server +``` + +This tests: +- Tool listing +- Vector search (`ask262_search_spec_sections`) +- Section content retrieval (`ask262_get_section_content`) +- Code evaluation with console capture (`ask262_evaluate_in_engine262`) + +### Prerequisites + +Before running the MCP server: +1. Ensure `storage/` directory exists with ingested spec vectors (`bun run ingest`) +2. Ensure Ollama is running with `qwen3-embedding:0.6b` model + ## Code Style Guidelines ### Imports & Modules diff --git a/package.json b/package.json index 112aba7..973dd52 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "test-search-spec-sections": "bun run src/test/manual/test-search-spec-sections.ts", "agent": "bun run src/agent.ts", "build": "bun run src/setup/buildGraph.ts", + "mcp-server": "bun run src/mcp-server.ts", + "test-mcp-server": "bun run src/test/manual/test-mcp-server.ts", "test": "bun test" }, "keywords": [], @@ -28,6 +30,7 @@ "@langchain/core": "^0.2.0", "@langchain/ollama": "^0.1.0", "@langchain/openai": "^0.1.0", + "@modelcontextprotocol/sdk": "^1.0.4", "acorn": "^8.16.0", "cheerio": "^1.2.0", "glob": "^13.0.6", diff --git a/src/agent-tools/evaluateInEngine262.ts b/src/agent-tools/evaluateInEngine262.ts index 3a103be..f4e9c6c 100644 --- a/src/agent-tools/evaluateInEngine262.ts +++ b/src/agent-tools/evaluateInEngine262.ts @@ -1,19 +1,45 @@ /** * Evaluate JavaScript code in engine262 and capture spec section marks. * Executes code in the engine262 JavaScript engine and returns the captured - * ECMAScript spec section marks as JSON. + * ECMAScript spec section marks. */ -import { DynamicStructuredTool } from "@langchain/core/tools"; import { z } from "zod"; +// #region Zod schemas (not exported) + +const consoleEntrySchema = z.object({ + method: z.string().describe("Console method name (log, warn, debug, error)"), + values: z.array(z.unknown()).describe("Values logged to console"), +}); + +const evaluateSuccessOutputSchema = z.object({ + importantSections: z + .array(z.string()) + .describe("Important spec sections hit during execution"), + otherSections: z + .array(z.string()) + .describe("Other spec sections hit during execution"), + consoleOutput: z + .array(consoleEntrySchema) + .describe("Console output captured during execution"), +}); + +const evaluateErrorOutputSchema = z.object({ + error: z.string().describe("Error message when execution fails"), +}); + +// #endregion + +// #region Exported Zod schemas + /** * Tool metadata for reuse in OpenCode tools. */ export const toolMetadata = { description: "Executes pure ECMAScript JavaScript code in the engine262 JavaScript engine and captures which ECMAScript specification sections are hit during execution. " + - "Returns JSON with importantSections, otherSections, and consoleOutput arrays. Useful for understanding how specific JavaScript operations map to the ECMAScript spec. " + + "Returns an object with importantSections, otherSections, and consoleOutput arrays. Useful for understanding how specific JavaScript operations map to the ECMAScript spec. " + "Code must be pure ECMAScript with no DOM, browser, or Node.js APIs (no fs, document, window, etc.). " + "console object with log/warn/debug/error methods and ask262Debug are available globally (no import needed). " + "Use ask262Debug.startImportant() and ask262Debug.stopImportant() to mark important sections. " + @@ -23,10 +49,42 @@ export const toolMetadata = { }, }; -const evaluateSchema = z.object({ +/** + * Input schema for the evaluate tool. + */ +export const inputSchema = z.object({ code: z.string().describe(toolMetadata.args.code), }); +/** + * Output schema for the evaluate tool (union of success and error outputs). + */ +export const outputSchema = z.union([ + evaluateSuccessOutputSchema, + evaluateErrorOutputSchema, +]); + +// #endregion + +// #region TypeScript types (inferred from Zod schemas) + +export type ConsoleEntry = z.infer; + +export type EvaluateSuccessOutput = z.infer; + +export type EvaluateErrorOutput = z.infer; + +export type EvaluateToolOutput = z.infer; + +export type EvaluateToolInput = z.infer; + +// #endregion + +/** + * Tool name constant. + */ +export const toolName = "ask262_evaluate_in_engine262"; + // Type definitions for engine262 module interface MarkData { readonly sectionIds: string[]; @@ -35,12 +93,6 @@ interface MarkData { readonly important: boolean; } -// Console log entry type -interface ConsoleEntry { - method: string; - values: unknown[]; -} - // biome-ignore lint/suspicious/noExplicitAny: engine262 is an external module without TypeScript types let engine262Module: any = null; @@ -56,166 +108,148 @@ async function loadEngine262() { } /** - * Creates the evaluateInEngine262 tool. + * Creates the evaluateInEngine262 tool function. * Executes JavaScript code in engine262 and captures spec section marks. - * @returns The evaluate_in_engine262 tool instance + * @returns Function that executes code and returns structured output */ export function createEvaluateInEngine262Tool() { - return new DynamicStructuredTool({ - name: "ask262_evaluate_in_engine262", - description: toolMetadata.description, - schema: evaluateSchema, - func: async ({ code }) => { - try { - const engine = await loadEngine262(); - const ask262Debug = engine.ask262Debug as { - marks: MarkData[]; - startTrace: () => void; - stopTrace: () => void; - startImportant: () => void; - stopImportant: () => void; - }; - const Agent = engine.Agent; - const ManagedRealm = engine.ManagedRealm; - const setSurroundingAgent = engine.setSurroundingAgent; - const OrdinaryObjectCreate = engine.OrdinaryObjectCreate; - const CreateBuiltinFunction = engine.CreateBuiltinFunction; - const CreateDataProperty = engine.CreateDataProperty; - const Value = engine.Value; - const skipDebugger = engine.skipDebugger; + return async ({ code }: EvaluateToolInput): Promise => { + const engine = await loadEngine262(); + const ask262Debug = engine.ask262Debug as { + marks: MarkData[]; + startTrace: () => void; + stopTrace: () => void; + startImportant: () => void; + stopImportant: () => void; + }; + const Agent = engine.Agent; + const ManagedRealm = engine.ManagedRealm; + const setSurroundingAgent = engine.setSurroundingAgent; + const OrdinaryObjectCreate = engine.OrdinaryObjectCreate; + const CreateBuiltinFunction = engine.CreateBuiltinFunction; + const CreateDataProperty = engine.CreateDataProperty; + const Value = engine.Value; + const skipDebugger = engine.skipDebugger; - // Reset marks from previous runs - ask262Debug.marks = []; + // TODO: Add reset method, allow making instances and use that. + // Reset marks from previous runs + ask262Debug.marks = []; - // Array to capture console output - const consoleOutput: ConsoleEntry[] = []; + // Array to capture console output + const consoleOutput: ConsoleEntry[] = []; - // Set up agent and realm - const agent = new Agent(); - setSurroundingAgent(agent); - const realm = new ManagedRealm(); + // Set up agent and realm + const agent = new Agent(); + setSurroundingAgent(agent); + const realm = new ManagedRealm(); - // Expose ask262Debug and console to the evaluated code - realm.scope(() => { - const debugObj = OrdinaryObjectCreate( - agent.intrinsic("%Object.prototype%"), - ); - skipDebugger( - CreateDataProperty( - realm.GlobalObject, - Value("ask262Debug"), - debugObj, - ), - ); + // Expose ask262Debug and console to the evaluated code + realm.scope(() => { + const debugObj = OrdinaryObjectCreate( + agent.intrinsic("%Object.prototype%"), + ); + skipDebugger( + CreateDataProperty(realm.GlobalObject, Value("ask262Debug"), debugObj), + ); - const startImportant = CreateBuiltinFunction( - () => { - ask262Debug.startImportant(); - return Value.undefined; - }, - 0, - Value("startImportant"), - [], - ); - skipDebugger( - CreateDataProperty( - debugObj, - Value("startImportant"), - startImportant, - ), - ); + const startImportant = CreateBuiltinFunction( + () => { + ask262Debug.startImportant(); + // biome-ignore lint/suspicious/noExplicitAny: engine262 uses custom value types + return (Value as any)("undefined"); + }, + 0, + Value("startImportant"), + [], + ); + skipDebugger( + CreateDataProperty(debugObj, Value("startImportant"), startImportant), + ); - const stopImportant = CreateBuiltinFunction( - () => { - ask262Debug.stopImportant(); - return Value.undefined; - }, - 0, - Value("stopImportant"), - [], - ); - skipDebugger( - CreateDataProperty(debugObj, Value("stopImportant"), stopImportant), - ); + const stopImportant = CreateBuiltinFunction( + () => { + ask262Debug.stopImportant(); + // biome-ignore lint/suspicious/noExplicitAny: engine262 uses custom value types + return (Value as any)("undefined"); + }, + 0, + Value("stopImportant"), + [], + ); + skipDebugger( + CreateDataProperty(debugObj, Value("stopImportant"), stopImportant), + ); - // Create console object with methods (excluding 'clear') - const consoleObj = OrdinaryObjectCreate( - agent.intrinsic("%Object.prototype%"), - ); - skipDebugger( - CreateDataProperty( - realm.GlobalObject, - Value("console"), - consoleObj, - ), - ); + // Create console object with methods (excluding 'clear') + const consoleObj = OrdinaryObjectCreate( + agent.intrinsic("%Object.prototype%"), + ); + skipDebugger( + CreateDataProperty(realm.GlobalObject, Value("console"), consoleObj), + ); - // Add console methods: log, warn, debug, error - const consoleMethods = ["log", "warn", "debug", "error"]; - for (const method of consoleMethods) { - const fn = CreateBuiltinFunction( - (args: unknown[]) => { - // Convert engine262 values to JavaScript values for the output - const jsValues = args.map((arg) => { - // Handle engine262 Value types - convert to primitive JS values - if (arg && typeof arg === "object") { - // Try to get string value if it's a JSStringValue - const strVal = (arg as { stringValue?: () => string }) - .stringValue; - if (typeof strVal === "function") { - return strVal.call(arg); - } - // Try other common properties - const value = (arg as { value?: unknown }).value; - if (value !== undefined) { - return value; - } - } - return arg; - }); - consoleOutput.push({ method, values: jsValues }); - return Value.undefined; - }, - 1, - Value(method), - [], - ); - skipDebugger(CreateDataProperty(consoleObj, Value(method), fn)); - } - }); - - // Start tracing - ask262Debug.startTrace(); - - // Execute the code - realm.evaluateScript(code); - - // Stop tracing - ask262Debug.stopTrace(); - - // Get captured marks - const marks = ask262Debug.marks; - - // Filter and group marks by important flag - const importantMarks = marks.filter((m) => m.important); - const otherMarks = marks.filter((m) => !m.important); - - // Extract sectionIds, remove fileRelativePath and lineNumber - const result = { - importantSections: importantMarks.map((m) => m.sectionIds), - otherSections: otherMarks.map((m) => m.sectionIds), - consoleOutput: consoleOutput, - }; - - // Return compressed JSON - return JSON.stringify(result); - } catch (error) { - console.error(`[Tool: ask262_evaluate_in_engine262] Error: ${error}`); - const errorResult = { - error: error instanceof Error ? error.message : String(error), - }; - return JSON.stringify(errorResult); + // Add console methods: log, warn, debug, error + const consoleMethods = ["log", "warn", "debug", "error"]; + for (const method of consoleMethods) { + const fn = CreateBuiltinFunction( + (args: unknown[]) => { + // Convert engine262 values to JavaScript values for the output + const jsValues = args.map((arg) => { + // Handle engine262 Value types - convert to primitive JS values + if (arg && typeof arg === "object") { + // Try to get string value if it's a JSStringValue + const strVal = (arg as { stringValue?: () => string }) + .stringValue; + if (typeof strVal === "function") { + return strVal.call(arg); + } + // Try other common properties + const value = (arg as { value?: unknown }).value; + if (value !== undefined) { + return value; + } + } + return arg; + }); + consoleOutput.push({ method, values: jsValues }); + // biome-ignore lint/suspicious/noExplicitAny: engine262 uses custom value types + return (Value as any)("undefined"); + }, + 1, + Value(method), + [], + ); + skipDebugger(CreateDataProperty(consoleObj, Value(method), fn)); } - }, - }); + }); + + // Start tracing + ask262Debug.startTrace(); + + try { + // Execute the code - only this part can fail + realm.evaluateScript(code); + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + }; + } + + // Stop tracing + ask262Debug.stopTrace(); + + // Get captured marks + const marks = ask262Debug.marks; + + // Filter and group marks by important flag + const importantMarks = marks.filter((m) => m.important); + const otherMarks = marks.filter((m) => !m.important); + + // Flatten sectionIds from all marks + return { + importantSections: importantMarks.flatMap((m) => m.sectionIds), + otherSections: otherMarks.flatMap((m) => m.sectionIds), + consoleOutput: consoleOutput, + }; + }; } diff --git a/src/agent-tools/getSectionContent.ts b/src/agent-tools/getSectionContent.ts index df6ae53..1ea9196 100644 --- a/src/agent-tools/getSectionContent.ts +++ b/src/agent-tools/getSectionContent.ts @@ -4,12 +4,25 @@ */ import type { Table } from "@lancedb/lancedb"; -import { DynamicStructuredTool } from "@langchain/core/tools"; import { z } from "zod"; -/** - * Tool metadata for reuse in OpenCode tools. - */ +// #region Zod schemas (not exported) + +const sectionContentSchema = z.object({ + text: z.string(), + sectionTitle: z.string().optional(), + partIndex: z.number().optional(), +}); + +const getSectionContentOutputSchema = z.object({ + content: z.string(), + sectionCount: z.number(), +}); + +// #endregion + +// #region Exported Zod schemas + export const toolMetadata = { description: "Retrieves all text chunks from a specific specification section by sectionid. " + @@ -24,68 +37,88 @@ export const toolMetadata = { }, }; -const getSectionContentSchema = z.object({ +export const inputSchema = z.object({ sectionId: z.string().describe(toolMetadata.args.sectionId), recursive: z.boolean().default(true).describe(toolMetadata.args.recursive), }); +export const outputSchema = getSectionContentOutputSchema; + +export const toolName = "ask262_get_section_content"; + +// #endregion + +// #region TypeScript types (inferred from Zod schemas) + +export type SectionContent = z.infer; + +export type GetSectionContentOutput = z.infer< + typeof getSectionContentOutputSchema +>; + +export type GetSectionContentInput = z.infer; + +// #endregion + /** - * Creates the get section content tool. + * Creates the get section content tool function. * Retrieves all text chunks from a specific specification section by sectionid. * Supports recursive fetching - if a section has children, it will fetch all descendants. * @param table - LanceDB table containing spec vectors + * @returns Function that retrieves content and returns structured output */ export function createGetSectionContentTool(table: Table) { - return new DynamicStructuredTool({ - name: "ask262_get_section_content", - description: toolMetadata.description, - schema: getSectionContentSchema, - func: async ({ sectionId, recursive }) => { - const allDocs: string[] = []; - const queue: string[] = [sectionId]; - const visited = new Set(); + return async ({ + sectionId, + recursive, + }: GetSectionContentInput): Promise => { + const allDocs: string[] = []; + const queue: string[] = [sectionId]; + const visited = new Set(); - while (queue.length > 0) { - const currentId = queue.shift()!; - if (visited.has(currentId)) continue; - visited.add(currentId); + while (queue.length > 0) { + const currentId = queue.shift(); + if (!currentId || visited.has(currentId)) continue; + visited.add(currentId); - const results = await table - .query() - .where(`sectionid = '${currentId}'`) - .limit(100) - .toArray(); + const results = await table + .query() + .where(`sectionid = '${currentId}'`) + .limit(100) + .toArray(); - // Sort by partindex to maintain order (nulls last for single-part sections) - const sortedResults = results.sort((a: unknown, b: unknown) => { - const aIndex = (a as { partindex?: number }).partindex ?? Infinity; - const bIndex = (b as { partindex?: number }).partindex ?? Infinity; - return aIndex - bIndex; - }); + // Sort by partindex to maintain order (nulls last for single-part sections) + const sortedResults = results.sort((a: unknown, b: unknown) => { + const aIndex = (a as { partindex?: number }).partindex ?? Infinity; + const bIndex = (b as { partindex?: number }).partindex ?? Infinity; + return aIndex - bIndex; + }); - for (const result of sortedResults) { - const typedResult = result as { - text?: string; - childrensectionids?: string[]; - sectiontitle?: string; - }; + for (const result of sortedResults) { + const typedResult = result as { + text?: string; + childrensectionids?: string[]; + sectiontitle?: string; + }; - if (typedResult.text) { - allDocs.push(typedResult.text); - } + if (typedResult.text) { + allDocs.push(typedResult.text); + } - // Add children to queue for recursive fetching only if recursive is true - if ( - recursive && - typedResult.childrensectionids && - Array.isArray(typedResult.childrensectionids) - ) { - queue.push(...typedResult.childrensectionids); - } + // Add children to queue for recursive fetching only if recursive is true + if ( + recursive && + typedResult.childrensectionids && + Array.isArray(typedResult.childrensectionids) + ) { + queue.push(...typedResult.childrensectionids); } } + } - return allDocs.join("\n\n---\n\n"); - }, - }); + return { + content: allDocs.join("\n\n---\n\n"), + sectionCount: visited.size, + }; + }; } diff --git a/src/agent-tools/index.ts b/src/agent-tools/index.ts index 0ee8ce5..9394888 100644 --- a/src/agent-tools/index.ts +++ b/src/agent-tools/index.ts @@ -4,16 +4,35 @@ */ export { + type ConsoleEntry, createEvaluateInEngine262Tool, + type EvaluateErrorOutput, + type EvaluateSuccessOutput, + type EvaluateToolInput, + type EvaluateToolOutput, + inputSchema as evaluateInputSchema, + outputSchema as evaluateOutputSchema, toolMetadata as evaluateToolMetadata, -} from "./evaluateInEngine262"; + toolName as evaluateToolName, +} from "./evaluateInEngine262.js"; export { createGetSectionContentTool, + type GetSectionContentInput, + type GetSectionContentOutput, + inputSchema as getSectionInputSchema, + outputSchema as getSectionOutputSchema, + type SectionContent, toolMetadata as sectionContentToolMetadata, -} from "./getSectionContent"; -export { createGraphExplorerTool } from "./graphExplorer"; -export { type RerankResult, rerankDocuments } from "./reranker"; + toolName as sectionContentToolName, +} from "./getSectionContent.js"; +export { createGraphExplorerTool } from "./graphExplorer.js"; export { createSearchSpecSectionsTool, + inputSchema as searchSpecInputSchema, + outputSchema as searchSpecOutputSchema, + type SearchSpecInput, + type SearchSpecOutput, + type SearchSpecResult, toolMetadata as searchSpecToolMetadata, -} from "./searchSpecSections"; + toolName as searchSpecToolName, +} from "./searchSpecSections.js"; diff --git a/src/agent-tools/searchSpecSections.ts b/src/agent-tools/searchSpecSections.ts index 0142246..a4e4b38 100644 --- a/src/agent-tools/searchSpecSections.ts +++ b/src/agent-tools/searchSpecSections.ts @@ -4,17 +4,32 @@ */ import type { Table } from "@lancedb/lancedb"; -import { DynamicStructuredTool } from "@langchain/core/tools"; import type { OllamaEmbeddings } from "@langchain/ollama"; import { z } from "zod"; -/** - * Tool metadata for reuse in OpenCode tools. - */ +// #region Zod schemas (not exported) + +const searchSpecResultSchema = z.object({ + sectionId: z.string(), + sectionTitle: z.string(), + score: z.number(), + partIndex: z.number().nullable(), + totalParts: z.number().nullable(), + content: z.string(), +}); + +const searchSpecOutputSchema = z.object({ + results: z.array(searchSpecResultSchema), +}); + +// #endregion + +// #region Exported Zod schemas + export const toolMetadata = { description: "Vector search the ECMAScript specification for sections relevant to a query. " + - "Returns JSON array with sectionId, sectionTitle, score, partIndex, totalParts, and content. " + + "Returns an array of sections with sectionId, sectionTitle, score, partIndex, totalParts, and content. " + "partIndex and totalParts indicate which chunk of a multi-part section this is " + "(0-indexed, partIndex+1/totalParts), null if single-part.", args: { @@ -23,42 +38,56 @@ export const toolMetadata = { }, }; -const searchSpecSchema = z.object({ +export const inputSchema = z.object({ query: z.string().describe(toolMetadata.args.query), }); +export const outputSchema = searchSpecOutputSchema; + +export const toolName = "ask262_search_spec_sections"; + +// #endregion + +// #region TypeScript types (inferred from Zod schemas) + +export type SearchSpecResult = z.infer; + +export type SearchSpecOutput = z.infer; + +export type SearchSpecInput = z.infer; + +// #endregion + /** - * Creates the search spec sections tool. + * Creates the search spec sections tool function. * Performs semantic vector search to find relevant spec sections. * @param table - LanceDB table containing spec vectors * @param embeddings - Ollama embeddings instance + * @returns Function that performs the search and returns structured output */ export function createSearchSpecSectionsTool( table: Table, embeddings: OllamaEmbeddings, ) { - return new DynamicStructuredTool({ - name: "ask262_search_spec_sections", - description: toolMetadata.description, - schema: searchSpecSchema, - func: async ({ query }) => { - // Generate embedding for the query - const queryVector = await embeddings.embedQuery(query); + return async ({ query }: SearchSpecInput): Promise => { + // Generate embedding for the query + const queryVector = await embeddings.embedQuery(query); - // Search using LanceDB directly, limit to top 5 results - const results = await table.search(queryVector).limit(5).toArray(); + // Search using LanceDB directly, limit to top 5 results + const results = await table.search(queryVector).limit(5).toArray(); - // Return documents with metadata as JSON - const output = results.map((r: Record) => ({ + // Return documents with metadata as structured objects + const output: SearchSpecResult[] = results.map( + (r: Record) => ({ sectionId: String(r.sectionid || "unknown"), sectionTitle: String(r.sectiontitle || "unknown"), score: Number(r._distance || 0), - partIndex: r.partindex ?? null, - totalParts: r.totalparts ?? null, + partIndex: (r.partindex as number | undefined) ?? null, + totalParts: (r.totalparts as number | undefined) ?? null, content: String(r.text || ""), - })); + }), + ); - return JSON.stringify(output); - }, - }); + return { results: output }; + }; } diff --git a/src/mcp-server.ts b/src/mcp-server.ts new file mode 100644 index 0000000..ffc59c7 --- /dev/null +++ b/src/mcp-server.ts @@ -0,0 +1,182 @@ +/** + * Ask262 MCP Server + * Provides MCP-compatible tools for exploring the ECMAScript specification. + */ + +import * as lancedbSdk from "@lancedb/lancedb"; +import { OllamaEmbeddings } from "@langchain/ollama"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + createEvaluateInEngine262Tool, + createGetSectionContentTool, + createSearchSpecSectionsTool, + type EvaluateToolInput, + type EvaluateToolOutput, + evaluateInputSchema, + evaluateOutputSchema, + evaluateToolMetadata, + evaluateToolName, + type GetSectionContentInput, + type GetSectionContentOutput, + getSectionInputSchema, + getSectionOutputSchema, + type SearchSpecInput, + type SearchSpecOutput, + searchSpecInputSchema, + searchSpecOutputSchema, + searchSpecToolMetadata, + searchSpecToolName, + sectionContentToolMetadata, + sectionContentToolName, +} from "./agent-tools/index.js"; +import { STORAGE_DIR } from "./constants.js"; + +// #region MCP Types + +/** + * MCP text content item for tool responses. + */ +export interface McpTextContent { + type: "text"; + text: string; +} + +// Base MCP output type with index signature for SDK compatibility +interface McpToolOutputBase { + [key: string]: unknown; + content: McpTextContent[]; +} + +// Evaluate tool MCP types +export type EvaluateToolMCPInput = EvaluateToolInput; + +export interface EvaluateToolMCPOutput extends McpToolOutputBase { + structuredContent: EvaluateToolOutput; + isError?: boolean; +} + +// Get section content tool MCP types +export type GetSectionContentMCPInput = GetSectionContentInput; + +export interface GetSectionContentMCPOutput extends McpToolOutputBase { + structuredContent: GetSectionContentOutput; + isError?: boolean; +} + +// Search spec tool MCP types +export type SearchSpecMCPInput = SearchSpecInput; + +export interface SearchSpecMCPOutput extends McpToolOutputBase { + structuredContent: SearchSpecOutput; + isError?: boolean; +} + +// #endregion + +// Initialize embeddings +const embeddings = new OllamaEmbeddings({ + model: "qwen3-embedding:0.6b", +}); + +async function main() { + // Connect to LanceDB + const db = await lancedbSdk.connect(STORAGE_DIR); + const table = await db.openTable("spec_vectors"); + + // Create tool instances + const searchSpecTool = createSearchSpecSectionsTool(table, embeddings); + const getSectionContentTool = createGetSectionContentTool(table); + const evaluateTool = createEvaluateInEngine262Tool(); + + // Create MCP server + const server = new McpServer({ + name: "ask262-server", + version: "1.0.0", + }); + + // Register search spec tool + server.registerTool( + searchSpecToolName, + { + description: searchSpecToolMetadata.description, + inputSchema: searchSpecInputSchema, + outputSchema: searchSpecOutputSchema, + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ query }: SearchSpecMCPInput): Promise => { + const result = await searchSpecTool({ query }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + structuredContent: result, + isError: false, + }; + }, + ); + + // Register get section content tool + server.registerTool( + sectionContentToolName, + { + description: sectionContentToolMetadata.description, + inputSchema: getSectionInputSchema, + outputSchema: getSectionOutputSchema, + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ + sectionId, + recursive, + }: GetSectionContentMCPInput): Promise => { + const result = await getSectionContentTool({ sectionId, recursive }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + structuredContent: result, + isError: false, + }; + }, + ); + + // Register evaluate in engine262 tool + server.registerTool( + evaluateToolName, + { + description: evaluateToolMetadata.description, + inputSchema: evaluateInputSchema, + outputSchema: evaluateOutputSchema, + annotations: { + readOnlyHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ code }: EvaluateToolMCPInput): Promise => { + const result = await evaluateTool({ code }); + const isError = "error" in result; + const text = isError ? result.error : JSON.stringify(result, null, 2); + return { + content: [{ type: "text", text }], + structuredContent: result, + isError, + }; + }, + ); + + // Use stdio transport for communication + const transport = new StdioServerTransport(); + await server.connect(transport); + + console.error("Ask262 MCP Server running on stdio"); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/src/test/manual/test-mcp-server.ts b/src/test/manual/test-mcp-server.ts new file mode 100644 index 0000000..2cfdaa7 --- /dev/null +++ b/src/test/manual/test-mcp-server.ts @@ -0,0 +1,139 @@ +/** + * Test script for Ask262 MCP Server + * Tests all available tools + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { + EvaluateToolMCPOutput, + GetSectionContentMCPOutput, + SearchSpecMCPOutput, +} from "../../mcp-server.js"; + +async function testMCPServer() { + console.log("Starting MCP Server tests...\n"); + + // Create transport connecting to the server + const transport = new StdioClientTransport({ + command: "bun", + args: ["run", "src/mcp-server.ts"], + }); + + // Create client + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + try { + // Connect to server + await client.connect(transport); + console.log("✓ Connected to MCP server\n"); + + // Test 1: List tools + console.log("Test 1: Listing available tools..."); + const tools = await client.listTools(); + console.log(`Found ${tools.tools.length} tools:`); + for (const tool of tools.tools) { + const desc = tool.description ?? "No description"; + console.log(` - ${tool.name}: ${desc.substring(0, 60)}...`); + } + console.log("✓ Tools listed successfully\n"); + + // Test 2: Search spec sections + console.log("Test 2: Testing ask262_search_spec_sections..."); + const searchResult = (await client.callTool({ + name: "ask262_search_spec_sections", + arguments: { + query: "array map method", + }, + })) as SearchSpecMCPOutput; + if (searchResult.isError) { + throw new Error(`Search failed: ${searchResult.content[0]?.text}`); + } + const searchData = searchResult.structuredContent; + console.log(`Found ${searchData.results?.length ?? 0} sections`); + if (searchData.results && searchData.results.length > 0) { + console.log( + ` First result: ${searchData.results[0].sectionId} - ${searchData.results[0].sectionTitle}`, + ); + } + console.log("✓ Search working (isError: false)\n"); + + // Test 3: Get section content + console.log("Test 3: Testing ask262_get_section_content..."); + const contentResult = (await client.callTool({ + name: "ask262_get_section_content", + arguments: { + sectionId: "sec-array-prototype-map", + recursive: false, + }, + })) as GetSectionContentMCPOutput; + if (contentResult.isError) { + throw new Error(`Get section failed: ${contentResult.content[0]?.text}`); + } + const contentData = contentResult.structuredContent; + console.log( + `Content length: ${contentData.content?.length ?? 0} characters`, + ); + console.log(`Sections visited: ${contentData.sectionCount ?? 0}`); + console.log("✓ Section content retrieved (isError: false)\n"); + + // Test 4: Evaluate in engine262 - success case + console.log("Test 4: Testing ask262_evaluate_in_engine262 (success)..."); + const evalResult = (await client.callTool({ + name: "ask262_evaluate_in_engine262", + arguments: { + code: "console.log('test'); let x = 1 + 2; console.log(x);", + }, + })) as EvaluateToolMCPOutput; + if (evalResult.isError) { + throw new Error(`Evaluate failed: ${evalResult.content[0]?.text}`); + } + // Type guard: check for error in structuredContent + const evalData = evalResult.structuredContent; + if ("error" in evalData) { + throw new Error(`Unexpected error: ${evalData.error}`); + } + console.log( + `Important sections: ${evalData.importantSections?.length ?? 0}`, + ); + console.log(`Other sections: ${evalData.otherSections?.length ?? 0}`); + console.log(`Console output: ${JSON.stringify(evalData.consoleOutput)}`); + console.log("✓ Code evaluation working (isError: false)\n"); + + // Test 5: Evaluate in engine262 - error case + console.log("Test 5: Testing ask262_evaluate_in_engine262 (error)..."); + const evalErrorResult = (await client.callTool({ + name: "ask262_evaluate_in_engine262", + arguments: { + code: "invalid syntax here @#$%", + }, + })) as EvaluateToolMCPOutput; + if (!evalErrorResult.isError) { + throw new Error("Expected error but got success"); + } + // Verify error is in structuredContent + const errorData = evalErrorResult.structuredContent; + if (!("error" in errorData)) { + throw new Error("Expected error in structuredContent"); + } + console.log(`Got expected error: ${errorData.error}`); + console.log("✓ Error handling working (isError: true)\n"); + + console.log("All tests passed! ✓"); + } catch (error) { + console.error("Test failed:", error); + process.exit(1); + } finally { + await client.close(); + } +} + +testMCPServer();