Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions bin/two-go-mcp.js
Original file line number Diff line number Diff line change
@@ -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");
});
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions src/mcp/server.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Type declarations for the two-go MCP server.

export interface McpTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}

export interface JsonRpcMessage {
jsonrpc?: string;
id?: string | number | null;
method?: string;
params?: Record<string, unknown>;
}

export interface JsonRpcResponse {
jsonrpc: "2.0";
id: string | number | null;
result?: unknown;
error?: { code: number; message: string };
}

export interface McpServer {
handle(message: JsonRpcMessage): Promise<JsonRpcResponse | null>;
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;
163 changes: 163 additions & 0 deletions src/mcp/server.js
Original file line number Diff line number Diff line change
@@ -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 };
}
100 changes: 100 additions & 0 deletions test/unit/mcp.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
Loading