mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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:
1 parent
24cf959005
commit
024de57978
9 files changed
+133
-88
No files matched your search
@@ -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,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,
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user