branch:
mcp.ts
12253 bytesRaw
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";
import {
  generateTypesFromJsonSchema,
  type JsonSchemaToolDescriptors
} from "./json-schema-types";
import { sanitizeToolName } from "./utils";
import type { Executor } from "./executor";

import type { JSONSchema7 } from "json-schema";

// -- Shared utilities --

const CHARS_PER_TOKEN = 4;
const MAX_TOKENS = 6000;
const MAX_CHARS = MAX_TOKENS * CHARS_PER_TOKEN;

function truncateResponse(content: unknown): string {
  const text =
    typeof content === "string"
      ? content
      : (JSON.stringify(content, null, 2) ?? "undefined");

  if (text.length <= MAX_CHARS) {
    return text;
  }

  const truncated = text.slice(0, MAX_CHARS);
  const estimatedTokens = Math.ceil(text.length / CHARS_PER_TOKEN);

  return `${truncated}\n\n--- TRUNCATED ---\nResponse was ~${estimatedTokens.toLocaleString()} tokens (limit: ${MAX_TOKENS.toLocaleString()}). Use more specific queries to reduce response size.`;
}

function formatError(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}

// -- codeMcpServer --

const CODE_DESCRIPTION = `Execute code to achieve a goal.

Available:
{{types}}

Write an async arrow function in JavaScript that returns the result.
Do NOT use TypeScript syntax — no type annotations, interfaces, or generics.
Do NOT define named functions then call them — just write the arrow function body directly.

{{example}}`;

/**
 * Wrap an existing MCP server with a single codemode `code` tool.
 *
 * Connects to the upstream server via in-memory transport, discovers its
 * tools, and returns a new MCP server with a `code` tool that exposes
 * all upstream tools as typed methods.
 */
export interface CodeMcpServerOptions {
  server: McpServer;
  executor: Executor;
}

export async function codeMcpServer(
  options: CodeMcpServerOptions
): Promise<McpServer> {
  const { server, executor } = options;
  const [clientTransport, serverTransport] =
    InMemoryTransport.createLinkedPair();

  await server.connect(serverTransport);

  const client = new Client({ name: "codemode-proxy", version: "1.0.0" });
  await client.connect(clientTransport);

  const { tools } = await client.listTools();

  // Build type hints
  const toolDescriptors: JsonSchemaToolDescriptors = {};
  for (const tool of tools) {
    toolDescriptors[tool.name] = {
      description: tool.description,
      inputSchema: tool.inputSchema as JSONSchema7
    };
  }
  const types = generateTypesFromJsonSchema(toolDescriptors);

  // Build executor fns — each upstream tool is a direct method
  const fns: Record<string, (...args: unknown[]) => Promise<unknown>> = {};
  for (const tool of tools) {
    const toolName = tool.name;
    fns[toolName] = async (args: unknown) => {
      const result = await client.callTool({
        name: toolName,
        arguments: args as Record<string, unknown>
      });
      return result;
    };
  }

  // Build example from first upstream tool with placeholder args
  const firstTool = tools[0];
  let example = "";
  if (firstTool) {
    const schema = firstTool.inputSchema as {
      properties?: Record<string, { type?: string; description?: string }>;
      required?: string[];
    };
    const props = schema.properties ?? {};
    const parts: string[] = [];
    for (const [key, prop] of Object.entries(props)) {
      if (prop.type === "number" || prop.type === "integer") {
        parts.push(`${key}: 0`);
      } else if (prop.type === "boolean") {
        parts.push(`${key}: true`);
      } else {
        parts.push(`${key}: "..."`);
      }
    }
    const args = parts.length > 0 ? `{ ${parts.join(", ")} }` : "{}";
    example = `Example: async () => { const r = await codemode.${sanitizeToolName(firstTool.name)}(${args}); return r; }`;
  }

  const description = CODE_DESCRIPTION.replace("{{types}}", types).replace(
    "{{example}}",
    example
  );

  const codemodeServer = new McpServer({
    name: "codemode",
    version: "1.0.0"
  });

  codemodeServer.registerTool(
    "code",
    {
      description,
      inputSchema: {
        code: z.string().describe("JavaScript async arrow function to execute")
      }
    },
    async ({ code }) => {
      try {
        const result = await executor.execute(code, [
          { name: "codemode", fns }
        ]);
        if (result.error) {
          return {
            content: [
              { type: "text" as const, text: `Error: ${result.error}` }
            ],
            isError: true
          };
        }
        return {
          content: [
            { type: "text" as const, text: truncateResponse(result.result) }
          ]
        };
      } catch (error) {
        return {
          content: [
            { type: "text" as const, text: `Error: ${formatError(error)}` }
          ],
          isError: true
        };
      }
    }
  );

  return codemodeServer;
}

// -- openApiMcpServer --

export interface RequestOptions {
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  path: string;
  query?: Record<string, string | number | boolean | undefined>;
  body?: unknown;
  contentType?: string;
  rawBody?: boolean;
}

export interface OpenApiMcpServerOptions {
  spec: Record<string, unknown>;
  executor: Executor;
  request: (options: RequestOptions) => Promise<unknown>;
  name?: string;
  version?: string;
  description?: string;
}

/**
 * Resolve internal $ref pointers in a JSON object against the root document.
 * Only handles `#/` internal refs. External file refs are left as-is.
 */
