Add standalone Paperclip MCP server package

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-04-06 07:17:48 -05:00
parent 08fea10ce1
commit 8cdba3ce18
12 changed files with 902 additions and 0 deletions

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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<unknown> {
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<T>(method: string, path: string, options: JsonRequestOptions = {}): Promise<T> {
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<string, string> = {
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;
}
}

View File

@@ -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),
};
}

View File

@@ -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),
});
}

View File

@@ -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);
}

View File

@@ -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);
});

View File

@@ -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<string, string>)["Authorization"]).toBe("Bearer token-123");
expect((init.headers as Record<string, string>)["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 /");
});
});

View File

@@ -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<string, unknown>) => Promise<{
content: Array<{ type: "text"; text: string }>;
}>;
}
function makeTool<TSchema extends z.ZodRawShape>(
name: string,
description: string,
schema: z.ZodObject<TSchema>,
execute: (input: z.infer<typeof schema>) => Promise<unknown>,
): 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),
});
},
),
];
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

View File

@@ -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" },