Files
PaperClipAI/packages/mcp-server/src/tools.ts
T
Devin Foley 243430f76e feat: agents see the company skill library at runtime (#12147)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - An agent's runtime mounts only its own enabled skills; nothing tells
the model what else the company skill library holds
> - From inside a sandbox, "installed but not enabled for me" and "does
not exist" look identical, so agents tell users freshly installed skills
are not installed
> - This pull request renders the library as a deterministic markdown
section appended to claude-local agent instructions, and adds a
paperclipListSkills MCP tool
> - The benefit is that agents report the true state ("installed, not
enabled for me — ask an operator to enable it") instead of a false
negative

## Linked Issues or Issue Description

**What existing behavior does this improve?**

How agents reason about the company skill library at runtime.

**Subsystem affected**

`packages/adapter-utils` (new pure builder),
`packages/adapters/claude-local` (instructions append),
`packages/mcp-server` (new tool).

**Current behavior**

The runtime hands adapters the full library list, but only the agent's
enabled skills are mounted, and no prompt content or MCP tool describes
the rest. Agents inspect their sandbox, find nothing, and report
installed skills as not installed.

**Proposed behavior**

A "Company skill library" markdown section lists every skill as
`enabled`, `installed, not enabled for you`, or `enabled but
unavailable: <cause>`, with instructions to report the not-enabled state
accurately and ask an operator to enable it. claude-local appends it to
the agent instructions text. A `paperclipListSkills` MCP tool exposes
the same list on demand.

**Breaking changes**

None. Other adapters are untouched (they can adopt the builder later);
the manifest is deterministic, so the claude-local prompt-bundle cache
only busts when the library actually changes.

## What Changed

- New `packages/adapter-utils/src/skill-library-manifest.ts` with
`buildSkillLibraryManifestMarkdown` (pure, key-sorted, deterministic;
renders the missing-cause detail from #12146).
- `packages/adapters/claude-local/src/server/execute.ts` appends the
manifest to `combinedInstructionsContents` (creating it when no
instructions file is configured).
- `packages/mcp-server/src/tools.ts` adds `paperclipListSkills` hitting
`GET /companies/:companyId/skills`.

## Verification

- `npx vitest run
packages/adapter-utils/src/skill-library-manifest.test.ts` (from repo
root) — 3 tests: byte-identical output for shuffled input, state
rendering incl. the unavailable cause, change detection.
- `cd packages/mcp-server && npx vitest run` — new tool routing test
passes (13 passed; 1 pre-existing failure on my machine reproduces
unchanged at the branch base).
- `cd packages/adapters/claude-local && npx vitest run` — 244 passed, 1
skipped.
- `pnpm run typecheck` clean in adapter-utils, mcp-server, claude-local.

## Risks

- Prompt growth is one line per installed skill plus a five-line header
— bounded and only present when the library is non-empty. Stacked on
#12146 so the manifest's "enabled but unavailable" state reflects real
materialization failures.

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking
and tool use, via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-25 14:01:52 -07:00

641 lines
24 KiB
TypeScript

import { z } from "zod";
import {
addIssueCommentSchema,
askUserQuestionsPayloadSchema,
checkoutIssueSchema,
createApprovalSchema,
createIssueInputSchema,
issueThreadInteractionContinuationPolicySchema,
requestCheckboxConfirmationPayloadSchema,
requestConfirmationPayloadSchema,
suggestTasksPayloadSchema,
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.ZodObject;
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().guid().optional().nullable();
const agentIdOptional = z.string().guid().optional().nullable();
const issueIdSchema = z.string().min(1);
const projectIdSchema = z.string().min(1);
const goalIdSchema = z.string().guid();
const approvalIdSchema = z.string().guid();
const documentKeySchema = z.string().trim().min(1).max(64);
const listIssuesSchema = z.object({
companyId: companyIdOptional,
status: z.string().optional(),
projectId: z.string().guid().optional(),
assigneeAgentId: z.string().guid().optional(),
participantAgentId: z.string().guid().optional(),
assigneeUserId: z.string().optional(),
touchedByUserId: z.string().optional(),
inboxArchivedByUserId: z.string().optional(),
unreadForUserId: z.string().optional(),
labelId: z.string().guid().optional(),
executionWorkspaceId: z.string().guid().optional(),
originKind: z.string().optional(),
originId: z.string().optional(),
includeRoutineExecutions: z.boolean().optional(),
includeLiveDescendantSummary: z.boolean().optional(),
q: z.string().optional(),
});
const listCommentsSchema = z.object({
issueId: issueIdSchema,
after: z.string().guid().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().guid().nullable().optional(),
});
const createIssueToolSchema = z.object({
companyId: companyIdOptional,
}).merge(createIssueInputSchema);
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 createSuggestTasksToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().guid().nullable().optional(),
sourceRunId: z.string().guid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: suggestTasksPayloadSchema,
});
const createAskUserQuestionsToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().guid().nullable().optional(),
sourceRunId: z.string().guid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: askUserQuestionsPayloadSchema,
});
const createRequestConfirmationToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().guid().nullable().optional(),
sourceRunId: z.string().guid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
payload: requestConfirmationPayloadSchema,
});
const createRequestCheckboxConfirmationToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().guid().nullable().optional(),
sourceRunId: z.string().guid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
});
const approvalDecisionSchema = z.object({
approvalId: approvalIdSchema,
action: z.enum(["approve", "reject", "requestRevision", "resubmit"]),
decisionNote: z.string().optional(),
payloadJson: z.string().optional(),
});
const createApprovalToolSchema = z.object({
companyId: companyIdOptional,
}).merge(createApprovalSchema);
const apiRequestSchema = z.object({
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
path: z.string().min(1),
jsonBody: z.string().optional(),
});
const workspaceRuntimeControlTargetSchema = z.object({
workspaceCommandId: z.string().min(1).optional().nullable(),
runtimeServiceId: z.string().guid().optional().nullable(),
serviceIndex: z.number().int().nonnegative().optional().nullable(),
});
const issueWorkspaceRuntimeControlSchema = z.object({
issueId: issueIdSchema,
action: z.enum(["start", "stop", "restart"]),
}).merge(workspaceRuntimeControlTargetSchema);
const waitForIssueWorkspaceServiceSchema = z.object({
issueId: issueIdSchema,
runtimeServiceId: z.string().guid().optional().nullable(),
serviceName: z.string().min(1).optional().nullable(),
timeoutSeconds: z.number().int().positive().max(300).optional(),
});
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readCurrentExecutionWorkspace(context: unknown): Record<string, unknown> | null {
if (!context || typeof context !== "object") return null;
const workspace = (context as { currentExecutionWorkspace?: unknown }).currentExecutionWorkspace;
return workspace && typeof workspace === "object" ? workspace as Record<string, unknown> : null;
}
function readWorkspaceRuntimeServices(workspace: Record<string, unknown> | null): Array<Record<string, unknown>> {
const raw = workspace?.runtimeServices;
return Array.isArray(raw)
? raw.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
: [];
}
function selectRuntimeService(
services: Array<Record<string, unknown>>,
input: { runtimeServiceId?: string | null; serviceName?: string | null },
) {
if (input.runtimeServiceId) {
return services.find((service) => service.id === input.runtimeServiceId) ?? null;
}
if (input.serviceName) {
return services.find((service) => service.serviceName === input.serviceName) ?? null;
}
return services.find((service) => service.status === "running" || service.status === "starting")
?? services[0]
?? null;
}
async function getIssueWorkspaceRuntime(client: PaperclipApiClient, issueId: string) {
const context = await client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/heartbeat-context`);
const workspace = readCurrentExecutionWorkspace(context);
return {
context,
workspace,
runtimeServices: readWorkspaceRuntimeServices(workspace),
};
}
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(
"paperclipListSkills",
"List the company skill library (all installed skills, independent of which agents have them enabled)",
z.object({ companyId: companyIdOptional }),
async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/skills`),
),
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().guid().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().guid() }),
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(
"paperclipGetIssueWorkspaceRuntime",
"Get the current execution workspace and runtime services for an issue, including service URLs",
z.object({ issueId: issueIdSchema }),
async ({ issueId }) => getIssueWorkspaceRuntime(client, issueId),
),
makeTool(
"paperclipControlIssueWorkspaceServices",
"Start, stop, or restart the current issue execution workspace runtime services",
issueWorkspaceRuntimeControlSchema,
async ({ issueId, action, ...target }) => {
const runtime = await getIssueWorkspaceRuntime(client, issueId);
const workspaceId = typeof runtime.workspace?.id === "string" ? runtime.workspace.id : null;
if (!workspaceId) {
throw new Error("Issue has no current execution workspace");
}
return client.requestJson(
"POST",
`/execution-workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`,
{ body: target },
);
},
),
makeTool(
"paperclipWaitForIssueWorkspaceService",
"Wait until an issue execution workspace runtime service is running and has a URL when one is exposed",
waitForIssueWorkspaceServiceSchema,
async ({ issueId, runtimeServiceId, serviceName, timeoutSeconds }) => {
const deadline = Date.now() + (timeoutSeconds ?? 60) * 1000;
let latest: Awaited<ReturnType<typeof getIssueWorkspaceRuntime>> | null = null;
while (Date.now() <= deadline) {
latest = await getIssueWorkspaceRuntime(client, issueId);
const service = selectRuntimeService(latest.runtimeServices, { runtimeServiceId, serviceName });
if (service?.status === "running" && service.healthStatus !== "unhealthy") {
return {
workspace: latest.workspace,
service,
};
}
await sleep(1000);
}
return {
timedOut: true,
latestWorkspace: latest?.workspace ?? null,
latestRuntimeServices: latest?.runtimeServices ?? [],
};
},
),
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(
"paperclipCreateApproval",
"Create a board approval request, optionally linked to one or more issues",
createApprovalToolSchema,
async ({ companyId, ...body }) =>
client.requestJson("POST", `/companies/${client.resolveCompanyId(companyId)}/approvals`, {
body,
}),
),
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; include resume=true when intentionally requesting follow-up on resumable closed work",
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; include resume=true when intentionally requesting follow-up on resumable closed work",
addCommentToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }),
),
makeTool(
"paperclipSuggestTasks",
"Create a suggest_tasks interaction on an issue",
createSuggestTasksToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "suggest_tasks",
...body,
},
}),
),
makeTool(
"paperclipAskUserQuestions",
"Create an ask_user_questions interaction on an issue",
createAskUserQuestionsToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "ask_user_questions",
...body,
},
}),
),
makeTool(
"paperclipRequestConfirmation",
"Create a request_confirmation interaction on an issue",
createRequestConfirmationToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "request_confirmation",
...body,
},
}),
),
makeTool(
"paperclipRequestCheckboxConfirmation",
"Create a request_checkbox_confirmation interaction on an issue",
createRequestCheckboxConfirmationToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "request_checkbox_confirmation",
...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().guid(),
}),
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("/") || path.includes("..")) {
throw new Error("path must start with / and be relative to /api, and must not contain '..'");
}
return client.requestJson(method, path, {
body: parseOptionalJson(jsonBody),
});
},
),
];
}