diff --git a/AGENTS.md b/AGENTS.md index dafced4..d3afbec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,18 +98,45 @@ Add to your MCP client configuration: } ``` -**OpenCode (`.opencode/mcp.json`):** +**OpenCode (`.opencode/mcp.json` or `~/.config/opencode/opencode.json`):** + +*stdio (local process):* ```json { - "servers": { + "$schema": "https://opencode.ai/config.json", + "mcp": { "ask262": { - "command": "bun", - "args": ["run", "src/mcp-server.ts"] + "type": "local", + "command": ["bun", "run", "src/mcp-server.ts"], + "enabled": true, + "environment": { + "OLLAMA_HOST": "http://localhost:11434" + } } } } ``` +*http (stateless JSON server):* +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "ask262": { + "type": "remote", + "url": "http://localhost:3000/mcp", + "enabled": true + } + } +} +``` + +Start the HTTP server first: +```bash +bun run mcp-http # Development +ask262-http # After npm install -g +``` + ### Testing Test the MCP server before configuring your client: diff --git a/Readme.md b/Readme.md index 11172c1..d780719 100644 --- a/Readme.md +++ b/Readme.md @@ -50,6 +50,8 @@ Add to your MCP client configuration using `bunx` (no installation required): ``` **OpenCode** (global config `~/.config/opencode/opencode.json` or project config `opencode.json`): + +*stdio (local process):* ```json { "$schema": "https://opencode.ai/config.json", @@ -63,6 +65,28 @@ Add to your MCP client configuration using `bunx` (no installation required): } ``` +*http (stateless JSON server):* +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "ask262": { + "type": "remote", + "url": "http://localhost:3000/mcp", + "enabled": true + } + } +} +``` + +Start the HTTP server first: +```bash +bun run mcp-http # Development +ask262-http # After npm install -g +# Or with custom port: +ASK262_PORT=8080 ask262-http +``` + ## Available Tools | Tool | Description | diff --git a/bun.lock b/bun.lock index 9a23823..e2d6dcd 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "cheerio": "^1.2.0", "glob": "^13.0.6", "graphology": "^0.26.0", + "hono": "^4.12.14", "langchain": "^0.2.0", "ora": "^9.3.0", }, @@ -270,7 +271,7 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], @@ -496,6 +497,8 @@ "@langchain/ollama/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "@modelcontextprotocol/sdk/hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + "apache-arrow/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="], "chalk-template/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/package.json b/package.json index 36d432b..345a4d0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "type": "module", "main": "src/mcp-server.ts", "bin": { - "ask262": "src/mcp-server.ts" + "ask262": "src/mcp-server.ts", + "ask262-http": "src/mcp-server-http.ts" }, "scripts": { "type-check": "tsc --noEmit", @@ -23,6 +24,7 @@ "test-evaluate-timeout": "bun run src/test/manual/test-evaluate-timeout.ts", "test-search-spec-sections": "bun run src/test/manual/test-search-spec-sections.ts", "test-mcp-server": "bun run src/test/manual/test-mcp-server.ts", + "mcp-http": "bun run src/mcp-server-http.ts", "test": "bun test", "prepublish": "npm run type-check && npm run lint && npm pack --dry-run", "release": "bash scripts/release.sh --patch" @@ -57,6 +59,7 @@ "cheerio": "^1.2.0", "glob": "^13.0.6", "graphology": "^0.26.0", + "hono": "^4.12.14", "langchain": "^0.2.0", "ora": "^9.3.0" }, diff --git a/src/mcp-server-http.ts b/src/mcp-server-http.ts new file mode 100644 index 0000000..915389d --- /dev/null +++ b/src/mcp-server-http.ts @@ -0,0 +1,300 @@ +#!/usr/bin/env bun + +/** + * Ask262 MCP HTTP Server (Non-streaming) + * Provides HTTP-based MCP with normal JSON responses (no SSE). + * Uses Hono framework for clean routing and middleware. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { serve } from "@hono/node-server"; +import * as lancedbSdk from "@lancedb/lancedb"; +import { OllamaEmbeddings } from "@langchain/ollama"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { z } from "zod"; +import { + createEvaluateInEngine262Tool, + createGetSectionContentTool, + createSearchSpecSectionsTool, + evaluateInputSchema, + evaluateOutputSchema, + evaluateToolMetadata, + evaluateToolName, + getSectionInputSchema, + getSectionOutputSchema, + searchSpecInputSchema, + searchSpecOutputSchema, + searchSpecToolMetadata, + searchSpecToolName, + sectionContentToolMetadata, + sectionContentToolName, +} from "./agent-tools/index.js"; +import { + EMBEDDING_MODEL, + STORAGE_DIR as STORAGE_DIR_REL, +} from "./constants.js"; + +// Resolve storage path relative to this script's directory +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const STORAGE_DIR = path.resolve(__dirname, "..", STORAGE_DIR_REL); + +// Initialize embeddings +const embeddings = new OllamaEmbeddings({ + model: EMBEDDING_MODEL, + baseUrl: process.env.OLLAMA_HOST, +}); + +// Server port (default: 3000) +const PORT = Number(process.env.ASK262_PORT) || 3000; + +/** + * Factory function to create a fresh MCP server instance. + * Each HTTP request gets its own isolated server (stateless mode). + */ +async function createMcpServer() { + // 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", + }, + { + capabilities: { + prompts: {}, + }, + }, + ); + + // Register search spec tool + server.registerTool( + searchSpecToolName, + { + description: searchSpecToolMetadata.description, + inputSchema: searchSpecInputSchema, + outputSchema: searchSpecOutputSchema, + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ query }) => { + console.log(`[TOOL] ${searchSpecToolName}: query="${query}"`); + const result = await searchSpecTool({ query }); + console.log( + `[TOOL] ${searchSpecToolName}: ${result.results.length} results`, + ); + 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 }) => { + console.log( + `[TOOL] ${sectionContentToolName}: sectionId="${sectionId}" recursive=${recursive}`, + ); + const result = await getSectionContentTool({ sectionId, recursive }); + console.log( + `[TOOL] ${sectionContentToolName}: ${result.content.length} chars, ${result.sectionCount} sections`, + ); + 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: true, + openWorldHint: false, + }, + }, + async ({ code }) => { + console.log(`[TOOL] ${evaluateToolName}: code length=${code.length}`); + const result = await evaluateTool({ code }); + const isError = result.error !== undefined; + if (isError) { + console.log(`[TOOL] ${evaluateToolName}: error - ${result.error}`); + } else { + console.log( + `[TOOL] ${evaluateToolName}: ${result.importantSections.length} important, ${result.otherSections.length} other sections`, + ); + } + const text = isError ? result.error : JSON.stringify(result, null, 2); + return { + content: [{ type: "text", text }], + structuredContent: result, + isError, + }; + }, + ); + + // Register prompt for tool orchestration guidance + server.registerPrompt( + "ask", + { + description: + "Explains JavaScript internals from the ECMAScript specification.", + argsSchema: { + question: z.string().describe("Question"), + }, + }, + async ({ question }) => ({ + description: "Ask262 orchestration guide", + messages: [ + { + role: "user", + content: { + type: "text", + text: question, + }, + }, + { + role: "assistant", + content: { + type: "text", + text: `I'll help you explore this using the ECMAScript specification. Let me orchestrate the ask262 tools to find accurate information. + +Available tools: +- ask262_search_spec_sections: Vector search to find relevant spec section ids +- ask262_get_section_content: Retrieve full text from a spec section id +- ask262_evaluate_in_engine262: Execute pure JS and capture which spec section ids are hit. Has 1-second timeout for safety. + +Based on your question: "${question}" + +I'll use one of these orchestration patterns: + +PATTERN 1 - For "What happens when I run this code?" questions: + - Use ask262Debug.startImportant() and ask262Debug.stopImportant() in the code to mark only important sections. + - STEP 1: ask262_evaluate_in_engine262(code: markedCode) + - STEP 2: ask262_get_section_content(sectionId: importantSections[0], recursive: true) + - Explain which spec sections were hit and why + +PATTERN 2 - For "How does X work?" questions (e.g., "${question}"): + - Flow A: Generate a specific code example and follow Pattern 1 + - Flow B: If no code example possible, search broadly: + * STEP 1: ask262_search_spec_sections(query: relevant keywords from "${question}") + * STEP 2: ask262_get_section_content(sectionId: foundSectionId, recursive: true) + +I prefer Pattern 1 when possible as it provides exact spec sections through execution. + +Key principles: +- Ignore internal knowledge about Javascript/ECMAScript - rely only on spec sections from tools +- Reference specific spec sections by section id (sec-array.prototype.map) or number/name (e.g., "23.1.3.21 Array.prototype.map") +- If I can't generate a relevant code example, I'll ask you to provide one`, + }, + }, + ], + }), + ); + + return server; +} + +export async function main() { + // Create Hono app + const app = new Hono(); + + // Enable CORS for all origins + app.use( + "*", + cors({ + origin: "*", + allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], + allowHeaders: [ + "Content-Type", + "mcp-session-id", + "Last-Event-ID", + "mcp-protocol-version", + ], + exposeHeaders: ["mcp-session-id", "mcp-protocol-version"], + }), + ); + + // Health check endpoint + app.get("/health", (c) => c.json({ status: "ok" })); + + // MCP endpoint - handles both GET and POST + app.all("/mcp", async (c) => { + // Get parsed body from Hono (automatic JSON parsing) + let parsedBody: unknown; + if (c.req.method === "POST") { + try { + parsedBody = await c.req.json(); + } catch { + // Invalid JSON - let SDK handle the error + parsedBody = await c.req.text(); + } + } + + // Create fresh server and transport for each request (stateless mode) + const server = await createMcpServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // Stateless mode + enableJsonResponse: true, // Use JSON responses instead of SSE streaming + }); + await server.connect(transport); + + // Use Web Standard handleRequest method + // Hono's c.req.raw is a Web Standard Request + const response = await transport.handleRequest(c.req.raw, { parsedBody }); + + // Return the Web Standard Response directly + return response; + }); + + // Start the server + console.log(`Ask262 MCP HTTP Server running on http://localhost:${PORT}`); + console.log(`MCP endpoint: POST http://localhost:${PORT}/mcp`); + console.log(`Health check: GET http://localhost:${PORT}/health`); + console.log(`Mode: Stateless JSON (non-streaming)`); + + serve({ + fetch: app.fetch, + port: PORT, + }); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +});