diff --git a/CHANGELOG.md b/CHANGELOG.md index d5a61f2..504162b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ All notable changes to this project are documented here. This project follows - **AI bug catching**: `aiReview(response)` returns a list of likely problems (bad types, missing fields, leaked secrets, status/body mismatches), and `aiFuzz(options)` generates adversarial request payloads to probe an endpoint. +- **MCP server** (`two-go-mcp` binary, `two-go/mcp`): a zero-dependency Model + Context Protocol server over stdio so agents can call `http_request` and the + importer and schema helpers as tools. ## [0.4.0] diff --git a/README.md b/README.md index b8b4401..16fe53e 100644 --- a/README.md +++ b/README.md @@ -669,6 +669,38 @@ for (const body of payloads) { Both are advisory. `aiReview` hands you findings, `aiFuzz` hands you inputs, and you decide what to do with them. +## MCP server + +two-go ships an MCP (Model Context Protocol) server so an AI agent like Claude +can drive it directly: make HTTP calls, generate suites, infer and validate +schemas. It runs over stdio with no dependencies. + +Register it with your MCP client. For Claude the config looks like: + +```json +{ + "mcpServers": { + "two-go": { "command": "npx", "args": ["-y", "two-go-mcp"] } + } +} +``` + +The tools it exposes: + +- `http_request`: send a request and get back status, headers, timing, and body. +- `gen_openapi` / `gen_postman`: generate a suite from a spec or collection. +- `infer_schema`: infer a JSON schema from a value. +- `validate_schema`: validate a value against a schema. + +The server logic is also importable if you want to host it yourself: + +```js +import { createServer } from "two-go/mcp"; + +const server = createServer(); +const response = await server.handle({ jsonrpc: "2.0", id: 1, method: "tools/list" }); +``` + ## TypeScript Types are written by hand and shipped with the package, so you get diff --git a/bin/two-go-mcp.js b/bin/two-go-mcp.js new file mode 100644 index 0000000..44cd51e --- /dev/null +++ b/bin/two-go-mcp.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +// two-go MCP server over stdio. Reads newline-delimited JSON-RPC messages on +// stdin and writes responses on stdout, so an MCP client (Claude and others) +// can use two-go's tools. Zero dependencies. + +import { createInterface } from "node:readline"; +import { readFileSync } from "node:fs"; +import { createServer } from "../src/mcp/server.js"; + +let version = "0.0.0"; +try { + version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; +} catch { + // keep the default if package.json cannot be read +} + +const server = createServer({ version }); +const rl = createInterface({ input: process.stdin }); + +rl.on("line", async (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let message; + try { + message = JSON.parse(trimmed); + } catch { + return; // ignore unparseable lines + } + const response = await server.handle(message); + if (response) process.stdout.write(JSON.stringify(response) + "\n"); +}); diff --git a/package.json b/package.json index af47648..ad0d930 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,12 @@ "./curl": { "types": "./src/curl.d.ts", "default": "./src/curl.js" }, "./infer-schema": { "types": "./src/infer-schema.d.ts", "default": "./src/infer-schema.js" }, "./importers": { "types": "./src/importers/index.d.ts", "default": "./src/importers/index.js" }, - "./ai": { "types": "./src/ai/index.d.ts", "default": "./src/ai/index.js" } + "./ai": { "types": "./src/ai/index.d.ts", "default": "./src/ai/index.js" }, + "./mcp": { "types": "./src/mcp/server.d.ts", "default": "./src/mcp/server.js" } }, "bin": { - "two-go": "bin/twogo.js" + "two-go": "bin/twogo.js", + "two-go-mcp": "bin/two-go-mcp.js" }, "files": [ "src", diff --git a/src/mcp/server.d.ts b/src/mcp/server.d.ts new file mode 100644 index 0000000..ef0974e --- /dev/null +++ b/src/mcp/server.d.ts @@ -0,0 +1,31 @@ +// Type declarations for the two-go MCP server. + +export interface McpTool { + name: string; + description: string; + inputSchema: Record; +} + +export interface JsonRpcMessage { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: Record; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: string | number | null; + result?: unknown; + error?: { code: number; message: string }; +} + +export interface McpServer { + handle(message: JsonRpcMessage): Promise; + tools: McpTool[]; + serverInfo: { name: string; version: string }; + protocolVersion: string; +} + +/** Create a transport-agnostic MCP server for two-go. */ +export declare function createServer(info?: { name?: string; version?: string }): McpServer; diff --git a/src/mcp/server.js b/src/mcp/server.js new file mode 100644 index 0000000..56607df --- /dev/null +++ b/src/mcp/server.js @@ -0,0 +1,163 @@ +// A tiny Model Context Protocol server for two-go, with no dependencies. It +// speaks JSON-RPC 2.0 and is transport agnostic: createServer().handle(message) +// takes one parsed message and returns the response object (or null for a +// notification). The bin wires it to stdio. Tools let an agent make real HTTP +// calls and use the importers and schema helpers. + +import { go } from "../index.js"; +import { fromOpenapi } from "../importers/openapi.js"; +import { fromPostman } from "../importers/postman.js"; +import { inferSchema } from "../infer-schema.js"; +import { validate } from "../schema.js"; + +const PROTOCOL_VERSION = "2024-11-05"; +const VERBS = new Set(["get", "put", "post", "patch", "delete", "head", "options"]); + +const TOOLS = [ + { + name: "http_request", + description: "Send an HTTP request and return status, headers, timing and body.", + inputSchema: { + type: "object", + properties: { + method: { type: "string", description: "GET, POST, etc. Defaults to GET." }, + url: { type: "string", description: "Full URL, or a path when baseUrl is set." }, + baseUrl: { type: "string" }, + headers: { type: "object" }, + query: { type: "object" }, + bearer: { type: "string", description: "Bearer token for the authorization header." }, + json: { description: "Body to send as JSON." }, + body: { type: "string", description: "Raw text body (used when json is absent)." }, + }, + required: ["url"], + }, + }, + { + name: "gen_openapi", + description: "Generate a two-go test suite source from an OpenAPI 3 document.", + inputSchema: { + type: "object", + properties: { spec: { type: "object" }, baseUrl: { type: "string" } }, + required: ["spec"], + }, + }, + { + name: "gen_postman", + description: "Generate a two-go test suite source from a Postman v2.1 collection.", + inputSchema: { + type: "object", + properties: { collection: { type: "object" }, baseUrl: { type: "string" } }, + required: ["collection"], + }, + }, + { + name: "infer_schema", + description: "Infer a JSON schema from an example value.", + inputSchema: { type: "object", properties: { value: {} }, required: ["value"] }, + }, + { + name: "validate_schema", + description: "Validate a value against a JSON schema. Returns { valid, errors }.", + inputSchema: { + type: "object", + properties: { value: {}, schema: { type: "object" } }, + required: ["value", "schema"], + }, + }, +]; + +function reply(id, result) { + return { jsonrpc: "2.0", id, result }; +} + +function errorReply(id, code, message) { + return { jsonrpc: "2.0", id, error: { code, message } }; +} + +// Create a server. info: { name, version }. +export function createServer(info = {}) { + const serverInfo = { name: info.name || "two-go", version: info.version || "0.0.0" }; + + async function callTool(name, args = {}) { + switch (name) { + case "http_request": { + const method = String(args.method || "GET").toLowerCase(); + const verb = VERBS.has(method) ? method : "get"; + const client = go({ baseURL: args.baseUrl || "" }); + let builder = client[verb](args.url || "/"); + if (args.query) builder = builder.query(args.query); + if (args.headers) builder = builder.headers(args.headers); + if (args.bearer) builder = builder.bearer(args.bearer); + if (args.json !== undefined) builder = builder.json(args.json); + else if (args.body !== undefined) { + builder = builder.text(typeof args.body === "string" ? args.body : JSON.stringify(args.body)); + } + const res = await builder; + return JSON.stringify( + { + status: res.status, + statusText: res.statusText, + headers: res.headers, + time: res.time, + url: res.url, + body: res.body, + }, + null, + 2 + ); + } + case "gen_openapi": + return fromOpenapi(args.spec || {}, { baseUrl: args.baseUrl }); + case "gen_postman": + return fromPostman(args.collection || {}, { baseUrl: args.baseUrl }); + case "infer_schema": + return JSON.stringify(inferSchema(args.value), null, 2); + case "validate_schema": + return JSON.stringify(validate(args.value, args.schema || {}), null, 2); + default: + throw new Error(`unknown tool: ${name}`); + } + } + + // Handle one JSON-RPC message. Returns the response object, or null when the + // message is a notification (no id) and needs no reply. + async function handle(message) { + const id = message ? message.id : undefined; + const method = message ? message.method : undefined; + const isNotification = id === undefined || id === null; + + try { + switch (method) { + case "initialize": + return reply(id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo, + }); + case "notifications/initialized": + return null; + case "ping": + return reply(id, {}); + case "tools/list": + return reply(id, { tools: TOOLS }); + case "tools/call": { + const params = message.params || {}; + const text = await callTool(params.name, params.arguments || {}); + return reply(id, { content: [{ type: "text", text: String(text) }] }); + } + default: + return isNotification ? null : errorReply(id, -32601, `method not found: ${method}`); + } + } catch (err) { + const messageText = err && err.message ? err.message : String(err); + if (isNotification) return null; + // Tool failures are reported as a tool result with isError, per MCP. + if (method === "tools/call") { + return reply(id, { content: [{ type: "text", text: messageText }], isError: true }); + } + return errorReply(id, -32603, messageText); + } + } + + return { handle, tools: TOOLS, serverInfo, protocolVersion: PROTOCOL_VERSION }; +} diff --git a/test/unit/mcp.test.mjs b/test/unit/mcp.test.mjs new file mode 100644 index 0000000..0eda77d --- /dev/null +++ b/test/unit/mcp.test.mjs @@ -0,0 +1,100 @@ +// Unit tests for the MCP server. The handler is exercised directly with parsed +// JSON-RPC messages. http_request runs against a throwaway local server. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; + +import { createServer } from "../../src/mcp/server.js"; + +test("initialize returns the protocol version and server info", async () => { + const s = createServer({ version: "1.2.3" }); + const res = await s.handle({ jsonrpc: "2.0", id: 1, method: "initialize" }); + assert.equal(res.id, 1); + assert.equal(res.result.protocolVersion, "2024-11-05"); + assert.equal(res.result.serverInfo.name, "two-go"); + assert.equal(res.result.serverInfo.version, "1.2.3"); + assert.ok(res.result.capabilities.tools); +}); + +test("notifications/initialized produces no response", async () => { + const s = createServer(); + assert.equal(await s.handle({ jsonrpc: "2.0", method: "notifications/initialized" }), null); +}); + +test("tools/list returns the tool names", async () => { + const s = createServer(); + const res = await s.handle({ jsonrpc: "2.0", id: 2, method: "tools/list" }); + const names = res.result.tools.map((t) => t.name); + assert.deepEqual( + names.sort(), + ["gen_openapi", "gen_postman", "http_request", "infer_schema", "validate_schema"] + ); +}); + +test("tools/call infer_schema returns a schema", async () => { + const s = createServer(); + const res = await s.handle({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "infer_schema", arguments: { value: { id: 1, name: "a" } } }, + }); + const schema = JSON.parse(res.result.content[0].text); + assert.equal(schema.type, "object"); + assert.deepEqual(schema.required.sort(), ["id", "name"]); +}); + +test("tools/call gen_openapi returns suite source", async () => { + const s = createServer(); + const res = await s.handle({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "gen_openapi", + arguments: { spec: { info: { title: "X" }, paths: { "/x": { get: { responses: { 200: {} } } } } } }, + }, + }); + assert.match(res.result.content[0].text, /suite\("X"/); +}); + +test("tools/call on an unknown tool is reported as an error result", async () => { + const s = createServer(); + const res = await s.handle({ + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { name: "nope", arguments: {} }, + }); + assert.equal(res.result.isError, true); + assert.match(res.result.content[0].text, /unknown tool/); +}); + +test("an unknown method returns a JSON-RPC error", async () => { + const s = createServer(); + const res = await s.handle({ jsonrpc: "2.0", id: 6, method: "does/not/exist" }); + assert.equal(res.error.code, -32601); +}); + +test("http_request actually performs a request", async () => { + const server = http.createServer((req, res) => { + res.writeHead(201, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, method: req.method })); + }); + await new Promise((r) => server.listen(0, r)); + const base = `http://localhost:${server.address().port}`; + + const s = createServer(); + const res = await s.handle({ + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { name: "http_request", arguments: { method: "POST", url: `${base}/things`, json: { a: 1 } } }, + }); + server.close(); + + const payload = JSON.parse(res.result.content[0].text); + assert.equal(payload.status, 201); + assert.equal(payload.body.ok, true); + assert.equal(payload.body.method, "POST"); +});