mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
Expand direct assistant MCP tools for work and configuration (#15380)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People also use assistants in Codex, Claude, and other MCP clients. > - The existing assistant connection can read work and create tasks or comments. > - It cannot edit tasks, exchange files, or manage normal agent and project settings. > - These operations must retain the person's permissions and Paperclip's execution rules. > - This pull request adds an explicit operation registry and separately consented configuration access. > - Assistants can manage work without receiving credentials or runner authority. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: assistant MCP, domain routes, consent UI, storage, and Product E2E. **Problem or motivation** A connected assistant cannot update tasks, maintain documents, attach files, or configure existing agents, projects, and skills. Users must leave the assistant for these routine actions. **Proposed solution** Add named tools and a restricted API registry to direct connections. Require separate configuration consent. Reuse domain routes and retry receipts. File uploads save the attachment when the byte transfer succeeds. **Alternatives considered** Arbitrary REST forwarding would expose administration and credential operations. Runner impersonation would bypass execution ownership. Separate upload completion calls add unnecessary client state. **Roadmap alignment** Checked ROADMAP.md and related MCP pull requests. This extends the human-authorized connection from #14933. It does not replace the runner or introduce agent impersonation. Companion Cloud routing and directory isolation: https://github.com/paperclipai/paperclip-cloud/pull/678. ## What Changed - Add task editing, finish/block, documents/revisions, deliverables, agent settings/instructions, projects/repositories, and skills/files. - Add an allowlisted API search/call registry with identical field restrictions, scopes, and retry identities. - Add unchecked configuration consent. Existing write grants retain their current authority. - Add hashed, expiring file transfer tickets and atomic upload receipts. No completion call is required. - Preserve company boundaries, human attribution, active native execution ownership, and execution review gates. - Add protocol/domain tests, consent stories, and eight paid Product E2E workflows. - Repair two CI fixture races: await cold route setup before assertions, and wait for asynchronously loaded connection copy. Both fixture suites pass (24 + 48 tests). ## Verification - Consent revision: one write-access checkbox controls requested work and configuration permissions in browser and device flows. All 16 consent tests, UI typecheck/build and token gates pass. Updated interactive stories cover default approval, opt-out and viewer restrictions. The paid browser helper uses the new exact label. Real GPT-5.4 Mini Product E2E passes 2/2 at `64f96373118eb190f8cba1c2ab17cb979555f3ad` (configuration + permission denial), campaign `local-2026-10-07T00-51-14-337Z`, no automatic retries, cleanup passed; $0.04149375 estimated assistant cost plus unpriced worker usage. Raw results, usage and source fingerprints are retained in the worktree. UI and Product E2E typechecks pass. - Prior head `2f246d4b74f1f98c75ebcb37ae6753a748237fac`: all 52 checks pass; two optional Storybook checks skip. Greptile 5/5 on that head, no unresolved review threads. Final consent head `64f96373118eb190f8cba1c2ab17cb979555f3ad` also has all checks passing and Greptile 5/5 with no unresolved threads. The unchanged Cursor sandbox test had one 10-second timeout, passed in local isolation, and passed its single CI rerun; the failed attempt remains in [the CI run](https://github.com/paperclipai/paperclip/actions/runs/37554106934). The existing chat retry-denial browser test had one visibility failure; its single rerun passes, and the failed attempt remains in [the CI run](https://github.com/paperclipai/paperclip/actions/runs/37542735691). - Full workspace `pnpm -r typecheck` and `pnpm build` pass at final runtime source `b2196fae1`. UI token gates pass. - 139 MCP/OAuth/transfer/privacy tests and 76 grader calibration tests pass, including one-connection PostgreSQL OAuth and concurrent upload retries. - Paid Product E2E: all eight expanded cases qualified across Mini, Haiku and Sonnet. A merged-source repeat passed 23/24; one Haiku cell timed out before application startup. Final affected-case qualification passes 9/9 on all three models with grader v16, including the failed cell. Automatic retries disabled; failures, costs, source hashes and independent durable-state/file assertions are retained in [the verification record](doc/plans/2026-10-06-expanded-assistant-mcp-verification.md). - Actual Codex CLI, Claude Code and OpenCode clients completed local reads/mutations. Codex wrote a report, Claude updated it in a later conversation, and OpenCode uploaded/downloaded a file with matching SHA-256 and registered the attachment. Revoking the CLI grant rejects subsequent bridge initialization. - Butter staging is verified on final runtime `b2196fae1` ([deployment](https://github.com/paperclipai/paperclip-cloud/actions/runs/37538432138)). A fresh OpenCode workspace fetched the copied invitation, configured remote MCP, started OAuth and reached real consent with configuration unchecked. Invalid transfer tickets return 403 through Cloud. Human approval for the new persistent staging grant is pending; hosted task/file success is not yet claimed. The final transaction fix is deployed. - Full local `pnpm test:run` passed 15,614 general-server tests but stopped on two macOS timeouts. The heartbeat test passed in isolation; the existing 40,000-file Git stress fixture timed out again. Its Linux CI lane passes. Later local full-suite phases did not run after the timeout; this is not an all-green local full-suite claim. - Instructions and security limits are in `doc/public-mcp.md`; the saved plan is `doc/plans/2026-10-06-expanded-assistant-mcp-tools.md`. ## Risks - This expands the experimental direct MCP surface. Explicit schemas and domain permissions must stay synchronized. - Migration 0311 adds transfer tickets and upload receipts. Expired orphan cleanup must not remove committed attachments. - Configuration requires a new consent request containing that scope; the single write-access choice controls it alongside work mutations. Refreshing an old grant does not add it. - The public directory keeps its original ten tools through the companion Cloud change. - Hosted consent/work proof remains the final delivery gate. The PR stays draft while approval of the new staging grant is pending; code checks and review are green. Merging is a separate action. ## Model Used OpenAI Codex (GPT-6, tool use and code execution). The exact serving model ID and context window are not exposed in this session. Paid evaluation models: gpt-5.4-mini, claude-haiku-4-5-20251001; claude-sonnet-4-6. ## 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 (focused suites; full-suite macOS limitation disclosed above) - [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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
892b0b3606
commit
2d0c138122
42 files changed
+2475
-164
No files matched your search
@@ -211,7 +211,7 @@ function makeReflectionCoachAgent(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("agent instructions bundle routes", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
@@ -288,7 +288,13 @@ describe("agent instructions bundle routes", () => {
|
||||
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
|
||||
},
|
||||
});
|
||||
});
|
||||
// Cold route compilation belongs to fixture setup. Timing it as a request
|
||||
// let a timed-out import resume under the next case's reset mocks.
|
||||
await Promise.all([
|
||||
vi.importActual("../routes/agents.js"),
|
||||
vi.importActual("../middleware/index.js"),
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it("returns bundle metadata", async () => {
|
||||
const res = await requestApp(
|
||||
|
||||
@@ -7679,6 +7679,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
).toBe(1);
|
||||
expect(deferred).toHaveLength(1);
|
||||
|
||||
// The production reorder window is 750 ms; allow the subsequent database
|
||||
// drain to finish on loaded CI instead of sharing the default one-second budget.
|
||||
deferred.shift()?.();
|
||||
await vi.waitFor(async () => {
|
||||
const rows = await db
|
||||
@@ -7686,7 +7688,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
.from(chatConversations)
|
||||
.where(eq(chatConversations.endpointId, endpoint.id));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
}, { timeout: 10_000 });
|
||||
const [conversation] = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
@@ -7702,7 +7704,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
"unmentioned follow-up delivered first",
|
||||
]);
|
||||
expect(wakeup).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
}, { timeout: 10_000 });
|
||||
await service.shutdown();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createPublicMcpTransfers } from "../services/public-mcp/file-transfers.js";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { companySkillRoutes } from "../routes/company-skills.js";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { mcpAttachmentUploads, mcpFileTickets, principalPermissionGrants, heartbeatRuns } from "@paperclipai/db";
|
||||
import { createHmac, createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import express, { type Request } from "express";
|
||||
import request from "supertest";
|
||||
@@ -95,16 +104,16 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
}
|
||||
});
|
||||
|
||||
async function fixture(role = "member", write = true) {
|
||||
async function fixture(role = "member", write = true, configure = false) {
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Human", email: userId + "@example.com", createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Team", issuePrefix: "M" + randomBytes(4).toString("hex") }).returning();
|
||||
const [membership] = await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: role, status: "active" }).returning();
|
||||
const actor: Request["actor"] = { type: "board", source: "session", userId };
|
||||
const client = await oauth.register({ client_name: "Test client", redirect_uris: [redirectUri] }, randomUUID());
|
||||
const url = await oauth.authorize({ client_id: client.client_id, redirect_uri: redirectUri, resource: config.resource, scope: "paperclip:read paperclip:write offline_access", state: "state", response_type: "code", code_challenge: challenge, code_challenge_method: "S256" });
|
||||
const url = await oauth.authorize({ client_id: client.client_id, redirect_uri: redirectUri, resource: config.resource, scope: "paperclip:read paperclip:write paperclip:configure offline_access", state: "state", response_type: "code", code_challenge: challenge, code_challenge_method: "S256" });
|
||||
const id = url.split("/").at(-1)!;
|
||||
const consent = await oauth.consent(id, actor, { decision: "approve", companyId: company!.id, allowWrites: write });
|
||||
const consent = await oauth.consent(id, actor, { decision: "approve", companyId: company!.id, allowWrites: write, allowConfiguration: configure });
|
||||
const code = new URL(consent.redirectUrl).searchParams.get("code")!;
|
||||
const exchange = { grant_type: "authorization_code", client_id: client.client_id, redirect_uri: redirectUri, resource: config.resource, code, code_verifier: verifier };
|
||||
const tokens = await oauth.token(exchange);
|
||||
@@ -585,6 +594,321 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
} finally { wake.mockRestore(); }
|
||||
});
|
||||
|
||||
it("requires fresh explicit configuration consent and never upscopes old grants", async () => {
|
||||
const old = await fixture();
|
||||
const f = await fixture("member", true, true);
|
||||
const dispatch = vi.fn().mockResolvedValue({ id: randomUUID(), name: "Updated" });
|
||||
const execute = createPublicMcpExecutor(db, oauth, dispatch);
|
||||
const args = { companyId: old.company.id, agentId: randomUUID(), requestId: randomUUID(), changes: { title: "Writer" } };
|
||||
await expect(execute(old.tokens.access_token, "paperclip_update_agent", args)).rejects.toThrow("paperclip:configure");
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
await execute(f.tokens.access_token, "paperclip_update_agent", { ...args, companyId: f.company.id });
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect((await oauth.authenticate(f.tokens.access_token)).grant.scopes).toContain("paperclip:configure");
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.id, f.membership.id));
|
||||
await expect(execute(f.tokens.access_token, "paperclip_update_agent", { ...args, companyId: f.company.id, requestId: randomUUID() })).rejects.toThrow("permission");
|
||||
const refreshed = await oauth.token({ grant_type: "refresh_token", client_id: old.client.client_id, resource: config.resource, refresh_token: old.tokens.refresh_token });
|
||||
expect(refreshed.scope).not.toContain("configure");
|
||||
});
|
||||
|
||||
it("shares named/API receipts and refuses unknown operations, fields and cross-company arguments", async () => {
|
||||
const f = await fixture();
|
||||
const dispatch = vi.fn().mockResolvedValue({ id: randomUUID(), title: "New title" });
|
||||
const execute = createPublicMcpExecutor(db, oauth, dispatch);
|
||||
const args = { companyId: f.company.id, taskId: randomUUID(), requestId: randomUUID(), changes: { title: "New title" } };
|
||||
const first = await execute(f.tokens.access_token, "paperclip_update_task", args);
|
||||
expect(await execute(f.tokens.access_token, "paperclip_call_api", { companyId: f.company.id, operationId: "paperclip_update_task", arguments: args })).toEqual(first);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
for (const field of ["onBehalfOfUserId", "reviewPolicy", "executionPolicy", "interrupt", "reviewInteractionId", "permissions"]) {
|
||||
await expect(execute(f.tokens.access_token, "paperclip_update_task", { ...args, changes: { [field]: "unauthorized" } })).rejects.toThrow();
|
||||
}
|
||||
for (const operationId of ["DELETE /companies/:id", "paperclip_call_api", "approve_approval", "https://evil.example"]) {
|
||||
await expect(execute(f.tokens.access_token, "paperclip_call_api", { companyId: f.company.id, operationId, arguments: args })).rejects.toThrow();
|
||||
}
|
||||
await expect(execute(f.tokens.access_token, "paperclip_call_api", { companyId: f.company.id, operationId: "paperclip_update_task", arguments: { ...args, companyId: randomUUID() } })).rejects.toThrow("Company");
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
async function expandedFixture(configure = false) {
|
||||
const f = await fixture(configure ? "owner" : "member", true, configure);
|
||||
if (configure) await db.insert(principalPermissionGrants).values(["agents:configure", "skills:create"].map(permissionKey => ({ companyId: f.company.id, principalType: "user", principalId: f.actor.userId!, permissionKey })));
|
||||
const storageDir = await mkdtemp(join(tmpdir(), "mcp-files-"));
|
||||
const storage = createStorageService(createLocalDiskStorageProvider(storageDir));
|
||||
const api = express.Router(); api.use(boardMutationGuard());
|
||||
api.use(issueRoutes(db, storage)); api.use(activityRoutes(db)); api.use(projectRoutes(db)); api.use(agentRoutes(db)); api.use(companySkillRoutes(db));
|
||||
const dispatch = createMcpApiDispatch(api);
|
||||
const transfers = createPublicMcpTransfers(db, oauth, dispatch, storage);
|
||||
const execute = createPublicMcpExecutor(db, oauth, dispatch, transfers);
|
||||
const [issue] = await db.insert(issues).values({ companyId: f.company.id, title: "Work", status: "backlog" }).returning();
|
||||
const call = (name: string, a: Record<string, unknown> = {}) => execute(f.tokens.access_token, name, { companyId: f.company.id, ...a });
|
||||
return { ...f, issue: issue!, call, dispatch, transfers, storage, cleanup: () => rm(storageDir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
it("edits, blocks and completes tasks through real routes and rejects review overrides", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
expect(await f.call("paperclip_update_task", { taskId: f.issue.id, requestId: randomUUID(), changes: { title: "Edited", description: "Durable detail" } })).toHaveProperty("task.title", "Edited");
|
||||
expect(await f.call("paperclip_block_task", { taskId: f.issue.id, requestId: randomUUID(), unblockDescriptor: { owner: "board", action: "Supply the source file" } })).toHaveProperty("task.status", "blocked");
|
||||
expect(await f.call("paperclip_finish_task", { taskId: f.issue.id, requestId: randomUUID() })).toHaveProperty("task.status", "done");
|
||||
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, f.issue.id));
|
||||
const denied = await f.call("paperclip_finish_task", { taskId: f.issue.id, requestId: randomUUID() });
|
||||
expect(denied).toMatchObject({ outcome: "rejected", status: 403, code: "MCP_REVIEW_REQUIRED" });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issue.id)))[0]?.status).toBe("in_review");
|
||||
const logs = await db.select().from(activityLog).where(eq(activityLog.entityId, f.issue.id));
|
||||
expect(logs.some(v => v.actorType === "user" && v.actorId === f.actor.userId && v.action === "issue.updated")).toBe(true);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it.each(["queued", "running", "scheduled_retry"])("preserves %s execution ownership and rejects dependency cycles", async status => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.company.id, name: "Worker" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: agent!.id, status, runtimeMode: "native", nativeIssueId: f.issue.id }).returning();
|
||||
await db.update(issues).set({ assigneeAgentId: agent!.id, executionRunId: run!.id, status: "in_progress" }).where(eq(issues.id, f.issue.id));
|
||||
for (const changes of [{ status: "done" }, { status: "blocked", unblockDescriptor: { owner: "board", action: "Supply data" } }, { assigneeAgentId: null }, { status: "cancelled" }]) {
|
||||
expect(await f.call("paperclip_update_task", { taskId: f.issue.id, requestId: randomUUID(), changes })).toMatchObject({ outcome: "rejected", status: 409, code: "MCP_ACTIVE_EXECUTION" });
|
||||
}
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)))[0]?.status).toBe(status);
|
||||
expect(await f.call("paperclip_update_task", { taskId: f.issue.id, requestId: randomUUID(), changes: { title: "Clarified title" } })).toHaveProperty("task.title", "Clarified title");
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run!.id));
|
||||
await db.update(issues).set({ assigneeAgentId: null, executionRunId: null, status: "backlog" }).where(eq(issues.id, f.issue.id));
|
||||
const [second] = await db.insert(issues).values({ companyId: f.company.id, title: "Dependent", status: "backlog" }).returning();
|
||||
expect(await f.call("paperclip_update_task", { taskId: second!.id, requestId: randomUUID(), changes: { blockedByIssueIds: [f.issue.id] } })).toHaveProperty("task.id", second!.id);
|
||||
const cycle = await f.call("paperclip_update_task", { taskId: f.issue.id, requestId: randomUUID(), changes: { blockedByIssueIds: [second!.id] } });
|
||||
expect(cycle).toMatchObject({ outcome: "rejected" });
|
||||
expect([400, 409, 422]).toContain(cycle.status);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
["codex_local", "gpt-5.4", "modelReasoningEffort"],
|
||||
["claude_local", "claude-sonnet-4-6", "effort"],
|
||||
["paperclip_runner", "gpt-5.4", "modelReasoningEffort"],
|
||||
])("updates the effective %s reasoning setting and validates its model", async (adapterType, model, field) => {
|
||||
const f = await expandedFixture(true);
|
||||
try {
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.company.id, name: "Reasoning", adapterType,
|
||||
adapterConfig: { model, [field]: "high", instructionsBundleMode: "managed" }, runtimeConfig: { heartbeat: { enabled: false } } }).returning();
|
||||
const edited = await f.call("paperclip_update_agent", { agentId: agent!.id, requestId: randomUUID(), changes: { adapterConfig: { reasoningEffort: "low" } } });
|
||||
expect(edited, JSON.stringify(edited)).toHaveProperty("agent.adapterConfig.reasoningEffort", "low");
|
||||
const stored = (await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!;
|
||||
expect(stored.adapterConfig[field]).toBe("low");
|
||||
expect(stored.adapterConfig.reasoningEffort).toBeUndefined();
|
||||
expect(await f.call("paperclip_get_agent", { agentId: agent!.id })).toHaveProperty("agent.adapterConfig.reasoningEffort", "low");
|
||||
for (const reasoningEffort of ["ultra"]) {
|
||||
expect(await f.call("paperclip_update_agent", { agentId: agent!.id, requestId: randomUUID(), changes: { adapterConfig: { reasoningEffort } } })).toMatchObject({ outcome: "rejected" });
|
||||
}
|
||||
if (adapterType === "claude_local") {
|
||||
expect(await f.call("paperclip_update_agent", { agentId: agent!.id, requestId: randomUUID(), changes: { adapterConfig: { model: "claude-haiku-4-5" } } })).toMatchObject({ outcome: "rejected", status: 422 });
|
||||
}
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("writes durable documents and rejects stale revisions through both interfaces", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
const a = { taskId: f.issue.id, requestId: randomUUID(), key: "report", document: { format: "markdown", body: "First revision", baseRevisionId: null } };
|
||||
const first = await f.call("paperclip_write_document", a);
|
||||
expect(first).toHaveProperty("document.body", "First revision");
|
||||
const revision = (first.document as { latestRevisionId: string }).latestRevisionId;
|
||||
expect(revision).toBeTruthy();
|
||||
const second = await f.call("paperclip_write_document", { ...a, requestId: randomUUID(), document: { ...a.document, body: "Second revision", baseRevisionId: revision } });
|
||||
expect(second).toHaveProperty("document.body", "Second revision");
|
||||
expect(await f.call("paperclip_write_document", { ...a, requestId: randomUUID() })).toMatchObject({ outcome: "rejected", status: 409 });
|
||||
expect(await f.call("paperclip_read_document", { taskId: f.issue.id, key: "report" })).toHaveProperty("document.body", "Second revision");
|
||||
expect(await f.call("paperclip_list_document_revisions", { taskId: f.issue.id, key: "report" })).toHaveProperty("revisions");
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("creates and edits projects and agents while rejecting privileged configuration fields", async () => {
|
||||
const f = await expandedFixture(true);
|
||||
try {
|
||||
const created = await f.call("paperclip_create_project", { requestId: randomUUID(), project: { name: "Research", description: "Project context" } });
|
||||
expect(created).toHaveProperty("project.name", "Research");
|
||||
const id = (created.project as { id: string }).id;
|
||||
expect(await f.call("paperclip_update_project", { projectId: id, requestId: randomUUID(), changes: { name: "Updated project" } })).toHaveProperty("project.name", "Updated project");
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.company.id, name: "Writer", adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "managed", model: "gpt-5.4-mini", env: { SAFE_EXISTING: "retain" } }, runtimeConfig: { heartbeat: { enabled: false } } }).returning();
|
||||
const edited = await f.call("paperclip_update_agent", { agentId: agent!.id, requestId: randomUUID(), changes: { title: "Editor", budgetMonthlyCents: 1200, adapterConfig: { model: "gpt-5.4" } } });
|
||||
expect(edited, JSON.stringify(edited)).toHaveProperty("agent.title", "Editor");
|
||||
const stored = (await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!;
|
||||
expect(stored.adapterConfig.env).toEqual({ SAFE_EXISTING: { type: "plain", value: "retain" } });
|
||||
expect(stored.budgetMonthlyCents).toBe(1200);
|
||||
const read = await f.call("paperclip_get_agent", { agentId: agent!.id });
|
||||
expect(JSON.stringify(read)).not.toContain("SAFE_EXISTING");
|
||||
for (const changes of [{ permissions: {} }, { adapterConfig: { command: "evil" } }, { runtimeConfig: { debug: { providerTrace: "raw" } } }]) {
|
||||
await expect(f.call("paperclip_update_agent", { agentId: agent!.id, requestId: randomUUID(), changes })).rejects.toThrow();
|
||||
}
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("rejects unchecked legacy prompt edits through named and generic operations", async () => {
|
||||
const f = await expandedFixture(true);
|
||||
try {
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.company.id, name: "Legacy writer", adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "managed", promptTemplate: "Newer human instructions" } }).returning();
|
||||
for (const base of [{ baseHash: null }, { baseHash: "0".repeat(64) }, { baseRevisionId: randomUUID() }]) {
|
||||
const args = { companyId: f.company.id, agentId: agent!.id, requestId: randomUUID(), file: { path: "promptTemplate.legacy.md", content: "Stale assistant overwrite", ...base } };
|
||||
expect(await f.call("paperclip_update_agent_instructions", args)).toMatchObject({ outcome: "rejected", status: 422, code: "MCP_LEGACY_INSTRUCTIONS_UNVERSIONED" });
|
||||
expect(await f.call("paperclip_call_api", { operationId: "paperclip_update_agent_instructions", arguments: { ...args, requestId: randomUUID() } })).toMatchObject({ outcome: "rejected", status: 422, code: "MCP_LEGACY_INSTRUCTIONS_UNVERSIONED" });
|
||||
}
|
||||
const stored = (await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!;
|
||||
expect(stored.adapterConfig.promptTemplate).toBe("Newer human instructions");
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("version-checks agent instructions and skill files through the real domain services", async () => {
|
||||
const f = await expandedFixture(true);
|
||||
try {
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.company.id, name: "Writer", adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "managed" } }).returning();
|
||||
const input = { agentId: agent!.id, requestId: randomUUID(), file: { path: "AGENTS.md", content: "First instructions", baseRevisionId: null } };
|
||||
expect(await f.call("paperclip_update_agent_instructions", input)).toHaveProperty("instructions.content", "First instructions");
|
||||
const current = await f.call("paperclip_read_agent_instructions", { agentId: agent!.id });
|
||||
const instructions = current.instructions as { revision: { id: string } };
|
||||
expect(instructions.revision.id).toBeTruthy();
|
||||
expect(await f.call("paperclip_update_agent_instructions", { ...input, requestId: randomUUID(), file: { ...input.file, content: "New instructions", baseRevisionId: instructions.revision.id } })).toHaveProperty("instructions.content", "New instructions");
|
||||
expect(await f.call("paperclip_update_agent_instructions", { ...input, requestId: randomUUID() })).toMatchObject({ outcome: "rejected", status: 409 });
|
||||
expect(await f.call("paperclip_list_agent_instruction_revisions", { agentId: agent!.id })).toHaveProperty("revisions");
|
||||
const created = await f.call("paperclip_create_skill", { requestId: randomUUID(), skill: { name: "Source review", markdown: "# Source review\nReview citations carefully." } });
|
||||
expect(created).toHaveProperty("skill.id");
|
||||
const skill = created.skill as { id: string; currentVersionId: string };
|
||||
expect(skill.currentVersionId).toBeTruthy();
|
||||
const read = await f.call("paperclip_read_skill_file", { skillId: skill.id });
|
||||
const file = read.file as { content: string };
|
||||
const write = { skillId: skill.id, requestId: randomUUID(), file: { path: "SKILL.md", content: file.content + "\nPreserve source links.", expectedVersionId: skill.currentVersionId } };
|
||||
expect(await f.call("paperclip_write_skill_file", write)).toHaveProperty("file.versionId");
|
||||
expect(await f.call("paperclip_write_skill_file", { ...write, requestId: randomUUID() })).toMatchObject({ outcome: "rejected", status: 409 });
|
||||
expect(await f.call("paperclip_update_skill", { skillId: skill.id, requestId: randomUUID(), changes: { tagline: "Check sources" } })).toHaveProperty("skill.tagline", "Check sources");
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("completes OAuth and concurrent upload retries with a one-connection pool", async () => {
|
||||
const originalDb = db, originalOauth = oauth;
|
||||
db = createDb(temp.connectionString, { maxConnections: 1 });
|
||||
oauth = createPublicMcpOAuth(db, config);
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
// Fixture approval and PKCE redemption must also stay on their transaction.
|
||||
const f = await expandedFixture(); cleanup = f.cleanup;
|
||||
await oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id,
|
||||
resource: config.resource, refresh_token: f.tokens.refresh_token });
|
||||
const device = await deviceFixture();
|
||||
await oauth.consentDevice(device.codes.user_code, device.actor, { decision: "approve", companyId: device.company.id, allowWrites: true });
|
||||
await oauth.token(device.deviceExchange);
|
||||
const bytes = Buffer.from("one connection upload");
|
||||
const args = { taskId: f.issue.id, filename: "single.txt", contentType: "text/plain", byteSize: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
const links = [];
|
||||
for (let i = 0; i < 3; i++) links.push(new URL((await f.call("paperclip_get_upload_url", { ...args, requestId: randomUUID() })).url as string));
|
||||
const app = express(); app.use(f.transfers.router);
|
||||
const results = await Promise.all(links.flatMap(url => Array.from({ length: 2 }, () =>
|
||||
request(app).put(url.pathname + url.search).set("Content-Type", args.contentType).send(bytes).timeout(5000))));
|
||||
expect(results.map(result => result.status)).toEqual(Array(6).fill(200));
|
||||
expect(new Set(results.map(result => result.body.attachment.id)).size).toBe(3);
|
||||
expect((await f.call("paperclip_list_deliverables", { taskId: f.issue.id })).attachments).toHaveLength(3);
|
||||
} finally { db = originalDb; oauth = originalOauth; await cleanup?.(); }
|
||||
}, 15000);
|
||||
|
||||
it("automatically finalizes binary uploads once, survives lost responses, and enforces live revocation", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
const bytes = Buffer.from([0, 255, 18, 7, 64, 89, 33, 0]);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const args = { taskId: f.issue.id, requestId: randomUUID(), filename: "demo.mp4", contentType: "video/mp4", byteSize: bytes.length, sha256 };
|
||||
const prepared = await f.call("paperclip_get_upload_url", args);
|
||||
const url = new URL(prepared.url as string);
|
||||
const app = express(); app.use(f.transfers.router);
|
||||
const put = (body = bytes) => request(app).put(url.pathname + url.search).set("Content-Type", args.contentType).send(body);
|
||||
const responses = await Promise.all([put(), put()]);
|
||||
expect(responses.map(v => v.status)).toEqual([200, 200]);
|
||||
const attachment = responses[0]!.body.attachment;
|
||||
expect(responses[1]!.body.attachment.id).toBe(attachment.id);
|
||||
expect(await f.call("paperclip_get_upload_url", args)).toMatchObject({ status: "completed", attachment: { id: attachment.id, sha256 } });
|
||||
const outputs = await f.call("paperclip_list_deliverables", { taskId: f.issue.id });
|
||||
expect(outputs.attachments).toHaveLength(1);
|
||||
expect((await put(Buffer.from("conflicting bytes"))).status).toBe(403);
|
||||
const download = await f.call("paperclip_get_download_url", { attachmentId: attachment.id });
|
||||
const downloadUrl = new URL(download.url as string);
|
||||
const content = await request(app).get(downloadUrl.pathname + downloadUrl.search).buffer(true);
|
||||
expect(content.status).toBe(200);
|
||||
expect(createHash("sha256").update(content.body as Buffer).digest("hex")).toBe(sha256);
|
||||
const tickets = await db.select().from(mcpFileTickets).where(eq(mcpFileTickets.grantId, (await oauth.authenticate(f.tokens.access_token)).grant.id));
|
||||
expect(JSON.stringify(tickets)).not.toContain(url.searchParams.get("ticket"));
|
||||
const receipts = await db.select().from(mcpMutationReceipts).where(eq(mcpMutationReceipts.companyId, f.company.id));
|
||||
expect(JSON.stringify(receipts)).not.toContain("ticket=");
|
||||
const other = await fixture();
|
||||
await expect(createPublicMcpExecutor(db, oauth, f.dispatch, f.transfers)(other.tokens.access_token, "paperclip_get_download_url", { companyId: other.company.id, attachmentId: attachment.id })).rejects.toThrow();
|
||||
await oauth.revokeConnection((await oauth.authenticate(f.tokens.access_token)).grant.id, f.actor.userId!);
|
||||
expect((await request(app).get(downloadUrl.pathname + downloadUrl.search)).status).not.toBe(200);
|
||||
expect((await put()).status).not.toBe(200);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("rejects expired, malformed and over-limit file tickets and respects the experimental gate", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
const args = { taskId: f.issue.id, requestId: randomUUID(), filename: "data.json", contentType: "application/json", byteSize: 2, sha256: createHash("sha256").update("{}").digest("hex") };
|
||||
await expect(f.call("paperclip_get_upload_url", { ...args, byteSize: MAX_ATTACHMENT_BYTES + 1 })).rejects.toThrow();
|
||||
await expect(f.call("paperclip_get_upload_url", { ...args, filename: "../escape" })).rejects.toThrow();
|
||||
const prepared = await f.call("paperclip_get_upload_url", args);
|
||||
const url = new URL(prepared.url as string);
|
||||
const app = express(); app.use(f.transfers.router);
|
||||
const put = () => request(app).put(url.pathname + url.search).set("Content-Type", "application/json").send("{}");
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: false });
|
||||
try { expect((await put()).status).not.toBe(200); } finally { await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true }); }
|
||||
await db.update(mcpFileTickets).set({ expiresAt: new Date(0) }).where(eq(mcpFileTickets.tokenHash, hashMcpSecret(url.searchParams.get("ticket")!)));
|
||||
expect((await put()).status).toBe(403);
|
||||
expect((await request(app).get("/mcp/files/download?ticket=bad")).status).toBe(403);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("rejects partial, oversized and mismatched uploads without creating attachments", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
const bytes = Buffer.from("exact original content");
|
||||
const args = { taskId: f.issue.id, requestId: randomUUID(), filename: "data.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
const url = new URL((await f.call("paperclip_get_upload_url", args)).url as string);
|
||||
const app = express(); app.use(f.transfers.router);
|
||||
const put = (body: Buffer, type = "text/plain") => request(app).put(url.pathname + url.search).set("Content-Type", type).send(body);
|
||||
expect((await put(bytes.subarray(0, 4))).status).toBe(403);
|
||||
expect((await put(Buffer.alloc(MAX_ATTACHMENT_BYTES + 1))).status).toBe(413);
|
||||
expect((await put(bytes, "video/mp4")).status).toBe(403);
|
||||
expect((await f.call("paperclip_list_deliverables", { taskId: f.issue.id })).attachments).toHaveLength(0);
|
||||
expect((await put(bytes)).status).toBe(200);
|
||||
expect((await f.call("paperclip_list_deliverables", { taskId: f.issue.id })).attachments).toHaveLength(1);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("rejects authority lost during storage and safely reclaims the orphaned object", async () => {
|
||||
const f = await expandedFixture();
|
||||
try {
|
||||
const bytes = Buffer.from("orphan test");
|
||||
const args = { taskId: f.issue.id, requestId: randomUUID(), filename: "data.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
const prepared = await f.call("paperclip_get_upload_url", args);
|
||||
const principal = await oauth.authenticate(f.tokens.access_token);
|
||||
const originalPut = f.storage.putFile.bind(f.storage);
|
||||
const putSpy = vi.spyOn(f.storage, "putFile").mockImplementationOnce(async input => {
|
||||
const stored = await originalPut(input);
|
||||
await oauth.revokeConnection(principal.grant.id, f.actor.userId!);
|
||||
return stored;
|
||||
});
|
||||
const app = express(); app.use(f.transfers.router);
|
||||
const url = new URL(prepared.url as string);
|
||||
expect((await request(app).put(url.pathname + url.search).set("Content-Type", "text/plain").send(bytes)).status).not.toBe(200);
|
||||
putSpy.mockRestore();
|
||||
const [upload] = await db.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.id, prepared.uploadId as string));
|
||||
expect(upload!.attachmentId).toBeNull();
|
||||
const orphan = await f.storage.getObject(upload!.companyId, upload!.objectKey);
|
||||
orphan.stream.destroy();
|
||||
await db.update(mcpAttachmentUploads).set({ expiresAt: new Date(0) }).where(eq(mcpAttachmentUploads.id, upload!.id));
|
||||
const fresh = await expandedFixture();
|
||||
try {
|
||||
const clean = createPublicMcpTransfers(db, oauth, fresh.dispatch, f.storage);
|
||||
await clean.getUploadUrl(await oauth.authenticate(fresh.tokens.access_token), { ...args, companyId: fresh.company.id, taskId: fresh.issue.id, requestId: randomUUID() });
|
||||
expect((await db.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.id, upload!.id)))[0]!.cleanedAt).not.toBeNull();
|
||||
await expect(f.storage.getObject(upload!.companyId, upload!.objectKey)).rejects.toThrow();
|
||||
} finally { await fresh.cleanup(); }
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("exposes discovery and individually named protocol tools, with CSRF-protected management", async () => {
|
||||
const f = await fixture();
|
||||
const dispatch = vi.fn().mockResolvedValue([{ id: randomUUID(), name: "Engineer", adapterConfig: { secret: "hidden" } }]);
|
||||
@@ -597,7 +921,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
const rpc = (method: string, params?: unknown) => request(app).post("/mcp/paperclip").timeout({ response: 5000, deadline: 7000 }).set("Authorization", `Bearer ${f.tokens.access_token}`).set("Accept", "application/json, text/event-stream").send({ jsonrpc: "2.0", id: 1, method, ...(params ? { params } : {}) });
|
||||
expect((await rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } })).body.result.serverInfo.name).toBe("paperclip");
|
||||
const catalog = await rpc("tools/list");
|
||||
expect(catalog.body.result.tools).toHaveLength(10);
|
||||
expect(catalog.body.result.tools).toHaveLength(publicMcpCapabilities.length);
|
||||
expect(catalog.body.result.tools.find((t: { name: string }) => t.name === "paperclip_create_task").annotations.readOnlyHint).toBe(false);
|
||||
const result = await rpc("tools/call", { name: "paperclip_list_agents", arguments: { companyId: f.company.id } });
|
||||
expect(JSON.stringify(result.body)).not.toContain("hidden");
|
||||
@@ -607,6 +931,11 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
expect(failed.body.result.isError).toBe(true);
|
||||
expect(JSON.stringify(failed.body)).not.toContain("tenant-secret");
|
||||
expect(failed.body.result.content[0].text).toContain("could not confirm");
|
||||
dispatch.mockClear();
|
||||
const invalid = await rpc("tools/call", { name: "paperclip_finish_task", arguments: { companyId: f.company.id, taskId: randomUUID(), requestId: "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7" } });
|
||||
expect(invalid.body.result.structuredContent).toMatchObject({ outcome: "rejected", phase: "validation", issues: [{ path: "requestId", code: "invalid_format" }] });
|
||||
expect(invalid.body.result.structuredContent.issues[0].message).toContain("UUID");
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
const pending = await oauth.authorize({ client_id: f.client.client_id, redirect_uri: redirectUri, resource: config.resource, response_type: "code", code_challenge: challenge, code_challenge_method: "S256" });
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_API_ORIGIN", "https://cloud.example.test");
|
||||
try {
|
||||
@@ -728,7 +1057,7 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
.send({ jsonrpc: "2.0", id: 1, method, params: { ...params, ...extra } });
|
||||
expect((await rpc("server/discover")).body.result).toMatchObject({ resultType: "complete", supportedVersions: ["2026-07-28"], capabilities: { tools: {}, events: {} } });
|
||||
expect((await rpc("events/list")).body.result.events.map((e: { name: string }) => e.name)).toEqual(publicMcpEventDefinitions.map(e => e.name));
|
||||
expect((await rpc("tools/list")).body.result.tools).toHaveLength(10);
|
||||
expect((await rpc("tools/list")).body.result.tools).toHaveLength(publicMcpCapabilities.length);
|
||||
expect((await rpc("tools/call", { name: "paperclip_connection", arguments: {} }, { "Mcp-Name": "=?base64?cGFwZXJjbGlwX2Nvbm5lY3Rpb24=?=" })).body.result.structuredContent.companyId).toBe(f.company.id);
|
||||
expect((await rpc("events/list", {}, { "Mcp-Method": "tools/list" })).body.error.code).toBe(-32020);
|
||||
expect((await rpc("events/list", { _meta: {} })).body.error.code).toBe(-32602);
|
||||
|
||||
+7
-7
@@ -4,6 +4,7 @@ import { browserUseRoutes } from "./routes/browser-use.js";
|
||||
import { browserUseService } from "./services/browser-use.js";
|
||||
import { slackToolRoutes } from "./routes/slack-tools.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig } from "./services/public-mcp/oauth.js";
|
||||
import { createPublicMcpTransfers } from "./services/public-mcp/file-transfers.js";
|
||||
import { createMcpApiDispatch, createPublicMcpExecutor } from "./services/public-mcp/capabilities.js";
|
||||
import { createPublicMcpEvents, type PublicMcpEvents } from "./services/public-mcp/events.js";
|
||||
import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "./routes/public-mcp.js";
|
||||
@@ -552,12 +553,9 @@ export async function createApp(
|
||||
createChatWebhookDiagnostics(),
|
||||
chatWebhookBodyParser,
|
||||
);
|
||||
app.use(
|
||||
express.json({
|
||||
limit: DEFAULT_JSON_BODY_LIMIT,
|
||||
verify: captureRawBody,
|
||||
}),
|
||||
);
|
||||
const jsonBodyParser = express.json({ limit: DEFAULT_JSON_BODY_LIMIT, verify: captureRawBody });
|
||||
// File tickets authenticate before parsing bytes; JSON attachments must remain bytes too.
|
||||
app.use((req, res, next) => req.path === "/mcp/files/upload" ? next() : jsonBodyParser(req, res, next));
|
||||
app.use("/api", apiCompression());
|
||||
app.use(httpLogger);
|
||||
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
|
||||
@@ -992,7 +990,9 @@ export async function createApp(
|
||||
isBackgroundWorkEnabled: () => !isWarmStandby(),
|
||||
});
|
||||
publicMcpEvents.start();
|
||||
publicMcpIngress.use(publicMcpIngressRoutes(publicMcpOAuth, createPublicMcpExecutor(db, publicMcpOAuth, dispatch), publicMcpEvents));
|
||||
const transfers = createPublicMcpTransfers(db, publicMcpOAuth, dispatch, opts.storageService);
|
||||
publicMcpIngress.use(transfers.router);
|
||||
publicMcpIngress.use(publicMcpIngressRoutes(publicMcpOAuth, createPublicMcpExecutor(db, publicMcpOAuth, dispatch, transfers), publicMcpEvents));
|
||||
api.use(publicMcpManagementRoutes(publicMcpOAuth));
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export function isSecretSensitiveHttpRequest(
|
||||
url: string | undefined,
|
||||
): boolean {
|
||||
if (isPrivateWebhookHttpRequest(method, url)) return true;
|
||||
if (url && normalizePath(url).startsWith("/mcp/files/")) return true;
|
||||
if (isPrivateAgentCommentaryHttpRequest(url)) return true;
|
||||
if (!method || !url) return false;
|
||||
if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getExecutionBlocker } from "../services/execution-blocker.js";
|
||||
import { canRetryStoppedRun } from "../services/cancelled-native-startup.js";
|
||||
import { getNativeReviewAssignment, readNativeReviewAssignmentContext, type NativeReviewAssignmentContext } from "../services/native-runtime/native-review-participant.js";
|
||||
import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils";
|
||||
import { applyMcpReasoningEffort } from "../services/public-mcp/agent-config.js";
|
||||
import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js";
|
||||
import { selectDashboardRunIds } from "../services/dashboard-run-selection.js";
|
||||
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||
@@ -5344,6 +5345,9 @@ export function agentRoutes(
|
||||
});
|
||||
|
||||
router.put("/agents/:id/instructions-bundle/file", validate(upsertAgentInstructionsFileSchema), async (req, res) => {
|
||||
if (req.actor.source === "mcp_oauth" && req.body.path === "promptTemplate.legacy.md") {
|
||||
throw unprocessable("Migrate legacy prompt instructions to a managed instruction file in Paperclip before editing through an assistant", { code: "MCP_LEGACY_INSTRUCTIONS_UNVERSIONED" });
|
||||
}
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
|
||||
if (!existing) return;
|
||||
@@ -5610,7 +5614,7 @@ export function agentRoutes(
|
||||
runtimeConfig,
|
||||
existing.runtimeConfig,
|
||||
);
|
||||
requestedRuntimeConfig = runtimeConfig;
|
||||
requestedRuntimeConfig = req.actor.source === "mcp_oauth" ? { ...existing.runtimeConfig, ...runtimeConfig } : runtimeConfig;
|
||||
}
|
||||
const touchesAdapterConfiguration =
|
||||
hasOwn(patchData, "adapterType") ||
|
||||
@@ -5639,6 +5643,9 @@ export function agentRoutes(
|
||||
if (requestedAdapterConfig && !changingAdapterType && !replaceAdapterConfig) {
|
||||
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...rawEffectiveAdapterConfig };
|
||||
}
|
||||
if (req.actor.source === "mcp_oauth" && requestedAdapterConfig) {
|
||||
rawEffectiveAdapterConfig = applyMcpReasoningEffort(requestedAdapterType, rawEffectiveAdapterConfig, requestedAdapterConfig);
|
||||
}
|
||||
if (changingAdapterType) {
|
||||
// Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config
|
||||
// when the adapter type changes. Without this, a PATCH that includes
|
||||
|
||||
@@ -9457,7 +9457,7 @@ export function issueRoutes(
|
||||
agentId: actor.agentId ?? null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
allowBoardOverride: req.actor.type === "board",
|
||||
allowBoardOverride: req.actor.type === "board" && req.actor.source !== "mcp_oauth",
|
||||
commentBody: resolutionNote ?? null,
|
||||
});
|
||||
Object.assign(updateFields, transition.patch);
|
||||
@@ -12800,6 +12800,17 @@ export function issueRoutes(
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
async function assertMcpTaskParticipation(query: IssueQueueDb, issue: { id: string; companyId: string; checkoutRunId?: string | null; executionRunId?: string | null; status: string; executionState?: { status?: string } | null }) {
|
||||
if (issue.status === "in_review" || issue.executionState?.status === "pending") {
|
||||
throw forbidden("Review decisions must be made in Paperclip", { code: "MCP_REVIEW_REQUIRED" });
|
||||
}
|
||||
const [active] = await query.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, issue.companyId), inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
|
||||
sql`(${heartbeatRuns.id} = ${issue.checkoutRunId ?? null}::uuid or ${heartbeatRuns.id} = ${issue.executionRunId ?? null}::uuid or ${heartbeatRuns.nativeIssueId} = ${issue.id}::uuid or ${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id})`,
|
||||
)).limit(1);
|
||||
if (active) throw conflict("An agent currently owns execution. Wait for it to finish, or manage execution in Paperclip.", { code: "MCP_ACTIVE_EXECUTION" });
|
||||
}
|
||||
|
||||
router.patch(
|
||||
"/issues/:id",
|
||||
validateIssueMutationBody(updateIssueRouteSchema),
|
||||
@@ -12812,6 +12823,11 @@ export function issueRoutes(
|
||||
"Issue not found",
|
||||
);
|
||||
if (!existing) return;
|
||||
// MCP task changes cannot stop a run or implicitly decide a pending review.
|
||||
const mcpParticipationChange = req.actor.source === "mcp_oauth" &&
|
||||
["status", "assigneeAgentId", "assigneeUserId", "blockedByIssueIds", "parentId", "projectId"].some(key => req.body[key] !== undefined);
|
||||
if (mcpParticipationChange) await assertMcpTaskParticipation(db, existing);
|
||||
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectIssueWorkspaceCommandPaths(req.body),
|
||||
@@ -12907,8 +12923,9 @@ export function issueRoutes(
|
||||
const reviewVerdictRequested =
|
||||
existing.status === "in_review" &&
|
||||
(updateFields.status === "done" || updateFields.status === "cancelled");
|
||||
const mcpReviewSensitiveMutation = mcpParticipationChange;
|
||||
const reviewPolicySensitiveMutationRequested =
|
||||
req.body.reviewPolicy !== undefined ||
|
||||
mcpReviewSensitiveMutation || req.body.reviewPolicy !== undefined ||
|
||||
updateFields.status === "done" ||
|
||||
updateFields.status === "cancelled";
|
||||
if (
|
||||
@@ -12923,7 +12940,7 @@ export function issueRoutes(
|
||||
});
|
||||
}
|
||||
const shouldCancelActiveRunForCancelledStatus =
|
||||
existing.status !== "cancelled" && updateFields.status === "cancelled";
|
||||
req.actor.source !== "mcp_oauth" && existing.status !== "cancelled" && updateFields.status === "cancelled";
|
||||
if (resumeRequested === true && !commentBody) {
|
||||
res.status(400).json({ error: "Follow-up intent requires a comment" });
|
||||
return;
|
||||
@@ -13222,7 +13239,7 @@ export function issueRoutes(
|
||||
agentId: actor.agentId ?? null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
allowBoardOverride: req.actor.type === "board",
|
||||
allowBoardOverride: req.actor.type === "board" && req.actor.source !== "mcp_oauth",
|
||||
commentBody,
|
||||
reviewRequest: reviewRequest === undefined ? undefined : reviewRequest,
|
||||
monitorExplicitlyUpdated:
|
||||
@@ -13457,7 +13474,7 @@ export function issueRoutes(
|
||||
// Only this request may finish a mutation that intentionally stops its
|
||||
// own run (for example handing work to a signoff reviewer).
|
||||
const issueMutationStopId = randomUUID();
|
||||
if (assigneeWillChange && existing.assigneeAgentId) {
|
||||
if (req.actor.source !== "mcp_oauth" && assigneeWillChange && existing.assigneeAgentId) {
|
||||
await stopRunnerGoalForOwnershipChange({
|
||||
companyId: existing.companyId,
|
||||
issueId: existing.id,
|
||||
@@ -13493,7 +13510,7 @@ export function issueRoutes(
|
||||
updateFields.status !== existing.status &&
|
||||
isClosedIssueStatus(updateFields.status);
|
||||
if (
|
||||
!assigneeWillChange &&
|
||||
req.actor.source !== "mcp_oauth" && !assigneeWillChange &&
|
||||
terminalizingIssue &&
|
||||
existing.assigneeAgentId
|
||||
) {
|
||||
@@ -13603,6 +13620,7 @@ export function issueRoutes(
|
||||
) => {
|
||||
const lockedExisting = await svc.getByIdForUpdate(id, tx);
|
||||
if (!lockedExisting) return false;
|
||||
if (mcpReviewSensitiveMutation) await assertMcpTaskParticipation(tx ?? db, lockedExisting);
|
||||
const lockedPolicyChangeRequested =
|
||||
req.body.reviewPolicy !== undefined &&
|
||||
req.body.reviewPolicy !== lockedExisting.reviewPolicy;
|
||||
|
||||
@@ -64,7 +64,7 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp
|
||||
next();
|
||||
});
|
||||
router.get(["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource" + PUBLIC_MCP_PATH], (_req, res) => res.json({
|
||||
resource, authorization_servers: [origin], scopes_supported: ["paperclip:read", "paperclip:write"], bearer_methods_supported: ["header"],
|
||||
resource, authorization_servers: [origin], scopes_supported: ["paperclip:read", "paperclip:write", "paperclip:configure"], bearer_methods_supported: ["header"],
|
||||
resource_name: "Paperclip",
|
||||
}));
|
||||
router.get("/.well-known/oauth-authorization-server", (_req, res) => res.json({
|
||||
@@ -92,14 +92,14 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp
|
||||
let principal;
|
||||
try { if (!token) throw new Error(); principal = await oauth.authenticate(token); }
|
||||
catch {
|
||||
res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${resourceMetadata}", scope="paperclip:read paperclip:write", error="invalid_token"`);
|
||||
res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${resourceMetadata}", scope="paperclip:read paperclip:write paperclip:configure", error="invalid_token"`);
|
||||
res.status(401).json({ error: "invalid_token" }); return;
|
||||
}
|
||||
if (req.method !== "POST") { res.setHeader("Allow", "POST"); res.status(405).end(); return; }
|
||||
const listTools = async () => ({
|
||||
tools: publicMcpCapabilities.map((c) => ({
|
||||
name: c.name, description: c.description, inputSchema: z.toJSONSchema(c.schema) as { type: "object"; properties: Record<string, unknown> },
|
||||
annotations: { readOnlyHint: !c.write, destructiveHint: false, idempotentHint: true, openWorldHint: !!c.write },
|
||||
annotations: { readOnlyHint: !c.write && !c.configure, destructiveHint: !!c.destructive, idempotentHint: true, openWorldHint: !!c.write || !!c.configure },
|
||||
})),
|
||||
});
|
||||
const callTool = async (request: z.infer<typeof CallToolRequestSchema>) => {
|
||||
@@ -107,8 +107,19 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp
|
||||
const result = await execute(token!, request.params.name, request.params.arguments ?? {});
|
||||
return { isError: result.outcome === "unknown" || result.outcome === "rejected", content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result };
|
||||
} catch (error) {
|
||||
const message = error instanceof z.ZodError ? "Invalid tool arguments."
|
||||
: error instanceof McpApiError || error instanceof McpCapabilityError || error instanceof McpOAuthError ? error.message
|
||||
if (error instanceof z.ZodError) {
|
||||
const issues = error.issues.slice(0, 12).map(issue => ({
|
||||
path: issue.path.slice(0, 8).filter(part => typeof part === "number" || (typeof part === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,80}$/.test(part))).join("."),
|
||||
code: issue.code,
|
||||
message: issue.code === "invalid_format" && issue.format === "uuid"
|
||||
? "Use a valid UUID: 8-4-4-4-12 hex digits; the fourth group starts with 8, 9, a or b."
|
||||
: issue.code === "unrecognized_keys" ? "Remove unsupported fields."
|
||||
: "Check this field against the operation's input schema.",
|
||||
}));
|
||||
const result = { outcome: "rejected", phase: "validation", message: "Invalid tool arguments. No action was executed. Correct the indicated fields before trying again.", issues };
|
||||
return { isError: true, content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result };
|
||||
}
|
||||
const message = error instanceof McpApiError || error instanceof McpCapabilityError || error instanceof McpOAuthError ? error.message
|
||||
: "Paperclip could not confirm this operation. Before retrying a write, inspect the task and comments and keep the same requestId.";
|
||||
return { isError: true, content: [{ type: "text", text: message }] };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { codexLocalReasoningEffortsForModel, DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
|
||||
import { claudeLocalReasoningEffortsForModel, DEFAULT_CLAUDE_LOCAL_MODEL } from "@paperclipai/adapter-claude-local";
|
||||
import { unprocessable } from "../../errors.js";
|
||||
|
||||
function effortSettings(adapterType: string, config: Record<string, unknown>) {
|
||||
if (adapterType === "codex_local" || (adapterType === "paperclip_runner" && (config.provider ?? "codex") === "codex")) {
|
||||
return { field: "modelReasoningEffort", supported: codexLocalReasoningEffortsForModel(typeof config.model === "string" ? config.model : DEFAULT_CODEX_LOCAL_MODEL) };
|
||||
}
|
||||
if (adapterType === "claude_local") {
|
||||
return { field: "effort", supported: claudeLocalReasoningEffortsForModel(typeof config.model === "string" ? config.model : DEFAULT_CLAUDE_LOCAL_MODEL) };
|
||||
}
|
||||
return { field: null, supported: [] as readonly string[] };
|
||||
}
|
||||
|
||||
/** Translate the assistant's neutral setting at the live adapter boundary. */
|
||||
export function applyMcpReasoningEffort(adapterType: string, config: Record<string, unknown>, requested: Record<string, unknown>) {
|
||||
const settings = effortSettings(adapterType, config);
|
||||
const explicit = Object.hasOwn(requested, "reasoningEffort");
|
||||
const effort = explicit ? requested.reasoningEffort : settings.field ? config[settings.field] ?? config.reasoningEffort : undefined;
|
||||
if (effort !== undefined && (explicit || Object.hasOwn(requested, "model"))) {
|
||||
if (!settings.field || typeof effort !== "string" || !settings.supported.includes(effort)) {
|
||||
throw unprocessable("Reasoning effort is unsupported for this adapter and model", { code: "MCP_REASONING_EFFORT_UNSUPPORTED", supportedReasoningEfforts: settings.supported });
|
||||
}
|
||||
}
|
||||
if (!explicit || !settings.field) return config;
|
||||
const result = { ...config, [settings.field]: effort };
|
||||
delete result.reasoningEffort;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mcpAgentModelConfig(adapterType: string, config: Record<string, unknown>) {
|
||||
const settings = effortSettings(adapterType, config);
|
||||
return {
|
||||
...(typeof config.model === "string" ? { model: config.model } : {}),
|
||||
...(settings.field && (config[settings.field] ?? config.reasoningEffort) !== undefined
|
||||
? { reasoningEffort: config[settings.field] ?? config.reasoningEffort } : {}),
|
||||
supportedReasoningEfforts: settings.supported,
|
||||
};
|
||||
}
|
||||
@@ -10,16 +10,11 @@ import { DEFAULT_JSON_BODY_LIMIT } from "../../http/body-limits.js";
|
||||
import { logActivity } from "../activity-log.js";
|
||||
import { hashMcpSecret, type McpPrincipal, type PublicMcpOAuth } from "./oauth.js";
|
||||
|
||||
export class McpApiError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`Paperclip rejected the operation (HTTP ${status}). Check permissions, task state and agent availability in Paperclip.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Messages intentionally safe to return across the public tool boundary. */
|
||||
export class McpCapabilityError extends Error {}
|
||||
|
||||
export type ApiDispatch = (principal: McpPrincipal, method: "GET" | "POST", path: string, body?: unknown) => Promise<unknown>;
|
||||
export { McpApiError, McpCapabilityError } from "./contracts.js";
|
||||
export type { ApiDispatch } from "./contracts.js";
|
||||
import { McpApiError, McpCapabilityError, type ApiDispatch, type Capability, company, task, requestId, boundedLimit, object, pick, rows, taskFields, commentFields, documentFields, pathId, stable } from "./contracts.js";
|
||||
import { fileTransferCapabilities, type PublicMcpTransfers } from "./file-transfers.js";
|
||||
import { expandedMcpCapabilities } from "./expanded-capabilities.js";
|
||||
|
||||
/** Internal HTTP dispatch reuses domain authorization, validation, audit and scheduling.
|
||||
* Neither an arbitrary path nor an actor is accepted from the MCP client.
|
||||
@@ -44,35 +39,20 @@ export function createMcpApiDispatch(api: Router): ApiDispatch {
|
||||
});
|
||||
if (response.statusCode >= 400) {
|
||||
// Keep upstream error details out of the public tool boundary.
|
||||
throw new McpApiError(response.statusCode);
|
||||
let payload: Record<string, unknown> = {};
|
||||
try { payload = object(response.json()); } catch { /* Non-JSON upstream errors remain private. */ }
|
||||
const details = object(payload.details);
|
||||
const safe: Record<string, unknown> = {};
|
||||
for (const key of ["code", "currentRevisionId", "latestRevisionId", "currentVersionId"]) {
|
||||
const value = payload[key] ?? details[key];
|
||||
if (typeof value === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(value)) safe[key] = value;
|
||||
}
|
||||
throw new McpApiError(response.statusCode, safe);
|
||||
}
|
||||
return response.json<unknown>();
|
||||
return response.statusCode === 204 || !response.payload ? null : response.json<unknown>();
|
||||
};
|
||||
}
|
||||
|
||||
const company = { companyId: z.uuid().describe("The company explicitly authorized by this connection. Copy the exact companyId from paperclip_connection and wait for that result before dependent tool calls. Never guess an ID or use a placeholder.") };
|
||||
const task = { ...company, taskId: z.uuid().describe("The exact task UUID returned by paperclip_search_tasks or paperclip_create_task. Search by title or human-readable identifier first; never derive a UUID from a title or identifier.") };
|
||||
const requestId = z.uuid().describe("A new UUID for this intended action. Reuse this UUID and identical arguments on retries; never invent a second ID after an uncertain result.");
|
||||
const boundedLimit = z.number().int().min(1).max(100).default(30);
|
||||
const object = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
||||
const pick = (v: unknown, keys: string[]) => Object.fromEntries(keys.filter((k) => k in object(v)).map((k) => [k, object(v)[k]]));
|
||||
const rows = (v: unknown) => Array.isArray(v) ? v : [];
|
||||
const taskFields = ["id", "companyId", "identifier", "title", "description", "status", "priority", "assigneeAgentId", "assigneeUserId", "projectId", "parentId", "createdAt", "updatedAt", "completedAt"];
|
||||
const commentFields = ["id", "issueId", "body", "authorUserId", "authorAgentId", "createdAt"];
|
||||
const documentFields = ["id", "issueId", "key", "title", "format", "body", "latestRevisionNumber", "updatedAt"];
|
||||
const pathId = (id: unknown) => encodeURIComponent(String(id));
|
||||
const stable = (v: unknown): string => JSON.stringify(v, (_k, value) => {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, value[k]]));
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
type Capability = {
|
||||
name: string; description: string; schema: z.ZodObject; write?: boolean;
|
||||
run: (p: McpPrincipal, args: Record<string, unknown>, api: ApiDispatch, origin: string) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export const publicMcpCapabilities: Capability[] = [
|
||||
{
|
||||
name: "paperclip_connection", description: "Identify the connected person, explicitly authorized company and permissions. This connection acts as the person; assigning a task never impersonates its agent.",
|
||||
@@ -138,9 +118,10 @@ export const publicMcpCapabilities: Capability[] = [
|
||||
run: async (p, a, api) => ({ comment: pick(await api(p, "POST", `/issues/${pathId(a.taskId)}/comments`, { body: a.body, clientRequestId: a.requestId }), commentFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_list_deliverables", description: "Retrieve task documents and deliverable references, including completed work from earlier conversations. Open files through their Paperclip task page; this tool does not fetch arbitrary URLs.",
|
||||
name: "paperclip_list_deliverables", description: "Retrieve task documents, uploaded attachments and deliverable references, including completed work from earlier conversations. Download attachments with paperclip_get_download_url or through the task page; this tool does not fetch arbitrary URLs.",
|
||||
schema: z.object(task).strict(),
|
||||
run: async (p, a, api, origin) => ({
|
||||
attachments: rows(await api(p, "GET", `/issues/${pathId(a.taskId)}/attachments`)).map((v) => pick(v, ["id", "issueId", "originalFilename", "contentType", "byteSize", "sha256", "createdAt"])),
|
||||
documents: rows(await api(p, "GET", `/issues/${pathId(a.taskId)}/documents`)).map((v) => pick(v, documentFields)),
|
||||
deliverables: rows(await api(p, "GET", `/issues/${pathId(a.taskId)}/work-products`)).map((v) => pick(v, ["id", "type", "title", "url", "status", "summary", "updatedAt"])),
|
||||
taskUrl: origin + "/" + pathId(p.company.issuePrefix) + "/issues/" + pathId(a.taskId),
|
||||
@@ -159,19 +140,39 @@ export const publicMcpCapabilities: Capability[] = [
|
||||
url: origin + "/" + pathId(p.company.issuePrefix) + "/approvals/" + pathId(object(v).id),
|
||||
})) }),
|
||||
},
|
||||
...expandedMcpCapabilities, ...fileTransferCapabilities,
|
||||
];
|
||||
|
||||
export function createPublicMcpExecutor(db: Db, oauth: PublicMcpOAuth, api: ApiDispatch) {
|
||||
publicMcpCapabilities.push({
|
||||
name: "paperclip_search_api", description: "Search the allowed Paperclip work and configuration API operations and their exact argument schemas. These are the same authorized operations as the named tools; other REST endpoints are not callable.",
|
||||
schema: z.object({ ...company, query: z.string().max(200).default("") }).strict(),
|
||||
run: async (_p, a) => ({ operations: publicMcpCapabilities.filter(c => !["paperclip_search_api", "paperclip_call_api"].includes(c.name) && String(a.query).toLowerCase().split(/\s+/).every(word => (c.name + " " + c.description).toLowerCase().includes(word))).slice(0, 40).map(c => ({ operationId: c.name, description: c.description, scope: c.configure ? "paperclip:configure" : c.write ? "paperclip:write" : "paperclip:read", inputSchema: z.toJSONSchema(c.schema) })) }),
|
||||
}, {
|
||||
name: "paperclip_call_api", write: true, destructive: true, description: "Call an operation returned by paperclip_search_api using its exact arguments, including companyId and requestId for writes. Same permissions, company checks and retry identity as the named tool. No arbitrary URLs, headers or additional REST operations.",
|
||||
schema: z.object({ ...company, operationId: z.string().min(1).max(100), arguments: z.record(z.string(), z.unknown()) }).strict(),
|
||||
run: async () => { throw new McpCapabilityError("Resolve the registered operation before dispatch."); },
|
||||
});
|
||||
|
||||
export function createPublicMcpExecutor(db: Db, oauth: PublicMcpOAuth, api: ApiDispatch, transfers?: PublicMcpTransfers) {
|
||||
return async (accessToken: string, name: string, input: unknown) => {
|
||||
// Recheck membership, pause/archive state, expiry and revocation at invocation.
|
||||
const principal = await oauth.authenticate(accessToken);
|
||||
if (name === "paperclip_call_api") {
|
||||
const envelope = publicMcpCapabilities.find(c => c.name === name)!.schema.parse(input);
|
||||
if (envelope.companyId !== principal.grant.companyId) throw new McpCapabilityError("Company does not match the authorized connection.");
|
||||
name = String(envelope.operationId);
|
||||
if (["paperclip_call_api", "paperclip_search_api"].includes(name)) throw new McpCapabilityError("Choose a discovered operation.");
|
||||
input = envelope.arguments;
|
||||
}
|
||||
const capability = publicMcpCapabilities.find((c) => c.name === name);
|
||||
if (!capability) throw new McpCapabilityError("Unknown Paperclip operation.");
|
||||
const args = capability.schema.parse(input);
|
||||
if (args.companyId !== undefined && args.companyId !== principal.grant.companyId) throw new McpCapabilityError("Company does not match the authorized connection.");
|
||||
if (!principal.grant.scopes.includes("paperclip:read")) throw new McpCapabilityError("Read permission is required.");
|
||||
if (!capability.write) return capability.run(principal, args, api, oauth.config.origin);
|
||||
if (!principal.grant.scopes.includes("paperclip:write") || principal.actor.memberships?.[0]?.membershipRole === "viewer") throw new McpCapabilityError("Write permission is required.");
|
||||
if (!capability.write && !capability.configure) return capability.run(principal, args, api, oauth.config.origin, transfers);
|
||||
const scope = capability.configure ? "paperclip:configure" : "paperclip:write";
|
||||
if (!principal.grant.scopes.includes(scope) || principal.actor.memberships?.[0]?.membershipRole === "viewer") throw new McpCapabilityError(`${scope} permission is required. Reconnect and approve this permission.`);
|
||||
if (capability.ephemeral) return capability.run(principal, args, api, oauth.config.origin, transfers);
|
||||
|
||||
const key = { grantId: principal.grant.id, userId: principal.grant.userId, operation: name, requestId: String(args.requestId) };
|
||||
const argumentsHash = hashMcpSecret(stable(args));
|
||||
@@ -185,12 +186,12 @@ export function createPublicMcpExecutor(db: Db, oauth: PublicMcpOAuth, api: ApiD
|
||||
try {
|
||||
await logActivity(db, { companyId: principal.grant.companyId, actorType: "user", actorId: principal.grant.userId,
|
||||
action: "mcp.tool_called", entityType: "mcp_connection", entityId: principal.grant.id, details: { clientId: principal.grant.clientId, operation: name, requestId: key.requestId } });
|
||||
const result = await capability.run(principal, args, api, oauth.config.origin);
|
||||
const result = await capability.run(principal, args, api, oauth.config.origin, transfers);
|
||||
await db.update(mcpMutationReceipts).set({ status: "completed", result }).where(eq(mcpMutationReceipts.id, receipt.id));
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof McpApiError && error.status < 500) {
|
||||
const result = { outcome: "rejected", requestId: key.requestId, status: error.status, message: error.message };
|
||||
const result = { outcome: "rejected", requestId: key.requestId, status: error.status, message: error.message, ...error.details };
|
||||
await db.update(mcpMutationReceipts).set({ status: "completed", result }).where(eq(mcpMutationReceipts.id, receipt.id));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PublicMcpTransfers } from "./file-transfers.js";
|
||||
import { z } from "zod";
|
||||
import type { McpPrincipal } from "./oauth.js";
|
||||
|
||||
export class McpApiError extends Error {
|
||||
constructor(readonly status: number, readonly details: Record<string, unknown> = {}) {
|
||||
super(details.code === "MCP_ACTIVE_EXECUTION" ? "An agent currently owns execution. Wait for it to finish, or manage execution in Paperclip."
|
||||
: details.code === "MCP_REVIEW_REQUIRED" ? "A review is pending. Make the review decision in Paperclip before changing status or ownership."
|
||||
: status === 409 ? "The operation conflicts with current state. Read the latest revision, version, dependencies or task state before submitting a revised action."
|
||||
: status === 422 || status === 400 ? "Validation failed. Check the operation schema, dependency cycles, assignment and required blocker or revision fields."
|
||||
: `Paperclip rejected the operation (HTTP ${status}). Check your current organization permissions and connection scopes in Paperclip.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Messages intentionally safe to return across the public tool boundary. */
|
||||
export class McpCapabilityError extends Error {}
|
||||
|
||||
export type ApiDispatch = (principal: McpPrincipal, method: "GET" | "POST" | "PATCH" | "PUT", path: string, body?: unknown) => Promise<unknown>;
|
||||
|
||||
export const company = { companyId: z.uuid().describe("The company explicitly authorized by this connection. Copy the exact companyId from paperclip_connection and wait for that result before dependent tool calls. Never guess an ID or use a placeholder.") };
|
||||
export const task = { ...company, taskId: z.uuid().describe("The exact task UUID returned by paperclip_search_tasks or paperclip_create_task. Search by title or human-readable identifier first; never derive a UUID from a title or identifier.") };
|
||||
export const requestId = z.uuid().describe("A new UUID for this intended action. Reuse this UUID and identical arguments on retries; never invent a second ID after an uncertain result.");
|
||||
export const boundedLimit = z.number().int().min(1).max(100).default(30);
|
||||
export const object = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
||||
export const pick = (v: unknown, keys: string[]) => Object.fromEntries(keys.filter((k) => k in object(v)).map((k) => [k, object(v)[k]]));
|
||||
export const rows = (v: unknown) => Array.isArray(v) ? v : [];
|
||||
export const taskFields = ["id", "companyId", "identifier", "title", "description", "status", "priority", "assigneeAgentId", "assigneeUserId", "projectId", "parentId", "blockedByIssueIds", "unblockDescriptor", "executionState", "createdAt", "updatedAt", "completedAt"];
|
||||
export const commentFields = ["id", "issueId", "body", "authorUserId", "authorAgentId", "createdAt"];
|
||||
export const documentFields = ["id", "issueId", "key", "title", "format", "body", "latestRevisionId", "latestRevisionNumber", "updatedAt"];
|
||||
export const pathId = (id: unknown) => encodeURIComponent(String(id));
|
||||
export const stable = (v: unknown): string => JSON.stringify(v, (_k, value) => {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, value[k]]));
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
export type Capability = {
|
||||
name: string; description: string; schema: z.ZodObject; write?: boolean; configure?: boolean; destructive?: boolean; ephemeral?: boolean;
|
||||
run: (p: McpPrincipal, args: Record<string, unknown>, api: ApiDispatch, origin: string, transfers?: PublicMcpTransfers) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { z } from "zod";
|
||||
import { mcpAgentModelConfig } from "./agent-config.js";
|
||||
import {
|
||||
updateIssueSchema, upsertIssueDocumentSchema, updateAgentSchema, upsertAgentInstructionsFileSchema,
|
||||
aiRuntimeConnectionBindingSchema, createProjectSchema, updateProjectSchema,
|
||||
companySkillCreateSchema, companySkillUpdateSchema, companySkillFileUpdateSchema,
|
||||
createIssueWorkProductSchema, updateIssueWorkProductSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { McpApiError, company, task, requestId, pathId, object, pick, rows, taskFields, documentFields, type Capability } from "./contracts.js";
|
||||
|
||||
const agent = { ...company, agentId: z.uuid() };
|
||||
const project = { ...company, projectId: z.uuid() };
|
||||
const skill = { ...company, skillId: z.uuid() };
|
||||
const key = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,99}$/);
|
||||
const filePath = z.string().min(1).max(512).refine(v => !v.startsWith("/") && !v.includes("\\") && v.split("/").every(p => p !== ".." && p !== "." && p !== ""), "Use a relative file path without traversal");
|
||||
const taskChanges = updateIssueSchema.pick({ title: true, description: true, assigneeAgentId: true, assigneeUserId: true, projectId: true, parentId: true, blockedByIssueIds: true, unblockDescriptor: true, status: true, priority: true }).extend({
|
||||
title: z.string().trim().min(1).max(240).optional(), description: z.string().max(524288).nullable().optional(),
|
||||
assigneeAgentId: z.uuid().nullable().optional(), blockedByIssueIds: z.array(z.uuid()).max(100).optional(),
|
||||
}).strict();
|
||||
const agentChanges = updateAgentSchema.pick({ name: true, role: true, title: true, icon: true, reportsTo: true, capabilities: true, desiredSkills: true, budgetMonthlyCents: true }).extend({
|
||||
adapterConfig: z.object({ model: z.string().trim().min(1).max(256).optional(), reasoningEffort: z.enum(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]).optional() }).strict().optional(),
|
||||
runtimeConfig: z.object({ aiConnection: aiRuntimeConnectionBindingSchema }).strict().optional(),
|
||||
}).strict();
|
||||
const projectFields = { name: true, description: true, status: true, leadAgentId: true, goalIds: true, targetDate: true, color: true, icon: true } as const;
|
||||
const skillMetadata = companySkillUpdateSchema.unwrap().pick({ description: true, iconUrl: true, color: true, tagline: true, authorName: true, homepageUrl: true, categories: true }).strict();
|
||||
const deliverableFields = { type: true, provider: true, externalId: true, title: true, url: true, status: true, isPrimary: true, summary: true } as const;
|
||||
// Approval decisions are not exposed by changing a work product's review state/status.
|
||||
const deliverableStatus = z.enum(["active", "ready_for_review", "closed", "failed", "archived", "draft"]);
|
||||
const agentFields = ["id", "companyId", "name", "role", "title", "icon", "status", "reportsTo", "capabilities", "desiredSkills", "budgetMonthlyCents", "adapterType"];
|
||||
const projectOutputFields = ["id", "companyId", "name", "description", "status", "leadAgentId", "goalIds", "targetDate", "color", "icon", "repositories"];
|
||||
const workFields = ["id", "issueId", "type", "provider", "externalId", "title", "url", "status", "isPrimary", "summary", "metadata", "updatedAt"];
|
||||
const skillFields = ["id", "companyId", "name", "slug", "key", "description", "markdown", "versionId", "currentVersionId", "latestVersionId", "categories", "tagline", "iconUrl", "color", "authorName", "homepageUrl", "sharingScope", "updatedAt"];
|
||||
function agentOutput(value: unknown) {
|
||||
const v = object(value);
|
||||
return { ...pick(v, agentFields), adapterConfig: mcpAgentModelConfig(String(v.adapterType), object(v.adapterConfig)), runtimeConfig: pick(v.runtimeConfig, ["aiConnection"]) };
|
||||
}
|
||||
function projectOutput(value: unknown) {
|
||||
const v = object(value);
|
||||
return { ...pick(v, projectOutputFields), repositories: rows(v.workspaces).flatMap(raw => {
|
||||
const workspace = object(raw);
|
||||
if (typeof workspace.repoUrl !== "string") return [];
|
||||
let url: string | null = null;
|
||||
try {
|
||||
const parsed = new URL(workspace.repoUrl);
|
||||
if (["https:", "http:", "ssh:"].includes(parsed.protocol)) {
|
||||
parsed.username = ""; parsed.password = ""; parsed.search = ""; parsed.hash = "";
|
||||
url = parsed.toString();
|
||||
}
|
||||
} catch { /* Do not disclose opaque legacy remote strings or local paths. */ }
|
||||
return [{ id: object(workspace.metadata).githubRepositoryId ?? null, name: workspace.name, url }];
|
||||
}) };
|
||||
}
|
||||
const skillBase = (a: Record<string, unknown>) => `/companies/${pathId(a.companyId)}/skills`;
|
||||
const skillPath = (a: Record<string, unknown>) => `${skillBase(a)}/${pathId(a.skillId)}`;
|
||||
const nonempty = <T extends z.ZodObject>(schema: T) => schema.refine(v => Object.keys(v).length > 0, "Supply at least one change");
|
||||
|
||||
export const expandedMcpCapabilities: Capability[] = [
|
||||
{
|
||||
name: "paperclip_update_task", write: true, destructive: true,
|
||||
description: "Edit a task's title, description, priority, project, parent, dependencies, assignment or status as the connected person. Assignment/status changes may schedule agents and spend configured budget. Review decisions must be made in Paperclip. Reuse requestId on retries.",
|
||||
schema: z.object({ ...task, requestId, changes: nonempty(taskChanges) }).strict(),
|
||||
run: async (p, a, api) => ({ task: pick(await api(p, "PATCH", `/issues/${pathId(a.taskId)}`, a.changes), taskFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_finish_task", write: true, destructive: true,
|
||||
description: "Mark a task done as the connected person. This is not an agent run finalization or an approval decision. Review gates and existing execution controls still apply.",
|
||||
schema: z.object({ ...task, requestId }).strict(),
|
||||
run: async (p, a, api) => ({ task: pick(await api(p, "PATCH", `/issues/${pathId(a.taskId)}`, { status: "done" }), taskFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_block_task", write: true, destructive: true,
|
||||
description: "Block a task with an explicit owner and action needed to unblock it. Existing dependency, approval and execution controls apply; this does not impersonate its assigned agent.",
|
||||
schema: z.object({ ...task, requestId, unblockDescriptor: taskChanges.shape.unblockDescriptor.unwrap().unwrap() }).strict(),
|
||||
run: async (p, a, api) => ({ task: pick(await api(p, "PATCH", `/issues/${pathId(a.taskId)}`, { status: "blocked", unblockDescriptor: a.unblockDescriptor }), taskFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_write_document", write: true, destructive: true,
|
||||
description: "Create or update a durable Markdown task document. Read it first and supply latestRevisionId as baseRevisionId; use null only for a new document. A stale revision is rejected. For videos or binary files use paperclip_get_upload_url.",
|
||||
schema: z.object({ ...task, requestId, key, document: upsertIssueDocumentSchema.extend({ baseRevisionId: z.uuid().nullable() }).strict() }).strict(),
|
||||
run: async (p, a, api) => ({ document: pick(await api(p, "PUT", `/issues/${pathId(a.taskId)}/documents/${pathId(a.key)}`, a.document), documentFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_list_document_revisions", description: "Read a task document's revision history before editing or comparing saved results.",
|
||||
schema: z.object({ ...task, key }).strict(),
|
||||
run: async (p, a, api) => ({ revisions: await api(p, "GET", `/issues/${pathId(a.taskId)}/documents/${pathId(a.key)}/revisions`) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_register_deliverable", write: true,
|
||||
description: "Register a durable task deliverable. An attachmentId registers an uploaded file owned by this task. No external URL is fetched. This cannot approve a deliverable.",
|
||||
schema: z.object({ ...task, requestId, deliverable: createIssueWorkProductSchema.pick(deliverableFields).extend({ status: deliverableStatus.default("active") }).strict(), attachmentId: z.uuid().optional() }).strict(),
|
||||
run: async (p, a, api) => ({ deliverable: pick(await api(p, "POST", `/issues/${pathId(a.taskId)}/work-products`, await attachmentDeliverable(p, a, api)), workFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_update_deliverable", write: true, destructive: true,
|
||||
description: "Update a task deliverable, or replace its attachment reference with a newly uploaded file. Older attachments remain available. This cannot decide a review.",
|
||||
schema: z.object({ ...task, requestId, deliverableId: z.uuid(), deliverable: updateIssueWorkProductSchema.pick(deliverableFields).extend({ status: deliverableStatus.optional() }).strict(), attachmentId: z.uuid().optional() }).strict(),
|
||||
run: async (p, a, api) => {
|
||||
const owned = rows(await api(p, "GET", `/issues/${pathId(a.taskId)}/work-products`));
|
||||
if (!owned.some(v => object(v).id === a.deliverableId)) throw new McpApiError(404);
|
||||
return { deliverable: pick(await api(p, "PATCH", `/work-products/${pathId(a.deliverableId)}`, await attachmentDeliverable(p, a, api)), workFields) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paperclip_get_agent", description: "Get an agent's identity and non-secret operating configuration. Credentials, environment variables and permission policies are not returned.",
|
||||
schema: z.object(agent).strict(), run: async (p, a, api) => ({ agent: agentOutput(await api(p, "GET", `/agents/${pathId(a.agentId)}/configuration`)) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_update_agent", configure: true, destructive: true,
|
||||
description: "Update an existing agent's identity, reporting line, selected skills, model, budget, or binding to an existing AI connection. Requires configuration consent. Does not change credentials, execution commands or permission policies. New settings affect future execution.",
|
||||
schema: z.object({ ...agent, requestId, changes: nonempty(agentChanges) }).strict(), run: async (p, a, api) => ({ agent: agentOutput(await api(p, "PATCH", `/agents/${pathId(a.agentId)}`, a.changes)) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_read_agent_instructions", description: "Read an agent instruction file and its revision/hash. Defaults to AGENTS.md. Use the returned revision/hash when updating.",
|
||||
schema: z.object({ ...agent, path: filePath.default("AGENTS.md") }).strict(),
|
||||
run: async (p, a, api) => ({ instructions: await api(p, "GET", `/agents/${pathId(a.agentId)}/instructions-bundle/file?path=${pathId(a.path)}`) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_update_agent_instructions", configure: true, destructive: true,
|
||||
description: "Update an existing agent's instruction file. Requires configuration consent and the revision/hash from a prior read; use null for a new file. Managed paths and normal instruction permissions apply. Legacy promptTemplate.legacy.md must be migrated in Paperclip before editing.",
|
||||
schema: z.object({ ...agent, requestId, file: upsertAgentInstructionsFileSchema.omit({ clearLegacyPromptTemplate: true }).extend({ path: filePath }).refine(v => v.baseRevisionId !== undefined || v.baseHash !== undefined, "Read the file and provide its baseRevisionId or baseHash") }).strict(),
|
||||
run: async (p, a, api) => ({ instructions: await api(p, "PUT", `/agents/${pathId(a.agentId)}/instructions-bundle/file`, a.file) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_list_agent_instruction_revisions", description: "Read the revision history for an agent's entry instructions.",
|
||||
schema: z.object({ ...agent, path: filePath.optional(), cursor: z.uuid().optional() }).strict(),
|
||||
run: async (p, a, api) => ({ revisions: await api(p, "GET", `/agents/${pathId(a.agentId)}/instructions-bundle/history?${new URLSearchParams(Object.fromEntries(["path", "cursor"].filter(k => a[k] !== undefined).map(k => [k, String(a[k])])) )}`) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_get_project", description: "Read a project's settings and repository references.", schema: z.object(project).strict(),
|
||||
run: async (p, a, api) => ({ project: projectOutput(await api(p, "GET", `/projects/${pathId(a.projectId)}`)) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_create_project", configure: true, description: "Create a project, optionally selecting repository IDs from paperclip_list_project_repositories. Requires configuration consent. Does not create remote repositories or configure execution commands.",
|
||||
schema: z.object({ ...company, requestId, project: createProjectSchema.pick({ ...projectFields, repositoryIds: true }).strict() }).strict(),
|
||||
run: async (p, a, api) => ({ project: projectOutput(await api(p, "POST", `/companies/${pathId(a.companyId)}/projects`, { ...object(a.project), idempotencyKey: `mcp:${p.grant.userId}:${a.requestId}` })) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_update_project", configure: true, destructive: true, description: "Update a project's name, description, status, lead, goals, target date, icon or color. Requires configuration consent.",
|
||||
schema: z.object({ ...project, requestId, changes: nonempty(updateProjectSchema.pick(projectFields).strict()) }).strict(),
|
||||
run: async (p, a, api) => ({ project: projectOutput(await api(p, "PATCH", `/projects/${pathId(a.projectId)}`, a.changes)) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_list_project_repositories", description: "List repository choices available to this person and organization before creating a project or changing its repositories.", schema: z.object(company).strict(),
|
||||
run: async (p, a, api) => ({ repositories: await api(p, "GET", `/companies/${pathId(a.companyId)}/project-repositories`) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_set_project_repositories", configure: true, destructive: true, description: "Replace a project's repository selection with IDs from paperclip_list_project_repositories. Requires configuration consent and normal repository access.",
|
||||
schema: z.object({ ...project, requestId, repositoryIds: z.array(z.string().regex(/^\d+$/)).max(100) }).strict(),
|
||||
run: async (p, a, api) => ({ project: projectOutput(await api(p, "PUT", `/projects/${pathId(a.projectId)}/repositories`, { repositoryIds: a.repositoryIds })) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_list_skills", description: "List skills visible in the authorized organization.", schema: z.object(company).strict(),
|
||||
run: async (p, a, api) => ({ skills: rows(await api(p, "GET", skillBase(a))).map(v => pick(v, skillFields)) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_get_skill", description: "Read a skill's metadata and current version before editing.", schema: z.object(skill).strict(),
|
||||
run: async (p, a, api) => ({ skill: pick(await api(p, "GET", skillPath(a)), skillFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_create_skill", configure: true, description: "Create an organization skill with Markdown instructions. Requires configuration consent and normal skill policy. Does not enable public sharing.",
|
||||
schema: z.object({ ...company, requestId, skill: companySkillCreateSchema.pick({ name: true, slug: true, description: true, markdown: true, folderId: true, categories: true }).strict() }).strict(),
|
||||
run: async (p, a, api) => ({ skill: pick(await api(p, "POST", skillBase(a), { ...object(a.skill), idempotencyKey: `mcp:${a.requestId}` }), skillFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_update_skill", configure: true, destructive: true, description: "Update skill metadata. To update instructions or assets use paperclip_write_skill_file. Requires configuration consent; does not change sharing permissions.",
|
||||
schema: z.object({ ...skill, requestId, changes: nonempty(skillMetadata) }).strict(),
|
||||
run: async (p, a, api) => ({ skill: pick(await api(p, "PATCH", skillPath(a), a.changes), skillFields) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_read_skill_file", description: "Read a skill file such as SKILL.md. Binary skill assets use the returned encoding; large task files use attachment downloads.",
|
||||
schema: z.object({ ...skill, path: filePath.default("SKILL.md") }).strict(),
|
||||
run: async (p, a, api) => ({ file: await api(p, "GET", `${skillPath(a)}/files?path=${pathId(a.path)}`) }),
|
||||
},
|
||||
{
|
||||
name: "paperclip_write_skill_file", configure: true, destructive: true, description: "Write skill instructions or a bounded asset using utf8 or base64. Supply expectedVersionId from paperclip_get_skill, null for a new skill version. Normal skill policy and file safety checks apply.",
|
||||
schema: z.object({ ...skill, requestId, file: companySkillFileUpdateSchema.omit({ idempotencyKey: true }).extend({ path: filePath, content: z.string().max(524288), expectedVersionId: z.uuid().nullable() }).strict() }).strict(),
|
||||
run: async (p, a, api) => ({ file: await api(p, "PATCH", `${skillPath(a)}/files`, { ...object(a.file), idempotencyKey: `mcp:${a.requestId}` }) }),
|
||||
},
|
||||
];
|
||||
|
||||
async function attachmentDeliverable(p: Parameters<Capability["run"]>[0], a: Record<string, unknown>, api: Parameters<Capability["run"]>[2]) {
|
||||
const result = { ...object(a.deliverable) };
|
||||
if (!a.attachmentId) return result;
|
||||
const attachment = rows(await api(p, "GET", `/issues/${pathId(a.taskId)}/attachments`)).find(v => object(v).id === a.attachmentId);
|
||||
if (!attachment) throw new McpApiError(404);
|
||||
const contentPath = `/api/attachments/${a.attachmentId}/content`;
|
||||
return { ...result, type: "artifact", provider: "paperclip", externalId: a.attachmentId, metadata: {
|
||||
...pick(attachment, ["contentType", "byteSize", "originalFilename"]), attachmentId: a.attachmentId,
|
||||
contentPath, openPath: contentPath, downloadPath: contentPath + "?download=1",
|
||||
} };
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import express, { Router } from "express";
|
||||
import { and, eq, gt, isNull, lt, sql } from "drizzle-orm";
|
||||
import { type Db, mcpAttachmentUploads, mcpFileTickets, mcpOauthGrants, issues } from "@paperclipai/db";
|
||||
import { z } from "zod";
|
||||
import { MAX_ATTACHMENT_BYTES, isAllowedContentType } from "../../attachment-types.js";
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
import { authorizationService } from "../authorization.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { logActivity } from "../activity-log.js";
|
||||
import { hashMcpSecret, type PublicMcpOAuth, type McpPrincipal } from "./oauth.js";
|
||||
import { McpCapabilityError, company, task, requestId, pick, object, stable, type ApiDispatch, type Capability } from "./contracts.js";
|
||||
|
||||
const lifetime = 10 * 60_000;
|
||||
const uploadSchema = z.object({ ...task, requestId,
|
||||
filename: z.string().trim().min(1).max(240).regex(/^[^/\\\x00-\x1f\x7f]+$/),
|
||||
contentType: z.string().min(1).max(150).regex(/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/).refine(isAllowedContentType, "Unsupported attachment content type"),
|
||||
byteSize: z.number().int().min(1).max(MAX_ATTACHMENT_BYTES),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/).describe("SHA-256 of the exact file bytes. The upload must match this digest and byteSize."),
|
||||
}).strict();
|
||||
const attachmentFields = ["id", "issueId", "originalFilename", "contentType", "byteSize", "sha256", "createdAt"];
|
||||
const unavailable = () => new McpCapabilityError("This transfer is expired, revoked, invalid or no longer authorized. Request a new transfer link from Paperclip.");
|
||||
function requireWrite(p: McpPrincipal) {
|
||||
if (!p.grant.scopes.includes("paperclip:write") || p.actor.memberships?.[0]?.membershipRole === "viewer") throw unavailable();
|
||||
}
|
||||
|
||||
export function createPublicMcpTransfers(db: Db, oauth: PublicMcpOAuth, api: ApiDispatch, storage: StorageService) {
|
||||
const svc = issueService(db);
|
||||
async function taskAccess(p: McpPrincipal, taskId: string, write = false, queryDb?: Db) {
|
||||
// The public route uses the same domain decision. Inside the upload lock,
|
||||
// use its transaction: dispatching through the outer router would borrow a
|
||||
// second pool connection and deadlock when all connections hold uploads.
|
||||
const issue = queryDb
|
||||
? (await queryDb.select().from(issues).where(and(eq(issues.id, taskId), eq(issues.companyId, p.grant.companyId))))[0]
|
||||
: object(await api(p, "GET", `/issues/${taskId}`));
|
||||
if (!issue) throw unavailable();
|
||||
if (queryDb) {
|
||||
const scope = { issueId: String(issue.id), projectId: issue.projectId as string | null,
|
||||
parentIssueId: issue.parentId as string | null, assigneeAgentId: issue.assigneeAgentId as string | null,
|
||||
assigneeUserId: issue.assigneeUserId as string | null };
|
||||
const decision = await authorizationService(queryDb).decide({ actor: p.actor, action: "issue:read",
|
||||
resource: { type: "issue", companyId: p.grant.companyId, ...scope, status: String(issue.status) }, scope });
|
||||
if (!decision.allowed) throw unavailable();
|
||||
}
|
||||
if (issue.companyId !== p.grant.companyId) throw unavailable();
|
||||
if (write) {
|
||||
requireWrite(p);
|
||||
if (issue.conversationAgentId && issue.conversationUserId !== p.grant.userId) throw unavailable();
|
||||
}
|
||||
return issue;
|
||||
}
|
||||
async function attachmentAccess(p: McpPrincipal, id: string, queryDb?: Db) {
|
||||
const attachment = await (queryDb ? issueService(queryDb) : svc).getAttachmentById(id);
|
||||
if (!attachment || attachment.companyId !== p.grant.companyId) throw unavailable();
|
||||
await taskAccess(p, attachment.issueId, false, queryDb);
|
||||
return attachment;
|
||||
}
|
||||
async function sweep() {
|
||||
await db.delete(mcpFileTickets).where(lt(mcpFileTickets.expiresAt, new Date()));
|
||||
// Serialize cleanup against completion/reissue. Never remove a committed file.
|
||||
await db.transaction(async tx => {
|
||||
const abandoned = await tx.select().from(mcpAttachmentUploads).where(and(isNull(mcpAttachmentUploads.attachmentId), isNull(mcpAttachmentUploads.cleanedAt), lt(mcpAttachmentUploads.expiresAt, new Date()))).limit(20).for("update", { skipLocked: true });
|
||||
for (const upload of abandoned) {
|
||||
if (upload.storageProvider !== storage.provider) continue;
|
||||
await storage.deleteObject(upload.companyId, upload.objectKey);
|
||||
await tx.update(mcpAttachmentUploads).set({ cleanedAt: new Date() }).where(eq(mcpAttachmentUploads.id, upload.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
async function ticket(p: McpPrincipal, target: { uploadId?: string; attachmentId?: string }) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + lifetime);
|
||||
await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${p.grant.id}, 0))`);
|
||||
const [count] = await tx.select({ n: sql<number>`count(*)::int` }).from(mcpFileTickets).where(and(eq(mcpFileTickets.grantId, p.grant.id), gt(mcpFileTickets.expiresAt, new Date())));
|
||||
if (count.n >= 100) throw new McpCapabilityError("Too many active file links. Wait for earlier links to expire.");
|
||||
await tx.insert(mcpFileTickets).values({ tokenHash: hashMcpSecret(token), companyId: p.grant.companyId, grantId: p.grant.id, ...target, expiresAt });
|
||||
});
|
||||
return { url: `${oauth.config.origin}/mcp/files/${target.uploadId ? "upload" : "download"}?ticket=${token}`, expiresAt: expiresAt.toISOString() };
|
||||
}
|
||||
async function authorize(raw: unknown, upload: boolean) {
|
||||
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(raw)) throw unavailable();
|
||||
const [row] = await db.select().from(mcpFileTickets).where(and(eq(mcpFileTickets.tokenHash, hashMcpSecret(raw)), gt(mcpFileTickets.expiresAt, new Date())));
|
||||
if (!row || (upload ? !row.uploadId : !row.attachmentId)) throw unavailable();
|
||||
const principal = await oauth.authorizeGrant(row.grantId);
|
||||
if (row.companyId !== principal.grant.companyId) throw unavailable();
|
||||
if (upload) requireWrite(principal);
|
||||
return { row, principal };
|
||||
}
|
||||
const router = Router();
|
||||
router.use("/mcp/files", (_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store"); res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); next();
|
||||
});
|
||||
router.put("/mcp/files/upload", async (req, res, next) => {
|
||||
await authorize(req.query.ticket, true); next();
|
||||
}, express.raw({ type: () => true, limit: MAX_ATTACHMENT_BYTES }), async (req, res) => {
|
||||
const { row, principal } = await authorize(req.query.ticket, true);
|
||||
const bytes = req.body;
|
||||
if (!Buffer.isBuffer(bytes)) throw unavailable();
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
const attachment = await db.transaction(async tx => {
|
||||
const [upload] = await tx.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.id, row.uploadId!)).for("update");
|
||||
if (!upload || upload.companyId !== principal.grant.companyId || upload.userId !== principal.grant.userId || upload.storageProvider !== storage.provider) throw unavailable();
|
||||
if (upload.sha256 !== digest || upload.byteSize !== bytes.length || req.get("Content-Type")?.split(";")[0]?.trim().toLowerCase() !== upload.contentType) throw new McpCapabilityError("Upload bytes, size or Content-Type do not match the requested file.");
|
||||
await taskAccess(principal, upload.taskId, true, tx as unknown as Db);
|
||||
if (upload.attachmentId) return attachmentAccess(principal, upload.attachmentId, tx as unknown as Db);
|
||||
const stored = await storage.putFile({ companyId: upload.companyId, namespace: "mcp-transfers", objectKey: upload.objectKey, originalFilename: upload.originalFilename, contentType: upload.contentType, body: bytes });
|
||||
// Recheck after receiving/storing bytes. Lock the grant against concurrent revocation.
|
||||
const [grant] = await tx.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.id, row.grantId)).for("update");
|
||||
if (!grant || grant.revokedAt || row.expiresAt <= new Date()) throw unavailable();
|
||||
const current = await oauth.authorizeGrant(row.grantId, tx as unknown as Db);
|
||||
requireWrite(current);
|
||||
await taskAccess(current, upload.taskId, true, tx as unknown as Db);
|
||||
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, upload.taskId), eq(issues.companyId, upload.companyId))).for("update");
|
||||
if (!issue || (issue.conversationAgentId && issue.conversationUserId !== current.grant.userId)) throw unavailable();
|
||||
const result = await issueService(tx as unknown as Db).createAttachment({ issueId: upload.taskId, ...stored, createdByUserId: current.grant.userId });
|
||||
await tx.update(mcpAttachmentUploads).set({ attachmentId: result.id }).where(eq(mcpAttachmentUploads.id, upload.id));
|
||||
await logActivity(tx as unknown as Db, { companyId: upload.companyId, actorType: "user", actorId: current.grant.userId, action: "issue.attachment_added", entityType: "issue", entityId: upload.taskId,
|
||||
details: { attachmentId: result.id, originalFilename: result.originalFilename, contentType: result.contentType, byteSize: result.byteSize, mcpConnectionId: row.grantId } });
|
||||
return result;
|
||||
});
|
||||
res.json({ attachment: pick(attachment, attachmentFields) });
|
||||
});
|
||||
router.get("/mcp/files/download", async (req, res) => {
|
||||
const { row, principal } = await authorize(req.query.ticket, false);
|
||||
const attachment = await attachmentAccess(principal, row.attachmentId!);
|
||||
const file = await storage.getObject(attachment.companyId, attachment.objectKey);
|
||||
res.attachment(attachment.originalFilename ?? "attachment");
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader("Content-Length", attachment.byteSize);
|
||||
await pipeline(file.stream, res);
|
||||
});
|
||||
router.use("/mcp/files", ((err, _req, res, _next) => {
|
||||
if (res.headersSent) { res.destroy(); return; }
|
||||
const tooLarge = typeof err === "object" && err !== null && "type" in err && err.type === "entity.too.large";
|
||||
res.status(tooLarge ? 413 : err instanceof McpCapabilityError ? 403 : 503).json({ error: tooLarge ? "file_too_large" : "transfer_unavailable", message: err instanceof McpCapabilityError ? err.message : "Transfer could not be confirmed. Retry the same upload identity, or request another download link." });
|
||||
}) as express.ErrorRequestHandler);
|
||||
return {
|
||||
router,
|
||||
async getUploadUrl(p: McpPrincipal, input: unknown) {
|
||||
const a = uploadSchema.parse(input);
|
||||
await taskAccess(p, a.taskId, true);
|
||||
await sweep();
|
||||
const hash = hashMcpSecret(stable(a));
|
||||
const id = randomUUID();
|
||||
const upload = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${p.grant.companyId}:${p.grant.userId}:uploads`}, 0))`);
|
||||
const identity = and(eq(mcpAttachmentUploads.companyId, p.grant.companyId), eq(mcpAttachmentUploads.userId, p.grant.userId), eq(mcpAttachmentUploads.requestId, a.requestId));
|
||||
let [found] = await tx.select().from(mcpAttachmentUploads).where(identity).for("update");
|
||||
if (!found) {
|
||||
const [count] = await tx.select({ n: sql<number>`count(*)::int` }).from(mcpAttachmentUploads).where(and(eq(mcpAttachmentUploads.companyId, p.grant.companyId), eq(mcpAttachmentUploads.userId, p.grant.userId), isNull(mcpAttachmentUploads.attachmentId), gt(mcpAttachmentUploads.expiresAt, new Date())));
|
||||
if (count.n >= 100) throw new McpCapabilityError("Too many pending uploads. Wait for earlier uploads to expire.");
|
||||
[found] = await tx.insert(mcpAttachmentUploads).values({ id, companyId: p.grant.companyId, userId: p.grant.userId, requestId: a.requestId, taskId: a.taskId, argumentsHash: hash,
|
||||
originalFilename: a.filename, contentType: a.contentType, byteSize: a.byteSize, sha256: a.sha256,
|
||||
objectKey: `${p.grant.companyId}/mcp-transfers/${id}`, storageProvider: storage.provider, expiresAt: new Date(Date.now() + lifetime) }).returning();
|
||||
}
|
||||
if (!found || found.argumentsHash !== hash) throw new McpCapabilityError("requestId was already used for a different upload.");
|
||||
await tx.update(mcpAttachmentUploads).set({ expiresAt: new Date(Date.now() + lifetime), cleanedAt: null }).where(eq(mcpAttachmentUploads.id, found.id));
|
||||
return found;
|
||||
});
|
||||
if (upload.attachmentId) return { attachment: pick(await attachmentAccess(p, upload.attachmentId), attachmentFields), status: "completed" };
|
||||
return { ...await ticket(p, { uploadId: upload.id }), method: "PUT", headers: { "Content-Type": a.contentType }, uploadId: upload.id, maxBytes: MAX_ATTACHMENT_BYTES,
|
||||
instructions: "Send the exact file bytes with the given Content-Type. Success saves the attachment and returns its ID; there is no completion tool. Do not share this temporary file credential. Reuse requestId after a lost response." };
|
||||
},
|
||||
async getDownloadUrl(p: McpPrincipal, attachmentId: string) {
|
||||
const attachment = await attachmentAccess(p, attachmentId);
|
||||
await sweep();
|
||||
return { ...await ticket(p, { attachmentId }), method: "GET", attachment: pick(attachment, attachmentFields), instructions: "Download this file with your host's HTTP/file tools. This temporary credential permits only this download; do not share it." };
|
||||
},
|
||||
};
|
||||
}
|
||||
export type PublicMcpTransfers = ReturnType<typeof createPublicMcpTransfers>;
|
||||
export const fileTransferCapabilities: Capability[] = [
|
||||
{ name: "paperclip_get_upload_url", write: true, ephemeral: true,
|
||||
description: "Get a temporary URL to attach a file, video or document to a task. Compute its size and SHA-256 locally, then PUT the exact bytes to the URL. A successful upload saves the attachment immediately and returns its ID. Reuse requestId after a lost response. Requires host file/HTTP tools; if unavailable explain the manual upload step.",
|
||||
schema: uploadSchema, run: async (p, a, _api, _origin, transfers) => { if (!transfers) throw unavailable(); return transfers.getUploadUrl(p, a); } },
|
||||
{ name: "paperclip_get_download_url", ephemeral: true,
|
||||
description: "Get a ten-minute download URL for one task attachment from paperclip_list_deliverables. Use your host's HTTP/file tools to download it. This temporary credential does not grant access to other files.",
|
||||
schema: z.object({ ...company, attachmentId: z.uuid() }).strict(), run: async (p, a, _api, _origin, transfers) => { if (!transfers) throw unavailable(); return transfers.getDownloadUrl(p, String(a.attachmentId)); } },
|
||||
];
|
||||
@@ -67,17 +67,17 @@ const authorizeSchema = z.object({
|
||||
export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: { metadataFetch?: MetadataFetch } = {}) {
|
||||
const resolveMetadata = createClientMetadataResolver(options.metadataFetch);
|
||||
const settings = instanceSettingsService(db);
|
||||
const isEnabled = async () => (await settings.getExperimental()).enablePublicMcp === true;
|
||||
async function assertEnabled() {
|
||||
if (!await isEnabled()) throw new PublicMcpDisabledError();
|
||||
const isEnabled = async (queryDb: Db = db) => (await (queryDb === db ? settings : instanceSettingsService(queryDb)).getExperimental()).enablePublicMcp === true;
|
||||
async function assertEnabled(queryDb: Db = db) {
|
||||
if (!await isEnabled(queryDb)) throw new PublicMcpDisabledError();
|
||||
}
|
||||
const boardAuth = boardAuthService(db);
|
||||
|
||||
async function actorForGrant(grant: typeof mcpOauthGrants.$inferSelect): Promise<Request["actor"]> {
|
||||
async function actorForGrant(grant: typeof mcpOauthGrants.$inferSelect, queryDb: Db = db): Promise<Request["actor"]> {
|
||||
if (grant.revokedAt || grant.resource !== config.resource) throw invalidGrant();
|
||||
const access = await boardAuth.resolveBoardAccess(grant.userId);
|
||||
const access = await boardAuthService(queryDb).resolveBoardAccess(grant.userId);
|
||||
const membership = access.memberships.find((m) => m.companyId === grant.companyId && m.status === "active");
|
||||
const [company] = await db.select({ status: companies.status }).from(companies).where(eq(companies.id, grant.companyId));
|
||||
const [company] = await queryDb.select({ status: companies.status }).from(companies).where(eq(companies.id, grant.companyId));
|
||||
if (!access.user || !membership || !company || company.status === "archived") throw invalidGrant();
|
||||
return {
|
||||
type: "board", source: "mcp_oauth", userId: grant.userId,
|
||||
@@ -183,15 +183,16 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
}) };
|
||||
}
|
||||
|
||||
async function approveGrant(tx: Db, actor: Request["actor"], request: { requestedCompanyId: string | null; clientId: string; resource: string; scopes: string[] }, input: { companyId?: string; allowWrites: boolean }) {
|
||||
async function approveGrant(tx: Db, actor: Request["actor"], request: { requestedCompanyId: string | null; clientId: string; resource: string; scopes: string[] }, input: { companyId?: string; allowWrites: boolean; allowConfiguration?: boolean }) {
|
||||
if (actor.type !== "board" || !actor.userId || !["session", "cloud_tenant"].includes(actor.source ?? "")) throw new McpOAuthError("access_denied", "Sign in to approve an assistant connection.", 401);
|
||||
const access = await boardAuth.resolveBoardAccess(actor.userId);
|
||||
const access = await boardAuthService(tx).resolveBoardAccess(actor.userId);
|
||||
const membership = access.memberships.find(m => m.companyId === input.companyId && m.status === "active");
|
||||
if (!access.user || !membership || (request.requestedCompanyId && request.requestedCompanyId !== input.companyId)) throw new McpOAuthError("access_denied", "Choose an available organization for this request.", 403);
|
||||
const [company] = await tx.select({ status: companies.status }).from(companies).where(eq(companies.id, input.companyId!));
|
||||
if (!company || company.status === "archived") throw new McpOAuthError("access_denied", "This organization is no longer available.", 403);
|
||||
if (input.allowWrites && membership.membershipRole === "viewer") throw new McpOAuthError("access_denied", "Viewer access is read-only.", 403);
|
||||
const scopes = request.scopes.filter(s => s !== "paperclip:write" || input.allowWrites);
|
||||
if ((input.allowWrites || input.allowConfiguration) && membership.membershipRole === "viewer") throw new McpOAuthError("access_denied", "Viewer access is read-only.", 403);
|
||||
const scopes = request.scopes.filter(s => (s !== "paperclip:write" || input.allowWrites)
|
||||
&& (s !== "paperclip:configure" || input.allowConfiguration === true));
|
||||
const [grant] = await tx.insert(mcpOauthGrants).values({ companyId: input.companyId!, userId: actor.userId, clientId: request.clientId, resource: request.resource, scopes }).returning();
|
||||
await tx.insert(activityLog).values({ companyId: grant!.companyId, actorType: "user", actorId: actor.userId,
|
||||
action: "mcp.connection_authorized", entityType: "mcp_connection", entityId: grant!.id, details: { clientId: grant!.clientId, scopes } });
|
||||
@@ -216,7 +217,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
if (row.status !== "approved" || !row.grantId) return { error: "authorization_pending" };
|
||||
const [grant] = await tx.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.id, row.grantId)).for("update");
|
||||
if (!grant) return { error: "invalid_grant" };
|
||||
await actorForGrant(grant);
|
||||
await actorForGrant(grant, tx as unknown as Db);
|
||||
await tx.update(mcpOauthDeviceRequests).set({ status: "consumed" }).where(eq(mcpOauthDeviceRequests.id, row.id));
|
||||
return { tokens: await issueTokens(tx as unknown as Db, grant) };
|
||||
});
|
||||
@@ -259,10 +260,10 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
.where(and(eq(mcpOauthDeviceRequests.userCodeHash, deviceHash(userCode)), eq(mcpOauthDeviceRequests.status, "pending"), gt(mcpOauthDeviceRequests.expiresAt, new Date())));
|
||||
if (!row) throw new McpOAuthError("invalid_request", "This code is expired, already decided, or invalid. Start a new connection from your assistant.", 404);
|
||||
return { id: row.request.id, clientName: row.client.name, redirectOrigin: "", clientOrigin: row.client.id.startsWith("https://") ? new URL(row.client.id).origin : null,
|
||||
requestedWrite: row.request.scopes.includes("paperclip:write"), offlineAccess: row.request.scopes.includes("offline_access"), requestedCompanyId: row.request.requestedCompanyId,
|
||||
requestedConfigure: row.request.scopes.includes("paperclip:configure"), requestedWrite: row.request.scopes.includes("paperclip:write"), offlineAccess: row.request.scopes.includes("offline_access"), requestedCompanyId: row.request.requestedCompanyId,
|
||||
setupUrl: null, ...await consentContext(actor, row.request.requestedCompanyId) };
|
||||
},
|
||||
async consentDevice(userCode: string, actor: Request["actor"], input: { decision: "approve" | "deny"; companyId?: string; allowWrites: boolean }) {
|
||||
async consentDevice(userCode: string, actor: Request["actor"], input: { decision: "approve" | "deny"; companyId?: string; allowWrites: boolean; allowConfiguration?: boolean }) {
|
||||
await assertEnabled();
|
||||
if (actor.type !== "board" || !actor.userId || !["session", "cloud_tenant"].includes(actor.source ?? "")) throw new McpOAuthError("access_denied", "Sign in to approve a connection.", 401);
|
||||
return db.transaction(async tx => {
|
||||
@@ -331,12 +332,12 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
return {
|
||||
id, clientName: row.client.name, redirectOrigin: new URL(row.request.redirectUri).origin,
|
||||
clientOrigin: row.client.id.startsWith("https://") ? new URL(row.client.id).origin : null,
|
||||
requestedWrite: row.request.scopes.includes("paperclip:write"), offlineAccess: row.request.scopes.includes("offline_access"),
|
||||
requestedConfigure: row.request.scopes.includes("paperclip:configure"), requestedWrite: row.request.scopes.includes("paperclip:write"), offlineAccess: row.request.scopes.includes("offline_access"),
|
||||
requestedCompanyId: row.request.requestedCompanyId,
|
||||
...await consentContext(actor, row.request.requestedCompanyId), setupUrl,
|
||||
};
|
||||
},
|
||||
async consent(id: string, actor: Request["actor"], input: { decision: "approve" | "deny"; companyId?: string; allowWrites: boolean }) {
|
||||
async consent(id: string, actor: Request["actor"], input: { decision: "approve" | "deny"; companyId?: string; allowWrites: boolean; allowConfiguration?: boolean }) {
|
||||
await assertEnabled();
|
||||
if (actor.type !== "board" || !actor.userId || !["session", "cloud_tenant"].includes(actor.source ?? "")) {
|
||||
throw new McpOAuthError("access_denied", "Sign in to approve an assistant connection.", 401);
|
||||
@@ -387,7 +388,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
|| row.expiresAt <= new Date() || !row.grantId) throw invalidGrant();
|
||||
const [grant] = await tx.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.id, row.grantId)).for("update");
|
||||
if (!grant) throw invalidGrant();
|
||||
await actorForGrant(grant);
|
||||
await actorForGrant(grant, tx as unknown as Db);
|
||||
await tx.update(mcpOauthRequests).set({ consumedAt: new Date() }).where(eq(mcpOauthRequests.id, row.id));
|
||||
return issueTokens(tx as unknown as Db, grant);
|
||||
});
|
||||
@@ -411,7 +412,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
return null;
|
||||
}
|
||||
if (token.expiresAt <= new Date()) return null;
|
||||
await actorForGrant(grant);
|
||||
await actorForGrant(grant, tx as unknown as Db);
|
||||
if (input.scope !== undefined && input.scope !== grant.scopes.join(" ")) {
|
||||
throw new McpOAuthError("invalid_scope", "Refresh cannot change the consented scopes; reconnect instead.");
|
||||
}
|
||||
@@ -423,13 +424,13 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: {
|
||||
}
|
||||
throw new McpOAuthError("unsupported_grant_type", "Unsupported authorization grant.");
|
||||
},
|
||||
async authorizeGrant(grantId: string): Promise<McpPrincipal> {
|
||||
await assertEnabled();
|
||||
const [row] = await db.select({ grant: mcpOauthGrants, company: { id: companies.id, name: companies.name, issuePrefix: companies.issuePrefix, status: companies.status } })
|
||||
async authorizeGrant(grantId: string, queryDb: Db = db): Promise<McpPrincipal> {
|
||||
await assertEnabled(queryDb);
|
||||
const [row] = await queryDb.select({ grant: mcpOauthGrants, company: { id: companies.id, name: companies.name, issuePrefix: companies.issuePrefix, status: companies.status } })
|
||||
.from(mcpOauthGrants).innerJoin(companies, eq(mcpOauthGrants.companyId, companies.id))
|
||||
.where(eq(mcpOauthGrants.id, grantId));
|
||||
if (!row || !row.grant.scopes.includes("paperclip:read")) throw invalidGrant();
|
||||
return { ...row, actor: await actorForGrant(row.grant) };
|
||||
return { ...row, actor: await actorForGrant(row.grant, queryDb) };
|
||||
},
|
||||
async authenticate(token: string): Promise<McpPrincipal> {
|
||||
await assertEnabled();
|
||||
|
||||
@@ -97,7 +97,8 @@ export function createStorageService(provider: StorageProvider): StorageService
|
||||
|
||||
async putFile(input: PutFileInput): Promise<PutFileResult> {
|
||||
assertPutFileInput(input);
|
||||
const objectKey = buildObjectKey(input.companyId, input.namespace, input.originalFilename);
|
||||
const objectKey = input.objectKey ?? buildObjectKey(input.companyId, input.namespace, input.originalFilename);
|
||||
ensureCompanyPrefix(input.companyId, objectKey);
|
||||
const byteSize = "byteSize" in input ? input.byteSize : input.body.length;
|
||||
const contentType = input.contentType.trim().toLowerCase();
|
||||
try {
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface StorageProvider {
|
||||
}
|
||||
|
||||
export type PutFileInput = {
|
||||
/** Server-allocated, company-prefixed key for durable idempotent uploads. Never accept from client input. */
|
||||
objectKey?: string;
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename: string | null;
|
||||
|
||||
Reference in new issue
Block a user