mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
feat(mcp): enable assistant connections through experimental settings
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6aa0962d4f
commit
7a5f597e84
16 files changed
+140
-13
No files matched your search
@@ -356,7 +356,7 @@ Current local qualification:
|
||||
|
||||
This is a usable opt-in first release for existing teams. Applying Core updates
|
||||
adds migrations 0294, 0295 and 0296 plus disabled MCP code. An operator must enable
|
||||
`PAPERCLIP_PUBLIC_MCP_ENABLED`, configure `PAPERCLIP_PUBLIC_URL`, and expose the
|
||||
**Settings → Experimental → Assistant connections (MCP)**, configure the public URL, and expose the
|
||||
authenticated instance over HTTPS. A team member can then connect Codex or Claude
|
||||
Code directly, consent to a company, review work, delegate tasks as themselves,
|
||||
and retrieve results later. [The runbook](../public-mcp.md) includes commands.
|
||||
@@ -381,3 +381,7 @@ Subscriptions and pending deliveries persist in PostgreSQL. Callback ownership v
|
||||
Add restart, retry, isolation, revocation, callback verification and protocol compatibility tests. Extend the existing paid evaluation infrastructure with event-driven follow-up across the qualified models, retaining source/cost/evidence. A real ChatGPT Work Cloud subscription and plugin rescan against staging remain rollout acceptance gates until exercised. Claude and older clients retain tool-based follow-up.
|
||||
|
||||
Implementation and paid verification are complete for this addition: the three-model event-follow-up selection passed, followed by a Mini regression on the final quota/status changes. All evidence validates. The updated [results record](2026-10-01-public-mcp-paid-eval-results.md#mcp-events-qualification) retains the source fingerprints, setup failures, costs and remaining client rollout gates. Core persists finite subscriptions and signed deliveries; Cloud forwards MCP 2.0 metadata and preserves current hosted authority. Deployment and actual ChatGPT Work Cloud acceptance remain separate steps.
|
||||
|
||||
## Experimental setting (2026-10-02)
|
||||
|
||||
Replace the instance environment enable flag with the persisted `enablePublicMcp` experimental setting, off by default. Administrators toggle Assistant connections (MCP) without restarting. Recheck the setting for OAuth, tools, subscriptions and webhook delivery, while retaining connection revocation when disabled. Keep canonical public URL and the shared Cloud broker deployment configuration separate from tenant consent. Update direct-client setup and paid fixtures to use the same settings API as the UI.
|
||||
+12
-2
@@ -9,13 +9,23 @@ separate. The accepted roadmap is in
|
||||
|
||||
## Enable an instance
|
||||
|
||||
Apply database migrations with the normal instance upgrade workflow. Set:
|
||||
Apply database migrations with the normal instance upgrade workflow. In Paperclip,
|
||||
open **Settings → Experimental → Assistant connections (MCP)** and turn it on.
|
||||
Instance administrators can change this setting. It is off by default and takes
|
||||
effect immediately without restarting the server.
|
||||
|
||||
Use the instance's configured authentication public URL, or explicitly set:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_PUBLIC_MCP_ENABLED=true
|
||||
PAPERCLIP_PUBLIC_URL=https://YOUR-PAPERCLIP-HOST
|
||||
```
|
||||
|
||||
The retired `PAPERCLIP_PUBLIC_MCP_ENABLED` variable has no effect. The persisted
|
||||
`enablePublicMcp` setting controls discovery, sign-in, tools and event delivery.
|
||||
Turning it off blocks new calls and deliveries; already delegated work continues.
|
||||
Connection management and revocation remain available. Turning it back on allows
|
||||
unexpired connections and subscriptions to resume.
|
||||
|
||||
The URL must be an origin without a path, credentials, query or fragment. HTTP
|
||||
is allowed only for localhost/loopback development. The feature is disabled by
|
||||
default. Use authenticated deployment with real user accounts and company
|
||||
|
||||
@@ -108,6 +108,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: true,
|
||||
selfHostedDefault: true,
|
||||
},
|
||||
enablePublicMcp: {
|
||||
title: "Assistant connections (MCP)",
|
||||
description: "Let external assistants connect as a person to review work, delegate tasks, add feedback, and follow results.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableChatConnectors: {
|
||||
title: "Chat connectors",
|
||||
description:
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface InstanceExperimentalSettings {
|
||||
enableApps: boolean;
|
||||
/** Exposes chat connector setup and Board surfaces; existing delivery continues when hidden. */
|
||||
enableChatConnectors: boolean;
|
||||
/** Allow person-authorized assistant tools and task event delivery. */
|
||||
enablePublicMcp: boolean;
|
||||
/** @deprecated Compatibility key only. MCP aggregators are always enabled. */
|
||||
enableMcpAggregators: boolean;
|
||||
/** Show experimental memory connection setup. Existing connections remain usable. */
|
||||
|
||||
@@ -50,6 +50,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
||||
// always enabled; this remains accepted so older stored rows and managed
|
||||
// configs continue to load during upgrades.
|
||||
enableApps: z.boolean().default(true),
|
||||
enablePublicMcp: z.boolean().default(false),
|
||||
enableChatConnectors: z.boolean().default(false),
|
||||
// Compatibility only: old stored and managed values must still parse.
|
||||
enableMcpAggregators: z.boolean().default(true),
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("instance settings service", () => {
|
||||
enableApps: true,
|
||||
enableMcpAggregators: true,
|
||||
enableAgentChat: false,
|
||||
enablePublicMcp: false,
|
||||
enableChatConnectors: false,
|
||||
enableMemoryConnectors: false,
|
||||
enableConferenceRoomChat: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, mcpEventSubscriptions, createDb, authUsers, companies, 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";
|
||||
import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "../routes/public-mcp.js";
|
||||
@@ -29,10 +30,12 @@ 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,6 +49,8 @@ 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(); });
|
||||
|
||||
@@ -91,6 +96,35 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
expect(JSON.stringify(stored)).not.toContain(f.tokens.access_token);
|
||||
});
|
||||
|
||||
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();
|
||||
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("/.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("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 };
|
||||
@@ -456,6 +490,25 @@ 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("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 } });
|
||||
|
||||
+3
-1
@@ -561,7 +561,9 @@ 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);
|
||||
|
||||
@@ -45,6 +45,10 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp
|
||||
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",
|
||||
|
||||
@@ -232,6 +232,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,
|
||||
@@ -275,6 +276,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
enableStreamlinedUi: true,
|
||||
enableApps: true,
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: false,
|
||||
enableChatConnectors: false,
|
||||
enableMemoryConnectors: false,
|
||||
enablePipelines: false,
|
||||
|
||||
@@ -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);
|
||||
@@ -207,6 +208,7 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis
|
||||
}
|
||||
let running: Promise<void> | null = null;
|
||||
const tick = () => running ?? (running = (async () => {
|
||||
if (!await oauth.isEnabled()) return;
|
||||
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);
|
||||
for (const s of active) await enqueue(s);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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 { z } from "zod";
|
||||
@@ -20,10 +21,10 @@ export class McpOAuthError extends Error {
|
||||
}
|
||||
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 !== "/") {
|
||||
@@ -67,6 +68,11 @@ const authorizeSchema = z.object({
|
||||
}).strip();
|
||||
|
||||
export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
|
||||
const settings = instanceSettingsService(db);
|
||||
const isEnabled = async () => (await settings.getExperimental()).enablePublicMcp === true;
|
||||
async function assertEnabled() {
|
||||
if (!await isEnabled()) throw new McpOAuthError("temporarily_unavailable", "Assistant connections are disabled. Enable them in Settings > Experimental.", 503);
|
||||
}
|
||||
const boardAuth = boardAuthService(db);
|
||||
|
||||
async function actorForGrant(grant: typeof mcpOauthGrants.$inferSelect): Promise<Request["actor"]> {
|
||||
@@ -97,8 +103,9 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
config, isEnabled, assertEnabled,
|
||||
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 };
|
||||
@@ -125,6 +132,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
|
||||
return { ...parsed.data, client_id: client.id, client_id_issued_at: Math.floor(Date.now() / 1000) };
|
||||
},
|
||||
async authorize(input: 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;
|
||||
@@ -156,6 +164,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
|
||||
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())));
|
||||
@@ -175,6 +184,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
|
||||
};
|
||||
},
|
||||
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);
|
||||
}
|
||||
@@ -215,6 +225,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
|
||||
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 === "authorization_code") {
|
||||
@@ -266,6 +277,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig) {
|
||||
throw new McpOAuthError("unsupported_grant_type", "Use authorization_code or refresh_token.");
|
||||
},
|
||||
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 +285,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))
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function establishPublicMcpSession(api: RunnerApi, page: Page, secr
|
||||
api.setBrowserSession(cookies.join("; "));
|
||||
await page.context().addCookies(cookies.map(cookie => ({ name: cookie.slice(0, cookie.indexOf("=")), value: cookie.slice(cookie.indexOf("=") + 1), url: api.baseURL, httpOnly: true, sameSite: "Lax" })));
|
||||
await api.post("/api/bootstrap/claim");
|
||||
await api.patch("/api/instance/settings/experimental", { enablePublicMcp: true });
|
||||
return user.user.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ const serverEnvironment = buildPaperclipServerEnvironment(process.env, {
|
||||
PAPERCLIP_BIND_HOST: "127.0.0.1",
|
||||
PAPERCLIP_DEPLOYMENT_MODE: process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP === "1" ? "authenticated" : "local_trusted",
|
||||
...(process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP === "1" ? {
|
||||
PAPERCLIP_PUBLIC_MCP_ENABLED: "true",
|
||||
PAPERCLIP_PUBLIC_URL: `http://127.0.0.1:${port}`,
|
||||
PAPERCLIP_AUTH_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
PAPERCLIP_AUTH_BASE_URL_MODE: "explicit",
|
||||
|
||||
@@ -79,6 +79,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
enableStreamlinedUi: true,
|
||||
enableApps: true,
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: false,
|
||||
enableChatConnectors: false,
|
||||
enableMemoryConnectors: false,
|
||||
enablePipelines: false,
|
||||
@@ -231,6 +232,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
||||
expect(container.textContent).not.toContain("MCP aggregators");
|
||||
});
|
||||
|
||||
it("defaults assistant connections off and persists an explicit toggle in both directions", async () => {
|
||||
await renderPage();
|
||||
const selector = 'button[aria-label="Toggle assistant connections experimental setting"]';
|
||||
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(container.textContent).toContain("work already delegated continues");
|
||||
for (const enabled of [true, false]) {
|
||||
await act(() => container.querySelector<HTMLButtonElement>(selector)!.click());
|
||||
await flushReact();
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enablePublicMcp: enabled });
|
||||
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe(String(enabled));
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults chat connectors off and persists an explicit toggle in both directions", async () => {
|
||||
await renderPage();
|
||||
const selector = 'button[aria-label="Toggle chat connectors experimental setting"]';
|
||||
|
||||
@@ -305,6 +305,18 @@ export function InstanceExperimentalSettings() {
|
||||
ariaLabel="Toggle agent chat experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Assistant connections (MCP)"
|
||||
description="Connect Codex, Claude, and other assistants to your Paperclip team. People sign in, choose a team, and approve access to review work, delegate tasks, and add feedback."
|
||||
footnote="Requires an authenticated instance with a configured public URL. Takes effect immediately. Turning this off blocks assistant calls and event delivery; work already delegated continues."
|
||||
checked={experimentalQuery.data?.enablePublicMcp === true}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enablePublicMcp: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enablePublicMcp"
|
||||
managed={managedKeys.enablePublicMcp}
|
||||
ariaLabel="Toggle assistant connections experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Beta skills"
|
||||
description="Allow agents to pin beta releases of the Paperclip core skill. Disabling this returns every agent to the default live skill without removing saved pins."
|
||||
|
||||
Reference in new issue
Block a user