Invite assistants from Connections with scoped browser and device consent (#14933)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Public MCP lets people use their organization from an external
assistant.
> - Operators need a visible control for this experimental access.
> - Hosted users should select an organization once and then approve its
permissions.
> - This pull request adds the setting, invitation-first setup, and
browser or device consent.
> - Connections provides a copyable invitation with public instructions
that grant no access.
> - Users reach browser consent from their assistant and return to
inspect or revoke access.

## Linked Issues or Issue Description

Builds on merged foundation #14846. This PR now targets master. Related
settings convention: #13905.

**Current behavior**

The preview uses an environment variable to enable MCP. Hosted consent
repeats organization selection. Assistant access has no entry in
Connections, so users must already know the endpoint and how to reach
consent.

**Proposed behavior**

An administrator enables Settings → Experimental → Assistant connections
(MCP). A hosted connection shows the selected organization and its icon,
then asks for permissions. Requested write access starts checked when
the user’s role permits it; the user can opt out before connecting.
Direct instance connections show an organization picker with the first
available organization selected. The selection stays fixed across
refetches and still requires an explicit Connect action. Connections
includes Assistant Connection (MCP). Its setup page explains the
canonical endpoint, client configuration, browser authentication, and
connected access. It connects as the current person and does not select
or impersonate an agent.

**Reason and benefit**

Operators manage access with the other experiments. Users select one
organization, and both the UI and server enforce that choice.

**Breaking changes**

The old enable variable has no effect. Preview operators must enable the
setting once. Apply the additive consent-request migration before
deploying the tenant, then deploy the compatible Cloud broker. Existing
direct requests and grants keep their behavior.

## What Changed

- Simplify OAuth and device consent: show the Paperclip logo beside
“Connect {client} to Paperclip”, fall back to “your assistant”, and show
the identifying origin plus its favicon below, with the callback URL
also visible when different. Remove the hosted-organization creation
action. Default to the first available organization without silently
changing it on refetch; preserve company restrictions and write
opt-outs. Keep the button row contained on narrow screens.
- Make Copy invitation the primary action, using the shared animated
AgentSetupPrompt and a collapsed manual setup section with icon-labeled
line tabs. Remove redundant link actions, copy-status text, the extra
first-prompt well and revocation explanation from the setup page. Serve
shared version-aware HTML and Markdown instructions without private
organization data.
- Support guarded Client ID Metadata Documents alongside dynamic
registration, and include authorization response issuer identification.
- Add RFC 8628 device authorization with separately hashed codes,
expiry, shared request quotas, persistent polling backoff and atomic
redemption. Reuse human consent, role checks, scoped grants, audit and
revocation.
- Add CLI device login and a local stdio bridge. Store credentials
separately with private permissions and serialize rotating refreshes.
- Add device consent stories and five cold-start paid Product E2E cases
with independent grant, configuration and durable-work assertions.

- Add `enablePublicMcp` to the settings validator, normalizer, feature
catalog, and toggle UI. Check it live for OAuth, tools, subscriptions,
and event delivery. Keep connection management and revocation available
while disabled.
- Default the MCP origin to the existing auth public URL, with strict
validation and an explicit override.
- Persist the optional OAuth `company_id` restriction. Describe only
that company and reject approval for any other company, even if the
person belongs to both. Keep active-membership and role checks.
- Show the Paperclip icon and a large organization icon during consent.
Return the saved company logo through the company-scoped request
response and reuse the standard fallback icon. Use the requested concise
permission labels: “Read all of your Paperclip data” and “Allow write
access and creating tasks as me”. Use concise permission copy, retain a
compact client and callback-origin disclosure, and remove the footer
link.
- Default requested write access on for eligible roles. Preserve opt-out
across organization changes and refetch, reset defaults for a new
request, and submit read-only access when the request or role does not
allow writes. Align the shared checkbox with its label.
- Use organization wording in consent, management, settings, and
walkthroughs. Keep the organization fixed for hosted requests and retain
direct-instance choice.
- Add an Assistant Connection (MCP) card to the Connectors catalog, a
setup page in the app shell, and a return link from Experimental
settings. Include Codex, Claude Code, OpenCode, and generic remote MCP
instructions.
- Read the live gate and canonical server URL through authenticated
setup metadata. Show only the current person’s grants for the selected
organization, refresh after consent, and support revocation. Surface
catalog status failures with an explicit retry action; do not present
them as an empty connection list. Opening setup grants no authority.
- Start the eight guided chapters in Connections. Keep presenter notes
and chapter controls around real product pages in the app shell. Explain
the terminal, consent, delegation, retrieval, and revocation handoffs.
Mark conversation examples as illustrative. Cover first use, client
setup, connected, loading, and error states. Keep the existing consent
and management stories.
- Keep the paid-eval setup and browser helper aligned with the setting
and consent button.

## Verification

- Warm-standby integration fix `ec64ea05e`: public MCP ingress now
follows the Cloud claim guard; MCP and discovery paths return 503
instead of SPA HTML while unclaimed. Event polling checks the in-memory
claim before reading the persisted experimental setting. All 97 focused
OAuth/Cloud tests and server typecheck pass, including new request and
timer regressions for idle-before-claim and resume-after-claim behavior.
Fresh review is 5/5 with no unresolved threads, and all security scans
pass on this final head. All browser shards, typecheck, build, canary
installation and other test groups passed on the first attempt. The
unchanged Cursor sandbox default-command test timed out at 10 seconds;
the exact test passed locally without edits in 587 ms. The single
failed-job retry passed, with the original failure retained in workflow
37500711895. All 54 final-head checks pass on
`ec64ea05e9a03e2179d4e2f84c2de03761f7ce26` (two optional Storybook jobs
are intentionally skipped).

- Final master integration `8457828fc`: merged foundation #14846 and
current master, preserving the invitation changes and all 33 files from
the two newer upstream changes. No migration renumbering was required.
All 95 focused OAuth/Cloud integration tests, full recursive typecheck
and token gates pass. All CI gates passed on that integration head;
review identified the warm-standby issue fixed above.

- Security-review fix `8c1d0b696`: commit shared global/per-source
admission before outbound CIMD work, preserve failed-attempt receipts,
and validate resource/scope before fetching. Added migration
`0305_chubby_vin_gonzales.sql` and six concurrent/adversarial regression
cases. All 69 OAuth/metadata tests, 26 migration checks, full recursive
typecheck and production build pass. The security scanner passed that
commit. Follow-up `87f9658e7` limits only actual cache-miss fetches; 18
authorization requests sharing one proxy across two service instances
use just two fetches. All 70 OAuth/metadata tests and server typecheck
pass after that refinement. Final follow-up `8ebeae84c` reports
admission-storage failures as retryable HTTP 503 instead of invalid
client metadata. Its regression proves no outbound request before
admission and successful retry after storage recovers. All 71
OAuth/metadata tests and server typecheck pass. Final-head security
scanning passes; Greptile is 5/5 with no unresolved findings. CI passed
all browser shards, typecheck, build, token gates and canary
installation. One unchanged adapter-utils bridge test raced a
response-file write (expected a JSON error, received the safe
file-changed error). The exact test passed locally without edits. The
single failed-job retry passed; the original failure is retained in
workflow 37490609192. All 54 checks now pass on final head
`8ebeae84ca77c0cf7ac12c2006f0f8743fe50e0b`, with security scan and fresh
Greptile 5/5 and no unresolved threads. Foundation #14846 subsequently
merged as `e34abee670069cca84afb2efb86041bce7dccbec`; the final
integration above now targets master.

- Integration with current master: preserved the new Connections source
filters and pagination, kept all eval suites, and regenerated the
consent/device snapshots as migrations 0303/0304. All four MCP migration
SQL hashes are unchanged from the staging versions. Full recursive
typecheck and production build, 132 focused UI tests (including catalog
filtering), 89 server authorization/settings tests, 26 migration tests,
120 eval calibration tests and token gates pass. Review follow-up
`4820ce74c` also keeps active assistant grants in Installed, with
pending/error recovery and revocation/company-isolation coverage. All 76
setup/catalog tests, UI typecheck and token gates pass after that fix.
The unchanged signoff browser test timed out waiting for a heartbeat in
CI at `4820ce74c`; the exact test passed locally without code changes,
and the preceding CI head passed that shard. That same unchanged test
failed at the reviewer stage in the next CI run. All five signoff tests
passed three times locally (15/15), without test changes. All eight
browser shards pass at final head `8ebeae84c`; no browser-test edits or
failed-browser-job retries were needed.

- Setup-page refinement at `9ab009178`: all 17 focused setup/consent
tests pass, along with UI typecheck, production build, Storybook build
and token gates. Browser exercised the shared prompt preview and client
tab switching, and the updated InvitationCopied Storybook interaction
checks its clipboard fixture. All final-head CI checks pass at
`9ab009178`, with no unresolved review findings. Deployed successfully
to Butter in
https://github.com/paperclipai/paperclip-cloud/actions/runs/37475189524.
Verified the actual page, tab switching and line styling, removed
actions/copy, and successful native copy/paste of the complete Butter
invitation into a local-only test field. The existing Claude grant was
left intact.
- Consent follow-up at `dc8e9fd11`: all 10 consent tests and token gates
pass. UI typecheck and production build passed again at `4e4d5e4d9`;
Storybook build and eval-helper typecheck passed for `28101cf91`.
Follow-ups let the primary button wrap on narrow screens, preserve a
distinct callback URL, and use only bundled icons to avoid pre-consent
requests to client-selected sites. Browser-verified the real consent
component in desktop and 320px mobile stories, including default
selection, write access and preserved opt-out. Updated E2E
heading/default-selection helpers. All CI checks passed at `dc8e9fd11`,
with review 5/5 and no unresolved threads. The Butter preview
publication needed a retry because npm initially accepted the DB package
before making it visible; the retry succeeded and `dc8e9fd11` deployed.
Verified a fresh, unapproved native Codex CIMD request on Butter:
default organization/write selection, known-client heading and icon,
distinct callback origin, and removed creation action. No grant was
approved for this UI check. Prior paid runs below retain their exact
source provenance; this UI-only follow-up did not rerun paid
qualification.
- Source-pinned paid matrix at
`2992ef2710f47230e7f484c709c6ba02524f884c`: **15/15 passed**, five cases
each on GPT-5.4 Mini, Claude Haiku and Sonnet. Campaign
`local-2026-10-06T02-41-14-462Z`. Covers cold start, existing config,
unavailable host, denied consent and reconnect/later retrieval, with
independent configuration/grant/task/run/document assertions. Original
failures, transcripts, source fingerprints and billing remain retained.
- Final instruction follow-up `cda8178af`: **3/3 cold starts passed** on
Mini, Haiku and Sonnet. Campaign `local-2026-10-06T02-58-30-041Z`.
Latest `0637b9f1c` shares that same guidance across HTML, Markdown and
manual UI after review; generated Markdown is verified byte-identical to
the paid-evaluated version. Shared build, server/UI typechecks, token
gates and 63 auth/metadata tests passed again. Every CI gate passed at
prior HEAD `0637b9f1c`, with review 5/5 and no unresolved threads.
- Other focused checks: 11 CLI credential/refresh-lock tests, 120 eval
calibration tests, server/UI/eval typechecks, token gates and Storybook
build passed. Full recursive typecheck and production build passed
during implementation; CI also passed them at `2992ef271`.
- Local full-suite limitations: a large-file Git streaming test times
out on this Mac, and broader CLI/route runs hit DB hook timeouts. Fresh
MCP reruns passed, and the corresponding CI groups passed. No claim that
the local full suite is green.
- Actual clients: Codex 0.153.4 and Claude Code 2.1.245 reach CIMD
consent; device CLI reaches verification/consent. New grants await human
approval. Existing local OpenCode retrieved a saved result in a fresh
conversation through its previously approved grant.
- Fresh OpenCode 1.18.17 on Butter: started with no MCP config, received
the exact copied invitation, read public setup, configured its server
and started PKCE consent. Its shell command timed out; background retry
reached the client's own callback deadline while approval remained
pending. Latest instructions cover that handoff. **No completed Butter
read/delegation/result retrieval is claimed.**
- Cloud companion
https://github.com/paperclipai/paperclip-cloud/pull/672 passes
checks/review and deployed. Anonymous setup and device-protocol routing
verified. Core `2992ef271` deployed successfully and the actual Claude
web flow now reaches consent. Its extra JWT-bearer metadata is filtered
to implemented grants; unsupported token grants remain rejected. Final
`0637b9f1c` deployed successfully to Butter in
https://github.com/paperclipai/paperclip-cloud/actions/runs/37409195300;
live HTML and Markdown both contain the final guidance. The superseded
instruction-only build was canceled before deployment. This is a
core-only staging preview; private Cloud plugins are omitted. ChatGPT
web is signed out, so browser connector use is unverified.
- Screenshot gallery begins at Butter's dashboard and distinguishes real
setup/pending consent from local reuse and fixtures. It records the
timeout finding. New persistent access needs human confirmation before
the remaining actual-client acceptance work.
- Manual path: Connectors → Assistant Connection (MCP) → Copy invitation
→ paste into assistant → configure and start authorization → sign in and
approve → verify `paperclip_connection` → delegate → retrieve the saved
report later.
- Plan and instructions: `doc/plans/2026-10-05-assistant-invitations.md`
and `doc/public-mcp.md`.

## Risks

- Apply additive, replay-safe migration `0304_curvy_shadow_king.sql`
before using device authorization. The public setup link carries no
credential. Device codes and tokens stay private; neither sharing
instructions nor installing a plugin authorizes access.
- Apply additive migration `0305_chubby_vin_gonzales.sql` before
deploying the shared metadata admission gate. It retains at most 60
short-lived, hashed-source receipts per instance and rejects excess
attempts with 429.
- CIMD metadata fetching is a new external-input boundary. It requires
HTTPS, exact client ID and redirect validation, bounded responses and
guarded DNS/network access. Client names remain self-reported.
- Device support is per-instance. The central Cloud broker retains its
existing grant support. Host installation and tool reload capabilities
vary by client; instructions describe manual settings and restart
requirements.

- Consent names the registered client in its heading and displays its
identifying origin below. Known-origin icons are bundled; all other
origins show a neutral site icon without contacting client-selected
sites. Client names are self-reported; the callback origin is the
recipient check. The Cloud chooser also displays the original client and
receiving origin before tenant handoff.

- A user who accepts the preselected write permission can create tasks
and comments. Task creation and comments can start or wake agents and
use execution budget; the consent label uses the concise wording
explicitly requested by the maintainer. Scope requests, role checks, and
the final Connect action still apply.
- Migration `0303_supreme_garia.sql` adds one nullable UUID column with
`IF NOT EXISTS`. Requests without a company restriction keep the
direct-instance picker. The binding stays recorded if its company is
deleted; consent then fails closed.
- Deploy tenant support before the Cloud broker sends `company_id`.
Unknown or inaccessible organizations must never fall back to a
different company.
- The setting defaults off. Disabling access does not cancel work
already delegated. Existing tokens and unexpired subscriptions can
resume when enabled again; revocation remains separate.
- The catalog entry is visible for discovery while the feature is off.
Setup instructions, OAuth, and tool execution remain gated. No access is
granted by viewing the entry.
- Assistant sign-in starts in the external client so it owns PKCE and
callback state. Client command syntax can change and links to official
setup documentation are included.
- An authenticated instance and valid public URL are required. Hosting,
paid execution, and store publication remain separate rollout steps.

## Model Used

OpenAI GPT-6 in Codex, with tool use and code execution. The exact
serving model version and context window are not exposed in this
session.

## 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 checks pass;
unrelated local full-suite timeouts are explicitly recorded 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:
DottaandPaperclip authored and GitHub committed 2026-10-06 12:35:16 -05:00
1 parent 0fe47882cf
commit 22a3ea3414
68 files changed
+7019 -193

No files matched your search

@@ -7,6 +7,8 @@ import { cloudWarmStandbyMiddleware, cloudWarmStandbyServerOptions } from "../mi
import { healthRoutes } from "../routes/health.js";
import { emailChannelService } from "../services/email-channels.js";
import { createPluginJobScheduler } from "../services/plugin-job-scheduler.js";
import { createPublicMcpEvents } from "../services/public-mcp/events.js";
import type { PublicMcpOAuth } from "../services/public-mcp/oauth.js";
const healthOptions = {
deploymentMode: "authenticated" as const,
@@ -98,6 +100,26 @@ describe("unclaimed Cloud background work", () => {
}
});
it("MCP event timers do not read the persisted setting until claim", async () => {
vi.useFakeTimers();
let standby = true;
const isEnabled = vi.fn().mockResolvedValue(false);
const events = createPublicMcpEvents({} as Db, { isEnabled } as PublicMcpOAuth, vi.fn(), {
isBackgroundWorkEnabled: () => !standby,
});
try {
events.start();
await vi.advanceTimersByTimeAsync(10 * 60_000);
await events.tick();
expect(isEnabled).not.toHaveBeenCalled();
standby = false;
await vi.advanceTimersByTimeAsync(2000);
expect(isEnabled).toHaveBeenCalledOnce();
} finally {
await events.stop();
}
});
it("email and plugin timers leave SQL idle, then resume without restarting", async () => {
vi.useFakeTimers();
let enabled = false;
@@ -45,6 +45,7 @@ describe("instance settings service", () => {
enableApps: true,
enableMcpAggregators: true,
enableAgentChat: false,
enablePublicMcp: false,
enableCombinedInboxTasks: false,
enableChatConnectors: false,
enableMemoryConnectors: false,
@@ -97,6 +97,7 @@ const explicitOpenApiOperationCoverageExclusions = new Set([
// browser connection-management operations remain documented in the board API.
"GET /.well-known/oauth-authorization-server",
"POST /mcp/oauth/register",
"POST /mcp/oauth/device_authorization",
"GET /mcp/oauth/authorize",
"POST /mcp/oauth/token",
"POST /mcp/oauth/revoke",
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { createClientMetadataResolver, mcpRedirectMatches } from "../services/public-mcp/client-metadata.js";
import { mcpInvitation, mcpSetupMarkdown } from "@paperclipai/shared";
import { renderMcpSetup } from "../services/public-mcp/setup.js";
const id = "https://assistant.example/client.json";
const document = { client_id: id, client_name: "Example", redirect_uris: ["http://127.0.0.1:1234/callback"] };
const response = (data: unknown, headers: Record<string, string> = {}) => new Response(JSON.stringify(data), { headers: { "content-type": "application/json", ...headers } });
describe("public MCP client metadata", () => {
it("allows only the port to change for declared native loopback clients", () => {
const registered = ["http://127.0.0.1/callback", "http://localhost/callback", "http://[::1]/callback"];
for (const host of ["127.0.0.1", "localhost", "[::1]"]) {
expect(mcpRedirectMatches(registered, `http://${host}:55023/callback`, true)).toBe(true);
expect(mcpRedirectMatches(registered, `http://${host}:55023/callback`, false)).toBe(false);
}
for (const uri of ["http://127.0.0.1:55023/other", "http://127.0.0.1:55023/callback?next=evil", "http://127.0.0.1:55023/callback#code", "http://127.0.0.1.evil.test:55023/callback", "http://evil@127.0.0.1:55023/callback", "https://127.0.0.1:55023/callback", "http://127.0.0.2:55023/callback"]) {
expect(mcpRedirectMatches(registered, uri, true)).toBe(false);
}
expect(mcpRedirectMatches(["https://example.com/callback"], "https://example.com:55023/callback", true)).toBe(false);
expect(mcpRedirectMatches(["http://127.0.0.1/callback"], "http://localhost:55023/callback", true)).toBe(false);
});
it("validates the client identity and bounds cache reuse", async () => {
const fetch = vi.fn(async () => response(document, { "cache-control": "max-age=300" }));
const resolve = createClientMetadataResolver(fetch);
expect((await resolve(id)).client_name).toBe("Example");
await resolve(id); expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL(id));
});
it("selects implemented grants from a client's broader published capabilities", async () => {
const metadata = { ...document, redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
grant_types: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] };
expect((await createClientMetadataResolver(async () => response(metadata))(id)).grant_types).toEqual(["authorization_code", "refresh_token"]);
expect((await createClientMetadataResolver(async () => response({ ...metadata, grant_types: ["client_credentials"] }))(id)).grant_types).toEqual([]);
await expect(createClientMetadataResolver(async () => response({ ...metadata, grant_types: [null] }))(id)).rejects.toThrow();
});
it("recognizes older loopback-only native metadata without widening declared web clients", async () => {
expect((await createClientMetadataResolver(async () => response(document))(id)).application_type).toBe("native");
expect((await createClientMetadataResolver(async () => response({ ...document, application_type: "web" }))(id)).application_type).toBe("web");
expect((await createClientMetadataResolver(async () => response({ ...document, redirect_uris: [...document.redirect_uris, "https://remote.example/callback"] }))(id)).application_type).toBeUndefined();
});
it.each([
{ ...document, client_id: "https://other.example/client.json" },
{ ...document, redirect_uris: ["https://name:secret@example.com/callback"] },
{ ...document, token_endpoint_auth_method: "client_secret_post" },
{ ...document, client_name: "x".repeat(33000) },
])("rejects mismatched or unsafe metadata", async data => {
await expect(createClientMetadataResolver(async () => response(data))(id)).rejects.toThrow();
});
it("never follows redirects and honors no-store", async () => {
await expect(createClientMetadataResolver(async () => new Response(null, { status: 302, headers: { location: "http://169.254.169.254/" } }))(id)).rejects.toThrow();
const fetch = vi.fn(async () => response(document, { "cache-control": "no-store" }));
const resolve = createClientMetadataResolver(fetch); await resolve(id); await resolve(id);
expect(fetch).toHaveBeenCalledTimes(2);
});
it.each(["https://127.0.0.1/client.json", "https://169.254.169.254/client.json", "https://[::1]/client.json", "http://assistant.example/client.json"])("rejects private or insecure metadata URL %s", async url => {
await expect(createClientMetadataResolver()(url)).rejects.toThrow();
});
});
describe("assistant invitation content", () => {
it("contains no credential and tells a cold client to verify actual access", () => {
const resource = "https://paperclip.example/mcp/paperclip";
const company = { id: "44444444-4444-4444-8444-444444444444", name: "Butter" };
expect(mcpInvitation(resource, company)).toContain("company=" + company.id);
const markdown = mcpSetupMarkdown(resource, company.id);
expect(markdown).toContain("paperclip_connection");
expect(markdown).toContain("restart");
expect(markdown).not.toContain("paperclip_whoami");
expect(markdown).not.toContain("pcmcp_at_");
expect(renderMcpSetup(resource, company.id)).toContain("Instructions for assistants");
expect(renderMcpSetup(resource, company.id)).not.toContain(company.name);
});
});
+408 -6
View File
@@ -2,9 +2,10 @@ import { createHmac, createHash, randomBytes, randomUUID } from "node:crypto";
import express, { type Request } from "express";
import request from "supertest";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { activityLog, mcpEventAdmissions, mcpEventSubscriptions, createDb, authUsers, companies, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db";
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret } from "../services/public-mcp/oauth.js";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { activityLog, mcpEventAdmissions, mcpEventDeliveries, mcpEventSubscriptions, createDb, authUsers, companies, companyLogos, assets, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db";
import { instanceSettingsService } from "../services/instance-settings.js";
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
import { McpApiError, createMcpApiDispatch, createPublicMcpExecutor, publicMcpCapabilities } from "../services/public-mcp/capabilities.js";
import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "../routes/public-mcp.js";
import { authorizationService } from "../services/authorization.js";
@@ -17,22 +18,26 @@ import * as assignmentWakeups from "../services/issue-assignment-wakeup.js";
import { assertCompanyAccess } from "../routes/authz.js";
import { actorMiddleware } from "../middleware/auth.js";
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
import { cloudWarmStandbyMiddleware } from "../middleware/cloud-warm-standby.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createPublicMcpEvents, publicMcpEventDefinitions } from "../services/public-mcp/events.js";
import { eventFetch, signingKey, type EventFetch } from "../services/public-mcp/event-webhooks.js";
const support = await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn(`Public MCP database checks unavailable: ${support.reason}`);
const config = { origin: "https://paperclip.example", resource: "https://paperclip.example/mcp/paperclip" };
const redirectUri = "https://client.example/callback";
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
describe("public MCP configuration", () => {
it("is opt-in and requires an HTTPS origin outside loopback", () => {
it("uses the configured origin independently of the retired environment gate", () => {
expect(publicMcpConfig({})).toBeNull();
expect(() => publicMcpConfig({ PAPERCLIP_PUBLIC_MCP_ENABLED: "true", PAPERCLIP_PUBLIC_URL: "http://example.com" })).toThrow();
expect(publicMcpConfig({ PAPERCLIP_PUBLIC_MCP_ENABLED: "true", PAPERCLIP_PUBLIC_URL: "http://localhost:3100" })?.resource).toBe("http://localhost:3100/mcp/paperclip");
expect(() => publicMcpConfig({ PAPERCLIP_PUBLIC_URL: "http://example.com" })).toThrow();
expect(publicMcpConfig({ PAPERCLIP_PUBLIC_URL: "http://localhost:3100" })?.resource).toBe("http://localhost:3100/mcp/paperclip");
expect(publicMcpConfig({}, "https://paperclip.example")?.origin).toBe("https://paperclip.example");
expect(publicMcpConfig({ PAPERCLIP_PUBLIC_MCP_ENABLED: "true" })).toBeNull();
expect(publicMcpCapabilities.map((c) => c.name)).not.toEqual(expect.arrayContaining(["run_tool", "call_api"]));
});
});
@@ -46,9 +51,50 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
temp = await startEmbeddedPostgresTestDatabase("paperclip-public-mcp-");
db = createDb(temp.connectionString);
oauth = createPublicMcpOAuth(db, config);
expect(await oauth.isEnabled()).toBe(false);
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true });
}, 90000);
afterAll(async () => { await temp?.cleanup(); vi.unstubAllEnvs(); });
it("keeps public MCP and discovery out of SQL and SPA fallback until Cloud claim", async () => {
let standby = true;
const app = express();
const ui = express.Router();
ui.get(/.*/, (_req, res) => res.type("html").send("Paperclip UI"));
app.use(cloudWarmStandbyMiddleware(() => standby, express.Router(), ui));
app.use(publicMcpIngressRoutes(oauth, vi.fn()));
const select = vi.spyOn(db, "select");
try {
for (const path of [
"/mcp/setup", "/mcp/setup.md", "/mcp/paperclip", "/mcp/oauth/authorize",
"/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp/paperclip",
"/.well-known/oauth-authorization-server",
]) {
for (const method of ["get", "head"] as const) {
expect((await request(app)[method](path).set("authorization", "Bearer unclaimed")).status).toBe(503);
}
}
for (const path of ["/mcp/paperclip", "/mcp/oauth/register", "/mcp/oauth/device_authorization", "/mcp/oauth/token", "/mcp/oauth/revoke"]) {
const response = await request(app).post(path).send({});
expect(response.status).toBe(503);
expect(response.body).toEqual({ error: "workspace_unclaimed" });
}
// Consent routes still serve the UI shell without resolving a session.
expect((await request(app).get("/mcp-connect/request")).status).toBe(200);
expect((await request(app).get("/mcp-device")).status).toBe(200);
expect(select).not.toHaveBeenCalled();
standby = false;
const metadata = await request(app).get("/.well-known/oauth-protected-resource/mcp/paperclip");
expect(metadata.status).toBe(200);
expect(metadata.body.resource).toBe(config.resource);
expect(select).toHaveBeenCalled();
expect((await request(app).get("/mcp/setup.md")).status).toBe(200);
expect((await request(app).post("/mcp/paperclip")).status).toBe(401);
} finally {
select.mockRestore();
}
});
async function fixture(role = "member", write = true) {
const userId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Human", email: userId + "@example.com", createdAt: new Date(), updatedAt: new Date() });
@@ -65,6 +111,191 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
return { actor, company: company!, membership: membership!, client, tokens, exchange };
}
async function deviceFixture() {
const f = await fixture();
const client = await oauth.register({ client_name: "Device client", redirect_uris: [], grant_types: [DEVICE_GRANT, "refresh_token"], response_types: [] }, randomUUID());
const codes = await oauth.deviceAuthorize({ client_id: client.client_id, resource: config.resource, scope: "paperclip:read paperclip:write offline_access", company_id: f.company.id }, randomUUID());
const exchange = { grant_type: DEVICE_GRANT, client_id: client.client_id, resource: config.resource, device_code: codes.device_code };
return { ...f, deviceClient: client, codes, deviceExchange: exchange };
}
it("keeps device codes private and atomically binds approval to the human and company", async () => {
const f = await deviceFixture();
const replica = createPublicMcpOAuth(db, config);
const unauthenticated = await replica.describeDevice(f.codes.user_code, { type: "none" });
expect(unauthenticated.companies).toEqual([]);
expect(unauthenticated.requiresSignIn).toBe(true);
const stored = await db.select().from(mcpOauthDeviceRequests).where(eq(mcpOauthDeviceRequests.clientId, f.deviceClient.client_id));
expect(JSON.stringify(stored)).not.toContain(f.codes.device_code);
expect(JSON.stringify(stored)).not.toContain(f.codes.user_code);
await expect(replica.consentDevice(f.codes.user_code, f.actor, { decision: "approve", companyId: randomUUID(), allowWrites: true })).rejects.toMatchObject({ status: 403 });
await replica.consentDevice(f.codes.user_code, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: true });
await expect(replica.token({ ...f.deviceExchange, resource: "https://other.example/mcp" })).rejects.toThrow();
const results = await Promise.allSettled([oauth.token(f.deviceExchange), replica.token(f.deviceExchange)]);
expect(results.filter(r => r.status === "fulfilled")).toHaveLength(1);
const result = results.find(r => r.status === "fulfilled") as PromiseFulfilledResult<Awaited<ReturnType<typeof oauth.token>>>;
const principal = await oauth.authenticate(result.value.access_token);
expect(principal.actor.userId).toBe(f.actor.userId);
expect(principal.grant.companyId).toBe(f.company.id);
await replica.revokeConnection(principal.grant.id, f.actor.userId!);
await expect(oauth.authenticate(result.value.access_token)).rejects.toThrow();
await expect(oauth.token({ grant_type: "refresh_token", client_id: f.deviceClient.client_id, resource: config.resource, refresh_token: result.value.refresh_token })).rejects.toThrow();
});
it("persists polling backoff across replicas and handles denied and expired device requests", async () => {
const f = await deviceFixture();
await expect(oauth.token(f.deviceExchange)).rejects.toMatchObject({ code: "authorization_pending" });
const replica = createPublicMcpOAuth(db, config);
await expect(replica.token(f.deviceExchange)).rejects.toMatchObject({ code: "slow_down" });
const [stored] = await db.select().from(mcpOauthDeviceRequests).where(eq(mcpOauthDeviceRequests.clientId, f.deviceClient.client_id));
expect(stored!.intervalSeconds).toBe(10);
await replica.consentDevice(f.codes.user_code, f.actor, { decision: "deny", allowWrites: false });
await expect(oauth.token(f.deviceExchange)).rejects.toMatchObject({ code: "access_denied" });
await db.update(mcpOauthDeviceRequests).set({ expiresAt: new Date(0) }).where(eq(mcpOauthDeviceRequests.id, stored!.id));
await expect(oauth.token(f.deviceExchange)).rejects.toMatchObject({ code: "expired_token" });
});
it("accepts verified CIMD public clients without dynamic registration", async () => {
const f = await fixture();
const clientId = "https://client.example/public-mcp.json";
const cimd = createPublicMcpOAuth(db, config, { metadataFetch: async () => new Response(JSON.stringify({ client_id: clientId, client_name: "CIMD client", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] }), { headers: { "content-type": "application/json" } }) });
const id = (await cimd.authorize({ client_id: clientId, redirect_uri: redirectUri, response_type: "code", resource: config.resource, code_challenge: challenge, code_challenge_method: "S256" })).split("/").at(-1)!;
expect((await cimd.describeRequest(id, f.actor, null)).clientOrigin).toBe("https://client.example");
const consent = await cimd.consent(id, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: false });
expect(new URL(consent.redirectUrl).searchParams.get("iss")).toBe(config.origin);
const tokens = await cimd.token({ grant_type: "authorization_code", client_id: clientId, redirect_uri: redirectUri, resource: config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: verifier });
expect((await cimd.authenticate(tokens.access_token)).grant.companyId).toBe(f.company.id);
const [registered] = await db.select().from(mcpOauthClients).where(eq(mcpOauthClients.id, clientId));
expect(registered!.grantTypes).toEqual(["authorization_code", "refresh_token"]);
await expect(cimd.token({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", client_id: clientId, resource: config.resource })).rejects.toMatchObject({ code: "unsupported_grant_type" });
});
it("admits CIMD clients atomically under durable source quotas and cleans stale registrations", async () => {
const source = "cimd-quota-source";
const prefix = `https://quota-${randomUUID()}.example/`;
const cimd = createPublicMcpOAuth(db, config, { metadataFetch: async url => new Response(JSON.stringify({ client_id: url.toString(), client_name: "Quota client", redirect_uris: [redirectUri] }), { headers: { "content-type": "application/json" } }) });
const input = { client_id: prefix + "rejected.json", redirect_uri: redirectUri, response_type: "code", resource: "https://wrong.example/mcp", code_challenge: challenge, code_challenge_method: "S256" };
await expect(cimd.authorize(input, source)).rejects.toThrow();
expect(await db.select().from(mcpOauthClients).where(eq(mcpOauthClients.id, input.client_id))).toHaveLength(0);
const old = prefix + "old.json";
await db.insert(mcpOauthClients).values({ id: old, name: "Expired", redirectUris: [redirectUri], createdAt: new Date(Date.now() - 70 * 60_000) });
const ids = Array.from({ length: 7 }, (_, i) => prefix + i + ".json");
try {
for (const client_id of ids.slice(0, 6)) await cimd.authorize({ ...input, client_id, resource: config.resource }, source);
await expect(cimd.authorize({ ...input, client_id: ids[6], resource: config.resource }, source)).rejects.toMatchObject({ status: 429 });
expect(await db.select().from(mcpOauthClients).where(eq(mcpOauthClients.id, ids[6]!))).toHaveLength(0);
expect(await db.select().from(mcpOauthClients).where(eq(mcpOauthClients.id, old))).toHaveLength(0);
} finally {
for (const id of ids) {
await db.delete(mcpOauthRequests).where(eq(mcpOauthRequests.clientId, id));
await db.delete(mcpOauthClients).where(eq(mcpOauthClients.id, id));
}
}
});
describe("CIMD network admission", () => {
beforeEach(async () => { await db.delete(mcpOauthMetadataAdmissions); });
afterEach(async () => { await db.delete(mcpOauthMetadataAdmissions); });
const input = (index: number) => ({ client_id: `https://admission.example/${index}.json`, redirect_uri: redirectUri,
response_type: "code", resource: config.resource, code_challenge: challenge, code_challenge_method: "S256" });
const metadata = (url: URL) => new Response(JSON.stringify({ client_id: url.toString(), client_name: "Admission test",
redirect_uris: [redirectUri], grant_types: ["authorization_code", DEVICE_GRANT] }), { headers: { "content-type": "application/json" } });
it("rejects wrong resources, scopes and disabled connections before fetching", async () => {
const fetcher = vi.fn(async (url: URL) => metadata(url));
const client = createPublicMcpOAuth(db, config, { metadataFetch: fetcher });
for (const override of [{ resource: "https://wrong.example/mcp" }, { scope: "paperclip:read invalid" }]) {
await expect(client.authorize({ ...input(0), ...override })).rejects.toThrow();
await expect(client.deviceAuthorize({ ...input(0), ...override })).rejects.toThrow();
}
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: false });
try {
await expect(client.authorize(input(0))).rejects.toMatchObject({ status: 503 });
await expect(client.deviceAuthorize(input(0))).rejects.toMatchObject({ status: 503 });
} finally { await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true }); }
expect(fetcher).not.toHaveBeenCalled();
expect(await db.select().from(mcpOauthMetadataAdmissions)).toHaveLength(0);
});
it.each(["fetch failure", "invalid metadata", "redirect mismatch"])("retains admission after %s across replicas and both grants", async mode => {
const fetcher = vi.fn(async (url: URL) => {
if (mode === "fetch failure") throw new Error("Remote unavailable");
if (mode === "invalid metadata") return new Response("{}", { headers: { "content-type": "application/json" } });
// Browser rejects the callback; device rejects the unsupported grant.
return new Response(JSON.stringify({ client_id: url.toString(), client_name: "Mismatch", redirect_uris: ["https://other.example/callback"], grant_types: ["authorization_code"] }), { headers: { "content-type": "application/json" } });
});
const clients = [createPublicMcpOAuth(db, config, { metadataFetch: fetcher }), createPublicMcpOAuth(db, config, { metadataFetch: fetcher })];
const outcomes = await Promise.allSettled(Array.from({ length: 12 }, (_, i) =>
i % 2 ? clients[1]!.deviceAuthorize(input(i), "same-source") : clients[0]!.authorize(input(i), "same-source")));
expect(fetcher).toHaveBeenCalledTimes(6);
expect(outcomes.every(result => result.status === "rejected")).toBe(true);
expect(outcomes.filter(result => result.status === "rejected" && result.reason.status === 429)).toHaveLength(6);
const receipts = await db.select().from(mcpOauthMetadataAdmissions);
expect(receipts).toHaveLength(6);
expect(receipts.every(row => row.sourceHash === hashMcpSecret(config.resource + ":same-source"))).toBe(true);
expect(JSON.stringify(receipts)).not.toContain("same-source");
// Expired receipts are pruned on the next attempt, including after restart.
await db.update(mcpOauthMetadataAdmissions).set({ expiresAt: new Date(0) });
const restarted = createPublicMcpOAuth(db, config, { metadataFetch: fetcher });
await expect(restarted.authorize(input(99), "same-source")).rejects.not.toMatchObject({ status: 429 });
expect(fetcher).toHaveBeenCalledTimes(7);
expect(await db.select().from(mcpOauthMetadataAdmissions)).toHaveLength(1);
});
it("reports admission storage failures as retryable and never fetches without admission", async () => {
const fetcher = vi.fn(async (url: URL) => metadata(url));
const client = createPublicMcpOAuth(db, config, { metadataFetch: fetcher });
const storageFailure = vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("Database unavailable"));
try {
await expect(client.authorize(input(0), "storage-failure")).rejects.toMatchObject({ code: "temporarily_unavailable", status: 503 });
expect(fetcher).not.toHaveBeenCalled();
} finally { storageFailure.mockRestore(); }
expect(await db.select().from(mcpOauthMetadataAdmissions)).toHaveLength(0);
await expect(client.authorize(input(0), "storage-failure")).resolves.toContain("/mcp-connect/");
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("lets users behind one proxy reuse verified metadata beyond the network quota", async () => {
const clientId = `https://shared-proxy-${randomUUID()}.example/client.json`;
const fetcher = vi.fn(async (url: URL) => metadata(url));
const clients = [createPublicMcpOAuth(db, config, { metadataFetch: fetcher }), createPublicMcpOAuth(db, config, { metadataFetch: fetcher })];
for (let i = 0; i < 9; i++) {
const request = { ...input(i), client_id: clientId };
await expect(clients[i % 2]!.authorize(request, "shared-proxy")).resolves.toContain("/mcp-connect/");
await expect(clients[i % 2]!.deviceAuthorize(request, "shared-proxy")).resolves.toHaveProperty("user_code");
}
expect(fetcher).toHaveBeenCalledTimes(2);
expect(await db.select().from(mcpOauthMetadataAdmissions)).toHaveLength(2);
});
it("caps unique-source failures globally without blocking registered clients", async () => {
const fetcher = vi.fn(async () => { throw new Error("Remote unavailable"); });
const clients = [createPublicMcpOAuth(db, config, { metadataFetch: fetcher }), createPublicMcpOAuth(db, config, { metadataFetch: fetcher })];
const outcomes = await Promise.allSettled(Array.from({ length: 65 }, (_, i) =>
i % 2 ? clients[1]!.deviceAuthorize(input(i), `source-${i}`) : clients[0]!.authorize(input(i), `source-${i}`)));
expect(fetcher).toHaveBeenCalledTimes(60);
expect(outcomes.filter(result => result.status === "rejected" && result.reason.status === 429)).toHaveLength(5);
expect(await db.select().from(mcpOauthMetadataAdmissions)).toHaveLength(60);
const registered = await oauth.register({ client_name: "Local client", redirect_uris: [redirectUri] }, randomUUID());
await expect(clients[0]!.authorize({ ...input(99), client_id: registered.client_id })).resolves.toContain("/mcp-connect/");
expect(fetcher).toHaveBeenCalledTimes(60);
});
it("commits admission before slow network work without holding the shared lock", async () => {
let release!: () => void;
const pending = new Promise<void>(resolve => { release = resolve; });
const fetcher = vi.fn(async () => { await pending; throw new Error("Remote timeout"); });
const clients = [createPublicMcpOAuth(db, config, { metadataFetch: fetcher }), createPublicMcpOAuth(db, config, { metadataFetch: fetcher })];
const requests = Array.from({ length: 6 }, (_, i) => clients[i % 2]!.authorize(input(i), "slow-source"));
const outcomes = Promise.allSettled(requests);
try {
await expect.poll(() => fetcher.mock.calls.length).toBe(6);
await expect(clients[1]!.deviceAuthorize(input(7), "slow-source")).rejects.toMatchObject({ status: 429 });
expect(await db.select().from(mcpOauthMetadataAdmissions)).toHaveLength(6);
} finally { release(); await outcomes; }
});
});
it("requires exact redirect, resource, S256 PKCE and real browser consent", async () => {
await expect(oauth.register({ client_name: "bad", redirect_uris: ["https://u:p@example.com"] })).rejects.toThrow();
const client = await oauth.register({ client_name: "Client", redirect_uris: [redirectUri] });
@@ -91,6 +322,128 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
expect(JSON.stringify(stored)).not.toContain(f.tokens.access_token);
});
it("accepts CIMD native ephemeral ports but binds redemption to the authorized callback", async () => {
const f = await fixture();
const clientId = "https://native.example/client.json";
const callback = "http://127.0.0.1:55023/callback";
const cimd = createPublicMcpOAuth(db, config, { metadataFetch: async () => new Response(JSON.stringify({
client_id: clientId, client_name: "Native client", application_type: "native", redirect_uris: ["http://127.0.0.1/callback"],
}), { headers: { "content-type": "application/json" } }) });
const request = { client_id: clientId, redirect_uri: callback, response_type: "code", resource: config.resource, code_challenge: challenge, code_challenge_method: "S256" };
await expect(cimd.authorize({ ...request, redirect_uri: "http://127.0.0.1:55023/other" })).rejects.toThrow();
const id = (await cimd.authorize(request)).split("/").at(-1)!;
const consent = await cimd.consent(id, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: false });
expect(new URL(consent.redirectUrl).origin).toBe("http://127.0.0.1:55023");
const exchange = { grant_type: "authorization_code", client_id: clientId, redirect_uri: callback, resource: config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: verifier };
await expect(cimd.token({ ...exchange, redirect_uri: "http://127.0.0.1:55024/callback" })).rejects.toThrow();
expect((await cimd.authenticate((await cimd.token(exchange)).access_token)).grant.companyId).toBe(f.company.id);
});
it("pins consent to the requested organization without exposing other memberships", async () => {
const f = await fixture();
const [other] = await db.insert(companies).values({ name: "Other organization", issuePrefix: "M" + randomBytes(4).toString("hex") }).returning();
await db.insert(companyMemberships).values({ companyId: other!.id, principalType: "user", principalId: f.actor.userId!, membershipRole: "member", status: "active" });
const input = { client_id: f.client.client_id, redirect_uri: redirectUri, resource: config.resource, response_type: "code",
code_challenge: challenge, code_challenge_method: "S256", scope: "paperclip:read paperclip:write", company_id: f.company.id };
const id = (await oauth.authorize(input)).split("/").at(-1)!;
// The binding survives a new service instance, and the description reveals only this membership.
const resumed = createPublicMcpOAuth(db, config);
const [logo] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: randomUUID(), contentType: "image/png", byteSize: 1, sha256: "fixture" }).returning();
await db.insert(companyLogos).values({ companyId: f.company.id, assetId: logo!.id });
expect(await resumed.describeRequest(id, f.actor, null)).toMatchObject({ requestedCompanyId: f.company.id, companies: [{ id: f.company.id, logoUrl: `/api/assets/${logo!.id}/content` }] });
expect((await resumed.describeRequest(id, f.actor, null)).companies).toHaveLength(1);
expect((await resumed.describeRequest(id, { type: "none" }, null)).companies).toEqual([]);
await expect(resumed.consent(id, f.actor, { decision: "approve", companyId: other!.id, allowWrites: true })).rejects.toMatchObject({ status: 403 });
// A concurrent conversation has an independent binding; rejection did not consume either request.
const otherId = (await oauth.authorize({ ...input, company_id: other!.id })).split("/").at(-1)!;
expect((await resumed.describeRequest(otherId, f.actor, null)).companies).toEqual([{ id: other!.id, name: other!.name, logoUrl: null, canWrite: true }]);
const consent = await resumed.consent(id, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: true });
const tokens = await resumed.token({ ...f.exchange, code: new URL(consent.redirectUrl).searchParams.get("code")! });
expect((await resumed.authenticate(tokens.access_token)).grant.companyId).toBe(f.company.id);
await expect(resumed.consent(otherId, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: false })).rejects.toMatchObject({ status: 403 });
await resumed.consent(otherId, f.actor, { decision: "deny", allowWrites: false });
// Direct connections retain explicit choice across the user's companies.
const directId = (await oauth.authorize({ ...input, company_id: undefined })).split("/").at(-1)!;
const direct = await resumed.describeRequest(directId, f.actor, null);
expect(direct.requestedCompanyId).toBeNull();
expect(direct.companies.map(c => c.id).sort()).toEqual([f.company.id, other!.id].sort());
await resumed.consent(directId, f.actor, { decision: "approve", companyId: other!.id, allowWrites: false });
await expect(oauth.authorize({ ...input, company_id: "invalid" })).rejects.toThrow();
});
it("does not fall back to another organization when the requested one is unavailable", async () => {
const f = await fixture();
const begin = async (companyId: string) => (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", company_id: companyId })).split("/").at(-1)!;
const missing = await begin(randomUUID());
expect((await oauth.describeRequest(missing, f.actor, null)).companies).toEqual([]);
await expect(oauth.consent(missing, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: false })).rejects.toMatchObject({ status: 403 });
await oauth.consent(missing, f.actor, { decision: "deny", allowWrites: false });
for (const unavailable of ["membership", "archived"] as const) {
const id = await begin(f.company.id);
if (unavailable === "membership") await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.id, f.membership.id));
else await db.update(companies).set({ status: "archived" }).where(eq(companies.id, f.company.id));
expect((await oauth.describeRequest(id, f.actor, null)).companies).toEqual([]);
await expect(oauth.consent(id, f.actor, { decision: "approve", companyId: f.company.id, allowWrites: false })).rejects.toMatchObject({ status: 403 });
await oauth.consent(id, f.actor, { decision: "deny", allowWrites: false });
await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.id, f.membership.id));
}
});
it("reads the experimental switch live and keeps connection revocation available while disabled", async () => {
const f = await fixture();
const revocable = await fixture();
const settings = instanceSettingsService(db);
const dispatch = vi.fn();
const execute = createPublicMcpExecutor(db, oauth, dispatch);
const app = express(); app.use(express.json());
app.use(publicMcpIngressRoutes(oauth, execute));
app.use((req, _res, next) => { req.actor = f.actor; next(); });
app.use("/api", publicMcpManagementRoutes(oauth));
try {
await settings.updateExperimental({ enablePublicMcp: false });
expect(await createPublicMcpOAuth(db, config).isEnabled()).toBe(false);
for (const path of ["/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource/mcp/paperclip"]) {
expect((await request(app).get(path)).status).toBe(503);
}
expect((await request(app).post("/mcp/paperclip").set("Authorization", `Bearer ${f.tokens.access_token}`).send({})).status).toBe(503);
await expect(oauth.register({ client_name: "disabled", redirect_uris: [redirectUri] })).rejects.toMatchObject({ status: 503 });
await expect(oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: f.tokens.refresh_token })).rejects.toMatchObject({ status: 503 });
await expect(execute(f.tokens.access_token, "paperclip_list_agents", { companyId: f.company.id })).rejects.toMatchObject({ status: 503 });
expect(dispatch).not.toHaveBeenCalled();
const setup = await request(app).get("/api/mcp/setup").set("X-Forwarded-Host", "attacker.example");
expect(setup.status).toBe(200);
expect(setup.headers["cache-control"]).toBe("no-store");
expect(setup.body).toMatchObject({ enabled: false, serverUrl: config.resource });
expect((await request(app).get("/api/mcp/connections")).status).toBe(200);
expect((await request(app).post("/mcp/oauth/revoke").send({ token: revocable.tokens.access_token, client_id: revocable.client.client_id })).status).toBe(200);
} finally { await settings.updateExperimental({ enablePublicMcp: true }); }
expect((await request(app).get("/api/mcp/setup")).body).toMatchObject({ enabled: true, serverUrl: config.resource });
expect((await request(app).get("/.well-known/oauth-authorization-server")).status).toBe(200);
await expect(oauth.authenticate(f.tokens.access_token)).resolves.toMatchObject({ grant: { userId: f.actor.userId } });
await expect(oauth.authenticate(revocable.tokens.access_token)).rejects.toThrow();
});
it("limits assistant setup metadata to signed-in humans", async () => {
const app = express();
let actor: Request["actor"] = { type: "none" };
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", publicMcpManagementRoutes(oauth));
for (const candidate of [
{ type: "none" },
{ type: "agent", agentId: randomUUID(), companyId: randomUUID() },
{ type: "board", source: "local_implicit", userId: "board" },
{ type: "board", source: "mcp_oauth", userId: randomUUID() },
] as Request["actor"][]) {
actor = candidate;
expect((await request(app).get("/api/mcp/setup")).status).toBe(401);
}
for (const source of ["session", "cloud_tenant"] as const) {
actor = { type: "board", source, userId: randomUUID() };
expect((await request(app).get("/api/mcp/setup")).body).toMatchObject({ enabled: true, serverUrl: config.resource });
}
});
it("rotates refresh tokens and persists revocation on replay", async () => {
const f = await fixture();
const refresh = { grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: f.tokens.refresh_token };
@@ -473,6 +826,55 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
await f.service.unsubscribe(f.principal, f.input);
});
it("pauses queued event delivery and new subscriptions while the experimental switch is off", async () => {
const f = await eventFixture();
const settings = instanceSettingsService(db);
await f.service.subscribe(f.principal, f.input);
await f.activity(); f.setStatus(503); await f.service.tick();
const count = f.received.length;
try {
await settings.updateExperimental({ enablePublicMcp: false });
f.advance(10_000); f.setStatus(204);
await f.service.tick();
expect(f.received).toHaveLength(count);
await expect(f.service.subscribe(f.principal, f.input)).rejects.toMatchObject({ status: 503 });
await expect(oauth.authorizeGrant(f.principal.grant.id)).rejects.toMatchObject({ status: 503 });
} finally { await settings.updateExperimental({ enablePublicMcp: true }); }
await f.service.tick();
expect(f.received).toHaveLength(count + 1);
await f.service.unsubscribe(f.principal, f.input);
});
it("preserves the final delivery attempt when access is disabled after the tick begins", async () => {
const f = await eventFixture();
const settings = instanceSettingsService(db);
const subscription = await f.service.subscribe(f.principal, f.input);
await f.activity(); f.setStatus(503); await f.service.tick();
await db.update(mcpEventDeliveries).set({ attempts: 5 }).where(eq(mcpEventDeliveries.subscriptionId, subscription.id));
f.advance(10_000); f.setStatus(204);
const count = f.received.length;
const authorize = oauth.authorizeGrant.bind(oauth);
const gate = vi.spyOn(oauth, "authorizeGrant").mockImplementationOnce(async id => {
await settings.updateExperimental({ enablePublicMcp: false });
return authorize(id);
});
try {
await f.service.tick();
expect(f.received).toHaveLength(count);
const [paused] = await db.select().from(mcpEventDeliveries).where(eq(mcpEventDeliveries.subscriptionId, subscription.id));
expect(paused).toMatchObject({ attempts: 5, finishedAt: null, outcome: "paused" });
} finally {
gate.mockRestore();
await settings.updateExperimental({ enablePublicMcp: true });
}
await f.service.tick();
expect(f.received).toHaveLength(count + 1);
const [delivered] = await db.select().from(mcpEventDeliveries).where(eq(mcpEventDeliveries.subscriptionId, subscription.id));
expect(delivered).toMatchObject({ attempts: 6, outcome: "delivered" });
expect(delivered!.finishedAt).not.toBeNull();
await f.service.unsubscribe(f.principal, f.input);
});
it("reclaims stopped and expired monitors before admitting another subscription", async () => {
const f = await eventFixture();
const makeInput = (index: number) => ({ ...f.input, delivery: { ...f.input.delivery, url: f.input.delivery.url + "/" + index } });
+7 -3
View File
@@ -574,15 +574,17 @@ export async function createApp(
bindHost: opts.bindHost,
}),
);
const mcpConfig = publicMcpConfig();
let mcpConfig: ReturnType<typeof publicMcpConfig> = null;
try { mcpConfig = publicMcpConfig(process.env, opts.authPublicBaseUrl); }
catch { logger.warn("Assistant connections require an HTTPS public URL (HTTP loopback is allowed for development)."); }
const publicMcpOAuth = mcpConfig ? createPublicMcpOAuth(db, mcpConfig) : null;
const publicMcpIngress = Router();
app.use(publicMcpIngress);
app.use(cloudRuntimeIdentityMiddleware(db));
// A signed claim above commits identity before any normal request can seed
// company data. Unclaimed probes bypass session resolution as well as SQL.
app.use(cloudWarmStandbyMiddleware(isWarmStandby, health, staticUi));
app.use(publicMcpIngress);
// Connection-intent tools carry their own short-lived, run-bound bearer and
// must be reachable by remote adapters that intentionally do not receive an
// agent API key. Every request revalidates the active heartbeat row.
@@ -984,7 +986,9 @@ export async function createApp(
let publicMcpEvents: PublicMcpEvents | null = null;
if (publicMcpOAuth) {
const dispatch = createMcpApiDispatch(api);
publicMcpEvents = createPublicMcpEvents(db, publicMcpOAuth, dispatch);
publicMcpEvents = createPublicMcpEvents(db, publicMcpOAuth, dispatch, {
isBackgroundWorkEnabled: () => !isWarmStandby(),
});
publicMcpEvents.start();
publicMcpIngress.use(publicMcpIngressRoutes(publicMcpOAuth, createPublicMcpExecutor(db, publicMcpOAuth, dispatch), publicMcpEvents));
api.use(publicMcpManagementRoutes(publicMcpOAuth));
+3 -1
View File
@@ -21,7 +21,9 @@ export function cloudWarmStandbyMiddleware(
if (!isStandby()) return next();
req.actor = { type: "none", source: "none" };
const unavailable = () => res.status(503).json({ error: "workspace_unclaimed" });
if (/^\/api(?:\/|$)/i.test(req.path) || req.headers.upgrade || (req.method !== "GET" && req.method !== "HEAD")) {
const isProtocolPath = /^\/(?:api|mcp)(?:\/|$)/i.test(req.path)
|| /^\/\.well-known\/oauth-(?:protected-resource|authorization-server)(?:\/|$)/i.test(req.path);
if (isProtocolPath || req.headers.upgrade || (req.method !== "GET" && req.method !== "HEAD")) {
unavailable();
return;
}
+23 -2
View File
@@ -1681,8 +1681,8 @@ function resolveOperationAuthLevel(
path: string,
): OpenApiAuthLevel {
const key = operationKey(method, path);
if (key === "GET /api/mcp/requests/{id}") return "public";
if (path.startsWith("/api/mcp/requests/") || path.startsWith("/api/mcp/connections")) return "board";
if (key === "GET /api/mcp/requests/{id}" || key === "GET /api/mcp/device") return "public";
if (path === "/api/mcp/setup" || path === "/api/mcp/device/consent" || path.startsWith("/api/mcp/requests/") || path.startsWith("/api/mcp/connections")) return "board";
if (PUBLIC_OPERATIONS.has(key)) return "public";
if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run";
if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools";
@@ -11416,6 +11416,16 @@ registerCurrentRoute({
},
});
registerCurrentRoute({
method: "get", path: "/api/mcp/setup", tags: ["tool-gateway"],
summary: "Read assistant connection setup using a human browser session",
// Available while disabled; returns metadata only and never grants access.
responses: {
200: r.ok(z.object({ enabled: z.boolean(), serverUrl: z.string().url(), invitationUrl: z.string().url(), invitation: z.string() })),
401: r.unauthorized, 403: r.forbidden, 404: r.notFound,
},
});
registerCurrentRoute({
method: "get", path: "/api/mcp/requests/{id}", tags: ["tool-gateway"],
summary: "Describe an assistant connection request and available sign-in options",
@@ -11427,6 +11437,17 @@ registerCurrentRoute({
body: mcpConsentSchema,
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registerCurrentRoute({
method: "get", path: "/api/mcp/device", tags: ["tool-gateway"],
summary: "Describe a device approval request without revealing unauthenticated organization data",
responses: { 200: r.ok(), 400: r.badRequest, 404: r.notFound },
});
registerCurrentRoute({
method: "post", path: "/api/mcp/device/consent", tags: ["tool-gateway"],
summary: "Approve or deny a device request using a same-origin human browser session",
body: mcpConsentSchema.extend({ userCode: z.string().min(8).max(12) }),
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registerCurrentRoute({
method: "get", path: "/api/mcp/connections", tags: ["tool-gateway"],
summary: "List the signed-in person's assistant connections",
+43 -7
View File
@@ -5,8 +5,9 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
import { z } from "zod";
import { publicMcpEventDefinitions, type PublicMcpEvents } from "../services/public-mcp/events.js";
import { McpEventError } from "../services/public-mcp/event-webhooks.js";
import { PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, mcpConsentSchema } from "@paperclipai/shared";
import { McpOAuthError, type PublicMcpOAuth } from "../services/public-mcp/oauth.js";
import { PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, mcpConsentSchema, mcpInvitation, mcpSetupUrl } from "@paperclipai/shared";
import { renderMcpSetup, mcpSetupMarkdown } from "../services/public-mcp/setup.js";
import { DEVICE_GRANT, McpOAuthError, type PublicMcpOAuth } from "../services/public-mcp/oauth.js";
import { McpApiError, McpCapabilityError, publicMcpCapabilities, type createPublicMcpExecutor } from "../services/public-mcp/capabilities.js";
const oauthErrors: ErrorRequestHandler = (error, _req, res, next) => {
@@ -40,25 +41,46 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp
const { origin, resource } = oauth.config;
const prefix = origin + "/mcp/oauth";
const resourceMetadata = origin + "/.well-known/oauth-protected-resource" + PUBLIC_MCP_PATH;
router.get(["/mcp/setup", "/mcp/setup.md"], async (req, res) => {
await oauth.assertEnabled();
const company = z.uuid().optional().safeParse(req.query.company);
if (!company.success) throw new McpOAuthError("invalid_request", "Invalid organization hint.");
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'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
res.vary("Accept");
if (req.path.endsWith(".md") || req.accepts(["html", "text/markdown"]) === "text/markdown") {
res.type("text/markdown").send(mcpSetupMarkdown(resource, company.data));
} else res.type("html").send(renderMcpSetup(resource, company.data));
});
router.use(["/mcp/oauth", PUBLIC_MCP_PATH], (_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
res.setHeader("Referrer-Policy", "no-referrer");
next();
});
router.use(["/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server", PUBLIC_MCP_PATH, "/mcp/oauth/register", "/mcp/oauth/authorize", "/mcp/oauth/token"], async (_req, _res, next) => {
await oauth.assertEnabled();
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: PUBLIC_MCP_SCOPES, bearer_methods_supported: ["header"],
resource_name: "Paperclip team",
resource, authorization_servers: [origin], scopes_supported: ["paperclip:read", "paperclip:write"], bearer_methods_supported: ["header"],
resource_name: "Paperclip",
}));
router.get("/.well-known/oauth-authorization-server", (_req, res) => res.json({
issuer: origin, authorization_endpoint: prefix + "/authorize", token_endpoint: prefix + "/token",
registration_endpoint: prefix + "/register", revocation_endpoint: prefix + "/revoke",
response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"],
response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token", DEVICE_GRANT],
device_authorization_endpoint: prefix + "/device_authorization",
token_endpoint_auth_methods_supported: ["none"], revocation_endpoint_auth_methods_supported: ["none"],
code_challenge_methods_supported: ["S256"], scopes_supported: PUBLIC_MCP_SCOPES,
authorization_response_iss_parameter_supported: true,
client_id_metadata_document_supported: true,
}));
router.use("/mcp/oauth", authRateLimit(), express.urlencoded({ extended: false, limit: "16kb" }));
router.post("/mcp/oauth/register", async (req, res) => res.status(201).json(await oauth.register(req.body, req.ip ?? req.socket.remoteAddress ?? "unknown")));
router.get("/mcp/oauth/authorize", async (req, res) => res.redirect(303, await oauth.authorize(req.query)));
router.post("/mcp/oauth/device_authorization", async (req, res) => res.json(await oauth.deviceAuthorize(req.body, req.ip ?? req.socket.remoteAddress ?? "unknown")));
router.get("/mcp/oauth/authorize", async (req, res) => res.redirect(303, await oauth.authorize(req.query, req.ip ?? req.socket.remoteAddress ?? "unknown")));
router.post("/mcp/oauth/token", async (req, res) => res.json(await oauth.token(req.body ?? {})));
router.post("/mcp/oauth/revoke", async (req, res) => {
if (typeof req.body?.token !== "string" || typeof req.body?.client_id !== "string") throw new McpOAuthError("invalid_request", "A token and client_id are required.");
@@ -70,7 +92,7 @@ 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}", error="invalid_token"`);
res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${resourceMetadata}", scope="paperclip:read paperclip:write", error="invalid_token"`);
res.status(401).json({ error: "invalid_token" }); return;
}
if (req.method !== "POST") { res.setHeader("Allow", "POST"); res.status(405).end(); return; }
@@ -166,6 +188,16 @@ export function publicMcpManagementRoutes(oauth: PublicMcpOAuth) {
next();
};
router.use("/mcp", (_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); });
router.get("/mcp/device", authRateLimit(), async (req, res) => {
const code = z.string().regex(/^[A-Za-z0-9 -]{8,12}$/).safeParse(req.query.user_code);
if (!code.success) throw new McpOAuthError("invalid_request", "Enter the code shown by your assistant.");
res.json(await oauth.describeDevice(code.data, req.actor));
});
router.post("/mcp/device/consent", realUser, sameOrigin, authRateLimit(), async (req, res) => {
const parsed = mcpConsentSchema.extend({ userCode: z.string().regex(/^[A-Za-z0-9 -]{8,12}$/) }).safeParse(req.body);
if (!parsed.success) throw new McpOAuthError("invalid_request", "Enter your code and choose the requested access.");
res.json(await oauth.consentDevice(parsed.data.userCode, req.actor, parsed.data));
});
router.get("/mcp/requests/:id", async (req, res) => {
let setupUrl: string | null = null;
if (process.env.PAPERCLIP_CLOUD_API_ORIGIN) {
@@ -179,6 +211,10 @@ export function publicMcpManagementRoutes(oauth: PublicMcpOAuth) {
if (!parsed.success) throw new McpOAuthError("invalid_request", "Choose a company and the requested access.");
res.json(await oauth.consent(String(req.params.id), req.actor, parsed.data));
});
router.get("/mcp/setup", realUser, async (_req, res) => res.json({
enabled: await oauth.isEnabled(), serverUrl: oauth.config.resource,
invitationUrl: mcpSetupUrl(oauth.config.resource), invitation: mcpInvitation(oauth.config.resource),
}));
router.get("/mcp/connections", realUser, async (req, res) => res.json(await oauth.listConnections(req.actor.userId!)));
router.delete("/mcp/connections/:id", realUser, sameOrigin, async (req, res) => {
if (!z.uuid().safeParse(req.params.id).success) throw new McpOAuthError("invalid_request", "Invalid connection ID.");
+2
View File
@@ -233,6 +233,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
// continuing to accept the compatibility key in stored settings.
enableApps: true,
enableMcpAggregators: true,
enablePublicMcp: parsed.data.enablePublicMcp ?? false,
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false,
enablePipelines: parsed.data.enablePipelines ?? false,
@@ -278,6 +279,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableStreamlinedUi: true,
enableApps: true,
enableMcpAggregators: true,
enablePublicMcp: false,
enableChatConnectors: false,
enableMemoryConnectors: false,
enablePipelines: false,
@@ -0,0 +1,83 @@
import { z } from "zod";
import { guardedRemoteHttpFetch } from "../remote-http-fetch.js";
export function validMcpRedirect(value: string) {
try {
const url = new URL(value);
return !url.username && !url.password && !url.hash
&& (url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)));
} catch { return false; }
}
/** RFC 8252: native loopback listeners choose an ephemeral port, not a new callback. */
export function mcpRedirectMatches(registered: string[], requested: string, native: boolean) {
if (registered.includes(requested)) return true;
if (!native || !validMcpRedirect(requested)) return false;
const loopbackAuthority = /^(http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost))(?::[0-9]+)?(?=\/|$)/;
if (!loopbackAuthority.test(requested)) return false;
return registered.some(uri => validMcpRedirect(uri) && loopbackAuthority.test(uri)
&& uri.replace(loopbackAuthority, "$1") === requested.replace(loopbackAuthority, "$1"));
}
const metadataSchema = z.object({
client_id: z.string().max(2048), client_name: z.string().trim().min(1).max(100),
application_type: z.enum(["native", "web"]).optional(),
redirect_uris: z.array(z.string().max(2048).refine(validMcpRedirect)).max(10),
token_endpoint_auth_method: z.literal("none").default("none"),
// CIMD describes a client's capabilities across authorization servers. An
// extra capability (Claude web publishes jwt-bearer) must not disable PKCE.
// Retain only grants this server implements; token dispatch still rejects others.
grant_types: z.array(z.string().min(1).max(2048)).max(20).default(["authorization_code"])
.transform(grants => [...new Set(grants)].filter(grant =>
["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"].includes(grant))),
response_types: z.array(z.literal("code")).default(["code"]),
});
type Metadata = z.infer<typeof metadataSchema>;
export type MetadataFetch = (url: URL, init: RequestInit) => Promise<Response>;
const guardedFetch: MetadataFetch = (url, init) => guardedRemoteHttpFetch(url, init, {
allowPrivateNetwork: false, error: () => new Error("Client metadata URL is unavailable."),
connectTimeoutMs: 5000, responseTimeoutMs: 5000,
});
/** Client names are descriptive, not a verified brand identity. */
export function createClientMetadataResolver(fetcher: MetadataFetch = guardedFetch) {
const cache = new Map<string, { value: Metadata; expiresAt: number }>();
return async (id: string, admitFetch?: () => Promise<void>): Promise<Metadata> => {
const url = new URL(id);
if (url.protocol !== "https:" || url.pathname === "/" || url.username || url.password || url.hash || id.length > 2048) throw new Error("Invalid client metadata URL.");
const now = Date.now();
for (const [key, value] of cache) if (value.expiresAt <= now) cache.delete(key);
const hit = cache.get(id);
if (hit) return hit.value;
// Cached, already-validated metadata performs no outbound work. Charge only
// cache misses so users sharing a proxy can reconnect to known clients.
await admitFetch?.();
const response = await fetcher(url, { redirect: "error", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(5000) });
if (!response.ok || response.status >= 300 || !/^application\/(?:[\w.+-]+\+)?json(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
await response.body?.cancel(); throw new Error("Invalid client metadata response.");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Empty client metadata.");
const chunks: Uint8Array[] = []; let size = 0;
try {
while (true) {
const { done, value } = await reader.read(); if (done) break;
size += value.byteLength;
if (size > 32_768) throw new Error("Client metadata is too large.");
chunks.push(value);
}
} finally { await reader.cancel().catch(() => {}); }
const value = metadataSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
if (value.client_id !== id) throw new Error("Client metadata ID mismatch.");
// Older public clients (including Claude Code) omit the OIDC application_type.
// Infer native only when every declared callback is an HTTP loopback listener.
if (!value.application_type && value.redirect_uris.length > 0 && value.redirect_uris.every(uri => {
const redirect = new URL(uri);
return redirect.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(redirect.hostname);
})) value.application_type = "native";
const control = response.headers.get("cache-control") ?? "";
const maxAge = /(?:^|,)\s*max-age=(\d+)/i.exec(control)?.[1];
const ttl = /(?:no-store|no-cache)/i.test(control) ? 0 : Math.min(300, maxAge ? Number(maxAge) : 60) * 1000;
if (cache.size >= 1000) cache.delete(cache.keys().next().value!);
if (ttl > 0) cache.set(id, { value, expiresAt: now + ttl });
return value;
};
}
+11 -3
View File
@@ -7,7 +7,7 @@ import { localEncryptedProvider } from "../../secrets/local-encrypted-provider.j
import { logActivity } from "../activity-log.js";
import { logger } from "../../middleware/logger.js";
import { type ApiDispatch } from "./capabilities.js";
import { type McpPrincipal, type PublicMcpOAuth } from "./oauth.js";
import { PublicMcpDisabledError, type McpPrincipal, type PublicMcpOAuth } from "./oauth.js";
import { boundedJson, callbackUrl, eventFetch, McpEventError, postEvent, signingKey, verifyCallback, type EventFetch } from "./event-webhooks.js";
const names = ["paperclip.task.status_changed", "paperclip.task.comment_created", "paperclip.task.document_updated"] as const;
@@ -38,7 +38,7 @@ export const publicMcpEventDefinitions = names.map((name, index) => ({
}).strict()),
}));
export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDispatch, options: { fetch?: EventFetch; now?: () => number; cloudOrigin?: string } = {}) {
export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDispatch, options: { fetch?: EventFetch; now?: () => number; cloudOrigin?: string; isBackgroundWorkEnabled?: () => boolean } = {}) {
const fetcher = options.fetch ?? eventFetch;
const now = options.now ?? Date.now;
const cloudOrigin = options.cloudOrigin ?? process.env.PAPERCLIP_CLOUD_API_ORIGIN;
@@ -75,6 +75,7 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
}
async function subscribe(principal: McpPrincipal, raw: unknown, cloud?: CloudEventAuthority) {
await oauth.assertEnabled();
const requestedAt = new Date(now());
const input = subscribeSchema.parse(raw);
validate(input);
@@ -227,7 +228,12 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
await authorize(principal, filters.parse(s.arguments));
destination = await decrypt(s);
await authorizeCloud(principal, destination.cloud);
} catch {
} catch (error) {
if (error instanceof PublicMcpDisabledError) {
// A live disable pauses the claimed delivery without spending a retry.
await db.update(deliveries).set({ attempts: claim.attempts - 1, nextAttemptAt: new Date(now()), outcome: "paused" }).where(eq(deliveries.id, claim.id));
return false;
}
// Fail closed for this delivery, but transient authority failures must be
// recoverable. Retry without emitting application data, within the same bound.
if (claim.attempts >= 6) await finish("authority_unavailable");
@@ -251,6 +257,8 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
}
let running: Promise<void> | null = null;
const tick = () => running ?? (running = (async () => {
// The experimental setting is stored in SQL; check warm standby first.
if (options.isBackgroundWorkEnabled?.() === false || !await oauth.isEnabled()) return;
await db.delete(admissions).where(lte(admissions.expiresAt, new Date(now())));
await db.delete(subscriptions).where(lt(subscriptions.expiresAt, new Date(now() - 7 * 24 * hour)));
const active = await db.select().from(subscriptions).where(and(isNull(subscriptions.stoppedAt), gt(subscriptions.expiresAt, new Date(now())))).orderBy(asc(subscriptions.scannedAt)).limit(20);
+224 -64
View File
@@ -1,13 +1,15 @@
import { instanceSettingsService } from "../instance-settings.js";
import { createHash, randomBytes } from "node:crypto";
import { and, eq, gt, inArray, isNull, lt, notExists, sql } from "drizzle-orm";
import { and, eq, gt, inArray, isNull, lt, lte, notExists, sql } from "drizzle-orm";
import { z } from "zod";
import type { Request } from "express";
import {
type Db, activityLog, companies, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens,
type Db, activityLog, companies, companyLogos, mcpOauthClients, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens, mcpOauthDeviceRequests, mcpOauthMetadataAdmissions,
} from "@paperclipai/db";
import { PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, type McpConnectionRequest } from "@paperclipai/shared";
import { boardAuthService } from "../board-auth.js";
import { logActivity } from "../activity-log.js";
import { createClientMetadataResolver, mcpRedirectMatches, validMcpRedirect as validRedirect, type MetadataFetch } from "./client-metadata.js";
const minute = 60_000;
const accessLifetime = 15 * minute;
@@ -18,12 +20,15 @@ const secret = (prefix: string) => prefix + randomBytes(32).toString("base64url"
export class McpOAuthError extends Error {
constructor(readonly code: string, message: string, readonly status = 400) { super(message); }
}
export class PublicMcpDisabledError extends McpOAuthError {
constructor() { super("temporarily_unavailable", "Assistant connections are disabled. Enable them in Settings > Experimental.", 503); }
}
const invalidGrant = () => new McpOAuthError("invalid_grant", "Authorization is expired, revoked, or invalid.");
export function publicMcpConfig(env: NodeJS.ProcessEnv = process.env) {
if (env.PAPERCLIP_PUBLIC_MCP_ENABLED !== "true") return null;
if (!env.PAPERCLIP_PUBLIC_URL) throw new Error("PAPERCLIP_PUBLIC_URL is required when public MCP is enabled.");
const url = new URL(env.PAPERCLIP_PUBLIC_URL);
export function publicMcpConfig(env: NodeJS.ProcessEnv = process.env, authPublicBaseUrl?: string) {
const origin = env.PAPERCLIP_PUBLIC_URL ?? authPublicBaseUrl;
if (!origin) return null;
const url = new URL(origin);
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
|| url.username || url.password || url.search || url.hash || url.pathname !== "/") {
@@ -38,25 +43,17 @@ export type McpPrincipal = {
company: { id: string; name: string; issuePrefix: string; status: string };
};
function validRedirect(value: string) {
try {
const url = new URL(value);
return !url.username && !url.password && !url.hash
&& (url.protocol === "https:" || (url.protocol === "http:"
&& ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)));
} catch { return false; }
}
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
const registrationSchema = z.object({
client_name: z.string().trim().min(1).max(100),
redirect_uris: z.array(z.string().max(2048).refine(validRedirect)).min(1).max(10),
redirect_uris: z.array(z.string().max(2048).refine(validRedirect)).max(10).default([]),
token_endpoint_auth_method: z.literal("none").default("none"),
grant_types: z.array(z.enum(["authorization_code", "refresh_token"])).default(["authorization_code", "refresh_token"]),
grant_types: z.array(z.enum(["authorization_code", "refresh_token", DEVICE_GRANT])).default(["authorization_code", "refresh_token"]),
response_types: z.array(z.literal("code")).default(["code"]),
}).strip();
const authorizeSchema = z.object({
client_id: z.string().min(1).max(200),
client_id: z.string().min(1).max(2048),
redirect_uri: z.string().max(2048),
response_type: z.literal("code"),
resource: z.string().max(2048),
@@ -64,9 +61,16 @@ const authorizeSchema = z.object({
state: z.string().max(2048).optional(),
code_challenge: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
code_challenge_method: z.literal("S256"),
company_id: z.string().uuid().optional(),
}).strip();
export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
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 boardAuth = boardAuthService(db);
async function actorForGrant(grant: typeof mcpOauthGrants.$inferSelect): Promise<Request["actor"]> {
@@ -96,47 +100,208 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
};
}
async function admitMetadataFetch(source: string) {
const sourceHash = hashMcpSecret(config.resource + ":" + source);
// Commit admission before any remote work. Failed metadata/redirect checks
// must not roll this back; a unique client URL must not reset the quota.
await db.transaction(async tx => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(736721044)`);
await tx.delete(mcpOauthMetadataAdmissions).where(lte(mcpOauthMetadataAdmissions.expiresAt, sql`clock_timestamp()`));
const [counts] = await tx.select({ total: sql<number>`count(*)::int`,
source: sql<number>`(count(*) FILTER (WHERE ${mcpOauthMetadataAdmissions.sourceHash} = ${sourceHash}))::int`,
}).from(mcpOauthMetadataAdmissions);
if (!counts || counts.total >= 60 || counts.source >= 6) {
throw new McpOAuthError("temporarily_unavailable", "Client metadata verification capacity reached. Retry later.", 429);
}
await tx.insert(mcpOauthMetadataAdmissions).values({ sourceHash, expiresAt: sql`clock_timestamp() + interval '1 minute'` });
}).catch(error => {
if (error instanceof McpOAuthError) throw error;
throw new McpOAuthError("temporarily_unavailable", "Client metadata verification is temporarily unavailable. Retry later.", 503);
});
}
async function resolveClient(id: string, source: string) {
if (!id.startsWith("https://")) {
const [client] = await db.select().from(mcpOauthClients).where(eq(mcpOauthClients.id, id));
return client ? { ...client, native: false } : undefined;
}
let metadata;
try { metadata = await resolveMetadata(id, () => admitMetadataFetch(source)); }
catch (error) {
if (error instanceof McpOAuthError) throw error;
throw new McpOAuthError("invalid_client_metadata", "Could not verify the client's public metadata.");
}
return { id, name: metadata.client_name, redirectUris: metadata.redirect_uris,
grantTypes: metadata.grant_types, native: metadata.application_type === "native" };
}
async function retainMetadataClient(tx: Db, client: NonNullable<Awaited<ReturnType<typeof resolveClient>>>, source: string) {
if (!client.id.startsWith("https://")) return;
const sourceHash = hashMcpSecret(config.resource + ":" + source);
// Caller holds the shared registration lock and has admitted this request.
await pruneClients(tx);
const [existing] = await tx.select().from(mcpOauthClients).where(eq(mcpOauthClients.id, client.id));
if (!existing) await admitClient(tx, sourceHash);
const value = { name: client.name, redirectUris: client.redirectUris, grantTypes: client.grantTypes };
await tx.insert(mcpOauthClients).values({ id: client.id, registrationSourceHash: sourceHash, ...value })
.onConflictDoUpdate({ target: mcpOauthClients.id, set: value });
}
async function pruneClients(tx: Db) {
const now = new Date();
await tx.delete(mcpOauthRequests).where(lt(mcpOauthRequests.expiresAt, now));
await tx.delete(mcpOauthDeviceRequests).where(lt(mcpOauthDeviceRequests.expiresAt, now));
await tx.delete(mcpOauthClients).where(and(
lt(mcpOauthClients.createdAt, new Date(now.getTime() - 60 * minute)),
notExists(tx.select({ id: mcpOauthGrants.id }).from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId, mcpOauthClients.id))),
notExists(tx.select({ id: mcpOauthRequests.id }).from(mcpOauthRequests).where(eq(mcpOauthRequests.clientId, mcpOauthClients.id))),
notExists(tx.select({ id: mcpOauthDeviceRequests.id }).from(mcpOauthDeviceRequests).where(eq(mcpOauthDeviceRequests.clientId, mcpOauthClients.id))),
));
}
async function admitClient(tx: Db, sourceHash: string) {
const [counts] = await tx.select({ total: sql<number>`count(*)::int`,
sourceTotal: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.registrationSourceHash} = ${sourceHash}))::int`,
sourceRecent: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.registrationSourceHash} = ${sourceHash} AND ${mcpOauthClients.createdAt} > ${new Date(Date.now() - minute).toISOString()}::timestamptz))::int`,
recent: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.createdAt} > ${new Date(Date.now() - minute).toISOString()}::timestamptz))::int`,
}).from(mcpOauthClients).where(notExists(tx.select({ id: mcpOauthGrants.id }).from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId, mcpOauthClients.id))));
if (!counts || counts.total >= 10_000 || counts.recent >= 60 || counts.sourceTotal >= 30 || counts.sourceRecent >= 6) {
throw new McpOAuthError("temporarily_unavailable", "Registration capacity reached. Retry later.", 429);
}
}
async function consentContext(actor: Request["actor"], requestedCompanyId: string | null) {
const signedIn = actor.type === "board" && !!actor.userId && ["session", "cloud_tenant"].includes(actor.source ?? "");
const access = signedIn ? await boardAuth.resolveBoardAccess(actor.userId!) : null;
const available = access?.user && access.companyIds.length ? await db.select({ id: companies.id, name: companies.name, status: companies.status, logoAssetId: companyLogos.assetId })
.from(companies).leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)).where(inArray(companies.id, access.companyIds)) : [];
return { requiresSignIn: !access?.user, companies: available.flatMap((company) => {
if (requestedCompanyId && company.id !== requestedCompanyId) return [];
const membership = access?.memberships.find((m) => m.companyId === company.id);
return membership?.status === "active" && company.status !== "archived"
? [{ id: company.id, name: company.name, logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null, canWrite: membership.membershipRole !== "viewer" }] : [];
}) };
}
async function approveGrant(tx: Db, actor: Request["actor"], request: { requestedCompanyId: string | null; clientId: string; resource: string; scopes: string[] }, input: { companyId?: string; allowWrites: 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 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);
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 } });
return grant!;
}
function deviceHash(code: string) { return hashMcpSecret(code.toUpperCase().replaceAll("-", "").replaceAll(" ", "")); }
async function deviceToken(input: Record<string, unknown>, clientId: string) {
if (typeof input.device_code !== "string") throw invalidGrant();
const outcome = await db.transaction(async (tx) => {
const [row] = await tx.select().from(mcpOauthDeviceRequests).where(eq(mcpOauthDeviceRequests.deviceCodeHash, hashMcpSecret(input.device_code as string))).for("update");
if (!row || row.clientId !== clientId || row.resource !== config.resource || row.status === "consumed") return { error: "invalid_grant" };
const now = new Date();
if (row.expiresAt <= now) return { error: "expired_token" };
if (row.status === "denied") return { error: "access_denied" };
if (row.nextPollAt > now) {
const intervalSeconds = row.intervalSeconds + 5;
await tx.update(mcpOauthDeviceRequests).set({ intervalSeconds, nextPollAt: new Date(now.getTime() + intervalSeconds * 1000) }).where(eq(mcpOauthDeviceRequests.id, row.id));
return { error: "slow_down" };
}
await tx.update(mcpOauthDeviceRequests).set({ nextPollAt: new Date(now.getTime() + row.intervalSeconds * 1000) }).where(eq(mcpOauthDeviceRequests.id, row.id));
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 tx.update(mcpOauthDeviceRequests).set({ status: "consumed" }).where(eq(mcpOauthDeviceRequests.id, row.id));
return { tokens: await issueTokens(tx as unknown as Db, grant) };
});
if (outcome.error) throw new McpOAuthError(outcome.error, "Device authorization is pending, denied, expired, or polling too quickly. Follow the returned OAuth error.");
return outcome.tokens!;
}
return {
config,
config, isEnabled, assertEnabled,
async deviceAuthorize(input: unknown, source = "unknown") {
await assertEnabled();
const p = z.object({ client_id: z.string().min(1).max(2048), resource: z.literal(config.resource), scope: z.string().max(200).default("paperclip:read"), company_id: z.uuid().optional() }).safeParse(input);
if (!p.success) throw new McpOAuthError("invalid_request", "Supply a registered client and the exact Paperclip resource.");
const scopes = [...new Set(p.data.scope.split(/\s+/).filter(Boolean))];
if (!scopes.includes("paperclip:read") || scopes.some(s => !(PUBLIC_MCP_SCOPES as readonly string[]).includes(s))) throw new McpOAuthError("invalid_scope", "Unsupported Paperclip scope.");
const client = await resolveClient(p.data.client_id, source);
if (!client?.grantTypes.includes(DEVICE_GRANT)) throw new McpOAuthError("unauthorized_client", "Register a device authorization client.");
const deviceCode = secret("pcmcp_device_");
const alphabet = "BCDFGHJKLMNPQRSTVWXYZ23456789";
const chars = Array.from(randomBytes(8), byte => alphabet[byte % alphabet.length]).join("");
const userCode = chars.slice(0, 4) + "-" + chars.slice(4);
const sourceHash = hashMcpSecret(config.resource + ":" + source);
await db.transaction(async tx => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(736721042)`);
await tx.execute(sql`SELECT pg_advisory_xact_lock(736721043)`);
const now = new Date();
await tx.delete(mcpOauthDeviceRequests).where(lt(mcpOauthDeviceRequests.expiresAt, now));
const [count] = await tx.select({ total: sql<number>`count(*)::int`, source: sql<number>`(count(*) FILTER (WHERE ${mcpOauthDeviceRequests.sourceHash} = ${sourceHash}))::int` }).from(mcpOauthDeviceRequests);
if (!count || count.total >= 1000 || count.source >= 10) throw new McpOAuthError("temporarily_unavailable", "Too many device connection attempts. Try again later.", 429);
await retainMetadataClient(tx as unknown as Db, client, source);
await tx.insert(mcpOauthDeviceRequests).values({ clientId: client.id, deviceCodeHash: hashMcpSecret(deviceCode), userCodeHash: deviceHash(userCode),
resource: config.resource, scopes, requestedCompanyId: p.data.company_id ?? null, sourceHash, expiresAt: new Date(now.getTime() + 10 * minute) });
});
return { device_code: deviceCode, user_code: userCode, verification_uri: config.origin + "/mcp-device", verification_uri_complete: config.origin + "/mcp-device?user_code=" + userCode, expires_in: 600, interval: 5 };
},
async describeDevice(userCode: string, actor: Request["actor"]): Promise<McpConnectionRequest> {
await assertEnabled();
const [row] = await db.select({ request: mcpOauthDeviceRequests, client: mcpOauthClients }).from(mcpOauthDeviceRequests)
.innerJoin(mcpOauthClients, eq(mcpOauthClients.id, mcpOauthDeviceRequests.clientId))
.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,
setupUrl: null, ...await consentContext(actor, row.request.requestedCompanyId) };
},
async consentDevice(userCode: string, actor: Request["actor"], input: { decision: "approve" | "deny"; companyId?: string; allowWrites: 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 => {
const [row] = await tx.select().from(mcpOauthDeviceRequests).where(eq(mcpOauthDeviceRequests.userCodeHash, deviceHash(userCode))).for("update");
if (!row || row.status !== "pending" || row.expiresAt <= new Date()) throw invalidGrant();
const grant = input.decision === "approve" ? await approveGrant(tx as unknown as Db, actor, row, input) : null;
await tx.update(mcpOauthDeviceRequests).set({ status: grant ? "approved" : "denied", grantId: grant?.id ?? null }).where(eq(mcpOauthDeviceRequests.id, row.id));
return { status: grant ? "approved" as const : "denied" as const };
});
},
async register(input: unknown, source = "unknown") {
await assertEnabled();
const parsed = registrationSchema.safeParse(input);
if (!parsed.success) throw new McpOAuthError("invalid_client_metadata", "Supply a client name, valid redirect URIs, and public-client PKCE authentication.");
const client = { id: secret("pcmcp_client_"), name: parsed.data.client_name, registrationSourceHash: hashMcpSecret(config.resource + ":" + source), redirectUris: parsed.data.redirect_uris };
if (parsed.data.grant_types.includes("authorization_code") && parsed.data.redirect_uris.length === 0) throw new McpOAuthError("invalid_client_metadata", "Authorization code clients need a redirect URI.");
const client = { id: secret("pcmcp_client_"), name: parsed.data.client_name, registrationSourceHash: hashMcpSecret(config.resource + ":" + source), redirectUris: parsed.data.redirect_uris, grantTypes: parsed.data.grant_types };
await db.transaction(async (tx) => {
// Bound public DCR across replicas, not only per-IP in each process.
await tx.execute(sql`SELECT pg_advisory_xact_lock(736721042)`);
const now = new Date();
await tx.delete(mcpOauthRequests).where(lt(mcpOauthRequests.expiresAt, now));
await tx.delete(mcpOauthClients).where(and(
lt(mcpOauthClients.createdAt, new Date(now.getTime() - 60 * minute)),
notExists(tx.select({ id: mcpOauthGrants.id }).from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId, mcpOauthClients.id))),
notExists(tx.select({ id: mcpOauthRequests.id }).from(mcpOauthRequests).where(eq(mcpOauthRequests.clientId, mcpOauthClients.id))),
));
const [counts] = await tx.select({ total: sql<number>`count(*)::int`,
sourceTotal: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.registrationSourceHash} = ${client.registrationSourceHash}))::int`,
sourceRecent: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.registrationSourceHash} = ${client.registrationSourceHash} AND ${mcpOauthClients.createdAt} > ${new Date(now.getTime() - minute).toISOString()}::timestamptz))::int`,
recent: sql<number>`(count(*) FILTER (WHERE ${mcpOauthClients.createdAt} > ${new Date(now.getTime() - minute).toISOString()}::timestamptz))::int`,
}).from(mcpOauthClients).where(notExists(tx.select({ id: mcpOauthGrants.id }).from(mcpOauthGrants).where(eq(mcpOauthGrants.clientId, mcpOauthClients.id))));
if (!counts || counts.total >= 10_000 || counts.recent >= 60 || counts.sourceTotal >= 30 || counts.sourceRecent >= 6) {
throw new McpOAuthError("temporarily_unavailable", "Registration capacity reached. Retry later.", 429);
}
await pruneClients(tx as unknown as Db);
await admitClient(tx as unknown as Db, client.registrationSourceHash);
await tx.insert(mcpOauthClients).values(client);
});
return { ...parsed.data, client_id: client.id, client_id_issued_at: Math.floor(Date.now() / 1000) };
},
async authorize(input: unknown) {
async authorize(input: unknown, source = "unknown") {
await assertEnabled();
const parsed = authorizeSchema.safeParse(input);
if (!parsed.success) throw new McpOAuthError("invalid_request", "A registered client, exact redirect URI, resource, and S256 PKCE challenge are required.");
const p = parsed.data;
const [client] = await db.select().from(mcpOauthClients).where(eq(mcpOauthClients.id, p.client_id));
if (!client || !client.redirectUris.includes(p.redirect_uri)) {
throw new McpOAuthError("invalid_request", "Unknown client or redirect URI.");
}
if (p.resource !== config.resource) throw new McpOAuthError("invalid_target", "Resource does not match this Paperclip MCP endpoint.");
const scopes = [...new Set(p.scope.split(/\s+/).filter(Boolean))];
if (!scopes.includes("paperclip:read") || scopes.some((s) => !(PUBLIC_MCP_SCOPES as readonly string[]).includes(s))) {
throw new McpOAuthError("invalid_scope", "Unsupported Paperclip scope.");
}
const client = await resolveClient(p.client_id, source);
if (!client || !client.grantTypes.includes("authorization_code") || !mcpRedirectMatches(client.redirectUris, p.redirect_uri, client.native)) {
throw new McpOAuthError("invalid_request", "Unknown client or redirect URI.");
}
const id = secret("pcmcp_request_");
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(736721042)`);
@@ -148,33 +313,31 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
if (!counts || counts.total >= 1000 || counts.client >= 10) {
throw new McpOAuthError("temporarily_unavailable", "Too many pending connection requests. Retry later.", 429);
}
await retainMetadataClient(tx as unknown as Db, client, source);
await tx.insert(mcpOauthRequests).values({
id, clientId: client.id, redirectUri: p.redirect_uri, resource: p.resource, scopes,
requestedCompanyId: p.company_id ?? null,
state: p.state ?? null, challenge: p.code_challenge, expiresAt: new Date(now.getTime() + 10 * minute),
});
});
return config.origin + "/mcp-connect/" + id;
},
async describeRequest(id: string, actor: Request["actor"], setupUrl: string | null): Promise<McpConnectionRequest> {
await assertEnabled();
const [row] = await db.select({ request: mcpOauthRequests, client: mcpOauthClients })
.from(mcpOauthRequests).innerJoin(mcpOauthClients, eq(mcpOauthClients.id, mcpOauthRequests.clientId))
.where(and(eq(mcpOauthRequests.id, id), isNull(mcpOauthRequests.decidedAt), gt(mcpOauthRequests.expiresAt, new Date())));
if (!row) throw new McpOAuthError("invalid_request", "Connection request is expired or already decided.", 404);
const signedIn = actor.type === "board" && !!actor.userId && ["session", "cloud_tenant"].includes(actor.source ?? "");
const access = signedIn ? await boardAuth.resolveBoardAccess(actor.userId!) : null;
const available = access?.user && access.companyIds.length ? await db.select({ id: companies.id, name: companies.name, status: companies.status })
.from(companies).where(inArray(companies.id, access.companyIds)) : [];
return {
id, clientName: row.client.name, redirectOrigin: new URL(row.request.redirectUri).origin,
requestedWrite: row.request.scopes.includes("paperclip:write"), offlineAccess: row.request.scopes.includes("offline_access"), requiresSignIn: !access?.user,
companies: available.flatMap((company) => {
const membership = access?.memberships.find((m) => m.companyId === company.id);
return membership?.status === "active" && company.status !== "archived"
? [{ id: company.id, name: company.name, canWrite: membership.membershipRole !== "viewer" }] : [];
}), setupUrl,
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,
...await consentContext(actor, row.request.requestedCompanyId), setupUrl,
};
},
async consent(id: string, actor: Request["actor"], input: { decision: "approve" | "deny"; companyId?: string; allowWrites: 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);
}
@@ -187,36 +350,31 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
const [row] = await tx.select().from(mcpOauthRequests).where(eq(mcpOauthRequests.id, id)).for("update");
if (!row || row.decidedAt || row.expiresAt <= new Date()) throw invalidGrant();
const redirect = new URL(row.redirectUri);
redirect.searchParams.set("iss", config.origin);
if (row.state !== null) redirect.searchParams.set("state", row.state);
if (input.decision === "deny") {
await tx.update(mcpOauthRequests).set({ decidedAt: new Date() }).where(eq(mcpOauthRequests.id, id));
redirect.searchParams.set("error", "access_denied");
return { redirectUrl: redirect.toString(), grant: null };
}
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 company is no longer available.", 403);
if (input.allowWrites && membership?.membershipRole === "viewer") throw new McpOAuthError("access_denied", "Viewer access is read-only.", 403);
const scopes = row.scopes.filter((s) => s !== "paperclip:write" || input.allowWrites);
const [grant] = await tx.insert(mcpOauthGrants).values({
companyId: input.companyId!, userId: actor.userId!, clientId: row.clientId, resource: row.resource, scopes,
}).returning();
if (row.requestedCompanyId && input.companyId !== row.requestedCompanyId) {
throw new McpOAuthError("access_denied", "This request is for a different organization. Start a new connection to change organizations.", 403);
}
const grant = await approveGrant(tx as unknown as Db, actor, row, input);
const code = secret("pcmcp_code_");
await tx.update(mcpOauthRequests).set({
grantId: grant!.id, codeHash: hashMcpSecret(code), decidedAt: new Date(), expiresAt: new Date(Date.now() + minute),
}).where(eq(mcpOauthRequests.id, id));
redirect.searchParams.set("code", code);
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 },
});
return { redirectUrl: redirect.toString(), grant };
});
return { redirectUrl: result.redirectUrl };
},
async token(input: Record<string, unknown>) {
await assertEnabled();
const clientId = typeof input.client_id === "string" ? input.client_id : "";
if (!clientId || input.resource !== config.resource) throw new McpOAuthError("invalid_target", "Client and matching resource are required.");
if (input.grant_type === DEVICE_GRANT) return deviceToken(input, clientId);
if (input.grant_type === "authorization_code") {
if (typeof input.code !== "string" || typeof input.code_verifier !== "string"
|| !/^[A-Za-z0-9._~-]{43,128}$/.test(input.code_verifier)) throw invalidGrant();
@@ -263,9 +421,10 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
if (!result) throw invalidGrant();
return result;
}
throw new McpOAuthError("unsupported_grant_type", "Use authorization_code or refresh_token.");
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 } })
.from(mcpOauthGrants).innerJoin(companies, eq(mcpOauthGrants.companyId, companies.id))
.where(eq(mcpOauthGrants.id, grantId));
@@ -273,6 +432,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
return { ...row, actor: await actorForGrant(row.grant) };
},
async authenticate(token: string): Promise<McpPrincipal> {
await assertEnabled();
const [row] = await db.select({ token: mcpOauthTokens, grant: mcpOauthGrants, company: { id: companies.id, name: companies.name, issuePrefix: companies.issuePrefix, status: companies.status } })
.from(mcpOauthTokens).innerJoin(mcpOauthGrants, eq(mcpOauthTokens.grantId, mcpOauthGrants.id))
.innerJoin(companies, eq(companies.id, mcpOauthGrants.companyId))
+21
View File
@@ -0,0 +1,21 @@
import { assistantClientNames, mcpAuthorizationHandoffInstructions, mcpSetupMarkdown, mcpSetupSteps, type AssistantClient } from "@paperclipai/shared";
const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
/** No organization lookup: a public hint must not disclose private company data. */
export function renderMcpSetup(serverUrl: string, companyId?: string) {
const markdownUrl = new URL("/mcp/setup.md", serverUrl);
if (companyId) markdownUrl.searchParams.set("company", companyId);
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect your assistant to Paperclip</title></head><body><main>
<h1>Connect your assistant to Paperclip</h1>
<p>Follow the setup instructions for your assistant, then approve its access in Paperclip.</p>
<p>This link gives instructions, not access. Your assistant connects as you, not as a Paperclip agent.</p>
<p><a href="${escapeHtml(markdownUrl.toString())}">Instructions for assistants (Markdown)</a></p>
<p>${escapeHtml(mcpAuthorizationHandoffInstructions)}</p>
<p>MCP server: <code>${escapeHtml(serverUrl)}</code></p>
${companyId ? `<p>Requested organization ID: <code>${escapeHtml(companyId)}</code>. Verify this organization after approval.</p>` : ""}
${Object.entries(assistantClientNames).map(([id, name]) => `<section><h2>${escapeHtml(name)}</h2><ol>${mcpSetupSteps(serverUrl, id as AssistantClient).map(step => `<li><p>${escapeHtml(step.text)}</p>${step.code ? `<pre><code>${escapeHtml(step.code)}</code></pre>` : ""}</li>`).join("")}</ol></section>`).join("")}
<p>Keep existing client configuration. If a Paperclip entry points to a different instance, use a distinct server name. Verify the connected person and organization before doing work.</p>
</main></body></html>`;
}
export { mcpSetupMarkdown };