From 7a5f597e84941f21d1bd6fa576a2a986bcdfc129 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 09:32:06 -0500 Subject: [PATCH] feat(mcp): enable assistant connections through experimental settings Co-Authored-By: Paperclip --- ...-09-30-paperclip-public-mcp-and-plugins.md | 6 +- doc/public-mcp.md | 14 ++++- packages/shared/src/feature-catalog.ts | 7 +++ packages/shared/src/types/instance.ts | 2 + packages/shared/src/validators/instance.ts | 1 + .../instance-settings-service.test.ts | 1 + server/src/__tests__/public-mcp.test.ts | 59 ++++++++++++++++++- server/src/app.ts | 4 +- server/src/routes/public-mcp.ts | 4 ++ server/src/services/instance-settings.ts | 2 + server/src/services/public-mcp/events.ts | 2 + server/src/services/public-mcp/oauth.ts | 23 ++++++-- tests/runner-e2e/public-mcp-flow.ts | 1 + tests/runner-e2e/server.ts | 1 - .../InstanceExperimentalSettings.test.tsx | 14 +++++ ui/src/pages/InstanceExperimentalSettings.tsx | 12 ++++ 16 files changed, 140 insertions(+), 13 deletions(-) diff --git a/doc/plans/2026-09-30-paperclip-public-mcp-and-plugins.md b/doc/plans/2026-09-30-paperclip-public-mcp-and-plugins.md index 9a1a0cdbf6e..c351b377f8a 100644 --- a/doc/plans/2026-09-30-paperclip-public-mcp-and-plugins.md +++ b/doc/plans/2026-09-30-paperclip-public-mcp-and-plugins.md @@ -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. diff --git a/doc/public-mcp.md b/doc/public-mcp.md index d5c618a046d..25bf6f00a6f 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -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 diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 9660136a2e1..2cd27bf5e0f 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -108,6 +108,13 @@ export const INSTANCE_FEATURE_CATALOG: Record { enableApps: true, enableMcpAggregators: true, enableAgentChat: false, + enablePublicMcp: false, enableChatConnectors: false, enableMemoryConnectors: false, enableConferenceRoomChat: false, diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index b0ade0752fb..a6e84167495 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -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 } }); diff --git a/server/src/app.ts b/server/src/app.ts index 4e01262fa93..84d7a7e8917 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -561,7 +561,9 @@ export async function createApp( bindHost: opts.bindHost, }), ); - const mcpConfig = publicMcpConfig(); + let mcpConfig: ReturnType = 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); diff --git a/server/src/routes/public-mcp.ts b/server/src/routes/public-mcp.ts index 03f356ec552..8f8a6dde015 100644 --- a/server/src/routes/public-mcp.ts +++ b/server/src/routes/public-mcp.ts @@ -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", diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 2e7d555f0c2..2512d26da22 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -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, diff --git a/server/src/services/public-mcp/events.ts b/server/src/services/public-mcp/events.ts index 04ac1b08150..105faaaec83 100644 --- a/server/src/services/public-mcp/events.ts +++ b/server/src/services/public-mcp/events.ts @@ -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 | 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); diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index 07f6acf2abd..e64926a0e0e 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -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 { @@ -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 { + 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) { + 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 { + 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 { + 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)) diff --git a/tests/runner-e2e/public-mcp-flow.ts b/tests/runner-e2e/public-mcp-flow.ts index 421aba7cfeb..0e80ff86e03 100644 --- a/tests/runner-e2e/public-mcp-flow.ts +++ b/tests/runner-e2e/public-mcp-flow.ts @@ -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; } diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index 233c1761e22..3652bd2677c 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -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", diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 8e7d7d9654a..9e5c0b22c02 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -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(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"]'; diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 8fc3a2c6410..2f46cefeade 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -305,6 +305,18 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle agent chat experimental setting" /> + toggleMutation.mutate({ enablePublicMcp: checked })} + disabled={toggleMutation.isPending} + settingKey="enablePublicMcp" + managed={managedKeys.enablePublicMcp} + ariaLabel="Toggle assistant connections experimental setting" + /> +