feat(mcp): simplify branded organization consent

Show the Paperclip icon and the organization's saved logo or standard fallback. Default requested write access on for eligible roles, keep opt-out and role restrictions, align the checkbox, and remove redundant consent copy and navigation. Update the walkthrough and cover default access, opt-out, role changes, and logo scoping.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-03 06:50:24 -05:00
1 parent 24cf959005
commit 024de57978
9 files changed
+133 -88

No files matched your search

+5 -3
View File
@@ -3,7 +3,7 @@ import express, { type Request } from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { activityLog, mcpEventDeliveries, mcpEventSubscriptions, createDb, authUsers, companies, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db";
import { activityLog, mcpEventDeliveries, mcpEventSubscriptions, createDb, authUsers, companies, companyLogos, assets, companyMemberships, mcpOauthTokens, mcpOauthRequests, mcpOauthGrants, mcpOauthClients, mcpMutationReceipts, agents, issues, issueComments, instanceUserRoles } from "@paperclipai/db";
import { instanceSettingsService } from "../services/instance-settings.js";
import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret } from "../services/public-mcp/oauth.js";
import { McpApiError, createMcpApiDispatch, createPublicMcpExecutor, publicMcpCapabilities } from "../services/public-mcp/capabilities.js";
@@ -105,13 +105,15 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
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);
expect(await resumed.describeRequest(id, f.actor, null)).toMatchObject({ requestedCompanyId: f.company.id, companies: [{ id: f.company.id }] });
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.map(c => c.id)).toEqual([other!.id]);
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);
+4 -4
View File
@@ -4,7 +4,7 @@ import { and, eq, gt, inArray, isNull, lt, 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,
} from "@paperclipai/db";
import { PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES, type McpConnectionRequest } from "@paperclipai/shared";
import { boardAuthService } from "../board-auth.js";
@@ -176,8 +176,8 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
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)) : [];
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 {
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,
@@ -186,7 +186,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
if (row.request.requestedCompanyId && company.id !== row.request.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, canWrite: membership.membershipRole !== "viewer" }] : [];
? [{ id: company.id, name: company.name, logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null, canWrite: membership.membershipRole !== "viewer" }] : [];
}), setupUrl,
};
},