function resolveRefs(
  obj: unknown,
  root: Record<string, unknown>,
  seen = new Set<string>()
): unknown {
  if (obj === null || obj === undefined) return obj;
  if (typeof obj !== "object") return obj;
  if (Array.isArray(obj))
    return obj.map((item) => resolveRefs(item, root, seen));

  const record = obj as Record<string, unknown>;

  if ("$ref" in record && typeof record.$ref === "string") {
    const ref = record.$ref;
    if (seen.has(ref)) return { $circular: ref };
    if (!ref.startsWith("#/")) return record;
    seen.add(ref);

    const parts = ref
      .slice(2)
      .split("/")
      .map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
    let resolved: unknown = root;
    for (const part of parts) {
      resolved = (resolved as Record<string, unknown>)?.[part];
    }
    const result = resolveRefs(resolved, root, seen);
    seen.delete(ref);
    return result;
  }

  const result: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(record)) {
    result[key] = resolveRefs(value, root, seen);
  }
  return result;
}

const SPEC_TYPES = `
// OpenAPI 3.x spec with $refs resolved inline.
// The spec object follows the standard OpenAPI 3.x structure.

interface OperationObject {
  summary?: string;
  description?: string;
  operationId?: string;
  tags?: string[];
  parameters?: Array<{
    name: string;
    in: "query" | "header" | "path" | "cookie";
    required?: boolean;
    schema?: unknown;
    description?: string;
  }>;
  requestBody?: {
    required?: boolean;
    description?: string;
    content?: Record<string, { schema?: unknown }>;
  };
  responses?: Record<string, {
    description?: string;
    content?: Record<string, { schema?: unknown }>;
  }>;
  security?: Array<Record<string, string[]>>;
  deprecated?: boolean;
}

interface PathItem {
  summary?: string;
  description?: string;
  get?: OperationObject;
  post?: OperationObject;
  put?: OperationObject;
  patch?: OperationObject;
  delete?: OperationObject;
  head?: OperationObject;
  options?: OperationObject;
  trace?: OperationObject;
  parameters?: OperationObject["parameters"];
}

interface OpenApiSpec {
  openapi: string;
  info: { title: string; version: string; description?: string };
  paths: Record<string, PathItem>;
  servers?: Array<{ url: string; description?: string }>;
  components?: Record<string, unknown>;
  tags?: Array<{ name: string; description?: string }>;
}

declare const codemode: {
  spec(): Promise<OpenApiSpec>;
};
`;

const REQUEST_TYPES = `
interface RequestOptions {
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  path: string;
  query?: Record<string, string | number | boolean | undefined>;
  body?: unknown;
  contentType?: string;
  rawBody?: boolean;
}

declare const codemode: {
  request(options: RequestOptions): Promise<unknown>;
};
`;

/**
 * Create an MCP server with search + execute tools from an OpenAPI spec.
 *
 * The search tool lets the LLM query the spec to find endpoints.
 * The execute tool lets the LLM call the API via a user-provided
 * request function that runs on the host (auth never enters the sandbox).
 */
export function openApiMcpServer(options: OpenApiMcpServerOptions): McpServer {
  const {
    executor,
    request: requestFn,
    name = "openapi",
    version = "1.0.0",
    description
  } = options;

  const resolved = resolveRefs(options.spec, options.spec);

  const server = new McpServer({ name, version });

  // --- search tool ---
  server.registerTool(
    "search",
    {
      description: `Search the OpenAPI spec. All $refs are pre-resolved inline.

Types:
${SPEC_TYPES}

Your code must be an async arrow function that returns the result.

Examples:

// List all paths
async () => {
  const spec = await codemode.spec();
  return Object.keys(spec.paths);
}

// Find endpoints by tag
async () => {
  const spec = await codemode.spec();
  const results = [];
  for (const [path, methods] of Object.entries(spec.paths)) {
    for (const [method, op] of Object.entries(methods)) {
      if (op.tags?.some(t => t.toLowerCase() === 'your_tag')) {
        results.push({ method: method.toUpperCase(), path, summary: op.summary });
      }
    }
  }
  return results;
}`,
      inputSchema: {
        code: z
          .string()
          .describe("JavaScript async arrow function to search the spec")
      }
    },
    async ({ code }) => {
      try {
        const result = await executor.execute(code, [
          { name: "codemode", fns: { spec: async () => resolved } }
        ]);
        if (result.error) {
          return {
            content: [
              { type: "text" as const, text: `Error: ${result.error}` }
            ],
            isError: true
          };
        }
        return {
          content: [
            { type: "text" as const, text: truncateResponse(result.result) }
          ]
        };
      } catch (error) {
        return {
          content: [
            { type: "text" as const, text: `Error: ${formatError(error)}` }
          ],
          isError: true
        };
      }
    }
  );

  // --- execute tool ---
  const executeDescription = `Execute API calls using JavaScript code. First use 'search' to find the right endpoints.

Available in your code:
${REQUEST_TYPES}

Your code must be an async arrow function that returns the result.

Example:
async () => {
  return await codemode.request({ method: "GET", path: "/your/endpoint" });
}${description ? `\n\n${description}` : ""}`;

  server.registerTool(
    "execute",
    {
      description: executeDescription,
      inputSchema: {
        code: z.string().describe("JavaScript async arrow function to execute")
      }
    },
    async ({ code }) => {
      try {
        const result = await executor.execute(code, [
          {
            name: "codemode",
            fns: {
              request: (args: unknown) => requestFn(args as RequestOptions)
            }
          }
        ]);
        if (result.error) {
          return {
            content: [
              { type: "text" as const, text: `Error: ${result.error}` }
            ],
            isError: true
          };
        }
        return {
          content: [
            { type: "text" as const, text: truncateResponse(result.result) }
          ]
        };
      } catch (error) {
        return {
          content: [
            { type: "text" as const, text: `Error: ${formatError(error)}` }
          ],
          isError: true
        };
      }
    }
  );

  return server;
}