From 8cdba3ce1869a265e1e273338cdf11a498e0f3e2 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 07:17:48 -0500 Subject: [PATCH] Add standalone Paperclip MCP server package Co-Authored-By: Paperclip --- packages/mcp-server/README.md | 76 +++++ packages/mcp-server/package.json | 55 ++++ packages/mcp-server/src/client.ts | 114 +++++++ packages/mcp-server/src/config.ts | 39 +++ packages/mcp-server/src/format.ts | 31 ++ packages/mcp-server/src/index.ts | 30 ++ packages/mcp-server/src/stdio.ts | 7 + packages/mcp-server/src/tools.test.ts | 121 ++++++++ packages/mcp-server/src/tools.ts | 413 ++++++++++++++++++++++++++ packages/mcp-server/tsconfig.json | 8 + packages/mcp-server/vitest.config.ts | 7 + tsconfig.json | 1 + 12 files changed, 902 insertions(+) create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/client.ts create mode 100644 packages/mcp-server/src/config.ts create mode 100644 packages/mcp-server/src/format.ts create mode 100644 packages/mcp-server/src/index.ts create mode 100644 packages/mcp-server/src/stdio.ts create mode 100644 packages/mcp-server/src/tools.test.ts create mode 100644 packages/mcp-server/src/tools.ts create mode 100644 packages/mcp-server/tsconfig.json create mode 100644 packages/mcp-server/vitest.config.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 0000000000..602521e286 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,76 @@ +# Paperclip MCP Server + +Model Context Protocol server for Paperclip. + +This package is a thin MCP wrapper over the existing Paperclip REST API. It does +not talk to the database directly and it does not reimplement business logic. + +## Authentication + +The server reads its configuration from environment variables: + +- `PAPERCLIP_API_URL` - Paperclip base URL, for example `http://localhost:3100` +- `PAPERCLIP_API_KEY` - bearer token used for `/api` requests +- `PAPERCLIP_COMPANY_ID` - optional default company for company-scoped tools +- `PAPERCLIP_AGENT_ID` - optional default agent for checkout helpers +- `PAPERCLIP_RUN_ID` - optional run id forwarded on mutating requests + +## Usage + +```sh +npx -y @paperclipai/mcp-server +``` + +Or locally in this repo: + +```sh +pnpm --filter @paperclipai/mcp-server build +node packages/mcp-server/dist/stdio.js +``` + +## Tool Surface + +Read tools: + +- `paperclipMe` +- `paperclipInboxLite` +- `paperclipListAgents` +- `paperclipGetAgent` +- `paperclipListIssues` +- `paperclipGetIssue` +- `paperclipGetHeartbeatContext` +- `paperclipListComments` +- `paperclipGetComment` +- `paperclipListIssueApprovals` +- `paperclipListDocuments` +- `paperclipGetDocument` +- `paperclipListDocumentRevisions` +- `paperclipListProjects` +- `paperclipGetProject` +- `paperclipListGoals` +- `paperclipGetGoal` +- `paperclipListApprovals` +- `paperclipGetApproval` +- `paperclipGetApprovalIssues` +- `paperclipListApprovalComments` + +Write tools: + +- `paperclipCreateIssue` +- `paperclipUpdateIssue` +- `paperclipCheckoutIssue` +- `paperclipReleaseIssue` +- `paperclipAddComment` +- `paperclipUpsertIssueDocument` +- `paperclipRestoreIssueDocumentRevision` +- `paperclipLinkIssueApproval` +- `paperclipUnlinkIssueApproval` +- `paperclipApprovalDecision` +- `paperclipAddApprovalComment` + +Escape hatch: + +- `paperclipApiRequest` + +`paperclipApiRequest` is limited to paths under `/api` and JSON bodies. It is +meant for endpoints that do not yet have a dedicated MCP tool. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 0000000000..6edba34dc0 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,55 @@ +{ + "name": "@paperclipai/mcp-server", + "version": "0.1.0", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/mcp-server" + }, + "type": "module", + "bin": { + "paperclip-mcp-server": "./dist/stdio.js" + }, + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "bin": { + "paperclip-mcp-server": "./dist/stdio.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@paperclipai/shared": "workspace:*", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/mcp-server/src/client.ts b/packages/mcp-server/src/client.ts new file mode 100644 index 0000000000..a2fd8e356b --- /dev/null +++ b/packages/mcp-server/src/client.ts @@ -0,0 +1,114 @@ +import type { PaperclipMcpConfig } from "./config.js"; + +export class PaperclipApiError extends Error { + readonly status: number; + readonly method: string; + readonly path: string; + readonly body: unknown; + + constructor(input: { + status: number; + method: string; + path: string; + body: unknown; + message: string; + }) { + super(input.message); + this.name = "PaperclipApiError"; + this.status = input.status; + this.method = input.method; + this.path = input.path; + this.body = input.body; + } +} + +export interface JsonRequestOptions { + body?: unknown; + includeRunId?: boolean; +} + +function isWriteMethod(method: string): boolean { + return !["GET", "HEAD"].includes(method.toUpperCase()); +} + +function buildErrorMessage(method: string, path: string, status: number, body: unknown): string { + if (body && typeof body === "object" && "error" in body && typeof body.error === "string") { + return `${method} ${path} failed with ${status}: ${body.error}`; + } + return `${method} ${path} failed with ${status}`; +} + +async function parseResponseBody(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +export class PaperclipApiClient { + constructor(private readonly config: PaperclipMcpConfig) {} + + get defaults() { + return { + companyId: this.config.companyId, + agentId: this.config.agentId, + runId: this.config.runId, + }; + } + + resolveCompanyId(companyId?: string | null): string { + const resolved = companyId?.trim() || this.config.companyId; + if (!resolved) { + throw new Error("companyId is required because PAPERCLIP_COMPANY_ID is not set"); + } + return resolved; + } + + resolveAgentId(agentId?: string | null): string { + const resolved = agentId?.trim() || this.config.agentId; + if (!resolved) { + throw new Error("agentId is required because PAPERCLIP_AGENT_ID is not set"); + } + return resolved; + } + + async requestJson(method: string, path: string, options: JsonRequestOptions = {}): Promise { + if (!path.startsWith("/")) { + throw new Error(`API path must start with "/": ${path}`); + } + + const url = new URL(path.slice(1), `${this.config.apiUrl}/`); + const headers: Record = { + Authorization: `Bearer ${this.config.apiKey}`, + Accept: "application/json", + }; + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + if ((options.includeRunId ?? isWriteMethod(method)) && this.config.runId) { + headers["X-Paperclip-Run-Id"] = this.config.runId; + } + + const response = await fetch(url, { + method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const parsedBody = await parseResponseBody(response); + + if (!response.ok) { + throw new PaperclipApiError({ + status: response.status, + method: method.toUpperCase(), + path, + body: parsedBody, + message: buildErrorMessage(method.toUpperCase(), path, response.status, parsedBody), + }); + } + + return parsedBody as T; + } +} diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts new file mode 100644 index 0000000000..c1de29eb4f --- /dev/null +++ b/packages/mcp-server/src/config.ts @@ -0,0 +1,39 @@ +export interface PaperclipMcpConfig { + apiUrl: string; + apiKey: string; + companyId: string | null; + agentId: string | null; + runId: string | null; +} + +function nonEmpty(value: string | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +export function normalizeApiUrl(apiUrl: string): string { + const trimmed = stripTrailingSlash(apiUrl.trim()); + return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`; +} + +export function readConfigFromEnv(env: NodeJS.ProcessEnv = process.env): PaperclipMcpConfig { + const apiUrl = nonEmpty(env.PAPERCLIP_API_URL); + if (!apiUrl) { + throw new Error("Missing PAPERCLIP_API_URL"); + } + const apiKey = nonEmpty(env.PAPERCLIP_API_KEY); + if (!apiKey) { + throw new Error("Missing PAPERCLIP_API_KEY"); + } + + return { + apiUrl: normalizeApiUrl(apiUrl), + apiKey, + companyId: nonEmpty(env.PAPERCLIP_COMPANY_ID), + agentId: nonEmpty(env.PAPERCLIP_AGENT_ID), + runId: nonEmpty(env.PAPERCLIP_RUN_ID), + }; +} diff --git a/packages/mcp-server/src/format.ts b/packages/mcp-server/src/format.ts new file mode 100644 index 0000000000..b2e6a4cd25 --- /dev/null +++ b/packages/mcp-server/src/format.ts @@ -0,0 +1,31 @@ +import { PaperclipApiError } from "./client.js"; + +type McpTextResponse = { + content: Array<{ type: "text"; text: string }>; +}; + +export function formatTextResponse(value: unknown): McpTextResponse { + return { + content: [ + { + type: "text", + text: typeof value === "string" ? value : JSON.stringify(value, null, 2), + }, + ], + }; +} + +export function formatErrorResponse(error: unknown): McpTextResponse { + if (error instanceof PaperclipApiError) { + return formatTextResponse({ + error: error.message, + status: error.status, + method: error.method, + path: error.path, + body: error.body, + }); + } + return formatTextResponse({ + error: error instanceof Error ? error.message : String(error), + }); +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 0000000000..dc9fdc5cd7 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,30 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { PaperclipApiClient } from "./client.js"; +import { readConfigFromEnv, type PaperclipMcpConfig } from "./config.js"; +import { createToolDefinitions } from "./tools.js"; + +export function createPaperclipMcpServer(config: PaperclipMcpConfig = readConfigFromEnv()) { + const server = new McpServer({ + name: "paperclip", + version: "0.1.0", + }); + + const client = new PaperclipApiClient(config); + const tools = createToolDefinitions(client); + for (const tool of tools) { + server.tool(tool.name, tool.description, tool.schema.shape, tool.execute); + } + + return { + server, + tools, + client, + }; +} + +export async function runServer(config: PaperclipMcpConfig = readConfigFromEnv()) { + const { server } = createPaperclipMcpServer(config); + const transport = new StdioServerTransport(); + await server.connect(transport); +} diff --git a/packages/mcp-server/src/stdio.ts b/packages/mcp-server/src/stdio.ts new file mode 100644 index 0000000000..145b236285 --- /dev/null +++ b/packages/mcp-server/src/stdio.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runServer } from "./index.js"; + +void runServer().catch((error) => { + console.error("Failed to start Paperclip MCP server:", error); + process.exit(1); +}); diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts new file mode 100644 index 0000000000..f1d222857b --- /dev/null +++ b/packages/mcp-server/src/tools.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PaperclipApiClient } from "./client.js"; +import { createToolDefinitions } from "./tools.js"; + +function makeClient() { + return new PaperclipApiClient({ + apiUrl: "http://localhost:3100/api", + apiKey: "token-123", + companyId: "11111111-1111-1111-1111-111111111111", + agentId: "22222222-2222-2222-2222-222222222222", + runId: "33333333-3333-3333-3333-333333333333", + }); +} + +function getTool(name: string) { + const tool = createToolDefinitions(makeClient()).find((candidate) => candidate.name === name); + if (!tool) throw new Error(`Missing tool ${name}`); + return tool; +} + +function mockJsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("paperclip MCP tools", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("adds auth headers and run id to mutating requests", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mockJsonResponse({ ok: true }), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("paperclipUpdateIssue"); + await tool.execute({ + issueId: "PAP-1135", + status: "done", + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135"); + expect(init.method).toBe("PATCH"); + expect((init.headers as Record)["Authorization"]).toBe("Bearer token-123"); + expect((init.headers as Record)["X-Paperclip-Run-Id"]).toBe( + "33333333-3333-3333-3333-333333333333", + ); + }); + + it("uses default company id for company-scoped list tools", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mockJsonResponse([{ id: "issue-1" }]), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("paperclipListIssues"); + const response = await tool.execute({}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(String(url)).toBe( + "http://localhost:3100/api/companies/11111111-1111-1111-1111-111111111111/issues", + ); + expect(response.content[0]?.text).toContain("issue-1"); + }); + + it("uses default agent id for checkout requests", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mockJsonResponse({ id: "PAP-1135", status: "in_progress" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("paperclipCheckoutIssue"); + await tool.execute({ + issueId: "PAP-1135", + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + agentId: "22222222-2222-2222-2222-222222222222", + expectedStatuses: ["todo", "backlog", "blocked"], + }); + }); + + it("defaults issue document format to markdown", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mockJsonResponse({ key: "plan", latestRevisionNumber: 2 }), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("paperclipUpsertIssueDocument"); + await tool.execute({ + issueId: "PAP-1135", + key: "plan", + body: "# Updated", + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + format: "markdown", + body: "# Updated", + }); + }); + + it("rejects invalid generic request paths", async () => { + vi.stubGlobal("fetch", vi.fn()); + + const tool = getTool("paperclipApiRequest"); + const response = await tool.execute({ + method: "GET", + path: "issues", + }); + + expect(response.content[0]?.text).toContain("path must start with /"); + }); +}); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts new file mode 100644 index 0000000000..cfed2f6638 --- /dev/null +++ b/packages/mcp-server/src/tools.ts @@ -0,0 +1,413 @@ +import { z } from "zod"; +import { + addIssueCommentSchema, + checkoutIssueSchema, + createIssueSchema, + updateIssueSchema, + upsertIssueDocumentSchema, + linkIssueApprovalSchema, +} from "@paperclipai/shared"; +import { PaperclipApiClient } from "./client.js"; +import { formatErrorResponse, formatTextResponse } from "./format.js"; + +export interface ToolDefinition { + name: string; + description: string; + schema: z.AnyZodObject; + execute: (input: Record) => Promise<{ + content: Array<{ type: "text"; text: string }>; + }>; +} + +function makeTool( + name: string, + description: string, + schema: z.ZodObject, + execute: (input: z.infer) => Promise, +): ToolDefinition { + return { + name, + description, + schema, + execute: async (input) => { + try { + const parsed = schema.parse(input); + return formatTextResponse(await execute(parsed)); + } catch (error) { + return formatErrorResponse(error); + } + }, + }; +} + +function parseOptionalJson(raw: string | undefined | null): unknown { + if (!raw || raw.trim().length === 0) return undefined; + return JSON.parse(raw); +} + +const companyIdOptional = z.string().uuid().optional().nullable(); +const agentIdOptional = z.string().uuid().optional().nullable(); +const issueIdSchema = z.string().min(1); +const projectIdSchema = z.string().min(1); +const goalIdSchema = z.string().uuid(); +const approvalIdSchema = z.string().uuid(); +const documentKeySchema = z.string().trim().min(1).max(64); + +const listIssuesSchema = z.object({ + companyId: companyIdOptional, + status: z.string().optional(), + projectId: z.string().uuid().optional(), + assigneeAgentId: z.string().uuid().optional(), + participantAgentId: z.string().uuid().optional(), + assigneeUserId: z.string().optional(), + touchedByUserId: z.string().optional(), + inboxArchivedByUserId: z.string().optional(), + unreadForUserId: z.string().optional(), + labelId: z.string().uuid().optional(), + executionWorkspaceId: z.string().uuid().optional(), + originKind: z.string().optional(), + originId: z.string().optional(), + includeRoutineExecutions: z.boolean().optional(), + q: z.string().optional(), +}); + +const listCommentsSchema = z.object({ + issueId: issueIdSchema, + after: z.string().uuid().optional(), + order: z.enum(["asc", "desc"]).optional(), + limit: z.number().int().positive().max(500).optional(), +}); + +const upsertDocumentToolSchema = z.object({ + issueId: issueIdSchema, + key: documentKeySchema, + title: z.string().trim().max(200).nullable().optional(), + format: z.enum(["markdown"]).default("markdown"), + body: z.string().max(524288), + changeSummary: z.string().trim().max(500).nullable().optional(), + baseRevisionId: z.string().uuid().nullable().optional(), +}); + +const createIssueToolSchema = z.object({ + companyId: companyIdOptional, +}).merge(createIssueSchema); + +const updateIssueToolSchema = z.object({ + issueId: issueIdSchema, +}).merge(updateIssueSchema); + +const checkoutIssueToolSchema = z.object({ + issueId: issueIdSchema, + agentId: agentIdOptional, + expectedStatuses: checkoutIssueSchema.shape.expectedStatuses.optional(), +}); + +const addCommentToolSchema = z.object({ + issueId: issueIdSchema, +}).merge(addIssueCommentSchema); + +const approvalDecisionSchema = z.object({ + approvalId: approvalIdSchema, + action: z.enum(["approve", "reject", "requestRevision", "resubmit"]), + decisionNote: z.string().optional(), + payloadJson: z.string().optional(), +}); + +const apiRequestSchema = z.object({ + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), + path: z.string().min(1), + jsonBody: z.string().optional(), +}); + +export function createToolDefinitions(client: PaperclipApiClient): ToolDefinition[] { + return [ + makeTool( + "paperclipMe", + "Get the current authenticated Paperclip actor details", + z.object({}), + async () => client.requestJson("GET", "/agents/me"), + ), + makeTool( + "paperclipInboxLite", + "Get the current authenticated agent inbox-lite assignment list", + z.object({}), + async () => client.requestJson("GET", "/agents/me/inbox-lite"), + ), + makeTool( + "paperclipListAgents", + "List agents in a company", + z.object({ companyId: companyIdOptional }), + async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/agents`), + ), + makeTool( + "paperclipGetAgent", + "Get a single agent by id", + z.object({ agentId: z.string().min(1), companyId: companyIdOptional }), + async ({ agentId, companyId }) => { + const qs = companyId ? `?companyId=${encodeURIComponent(companyId)}` : ""; + return client.requestJson("GET", `/agents/${encodeURIComponent(agentId)}${qs}`); + }, + ), + makeTool( + "paperclipListIssues", + "List issues for a company with optional filters", + listIssuesSchema, + async (input) => { + const companyId = client.resolveCompanyId(input.companyId); + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(input)) { + if (key === "companyId" || value === undefined || value === null) continue; + params.set(key, String(value)); + } + const qs = params.toString(); + return client.requestJson("GET", `/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); + }, + ), + makeTool( + "paperclipGetIssue", + "Get a single issue by UUID or identifier", + z.object({ issueId: issueIdSchema }), + async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}`), + ), + makeTool( + "paperclipGetHeartbeatContext", + "Get compact heartbeat context for an issue", + z.object({ issueId: issueIdSchema, wakeCommentId: z.string().uuid().optional() }), + async ({ issueId, wakeCommentId }) => { + const qs = wakeCommentId ? `?wakeCommentId=${encodeURIComponent(wakeCommentId)}` : ""; + return client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/heartbeat-context${qs}`); + }, + ), + makeTool( + "paperclipListComments", + "List issue comments with incremental options", + listCommentsSchema, + async ({ issueId, after, order, limit }) => { + const params = new URLSearchParams(); + if (after) params.set("after", after); + if (order) params.set("order", order); + if (limit) params.set("limit", String(limit)); + const qs = params.toString(); + return client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/comments${qs ? `?${qs}` : ""}`); + }, + ), + makeTool( + "paperclipGetComment", + "Get a specific issue comment by id", + z.object({ issueId: issueIdSchema, commentId: z.string().uuid() }), + async ({ issueId, commentId }) => + client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/comments/${encodeURIComponent(commentId)}`), + ), + makeTool( + "paperclipListIssueApprovals", + "List approvals linked to an issue", + z.object({ issueId: issueIdSchema }), + async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/approvals`), + ), + makeTool( + "paperclipListDocuments", + "List issue documents", + z.object({ issueId: issueIdSchema }), + async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/documents`), + ), + makeTool( + "paperclipGetDocument", + "Get one issue document by key", + z.object({ issueId: issueIdSchema, key: documentKeySchema }), + async ({ issueId, key }) => + client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}`), + ), + makeTool( + "paperclipListDocumentRevisions", + "List revisions for an issue document", + z.object({ issueId: issueIdSchema, key: documentKeySchema }), + async ({ issueId, key }) => + client.requestJson( + "GET", + `/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}/revisions`, + ), + ), + makeTool( + "paperclipListProjects", + "List projects in a company", + z.object({ companyId: companyIdOptional }), + async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/projects`), + ), + makeTool( + "paperclipGetProject", + "Get a project by id or company-scoped short reference", + z.object({ projectId: projectIdSchema, companyId: companyIdOptional }), + async ({ projectId, companyId }) => { + const qs = companyId ? `?companyId=${encodeURIComponent(companyId)}` : ""; + return client.requestJson("GET", `/projects/${encodeURIComponent(projectId)}${qs}`); + }, + ), + makeTool( + "paperclipListGoals", + "List goals in a company", + z.object({ companyId: companyIdOptional }), + async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/goals`), + ), + makeTool( + "paperclipGetGoal", + "Get a goal by id", + z.object({ goalId: goalIdSchema }), + async ({ goalId }) => client.requestJson("GET", `/goals/${encodeURIComponent(goalId)}`), + ), + makeTool( + "paperclipListApprovals", + "List approvals in a company", + z.object({ companyId: companyIdOptional, status: z.string().optional() }), + async ({ companyId, status }) => { + const qs = status ? `?status=${encodeURIComponent(status)}` : ""; + return client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/approvals${qs}`); + }, + ), + makeTool( + "paperclipGetApproval", + "Get an approval by id", + z.object({ approvalId: approvalIdSchema }), + async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}`), + ), + makeTool( + "paperclipGetApprovalIssues", + "List issues linked to an approval", + z.object({ approvalId: approvalIdSchema }), + async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}/issues`), + ), + makeTool( + "paperclipListApprovalComments", + "List comments for an approval", + z.object({ approvalId: approvalIdSchema }), + async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}/comments`), + ), + makeTool( + "paperclipCreateIssue", + "Create a new issue", + createIssueToolSchema, + async ({ companyId, ...body }) => + client.requestJson("POST", `/companies/${client.resolveCompanyId(companyId)}/issues`, { body }), + ), + makeTool( + "paperclipUpdateIssue", + "Patch an issue, optionally including a comment", + updateIssueToolSchema, + async ({ issueId, ...body }) => + client.requestJson("PATCH", `/issues/${encodeURIComponent(issueId)}`, { body }), + ), + makeTool( + "paperclipCheckoutIssue", + "Checkout an issue for an agent", + checkoutIssueToolSchema, + async ({ issueId, agentId, expectedStatuses }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/checkout`, { + body: { + agentId: client.resolveAgentId(agentId), + expectedStatuses: expectedStatuses ?? ["todo", "backlog", "blocked"], + }, + }), + ), + makeTool( + "paperclipReleaseIssue", + "Release an issue checkout", + z.object({ issueId: issueIdSchema }), + async ({ issueId }) => client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/release`, { body: {} }), + ), + makeTool( + "paperclipAddComment", + "Add a comment to an issue", + addCommentToolSchema, + async ({ issueId, ...body }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }), + ), + makeTool( + "paperclipUpsertIssueDocument", + "Create or update an issue document", + upsertDocumentToolSchema, + async ({ issueId, key, ...body }) => + client.requestJson( + "PUT", + `/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}`, + { body }, + ), + ), + makeTool( + "paperclipRestoreIssueDocumentRevision", + "Restore a prior revision of an issue document", + z.object({ + issueId: issueIdSchema, + key: documentKeySchema, + revisionId: z.string().uuid(), + }), + async ({ issueId, key, revisionId }) => + client.requestJson( + "POST", + `/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}/restore`, + { body: {} }, + ), + ), + makeTool( + "paperclipLinkIssueApproval", + "Link an approval to an issue", + z.object({ issueId: issueIdSchema }).merge(linkIssueApprovalSchema), + async ({ issueId, approvalId }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/approvals`, { + body: { approvalId }, + }), + ), + makeTool( + "paperclipUnlinkIssueApproval", + "Unlink an approval from an issue", + z.object({ issueId: issueIdSchema, approvalId: approvalIdSchema }), + async ({ issueId, approvalId }) => + client.requestJson( + "DELETE", + `/issues/${encodeURIComponent(issueId)}/approvals/${encodeURIComponent(approvalId)}`, + ), + ), + makeTool( + "paperclipApprovalDecision", + "Approve, reject, request revision, or resubmit an approval", + approvalDecisionSchema, + async ({ approvalId, action, decisionNote, payloadJson }) => { + const path = + action === "approve" + ? `/approvals/${encodeURIComponent(approvalId)}/approve` + : action === "reject" + ? `/approvals/${encodeURIComponent(approvalId)}/reject` + : action === "requestRevision" + ? `/approvals/${encodeURIComponent(approvalId)}/request-revision` + : `/approvals/${encodeURIComponent(approvalId)}/resubmit`; + + const body = + action === "resubmit" + ? { payload: parseOptionalJson(payloadJson) ?? {} } + : { decisionNote }; + + return client.requestJson("POST", path, { body }); + }, + ), + makeTool( + "paperclipAddApprovalComment", + "Add a comment to an approval", + z.object({ approvalId: approvalIdSchema, body: z.string().min(1) }), + async ({ approvalId, body }) => + client.requestJson("POST", `/approvals/${encodeURIComponent(approvalId)}/comments`, { + body: { body }, + }), + ), + makeTool( + "paperclipApiRequest", + "Make a JSON request to an existing Paperclip /api endpoint for unsupported operations", + apiRequestSchema, + async ({ method, path, jsonBody }) => { + if (!path.startsWith("/")) { + throw new Error("path must start with / and be relative to /api"); + } + return client.requestJson(method, path, { + body: parseOptionalJson(jsonBody), + }); + }, + ), + ]; +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 0000000000..5a24989cd3 --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/mcp-server/vitest.config.ts b/packages/mcp-server/vitest.config.ts new file mode 100644 index 0000000000..f624398e8d --- /dev/null +++ b/packages/mcp-server/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 9a5267db6e..e597c33111 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "files": [], "references": [ { "path": "./packages/adapter-utils" }, + { "path": "./packages/mcp-server" }, { "path": "./packages/shared" }, { "path": "./packages/db" }, { "path": "./packages/adapters/claude-local" },