mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Add standalone Paperclip MCP server package
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
76
packages/mcp-server/README.md
Normal file
76
packages/mcp-server/README.md
Normal 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.
|
||||
55
packages/mcp-server/package.json
Normal file
55
packages/mcp-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
114
packages/mcp-server/src/client.ts
Normal file
114
packages/mcp-server/src/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
39
packages/mcp-server/src/config.ts
Normal file
39
packages/mcp-server/src/config.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
31
packages/mcp-server/src/format.ts
Normal file
31
packages/mcp-server/src/format.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
30
packages/mcp-server/src/index.ts
Normal file
30
packages/mcp-server/src/index.ts
Normal 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);
|
||||
}
|
||||
7
packages/mcp-server/src/stdio.ts
Normal file
7
packages/mcp-server/src/stdio.ts
Normal 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);
|
||||
});
|
||||
121
packages/mcp-server/src/tools.test.ts
Normal file
121
packages/mcp-server/src/tools.test.ts
Normal 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 /");
|
||||
});
|
||||
});
|
||||
413
packages/mcp-server/src/tools.ts
Normal file
413
packages/mcp-server/src/tools.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
8
packages/mcp-server/tsconfig.json
Normal file
8
packages/mcp-server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/mcp-server/vitest.config.ts
Normal file
7
packages/mcp-server/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user