diff --git a/apps/mesh/e2e/pages/settings-connections.ts b/apps/mesh/e2e/pages/settings-connections.ts index 63df41ff0d..71472db81e 100644 --- a/apps/mesh/e2e/pages/settings-connections.ts +++ b/apps/mesh/e2e/pages/settings-connections.ts @@ -39,4 +39,25 @@ export class SettingsConnectionsPage { .last() .click(); } + + /** Click an access tab by its label (the button text also carries a count badge). */ + async clickTab(label: "All" | "Shared" | "Personal"): Promise { + await this.page + .getByRole("button", { name: new RegExp(`^${label}\\b`) }) + .click(); + } + + /** Assert a connection card with the given title is visible. */ + async expectConnectionVisible(title: string): Promise { + await expect( + this.page.getByRole("heading", { name: title, exact: true }), + ).toBeVisible(); + } + + /** Assert no connection card with the given title is present. */ + async expectConnectionHidden(title: string): Promise { + await expect( + this.page.getByRole("heading", { name: title, exact: true }), + ).toHaveCount(0); + } } diff --git a/apps/mesh/e2e/tests/connect-card.spec.ts b/apps/mesh/e2e/tests/connect-card.spec.ts new file mode 100644 index 0000000000..995f5ce2bb --- /dev/null +++ b/apps/mesh/e2e/tests/connect-card.spec.ts @@ -0,0 +1,350 @@ +/** + * E2E: just-in-time connection gate — the PARENT-agent connect-card path. + * + * When a Virtual MCP agent declares a typed slot (an `app_id` requirement) + * that the invoking user has no matching connection for, the run can't + * assemble its tools. The decopilot harness throws `SlotUnresolvedError` + * inside `assembleDecopilotTools` (apps/mesh/src/harnesses/decopilot/index.ts) + * — deterministically, BEFORE any LLM call — and emits a well-formed + * UI-message-stream envelope: `start`, a short terminal text part, a + * `data-connect-required` chunk (rendered as the ConnectCard in chat), and a + * `finish` chunk. Server-side, `resolveThreadStatus` + * (apps/mesh/src/api/routes/decopilot/status.ts) maps a response carrying a + * `data-connect-required` part to thread status `requires_action` (NOT + * `failed`). + * + * Why this is deterministic under a REAL LLM e2e: the parent gate fires during + * tool assembly, before the model runs, so the card does not depend on the + * model producing anything. We use a unique synthetic slot app_id + * (`e2e-missing-`) for which no connection exists and which cannot fall + * back to an org-shared one, so `resolveSlot` returns null every time. + * + * What this asserts (API/stream level — no browser/LLM dependency): + * 1. The run's SSE stream contains a `data-connect-required` part whose + * `data.appIds` includes the synthetic slot app_id and whose + * `data.agentTitle` equals the agent title. + * 2. The stream also contains a `finish` chunk — the regression guard for + * the critical bug where the parent path emitted no `finish` and the + * client hung in "streaming" forever. + * 3. After the run, the thread status is `requires_action` (NOT `failed`) — + * the regression guard for the false-failure bug. + * + * The subagent / parallel-subtask scenarios are intentionally NOT exercised + * here: they require the real LLM to choose the `subtask` tool, which is + * non-deterministic. That boundary already has unit coverage in + * apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.test.ts. + */ + +import type { APIRequestContext, Page } from "@playwright/test"; +import { expect, test } from "../fixtures/test"; +import { callSelfMcpTool } from "../fixtures/mcp-tools"; + +const BASE_URL = `http://localhost:${process.env.PORT ?? "3000"}`; + +interface VirtualMcpItem { + id: string; + title: string; + slots: Array<{ slot_app_id: string }>; +} + +/** Resolve the authed user's org id (the slug→id the fixture doesn't carry). */ +async function resolveOrgId( + api: APIRequestContext, + orgSlug: string, +): Promise { + const res = await api.get("/api/auth/organization/list"); + if (!res.ok()) { + throw new Error(`organization/list → HTTP ${res.status()}`); + } + const body = (await res.json()) as + | Array<{ id: string; slug: string }> + | { data?: Array<{ id: string; slug: string }> }; + const orgs = Array.isArray(body) ? body : (body.data ?? []); + const org = orgs.find((o) => o.slug === orgSlug); + if (!org?.id) { + throw new Error(`No org id found for slug ${orgSlug}`); + } + return org.id; +} + +/** + * Make decopilot's per-request model resolution deterministic and + * network-free. `resolveTier(ctx, "smart")` takes a fast path when the org has + * an explicit `simple_mode.tiers.smart` slot pointing at an existing key — it + * returns that credentialId/modelId without ever calling the provider's + * `listModels` (which would hit the network). A dummy key is fine: the parent + * connect-gate throws during tool assembly, long before any real model call. + * + * The credential's providerId ("openrouter") maps to the "decopilot" harness + * (resolveHarnessId), so the run stays on the decopilot path where the gate + * lives — we deliberately do NOT pass harnessId: "claude-code" (a different + * harness that bypasses the gate). + */ +async function seedSmartTier( + api: APIRequestContext, + orgSlug: string, + orgId: string, +): Promise { + const key = await callSelfMcpTool<{ id: string }>( + api, + orgSlug, + "AI_PROVIDER_KEY_CREATE", + { + providerId: "openrouter", + label: `connect-card-e2e-${Date.now()}`, + apiKey: "sk-or-e2e-dummy-key", + }, + ); + await callSelfMcpTool(api, orgSlug, "ORGANIZATION_SETTINGS_UPDATE", { + organizationId: orgId, + simple_mode: { + tiers: { + fast: null, + smart: { + keyId: key.id, + modelId: "anthropic/claude-sonnet-4.6", + title: "Smart (e2e)", + }, + thinking: null, + image: null, + web_research: null, + }, + }, + }); +} + +/** Create an agent with one unresolved typed slot; assert the slot persisted. */ +async function createAgentWithUnresolvedSlot( + api: APIRequestContext, + orgSlug: string, + slotAppId: string, +): Promise<{ agentId: string; agentTitle: string }> { + const agentTitle = `Connect Card E2E Agent ${Date.now()}`; + const created = await callSelfMcpTool<{ item: VirtualMcpItem }>( + api, + orgSlug, + "COLLECTION_VIRTUAL_MCP_CREATE", + { + data: { + title: agentTitle, + connections: [], + status: "active", + pinned: false, + slots: [{ slot_app_id: slotAppId }], + }, + }, + ); + const agentId = created.item.id; + + // Read the agent back so the test fails loudly if the slot-create shape was + // wrong (e.g. a future schema change drops slots silently). + const fetched = await callSelfMcpTool<{ item: VirtualMcpItem }>( + api, + orgSlug, + "COLLECTION_VIRTUAL_MCP_GET", + { id: agentId }, + ); + expect(fetched.item.slots.map((s) => s.slot_app_id)).toContain(slotAppId); + + return { agentId, agentTitle: created.item.title }; +} + +/** Pull the cookie header off the Playwright context for raw streaming fetch. */ +async function cookieHeader(page: Page): Promise { + const cookies = await page.context().cookies(BASE_URL); + return cookies.map((c) => `${c.name}=${c.value}`).join("; "); +} + +interface StreamCapture { + raw: string; + parts: Array<{ type: string; data?: Record }>; +} + +/** + * Tail the per-thread `/stream` SSE endpoint until a `finish` chunk arrives + * (or the timeout fires), collecting every parsed AI-SDK chunk. + * + * The endpoint uses `deliverPolicy: "new"` for an idle thread and purges the + * JetStream buffer on terminal events, so we MUST be subscribed before the run + * pumps its chunks. Callers start this (without awaiting) BEFORE POSTing the + * message and await it after. + * + * Wire format is the AI SDK's JSON-to-SSE transform: each chunk is a + * `data: {json}\n\n` event; `: keepalive\n\n` comment lines are interleaved by + * the server's keepalive wrapper and ignored here. + */ +async function tailStreamUntilFinish( + page: Page, + orgSlug: string, + threadId: string, + timeoutMs = 20_000, +): Promise { + const cookie = await cookieHeader(page); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + const parts: StreamCapture["parts"] = []; + let raw = ""; + + try { + const res = await fetch( + `${BASE_URL}/api/${orgSlug}/decopilot/threads/${threadId}/stream`, + { + headers: { cookie, accept: "text/event-stream" }, + signal: controller.signal, + }, + ); + // 204 = no JetStream tail available (NATS not wired). Surface it rather + // than reading a null body — the caller decides how to fail. + if (res.status === 204) { + throw new Error("/stream → HTTP 204 (no JetStream tail available)"); + } + if (!res.ok || !res.body) { + throw new Error(`/stream → HTTP ${res.status} (body=${!!res.body})`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let sawFinish = false; + + while (!sawFinish) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + raw += text; + buffer += text; + + // SSE events are separated by a blank line (\n\n). + let sep: number; + while ((sep = buffer.indexOf("\n\n")) !== -1) { + const event = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + for (const line of event.split("\n")) { + if (!line.startsWith("data:")) continue; // skip ": keepalive" comments + const payload = line.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + try { + const chunk = JSON.parse(payload) as { + type?: string; + data?: Record; + }; + if (typeof chunk.type === "string") { + parts.push({ type: chunk.type, data: chunk.data }); + if (chunk.type === "finish") sawFinish = true; + } + } catch { + // Partial / non-JSON line — ignore; the next read completes it. + } + } + } + } + reader.releaseLock(); + } catch (err) { + // The timeout aborts `reader.read()` with an AbortError. That's an + // expected end-of-wait, not a test failure here — return whatever we + // captured and let the assertions report the precise gap (with `raw`). + if (!(err instanceof Error && err.name === "AbortError")) { + throw err; + } + } finally { + clearTimeout(timer); + controller.abort(); + } + + return { raw, parts }; +} + +test("parent agent with an unresolved slot emits a connect-required card + finish, and the thread requires_action (never fails)", async ({ + authedPage, +}) => { + const { page, orgSlug } = authedPage; + const api = page.context().request; + + const orgId = await resolveOrgId(api, orgSlug); + await seedSmartTier(api, orgSlug, orgId); + + // Synthetic, never-connected app_id → resolveSlot returns null every time. + const slotAppId = `e2e-missing-${Date.now()}`; + const { agentId, agentTitle } = await createAgentWithUnresolvedSlot( + api, + orgSlug, + slotAppId, + ); + + const thread = await callSelfMcpTool<{ item: { id: string } }>( + api, + orgSlug, + "COLLECTION_THREADS_CREATE", + { data: { virtual_mcp_id: agentId, title: "Connect Card E2E Thread" } }, + ); + const threadId = thread.item.id; + + // Subscribe to the stream BEFORE posting so deliverPolicy:"new" catches the + // run's chunks live (the buffer is purged on terminal events). + const streamPromise = tailStreamUntilFinish(page, orgSlug, threadId); + + // POST the user message. The decopilot run is enqueued and returns 202; the + // gate fires asynchronously during dispatch and pumps the card chunks to the + // per-thread JetStream subject that the tail above is reading. + const post = await api.post( + `/api/${orgSlug}/decopilot/threads/${threadId}/messages`, + { + data: { + messages: [{ role: "user", parts: [{ type: "text", text: "hi" }] }], + agent: { id: agentId }, + branch: "ephemeral", + // Pin the cluster sandbox so dispatch-target resolution stays + // in-cluster (loopback) and never needs an online user-desktop link + // daemon (which this env / CI has none of). Without pinning, the + // default can resolve to "user-desktop" and 409. "cluster" and + // "user-desktop" are the only valid kinds (local-docker was removed in + // the local-docker-sandbox drop). The gate under test lives in the + // decopilot harness; we intentionally do NOT set harnessId (it derives + // "decopilot" from the openrouter credential), so the parent + // connect-gate path still runs. + sandboxProviderKind: "cluster", + }, + headers: { "content-type": "application/json" }, + }, + ); + if (post.status() !== 202) { + // Surface the server's error body to make a tier/config regression obvious + // instead of failing later on an empty stream. + const body = await post.text().catch(() => ""); + throw new Error( + `POST /messages expected 202, got ${post.status()}: ${body}`, + ); + } + + const capture = await streamPromise; + + // 1. The connect-required card part carries the missing app id + agent title. + const connect = capture.parts.find((p) => p.type === "data-connect-required"); + expect( + connect, + `expected a data-connect-required part; got types: ${capture.parts + .map((p) => p.type) + .join(", ")} | raw: ${capture.raw.slice(0, 2000)}`, + ).toBeTruthy(); + expect(connect?.data?.appIds).toContain(slotAppId); + expect(connect?.data?.agentTitle).toBe(agentTitle); + + // 2. The stream emitted a `finish` chunk (no-finish bug regression guard). + expect(capture.parts.some((p) => p.type === "finish")).toBe(true); + + // 3. The thread resolves to requires_action — NOT failed (false-failure + // regression guard). The reactor persists this on the FINISH event, so + // poll until the terminal status lands. + await expect + .poll( + async () => { + const got = await callSelfMcpTool<{ + item: { status: string } | null; + }>(api, orgSlug, "COLLECTION_THREADS_GET", { id: threadId }); + return got.item?.status ?? null; + }, + { timeout: 15_000, intervals: [250, 500, 1000] }, + ) + .toBe("requires_action"); +}); diff --git a/apps/mesh/e2e/tests/connections-access-tabs.spec.ts b/apps/mesh/e2e/tests/connections-access-tabs.spec.ts new file mode 100644 index 0000000000..94899805c9 --- /dev/null +++ b/apps/mesh/e2e/tests/connections-access-tabs.spec.ts @@ -0,0 +1,38 @@ +import { signUp } from "../fixtures/auth"; +import { SettingsConnectionsPage } from "../pages/settings-connections"; +import { + extractOrgSlugFromUrl, + test, + waitForPostSignupRedirect, +} from "../fixtures/test"; + +test.describe("Connections access tabs", () => { + test("a new connection appears under Personal but not Shared", async ({ + page, + }) => { + await signUp(page); + await waitForPostSignupRedirect(page); + const orgSlug = extractOrgSlugFromUrl(page); + + const connections = new SettingsConnectionsPage(page); + await connections.goto(orgSlug); + + // Create a custom HTTP connection — defaults to access "user" (Personal). + await connections.openCreateDialog(); + await connections.fillHttpConnection({ + name: "Personal MCP", + url: "https://personal.example.com/mcp", + }); + await connections.submit(); + await page.waitForURL(/\/settings\/connections\/.+/, { timeout: 10_000 }); + + // Back to the list and check the tabs. + await connections.goto(orgSlug); + + await connections.clickTab("Personal"); + await connections.expectConnectionVisible("Personal MCP"); + + await connections.clickTab("Shared"); + await connections.expectConnectionHidden("Personal MCP"); + }); +}); diff --git a/apps/mesh/migrations/097-connection-access-and-slots.integration.test.ts b/apps/mesh/migrations/097-connection-access-and-slots.integration.test.ts new file mode 100644 index 0000000000..9a96a3f2e5 --- /dev/null +++ b/apps/mesh/migrations/097-connection-access-and-slots.integration.test.ts @@ -0,0 +1,259 @@ +/** + * Integration test for migration 097: connection access + agent slots. + * + * Runs against the real-Postgres harness, where the schema is already + * fully migrated (097 applied) before tests start. We therefore assert the + * *resulting* schema behavior directly: + * 1. New inserts that omit access get the post-migration default 'user'. + * 2. CHECK constraint rejects access values other than 'user' / 'org'. + * 3. Partial unique index R4: one user-private connection per (org, user, app_id). + * 4. connection_aggregations XOR: a row carries a concrete child OR a slot, + * never both/neither. + * 5. Partial unique index for slots: one slot per (agent, app_id). + * + * The backfill (existing rows → 'org') and `down` rollback paths exercised + * the migrator's up/down mechanics against an ephemeral PGlite database. + * They are intentionally omitted here: rolling the shared integration DB + * down to 096 (or dropping the access column via `down`) would corrupt the + * schema for every sibling `*.integration.test.ts` file that runs against + * the same Postgres service. This mirrors the 087–092 migration tests, + * which assert `up()` behavior only. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { sql } from "kysely"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../src/database/test-db-pg"; +import type { MeshDatabase } from "../src/database"; + +const USER_A = "user_test"; +const USER_B = "user_1"; // both seeded by seedCommonTestPgFixtures +const ORG = "org_test"; + +interface ConnectionRow { + id: string; + access: string; + app_id: string | null; + created_by: string; +} + +async function insertConnection( + database: MeshDatabase, + id: string, + opts: { + appId?: string | null; + createdBy?: string; + access?: string; // when omitted, DB default applies + } = {}, +): Promise { + const now = new Date().toISOString(); + const createdBy = opts.createdBy ?? USER_A; + const appId = opts.appId ?? null; + if (opts.access === undefined) { + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, status, created_at, updated_at + ) VALUES ( + ${id}, ${ORG}, ${createdBy}, 'test', 'HTTP', + 'https://example.com', ${appId}, + 'active', ${now}, ${now} + ) + `.execute(database.db); + } else { + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, access, status, created_at, updated_at + ) VALUES ( + ${id}, ${ORG}, ${createdBy}, 'test', 'HTTP', + 'https://example.com', ${appId}, ${opts.access}, + 'active', ${now}, ${now} + ) + `.execute(database.db); + } +} + +async function insertVirtualParent( + database: MeshDatabase, + id: string, +): Promise { + const now = new Date().toISOString(); + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, status, created_at, updated_at + ) VALUES ( + ${id}, ${ORG}, ${USER_A}, 'agent', 'VIRTUAL', + ${"virtual://" + id}, 'active', ${now}, ${now} + ) + `.execute(database.db); +} + +async function getAccess(database: MeshDatabase, id: string): Promise { + const result = (await sql` + SELECT id, access, app_id, created_by FROM connections WHERE id = ${id} + `.execute(database.db)) as unknown as { rows: ConnectionRow[] }; + const row = result.rows[0]; + if (!row) throw new Error(`connection ${id} not found`); + return row.access; +} + +describe("migration 097 — connection access + slots", () => { + let database: MeshDatabase; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("new inserts that omit access default to 'user'", async () => { + await insertConnection(database, "conn_new"); + expect(await getAccess(database, "conn_new")).toBe("user"); + }); + + it("CHECK rejects invalid access values", async () => { + await expect( + insertConnection(database, "conn_bad", { access: "public" }), + ).rejects.toThrow(); + }); + + it("R4: same user cannot have two user-private connections with same app_id", async () => { + await insertConnection(database, "conn_gh1", { + appId: "mcp-github", + access: "user", + }); + await expect( + insertConnection(database, "conn_gh2", { + appId: "mcp-github", + access: "user", + }), + ).rejects.toThrow(); + }); + + it("R4: org-shared connections of same app_id are NOT restricted", async () => { + await insertConnection(database, "conn_org1", { + appId: "mcp-github", + access: "org", + }); + await insertConnection(database, "conn_org2", { + appId: "mcp-github", + access: "org", + }); + expect(await getAccess(database, "conn_org2")).toBe("org"); + }); + + it("R4: different users can each own a user-private connection of same app_id", async () => { + await insertConnection(database, "conn_a", { + appId: "mcp-github", + createdBy: USER_A, + access: "user", + }); + await insertConnection(database, "conn_b", { + appId: "mcp-github", + createdBy: USER_B, + access: "user", + }); + expect(await getAccess(database, "conn_b")).toBe("user"); + }); + + it("R4: user-private rows without app_id are exempt", async () => { + await insertConnection(database, "conn_noapp1", { + appId: null, + access: "user", + }); + await insertConnection(database, "conn_noapp2", { + appId: null, + access: "user", + }); + expect(await getAccess(database, "conn_noapp2")).toBe("user"); + }); + + it("aggregation XOR: row with both child_connection_id and slot_app_id is rejected", async () => { + await insertVirtualParent(database, "agent_xor1"); + await insertConnection(database, "conn_child_xor", { + appId: "mcp-github", + access: "org", + }); + const now = new Date().toISOString(); + await expect( + sql` + INSERT INTO connection_aggregations ( + id, parent_connection_id, child_connection_id, slot_app_id, + dependency_mode, created_at + ) VALUES ( + 'agg_both', 'agent_xor1', 'conn_child_xor', 'mcp-github', + 'direct', ${now} + ) + `.execute(database.db), + ).rejects.toThrow(); + }); + + it("aggregation XOR: row with neither child_connection_id nor slot_app_id is rejected", async () => { + await insertVirtualParent(database, "agent_xor2"); + const now = new Date().toISOString(); + await expect( + sql` + INSERT INTO connection_aggregations ( + id, parent_connection_id, child_connection_id, slot_app_id, + dependency_mode, created_at + ) VALUES ( + 'agg_none', 'agent_xor2', NULL, NULL, + 'direct', ${now} + ) + `.execute(database.db), + ).rejects.toThrow(); + }); + + it("aggregation slot: row with slot_app_id and NULL child_connection_id is accepted", async () => { + await insertVirtualParent(database, "agent_slot1"); + const now = new Date().toISOString(); + await sql` + INSERT INTO connection_aggregations ( + id, parent_connection_id, child_connection_id, slot_app_id, + dependency_mode, created_at + ) VALUES ( + 'agg_slot', 'agent_slot1', NULL, 'mcp-github', + 'direct', ${now} + ) + `.execute(database.db); + const result = (await sql<{ slot_app_id: string }>` + SELECT slot_app_id FROM connection_aggregations WHERE id = 'agg_slot' + `.execute(database.db)) as unknown as { rows: { slot_app_id: string }[] }; + expect(result.rows[0]?.slot_app_id).toBe("mcp-github"); + }); + + it("aggregation slot uniqueness: same agent cannot have two slots of same app_id", async () => { + await insertVirtualParent(database, "agent_dup"); + const now = new Date().toISOString(); + await sql` + INSERT INTO connection_aggregations ( + id, parent_connection_id, child_connection_id, slot_app_id, + dependency_mode, created_at + ) VALUES ( + 'agg_s1', 'agent_dup', NULL, 'mcp-github', + 'direct', ${now} + ) + `.execute(database.db); + await expect( + sql` + INSERT INTO connection_aggregations ( + id, parent_connection_id, child_connection_id, slot_app_id, + dependency_mode, created_at + ) VALUES ( + 'agg_s2', 'agent_dup', NULL, 'mcp-github', + 'direct', ${now} + ) + `.execute(database.db), + ).rejects.toThrow(); + }); +}); diff --git a/apps/mesh/migrations/097-connection-access-and-slots.ts b/apps/mesh/migrations/097-connection-access-and-slots.ts new file mode 100644 index 0000000000..56051396f1 --- /dev/null +++ b/apps/mesh/migrations/097-connection-access-and-slots.ts @@ -0,0 +1,95 @@ +/** + * Migration 097: Connection access + agent slots. + * + * Adds the foundation for per-user connections and typed slots in agents. + * + * Changes: + * 1. connections.access — 'user' | 'org'. Existing rows backfill to 'org'. + * New rows default to 'user' (private-by-default). + * 2. connection_aggregations.slot_app_id — nullable text. When set, + * child_connection_id must be NULL (XOR enforced by CHECK). + * child_connection_id is relaxed to NULLABLE. + * 3. Partial unique index (R4): one user-private connection per + * (organization, creator, app_id). + * 4. Partial unique index: one slot per (agent, app_id). + * + * Spec: docs/superpowers/specs/2026-05-27-private-connections-design.md + */ + +import { sql, type Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql` + ALTER TABLE connections + ADD COLUMN access text NOT NULL DEFAULT 'org' + `.execute(db); + + await sql` + ALTER TABLE connections + ADD CONSTRAINT connections_access_check + CHECK (access IN ('user', 'org')) + `.execute(db); + + await sql` + ALTER TABLE connections + ALTER COLUMN access SET DEFAULT 'user' + `.execute(db); + + await sql` + CREATE UNIQUE INDEX idx_connections_user_app_unique + ON connections (organization_id, created_by, app_id) + WHERE access = 'user' AND app_id IS NOT NULL + `.execute(db); + + await sql` + ALTER TABLE connection_aggregations + ADD COLUMN slot_app_id text + `.execute(db); + + await sql` + ALTER TABLE connection_aggregations + ALTER COLUMN child_connection_id DROP NOT NULL + `.execute(db); + + await sql` + ALTER TABLE connection_aggregations + ADD CONSTRAINT conn_agg_slot_xor + CHECK ( + (child_connection_id IS NOT NULL AND slot_app_id IS NULL) + OR + (child_connection_id IS NULL AND slot_app_id IS NOT NULL) + ) + `.execute(db); + + await sql` + CREATE UNIQUE INDEX idx_conn_agg_slot_unique + ON connection_aggregations (parent_connection_id, slot_app_id) + WHERE slot_app_id IS NOT NULL + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS idx_conn_agg_slot_unique`.execute(db); + await sql` + ALTER TABLE connection_aggregations + DROP CONSTRAINT IF EXISTS conn_agg_slot_xor + `.execute(db); + await sql` + ALTER TABLE connection_aggregations + ALTER COLUMN child_connection_id SET NOT NULL + `.execute(db); + await sql` + ALTER TABLE connection_aggregations + DROP COLUMN IF EXISTS slot_app_id + `.execute(db); + + await sql`DROP INDEX IF EXISTS idx_connections_user_app_unique`.execute(db); + await sql` + ALTER TABLE connections + DROP CONSTRAINT IF EXISTS connections_access_check + `.execute(db); + await sql` + ALTER TABLE connections + DROP COLUMN IF EXISTS access + `.execute(db); +} diff --git a/apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.integration.test.ts b/apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.integration.test.ts new file mode 100644 index 0000000000..d868d9d78a --- /dev/null +++ b/apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.integration.test.ts @@ -0,0 +1,105 @@ +/** + * Integration test for migration 098's exported helpers. + * + * The shared integration DB already has 098 applied (and is empty of seeded + * rows), so we cannot observe the one-time up() backfill against pre-existing + * data. Instead we test the idempotent, exported helpers directly against + * seeded rows — they ARE the body of up(). + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { type Kysely, sql } from "kysely"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../src/database/test-db-pg"; +import type { MeshDatabase } from "../src/database"; +import { + backfillConnectionAppIds, + flipAllConnectionsToOrg, +} from "./098-org-scope-connections-and-derive-app-id"; + +const USER = "user_test"; +const ORG = "org_test"; + +async function insertConn( + database: MeshDatabase, + id: string, + opts: { + type?: string; + url?: string | null; + appId?: string | null; + access?: string; + }, +): Promise { + const now = new Date().toISOString(); + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, access, status, created_at, updated_at + ) VALUES ( + ${id}, ${ORG}, ${USER}, ${id}, ${opts.type ?? "HTTP"}, + ${opts.url ?? "https://example.com/mcp"}, ${opts.appId ?? null}, + ${opts.access ?? "org"}, 'active', ${now}, ${now} + ) + `.execute(database.db); +} + +async function read( + database: MeshDatabase, + id: string, +): Promise<{ access: string; app_id: string | null }> { + const result = (await sql<{ access: string; app_id: string | null }>` + SELECT access, app_id FROM connections WHERE id = ${id} + `.execute(database.db)) as unknown as { + rows: { access: string; app_id: string | null }[]; + }; + return result.rows[0]!; +} + +describe("migration 098 helpers", () => { + let database: MeshDatabase; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("flips user-scoped connections to org", async () => { + await insertConn(database, "c_user", { access: "user", appId: "x-app" }); + await flipAllConnectionsToOrg(database.db as Kysely); + expect((await read(database, "c_user")).access).toBe("org"); + }); + + it("backfills a synthetic app_id for non-VIRTUAL rows with null app_id", async () => { + await insertConn(database, "c_null", { + url: "https://svc.com/mcp", + appId: null, + }); + await backfillConnectionAppIds(database.db as Kysely); + expect((await read(database, "c_null")).app_id).toBe("url:svc.com/mcp"); + }); + + it("leaves VIRTUAL rows' app_id null", async () => { + await insertConn(database, "c_virtual", { + type: "VIRTUAL", + url: "virtual://c_virtual", + appId: null, + }); + await backfillConnectionAppIds(database.db as Kysely); + expect((await read(database, "c_virtual")).app_id).toBeNull(); + }); + + it("preserves an existing registry app_id", async () => { + await insertConn(database, "c_reg", { appId: "deco/mcp-github" }); + await backfillConnectionAppIds(database.db as Kysely); + expect((await read(database, "c_reg")).app_id).toBe("deco/mcp-github"); + }); +}); diff --git a/apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.ts b/apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.ts new file mode 100644 index 0000000000..ff50df8320 --- /dev/null +++ b/apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.ts @@ -0,0 +1,71 @@ +/** + * Migration 098: org-scope existing connections + backfill synthetic app_ids. + * + * Companion to the private-connections model: + * 1. Reset every existing connection to access='org'. This dissolves the + * "private connection hard-bound as a concrete child" case in existing + * data — afterward every concrete child is org-scoped and rule-conformant. + * 2. Backfill a synthetic app_id (deriveAppId) for non-VIRTUAL rows that lack + * one. Because every row is org-scoped after step 1, the partial unique + * index idx_connections_user_app_unique (WHERE access='user') does not + * apply, so duplicate derived ids cannot collide here. + * + * The DB default for connections.access stays 'user' (migration 097), so newly + * created connections remain private-by-default; this migration only resets + * existing data. + * + * Spec: docs/superpowers/specs/2026-05-28-org-scoped-agents-typed-connections-design.md + */ + +import { sql, type Kysely } from "kysely"; +import { deriveAppId } from "../src/storage/derive-app-id"; + +interface ConnRow { + id: string; + connection_type: string; + connection_url: string | null; + connection_headers: string | null; + app_id: string | null; +} + +export async function flipAllConnectionsToOrg( + db: Kysely, +): Promise { + await sql`UPDATE connections SET access = 'org' WHERE access = 'user'`.execute( + db, + ); +} + +export async function backfillConnectionAppIds( + db: Kysely, +): Promise { + const result = (await sql` + SELECT id, connection_type, connection_url, connection_headers, app_id + FROM connections + WHERE app_id IS NULL AND connection_type <> 'VIRTUAL' + `.execute(db)) as unknown as { rows: ConnRow[] }; + + for (const row of result.rows) { + const appId = deriveAppId({ + connection_type: row.connection_type, + connection_url: row.connection_url, + connection_headers: row.connection_headers, + app_id: row.app_id, + }); + if (appId) { + await sql`UPDATE connections SET app_id = ${appId} WHERE id = ${row.id}`.execute( + db, + ); + } + } +} + +export async function up(db: Kysely): Promise { + await flipAllConnectionsToOrg(db); + await backfillConnectionAppIds(db); +} + +export async function down(): Promise { + // One-time data migration: prior null app_ids and per-row access values + // cannot be reliably reconstructed, so down() is a no-op. +} diff --git a/apps/mesh/migrations/index.ts b/apps/mesh/migrations/index.ts index d68d544b28..73f35d3fe5 100644 --- a/apps/mesh/migrations/index.ts +++ b/apps/mesh/migrations/index.ts @@ -95,7 +95,9 @@ import * as migration093backfillglobalsearchbasicusage from "./093-backfill-glob import * as migration094orgfileconfigs from "./094-org-file-configs.ts"; import * as migration095removeautomationtoolcallkind from "./095-remove-automation-tool-call-kind.ts"; import * as migration096orgfileconfigspublicurlbase from "./096-org-file-configs-public-url-base.ts"; +import * as migration097connectionaccessandslots from "./097-connection-access-and-slots.ts"; import * as migration097droplocaldockersandboxstate from "./097-drop-local-docker-sandbox-state.ts"; +import * as migration098orgscopeconnectionsandderiveappid from "./098-org-scope-connections-and-derive-app-id.ts"; /** * Core migrations for the Mesh application. @@ -212,8 +214,11 @@ const migrations: Record = { migration095removeautomationtoolcallkind, "096-org-file-configs-public-url-base": migration096orgfileconfigspublicurlbase, + "097-connection-access-and-slots": migration097connectionaccessandslots, "097-drop-local-docker-sandbox-state": migration097droplocaldockersandboxstate, + "098-org-scope-connections-and-derive-app-id": + migration098orgscopeconnectionsandderiveappid, }; export default migrations; diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 8bf276d63a..5996db9292 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -42,6 +42,7 @@ import { type DownstreamTokenData, } from "../storage/downstream-token"; import { resolveOriginTokenEndpoint } from "../oauth/resolve-token-endpoint"; +import { INTERNAL_VIEWER } from "../storage/ports"; import { createLogDeprecatedRoute, logDeprecatedRoute, @@ -234,7 +235,11 @@ async function getDecoStoreProjectLocator( ctx: MeshContext, organizationId: string, ): Promise { - // Find registry connection by URL within the organization + // Find registry connection by URL within the organization. + // INTERNAL_VIEWER: the deco-store registry is an org-shared resource that + // every member's OAuth flow needs to resolve. The lookup runs inside trusted + // infra (not directly from a user-facing handler returning raw rows to the + // caller) and only reads `configuration_state.project_locator`. const { items: connections } = await ctx.storage.connections.list( organizationId, { @@ -244,6 +249,7 @@ async function getDecoStoreProjectLocator( value: `${DECO_STORE_URL}%`, }, limit: 1, + viewer: INTERNAL_VIEWER, }, ); const registryConn = connections[0]; @@ -310,9 +316,15 @@ const oauthProxyHandler: MiddlewareHandler = async (c) => { } const orgScope = c.req.param("org") ? ctx.organization?.id : undefined; + // OAuth proxy entry point: org-scope is enforced above. The lookup is + // internal infra (we only need the connection_url to forward the OAuth + // call to origin) — INTERNAL_VIEWER so the proxy works for both org-shared + // and user-private connections owned by other members. The OAuth token that + // results from this flow is bound to the caller's session, not returned. const connection = await ctx.storage.connections.findById( connectionId, orgScope, + INTERNAL_VIEWER, ); if (!connection?.connection_url) { return c.json({ error: "Connection not found" }, 404); diff --git a/apps/mesh/src/api/routes/decopilot/status.test.ts b/apps/mesh/src/api/routes/decopilot/status.test.ts index 93a4ecca2b..0494078e0d 100644 --- a/apps/mesh/src/api/routes/decopilot/status.test.ts +++ b/apps/mesh/src/api/routes/decopilot/status.test.ts @@ -122,6 +122,24 @@ describe("resolveThreadStatus", () => { expect(resolveThreadStatus("tool-calls", parts)).toBe("completed"); }); + test("connect-required part with undefined finishReason -> requires_action", () => { + // The parent connect-gate (harnesses/decopilot/index.ts) emits a clean + // `finish` chunk with no model finishReason. Without the connect-required + // special-case this would fall through to "failed" (firing the failure + // sound). The `data-connect-required` part must resolve to a clean, + // user-actionable status instead. + const parts = [ + { type: "text", text: 'Couldn\'t run "Foo" — connect GitHub.' }, + { type: "data-connect-required" }, + ]; + expect(resolveThreadStatus(undefined, parts)).toBe("requires_action"); + }); + + test("connect-required part with stop finishReason -> requires_action", () => { + const parts = [{ type: "data-connect-required" }]; + expect(resolveThreadStatus("stop", parts)).toBe("requires_action"); + }); + test("length -> failed", () => { expect(resolveThreadStatus("length", [])).toBe("failed"); }); diff --git a/apps/mesh/src/api/routes/decopilot/status.ts b/apps/mesh/src/api/routes/decopilot/status.ts index 743aa5989c..c3bfa7e8fe 100644 --- a/apps/mesh/src/api/routes/decopilot/status.ts +++ b/apps/mesh/src/api/routes/decopilot/status.ts @@ -24,6 +24,15 @@ export function resolveThreadStatus( finishReason: string | undefined, responseParts: ResponsePart[] = [], ): Exclude { + // Connect-gate outcome: when the run terminated cleanly because the user + // is missing a required connection, the response carries a + // `data-connect-required` part. This is a clean, user-actionable end — + // never a failure (no failure sound) — regardless of the finish reason + // (the parent connect-gate emits `finish` without a model finishReason). + if (responseParts.some((p) => p.type === "data-connect-required")) { + return "requires_action"; + } + if (finishReason === "stop") { const text = responseParts .filter((p) => p.type === "text" && p.text) diff --git a/apps/mesh/src/api/routes/decopilot/types.ts b/apps/mesh/src/api/routes/decopilot/types.ts index b07257f452..60f26269a5 100644 --- a/apps/mesh/src/api/routes/decopilot/types.ts +++ b/apps/mesh/src/api/routes/decopilot/types.ts @@ -40,6 +40,11 @@ export type ChatMessage = UIMessage< "thread-title": { title: string; }; + "connect-required": { + agentId: string; + agentTitle: string; + appIds: string[]; + }; "generate-image": { toolCallId: string; images: Array<{ base64: string; mediaType: string }>; diff --git a/apps/mesh/src/api/routes/downstream-token.ts b/apps/mesh/src/api/routes/downstream-token.ts index cb576f6a38..30e33908a4 100644 --- a/apps/mesh/src/api/routes/downstream-token.ts +++ b/apps/mesh/src/api/routes/downstream-token.ts @@ -12,6 +12,7 @@ import { DownstreamTokenStorage, type DownstreamTokenData, } from "../../storage/downstream-token"; +import type { ConnectionViewer } from "../../storage/ports"; // Define Hono variables type type Variables = { @@ -42,10 +43,14 @@ export const createDownstreamTokenRoutes = () => { return c.json({ error: "Organization context required" }, 403); } - // Verify connection exists and user has access + // Verify connection exists and user has access. The viewer is the + // authenticated principal so user-private connections owned by other + // members are hidden — preventing a cross-user OAuth token overwrite. + const viewer: ConnectionViewer = userId; const connection = await ctx.storage.connections.findById( connectionId, organizationId, + viewer, ); if (!connection) { return c.json({ error: "Connection not found" }, 404); @@ -154,10 +159,14 @@ export const createDownstreamTokenRoutes = () => { return c.json({ error: "Organization context required" }, 403); } - // Verify connection exists and belongs to the user's organization + // Verify connection exists and belongs to the user's organization. + // viewer = userId so a member can't delete another user's OAuth token + // for a user-private connection they shouldn't see. + const viewer: ConnectionViewer = userId; const connection = await ctx.storage.connections.findById( connectionId, organizationId, + viewer, ); if (!connection) { return c.json({ error: "Connection not found" }, 404); @@ -188,10 +197,14 @@ export const createDownstreamTokenRoutes = () => { return c.json({ error: "Organization context required" }, 403); } - // Verify connection exists and belongs to the user's organization + // Verify connection exists and belongs to the user's organization. + // viewer = userId so a member can't probe whether another user has an + // active token on a user-private connection. + const viewer: ConnectionViewer = userId; const connection = await ctx.storage.connections.findById( connectionId, organizationId, + viewer, ); if (!connection) { return c.json({ error: "Connection not found" }, 404); diff --git a/apps/mesh/src/api/routes/mcp-proxy-factory.ts b/apps/mesh/src/api/routes/mcp-proxy-factory.ts index a2cf8f9b71..89e0c53ee3 100644 --- a/apps/mesh/src/api/routes/mcp-proxy-factory.ts +++ b/apps/mesh/src/api/routes/mcp-proxy-factory.ts @@ -19,6 +19,8 @@ import { import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { MCP_TOOL_CALL_TIMEOUT_MS } from "@/core/constants"; import type { MeshContext } from "../../core/mesh-context"; +import { INTERNAL_VIEWER } from "../../storage/ports"; +import type { ConnectionViewer } from "../../storage/ports"; // ============================================================================ // Types @@ -62,12 +64,21 @@ async function createMCPProxyDoNotUseDirectly( throw new Error("Organization context is required"); } - // Get connection details — scope the lookup to the caller's org when available + // Get connection details — scope the lookup to the caller's org when + // available. superUser callers are background workers crossing user/org + // boundaries (e.g. event-bus worker resolving a subscriber's connection), + // so they pass INTERNAL_VIEWER. User-facing callers thread the + // authenticated principal so user-private rows owned by other members are + // hidden. + const viewer: ConnectionViewer = superUser + ? INTERNAL_VIEWER + : (ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null); const connection = typeof connectionIdOrConnection === "string" ? await ctx.storage.connections.findById( connectionIdOrConnection, ctx.organization?.id, + viewer, ) : connectionIdOrConnection; if (!connection) { diff --git a/apps/mesh/src/api/routes/oauth-proxy.ts b/apps/mesh/src/api/routes/oauth-proxy.ts index aa25065d79..aa516b3fc1 100644 --- a/apps/mesh/src/api/routes/oauth-proxy.ts +++ b/apps/mesh/src/api/routes/oauth-proxy.ts @@ -15,6 +15,7 @@ import { Hono } from "hono"; import { ContextFactory } from "../../core/context-factory"; import type { MeshContext } from "../../core/mesh-context"; +import { INTERNAL_VIEWER } from "../../storage/ports"; import { retry, RetryError } from "@decocms/std"; import { authorizationServerMetadataUrls, @@ -53,9 +54,15 @@ async function getConnectionUrl( ctx: MeshContext, organizationId?: string, ): Promise { + // OAuth proxy is shared infrastructure — the URL lookup must succeed for + // any caller who legitimately reaches the proxy endpoint. Per-user access + // is enforced upstream by the route mounting (org membership) plus the + // OAuth flow itself, which requires the user to authenticate against the + // origin and bind the resulting token to their own session. const connection = await ctx.storage.connections.findById( connectionId, organizationId, + INTERNAL_VIEWER, ); return connection?.connection_url ?? null; } @@ -670,8 +677,13 @@ const authServerMetadataHandler = async (c: { // Fetch the connection (unscoped — connection IDs are globally unique) so we // can derive both the origin auth server and the owning org slug for - // org-scoped endpoint URLs. - const connection = await ctx.storage.connections.findById(connectionId); + // org-scoped endpoint URLs. INTERNAL_VIEWER because this is OAuth discovery + // metadata — the actual token exchange is bound to the caller's session. + const connection = await ctx.storage.connections.findById( + connectionId, + undefined, + INTERNAL_VIEWER, + ); if (!connection?.connection_url) { return c.json({ error: "Connection not found or no auth server" }, 404); } diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index 731e3ebd57..3f0cfac0f9 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -108,6 +108,7 @@ export const createProxyRoutes = () => { const result = await ctx.storage.connections.findById( connectionId, ctx.organization!.id, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, ); span.setStatus({ code: SpanStatusCode.OK }); return result; @@ -219,6 +220,7 @@ export const createProxyRoutes = () => { const connection = await ctx.storage.connections.findById( connectionId, ctx.organization?.id, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, ); if (connection?.connection_url) { const authResponse = await handleAuthError({ @@ -250,6 +252,7 @@ export const createProxyRoutes = () => { const connection = await ctx.storage.connections.findById( connectionId, ctx.organization?.id, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, ); if (!connection) { return c.json({ error: "Connection not found" }, 404); @@ -290,6 +293,7 @@ export const createProxyRoutes = () => { const connection = await ctx.storage.connections.findById( connectionId, ctx.organization?.id, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, ); if (connection?.connection_url) { const authResponse = await handleAuthError({ diff --git a/apps/mesh/src/core/slot-resolver.integration.test.ts b/apps/mesh/src/core/slot-resolver.integration.test.ts new file mode 100644 index 0000000000..0342db5fb8 --- /dev/null +++ b/apps/mesh/src/core/slot-resolver.integration.test.ts @@ -0,0 +1,171 @@ +/** + * Integration tests for slot-resolver against a real Postgres DB. Given a + * Kysely DB plus a context (organizationId, invokerUserId, appId), the + * resolver returns the caller's user-private connection of the matching + * shape, falling back to an org-shared one. Returns null when nothing + * matches. + * + * Resolution rules (from spec section "Slot resolution"): + * 1. Match connections in the same organization with the same app_id. + * 2. Only consider connections with status='active'. + * 3. Prefer access='user' AND created_by=invokerUserId. + * 4. Fall back to access='org' when no private match exists. + * 5. Return null when neither matches. + * + * Pure-logic coverage (SlotResolutionCache, SlotUnresolvedError) lives in + * the unit-tier `slot-resolver.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { sql } from "kysely"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../database/test-db-pg"; +import type { MeshDatabase } from "../database"; +import { resolveSlot } from "./slot-resolver"; + +const USER_A = "user_test"; +const USER_B = "user_1"; +const ORG = "org_test"; +const OTHER_ORG = "org_1"; + +async function insertConn( + database: MeshDatabase, + id: string, + opts: { + appId: string; + access: "user" | "org"; + createdBy?: string; + organizationId?: string; + status?: "active" | "inactive"; + }, +): Promise { + const now = new Date().toISOString(); + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, access, status, created_at, updated_at + ) VALUES ( + ${id}, ${opts.organizationId ?? ORG}, ${opts.createdBy ?? USER_A}, + 'test', 'HTTP', 'https://example.com', ${opts.appId}, + ${opts.access}, ${opts.status ?? "active"}, ${now}, ${now} + ) + `.execute(database.db); +} + +describe("resolveSlot", () => { + let database: MeshDatabase; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("prefers user-private when both user-private and org-shared exist", async () => { + await insertConn(database, "conn_org", { + appId: "mcp-github", + access: "org", + }); + await insertConn(database, "conn_user", { + appId: "mcp-github", + access: "user", + createdBy: USER_A, + }); + + const resolved = await resolveSlot(database.db, { + organizationId: ORG, + invokerUserId: USER_A, + appId: "mcp-github", + }); + + expect(resolved).toEqual({ + connectionId: "conn_user", + access: "user", + }); + }); + + it("falls back to org-shared when caller has no private connection", async () => { + await insertConn(database, "conn_org", { + appId: "mcp-github", + access: "org", + }); + + const resolved = await resolveSlot(database.db, { + organizationId: ORG, + invokerUserId: USER_B, + appId: "mcp-github", + }); + + expect(resolved).toEqual({ + connectionId: "conn_org", + access: "org", + }); + }); + + it("returns null when nothing matches", async () => { + const resolved = await resolveSlot(database.db, { + organizationId: ORG, + invokerUserId: USER_A, + appId: "mcp-github", + }); + expect(resolved).toBeNull(); + }); + + it("does not resolve inactive connections", async () => { + await insertConn(database, "conn_dead", { + appId: "mcp-github", + access: "user", + createdBy: USER_A, + status: "inactive", + }); + + const resolved = await resolveSlot(database.db, { + organizationId: ORG, + invokerUserId: USER_A, + appId: "mcp-github", + }); + + expect(resolved).toBeNull(); + }); + + it("does not leak across organizations", async () => { + await insertConn(database, "conn_other_org", { + appId: "mcp-github", + access: "user", + createdBy: USER_A, + organizationId: OTHER_ORG, + }); + + const resolved = await resolveSlot(database.db, { + organizationId: ORG, + invokerUserId: USER_A, + appId: "mcp-github", + }); + + expect(resolved).toBeNull(); + }); + + it("does not match another user's user-private connection", async () => { + await insertConn(database, "conn_other_user", { + appId: "mcp-github", + access: "user", + createdBy: USER_B, + }); + + const resolved = await resolveSlot(database.db, { + organizationId: ORG, + invokerUserId: USER_A, + appId: "mcp-github", + }); + + expect(resolved).toBeNull(); + }); +}); diff --git a/apps/mesh/src/core/slot-resolver.test.ts b/apps/mesh/src/core/slot-resolver.test.ts new file mode 100644 index 0000000000..1b1ff59966 --- /dev/null +++ b/apps/mesh/src/core/slot-resolver.test.ts @@ -0,0 +1,85 @@ +/** + * Unit tests for slot-resolver's pure-logic surface: the in-memory + * SlotResolutionCache and the SlotUnresolvedError type. + * + * DB-backed resolution behavior (the `resolveSlot` query) is covered in + * `slot-resolver.integration.test.ts`, which runs against real Postgres. + */ + +import { describe, expect, it } from "bun:test"; +import { SlotUnresolvedError, SlotResolutionCache } from "./slot-resolver"; + +describe("SlotResolutionCache", () => { + it("returns cached result without hitting the DB on repeated calls", async () => { + const cache = new SlotResolutionCache(); + let hitCount = 0; + + const result1 = await cache.resolve("user_a", "mcp-github", async () => { + hitCount++; + return { connectionId: "conn_user_a", access: "user" as const }; + }); + const result2 = await cache.resolve("user_a", "mcp-github", async () => { + hitCount++; + return { connectionId: "different_id", access: "user" as const }; + }); + + expect(result1).toEqual({ connectionId: "conn_user_a", access: "user" }); + expect(result2).toEqual({ connectionId: "conn_user_a", access: "user" }); + expect(hitCount).toBe(1); + }); + + it("caches null results too", async () => { + const cache = new SlotResolutionCache(); + let hitCount = 0; + + const result1 = await cache.resolve("user_a", "mcp-github", async () => { + hitCount++; + return null; + }); + const result2 = await cache.resolve("user_a", "mcp-github", async () => { + hitCount++; + return { connectionId: "conn_new", access: "user" as const }; + }); + + expect(result1).toBeNull(); + expect(result2).toBeNull(); + expect(hitCount).toBe(1); + }); + + it("scopes cache by (userId, appId)", async () => { + const cache = new SlotResolutionCache(); + let hitCount = 0; + + await cache.resolve("user_a", "mcp-github", async () => { + hitCount++; + return { connectionId: "ga", access: "user" as const }; + }); + await cache.resolve("user_b", "mcp-github", async () => { + hitCount++; + return { connectionId: "gb", access: "user" as const }; + }); + await cache.resolve("user_a", "mcp-linear", async () => { + hitCount++; + return { connectionId: "la", access: "user" as const }; + }); + + expect(hitCount).toBe(3); + }); +}); + +describe("SlotUnresolvedError", () => { + it("carries all app_ids and agent identity for the UI to surface", () => { + const err = new SlotUnresolvedError( + ["mcp-github", "google-gmail"], + "vmcp_123", + "My Agent", + ); + expect(err.appIds).toEqual(["mcp-github", "google-gmail"]); + expect(err.agentId).toBe("vmcp_123"); + expect(err.agentTitle).toBe("My Agent"); + expect(err.name).toBe("SlotUnresolvedError"); + expect(err.message).toContain("mcp-github"); + expect(err.message).toContain("google-gmail"); + expect(err.message).toContain("My Agent"); + }); +}); diff --git a/apps/mesh/src/core/slot-resolver.ts b/apps/mesh/src/core/slot-resolver.ts new file mode 100644 index 0000000000..a1f11500d3 --- /dev/null +++ b/apps/mesh/src/core/slot-resolver.ts @@ -0,0 +1,103 @@ +/** + * Slot resolver — translates a typed slot on an agent into a concrete + * connection_id at runtime, given the invoking user's identity. + * + * See: docs/superpowers/specs/2026-05-27-private-connections-design.md + * (section "Slot resolution") + * + * Resolution rules: + * 1. Same organization as the agent. + * 2. Same app_id as the slot. + * 3. Active connections only. + * 4. Prefer (access='user' AND created_by=invokerUserId). + * 5. Fall back to (access='org'). + * 6. Return null when neither matches; callers decide whether to throw + * SlotUnresolvedError or propagate null. + * + * The resolver is a pure function over the DB + context; no global state. + * For one agent run, callers should reuse a SlotResolutionCache instance + * so repeated slot lookups inside the run don't re-hit the DB. + */ + +import { sql, type Kysely } from "kysely"; +import type { Database } from "../storage/types"; + +export interface SlotResolveContext { + organizationId: string; + invokerUserId: string; + appId: string; +} + +export interface ResolvedSlot { + connectionId: string; + access: "user" | "org"; +} + +export class SlotUnresolvedError extends Error { + readonly appIds: string[]; + readonly agentId: string; + readonly agentTitle: string; + constructor(appIds: string[], agentId: string, agentTitle: string) { + super( + `Agent '${agentTitle}' (${agentId}) has unresolved slots for app_ids: ${appIds.join(", ")} — the caller has no matching connection.`, + ); + this.name = "SlotUnresolvedError"; + this.appIds = appIds; + this.agentId = agentId; + this.agentTitle = agentTitle; + } +} + +interface ResolvedRow { + id: string; + access: "user" | "org"; +} + +export async function resolveSlot( + db: Kysely, + ctx: SlotResolveContext, +): Promise { + const result = (await sql` + SELECT id, access + FROM connections + WHERE organization_id = ${ctx.organizationId} + AND app_id = ${ctx.appId} + AND status = 'active' + AND ( + (access = 'user' AND created_by = ${ctx.invokerUserId}) + OR access = 'org' + ) + ORDER BY (access = 'user') DESC + LIMIT 1 + `.execute(db)) as unknown as { rows: ResolvedRow[] }; + + const row = result.rows[0]; + if (!row) return null; + return { connectionId: row.id, access: row.access }; +} + +/** + * Per-run resolution cache. Reuse one instance for the duration of a + * single agent run so the same (userId, appId) lookup hits the DB once. + * + * Caches null results too — a missing connection won't appear mid-run + * unless the user creates one, and the cache lifetime is intentionally + * short (one run), so that race is acceptable. + */ +export class SlotResolutionCache { + private cache = new Map(); + + async resolve( + userId: string, + appId: string, + loader: () => Promise, + ): Promise { + const key = `${userId}::${appId}`; + if (this.cache.has(key)) { + return this.cache.get(key) ?? null; + } + const value = await loader(); + this.cache.set(key, value); + return value; + } +} diff --git a/apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.test.ts b/apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.test.ts index 0041e63012..c2e528aae6 100644 --- a/apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.test.ts +++ b/apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.test.ts @@ -30,6 +30,66 @@ const mockWriter = { merge: () => {}, } as never; +/** + * A minimal fake tracer whose startActiveSpan immediately invokes the + * callback with a no-op span and returns its result. The three-argument + * overload (name, options, fn) is the one used by createVirtualClientFrom. + */ +const fakeTracer = { + startActiveSpan: ( + _name: string, + _opts: unknown, + fn: (span: unknown) => unknown, + ) => { + const fakeSpan = { + setStatus: () => {}, + recordException: () => {}, + end: () => {}, + setAttribute: () => {}, + }; + return fn(fakeSpan); + }, +} as never; + +/** + * A virtualMcp entity that is active and has one typed slot for "app-x". + * No concrete connections so the slot block is the only path that matters. + */ +const virtualMcpWithSlot = { + id: "vmcp_1", + organization_id: "org_test", + status: "active", + title: "My Agent", + connections: [], + slots: [ + { + slot_app_id: "app-x", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], +} as never; + +/** + * Context that returns virtualMcpWithSlot and has no authenticated user, + * which causes createVirtualClientFrom to treat all slots as unresolved + * (invokerUserId === null path) and throw SlotUnresolvedError without + * requiring a real database. + */ +const mockCtxWithSlots = { + auth: {}, + tracer: fakeTracer, + storage: { + virtualMcps: { + findById: () => Promise.resolve(virtualMcpWithSlot), + }, + connections: { + findById: () => Promise.resolve(null), + }, + }, +} as never; + describe("SubtaskInputSchema", () => { describe("valid input", () => { test("accepts valid prompt and agent_id", () => { @@ -148,6 +208,62 @@ describe("createSubtaskTool", () => { expect(tool.description).toContain("Delegate"); expect(tool.inputSchema).toBeDefined(); }); + + test("emits data-connect-required chunk and yields error when SlotUnresolvedError is thrown", async () => { + const writeCalls: unknown[] = []; + const spyWriter = { + write: (chunk: unknown) => { + writeCalls.push(chunk); + }, + merge: () => {}, + } as never; + + const toolCallId = "tc_slot_test"; + const agentId = "vmcp_1"; + + const subtaskTool = createSubtaskTool( + spyWriter, + mockParams, + mockCtxWithSlots, + ); + + // Drive the async generator to completion. + const yielded: unknown[] = []; + const gen = subtaskTool.execute!( + { prompt: "do something", agent_id: agentId }, + { toolCallId, abortSignal: new AbortController().signal } as never, + ) as AsyncGenerator; + + for await (const value of gen) { + yielded.push(value); + } + + // writer.write must have been called with the connect-required chunk. + expect(writeCalls).toHaveLength(1); + const chunk = writeCalls[0] as { + type: string; + id: string; + data: { agentId: string; agentTitle: string; appIds: string[] }; + }; + expect(chunk.type).toBe("data-connect-required"); + expect(chunk.id).toBe(toolCallId); + expect(chunk.data.agentId).toBe(agentId); + expect(chunk.data.agentTitle).toBe("My Agent"); + expect(chunk.data.appIds).toEqual(["app-x"]); + + // The generator must have yielded exactly one value with the error text + // and finishReason "stop", then terminated cleanly. + expect(yielded).toHaveLength(1); + const result = yielded[0] as { + text: string; + error: string; + finishReason: string; + }; + expect(result.text).toBe(""); + expect(result.error).toContain("connect"); + expect(result.error).toContain("app-x"); + expect(result.finishReason).toBe("stop"); + }); }); describe("metadata isolation", () => { diff --git a/apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.ts b/apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.ts index beb0e3c137..819d0e5eb1 100644 --- a/apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.ts +++ b/apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.ts @@ -10,6 +10,7 @@ */ import type { MeshContext, OrganizationScope } from "@/core/mesh-context"; +import { SlotUnresolvedError } from "@/core/slot-resolver"; import { createVirtualClientFrom } from "@/mcp-clients/virtual-mcp"; import type { UIMessageStreamWriter } from "ai"; import { tool, zodSchema } from "ai"; @@ -93,12 +94,38 @@ export function createSubtaskTool( throw new Error("Agent is not active"); } - // 2. Create MCP client for the target. - const mcpClient = await createVirtualClientFrom( - virtualMcp, - ctx, - "passthrough", - ); + // 2. Create MCP client for the target. If the target agent has typed + // slots the invoking user hasn't connected, createVirtualClientFrom + // throws SlotUnresolvedError — surface it as a connect card to the + // user (data chunk) and a clear instruction to the model, instead of + // a generic "tool failed". + let mcpClient: Awaited>; + try { + mcpClient = await createVirtualClientFrom( + virtualMcp, + ctx, + "passthrough", + ); + } catch (err) { + if (err instanceof SlotUnresolvedError) { + writer.write({ + type: "data-connect-required", + id: toolCallId, + data: { + agentId: err.agentId, + agentTitle: err.agentTitle, + appIds: err.appIds, + }, + }); + yield { + text: "", + error: `Cannot run subagent "${err.agentTitle}": the user must connect ${err.appIds.join(", ")}. A connect card was shown — ask the user to connect, then retry.`, + finishReason: "stop", + }; + return; + } + throw err; + } try { // 3. Call runAgentLoop with subagent kind. diff --git a/apps/mesh/src/harnesses/decopilot/index.ts b/apps/mesh/src/harnesses/decopilot/index.ts index c17f0f9f8a..4e633eb944 100644 --- a/apps/mesh/src/harnesses/decopilot/index.ts +++ b/apps/mesh/src/harnesses/decopilot/index.ts @@ -38,10 +38,14 @@ import type { ChatMessage } from "../../api/routes/decopilot/types"; import type { ChatMode } from "../../api/routes/decopilot/mode-config"; import type { VirtualMCPEntity } from "@decocms/mesh-sdk"; import { processConversation } from "../../api/routes/decopilot/conversation"; -import { DEFAULT_WINDOW_SIZE } from "../../api/routes/decopilot/constants"; +import { + DEFAULT_WINDOW_SIZE, + generateMessageId, +} from "../../api/routes/decopilot/constants"; import { assembleDecopilotTools } from "./tools"; import { assembleDecopilotPrompt } from "./prompt"; import { runDecopilotStream } from "./run-stream"; +import { SlotUnresolvedError } from "@/core/slot-resolver"; import type { PendingImage } from "./built-in-tools"; import type { HtmlPageBuffer } from "./built-in-tools/vm-tools/html-page-buffer"; import { @@ -164,16 +168,59 @@ export const decopilotHarnessFactory: HarnessFactory = { }; } - const tools = await assembleDecopilotTools(effectiveInput, ctx, { - writer: pl.writer, - toolOutputMap: pl.toolOutputMap, - pendingImages: pl.pendingImages, - threadId: pl.threadId, - provider: pl.provider, - imageProvider: pl.imageProvider ?? pl.provider, - deepResearchProvider: pl.deepResearchProvider ?? pl.provider, - htmlPageBuffer: pl.htmlPageBuffer, - }); + let tools: Awaited>; + try { + tools = await assembleDecopilotTools(effectiveInput, ctx, { + writer: pl.writer, + toolOutputMap: pl.toolOutputMap, + pendingImages: pl.pendingImages, + threadId: pl.threadId, + provider: pl.provider, + imageProvider: pl.imageProvider ?? pl.provider, + deepResearchProvider: pl.deepResearchProvider ?? pl.provider, + htmlPageBuffer: pl.htmlPageBuffer, + }); + } catch (err) { + if (err instanceof SlotUnresolvedError) { + // The parent agent's own slots are unresolved for this user. + // Surface the connect card and end the run cleanly (no model + // call happens) instead of a generic stream error. + // + // We emit a complete UI-message-stream envelope rather than a + // bare data chunk so the run is well-formed end-to-end: + // - `start` (with a stable messageId) → the client seeds a real + // assistant message id, so an SSE reconnect dedupes by id + // instead of duplicating the card under id "". + // - a short terminal text part → the turn isn't visually empty + // (the model-facing twin of this text lives in subtask.ts). + // - the `data-connect-required` chunk → renders the connect card. + // - `finish` → the client's foldSubStream sees a `finish` chunk + // and flips status streaming→ready (without it the substream + // never closes and the UI hangs in "streaming" forever). + // `resolveThreadStatus` keys off the `data-connect-required` + // part to resolve `requires_action` (clean, no failure sound). + const messageId = generateMessageId(); + const textId = generateMessageId(); + const text = `Couldn't run "${err.agentTitle}" — connect ${err.appIds.join( + ", ", + )}. A connect card was shown.`; + yield { type: "start", messageId }; + yield { type: "text-start", id: textId }; + yield { type: "text-delta", id: textId, delta: text }; + yield { type: "text-end", id: textId }; + yield { + type: "data-connect-required", + data: { + agentId: err.agentId, + agentTitle: err.agentTitle, + appIds: err.appIds, + }, + }; + yield { type: "finish" }; + return; + } + throw err; + } try { // Run `processConversation` with the REAL tool set — the AI SDK's diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/index.ts b/apps/mesh/src/mcp-clients/virtual-mcp/index.ts index d6806cf67b..b643b1bb27 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/index.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/index.ts @@ -7,11 +7,20 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { isDecopilot } from "@decocms/mesh-sdk"; -import { SpanStatusCode } from "@opentelemetry/api"; +import { SpanStatusCode, trace } from "@opentelemetry/api"; import { getMcpListCache } from "../mcp-list-cache"; import type { MeshContext } from "../../core/mesh-context"; +import { + resolveSlot, + SlotResolutionCache, + SlotUnresolvedError, +} from "@/core/slot-resolver"; +import { INTERNAL_VIEWER } from "../../storage/ports"; import type { ConnectionEntity } from "../../tools/connection/schema"; -import type { VirtualMCPEntity } from "../../tools/virtual/schema"; +import type { + VirtualMCPConnection, + VirtualMCPEntity, +} from "../../tools/virtual/schema"; import { PassthroughClient } from "./passthrough-client"; import type { VirtualClientOptions } from "./types"; @@ -85,9 +94,17 @@ export async function createVirtualClientFrom( }, async (span) => { try { + // Internal infrastructure path — the virtual MCP loader must see every + // connection referenced by the agent regardless of access. Per-user + // visibility is enforced upstream by the agent's own access checks and + // by the slot resolver (see slot resolution block below). const result = await Promise.all( connectionIds.map((connId) => - ctx.storage.connections.findById(connId), + ctx.storage.connections.findById( + connId, + undefined, + INTERNAL_VIEWER, + ), ), ); span.setStatus({ code: SpanStatusCode.OK }); @@ -113,10 +130,110 @@ export async function createVirtualClientFrom( !isSelfReferencingVirtual(conn, virtualMcp.id), ); - // Build aggregator options + // --------------------------------------------------------------------- + // Slot resolution + // --------------------------------------------------------------------- + // For every typed slot declared on the agent, look up the caller's + // matching connection (user-private preferred, org-shared fallback) and + // augment both the loaded ConnectionEntity[] and the per-connection + // selection metadata that the PassthroughClient consumes. Resolved slots + // are indistinguishable from concrete child connections downstream — the + // PassthroughClient just sees one more connection_id with its + // selected_tools / selected_resources / selected_prompts. + // --------------------------------------------------------------------- + const resolvedConnections: ConnectionEntity[] = [...loadedConnections]; + const resolvedVMCPConnections: VirtualMCPConnection[] = [ + ...virtualMcp.connections, + ]; + + if (virtualMcp.slots.length > 0) { + const invokerUserId = ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null; + const slotCache = new SlotResolutionCache(); + const activeSpan = trace.getActiveSpan(); + const unresolvedAppIds: string[] = []; + + for (const slot of virtualMcp.slots) { + if (!invokerUserId) { + unresolvedAppIds.push(slot.slot_app_id); + continue; + } + + const resolved = await slotCache.resolve( + invokerUserId, + slot.slot_app_id, + () => + resolveSlot(ctx.db, { + organizationId: virtualMcp.organization_id, + invokerUserId, + appId: slot.slot_app_id, + }), + ); + if (!resolved) { + unresolvedAppIds.push(slot.slot_app_id); + continue; + } + + // Slot resolver already enforces per-user access by looking up the + // invoker's own slot row; once resolved, the connection lookup itself + // is just an entity hydration step, so INTERNAL_VIEWER is appropriate. + const resolvedEntity = await ctx.storage.connections.findById( + resolved.connectionId, + virtualMcp.organization_id, + INTERNAL_VIEWER, + ); + if (!resolvedEntity) { + // Defensive: resolver pointed at a row that disappeared (e.g. + // deleted between the resolveSlot SELECT and this findById). Treat + // as unresolved so the UI prompts the user to reconnect. + unresolvedAppIds.push(slot.slot_app_id); + continue; + } + + resolvedConnections.push(resolvedEntity); + resolvedVMCPConnections.push({ + connection_id: resolved.connectionId, + selected_tools: slot.selected_tools, + selected_resources: slot.selected_resources, + selected_prompts: slot.selected_prompts, + }); + + if (activeSpan) { + activeSpan.setAttribute( + `slot.${slot.slot_app_id}.app_id`, + slot.slot_app_id, + ); + activeSpan.setAttribute( + `slot.${slot.slot_app_id}.connection_id`, + resolved.connectionId, + ); + activeSpan.setAttribute( + `slot.${slot.slot_app_id}.access`, + resolved.access, + ); + } + } + + if (unresolvedAppIds.length > 0) { + // De-dupe in case two slots reference the same app_id. + const uniqueAppIds = [...new Set(unresolvedAppIds)]; + throw new SlotUnresolvedError( + uniqueAppIds, + virtualMcp.id ?? "", + virtualMcp.title, + ); + } + } + + // Build aggregator options. Note: we pass an *augmented* VirtualMCPEntity + // whose `connections` array contains both the agent's concrete children + // and the resolved-slot connections, so PassthroughClient's vmcpConnMap + // sees a single uniform list keyed by connection_id. const clientOptions: VirtualClientOptions = { - connections: loadedConnections, - virtualMcp, + connections: resolvedConnections, + virtualMcp: { + ...virtualMcp, + connections: resolvedVMCPConnections, + }, superUser, mcpListCache: getMcpListCache() ?? undefined, listTimeoutMs: options?.listTimeoutMs, diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts index c293223582..a8aa490e04 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.test.ts @@ -84,6 +84,7 @@ function makeVirtualMcp( instructions: opts?.instructions ?? null, }, connections, + slots: [], }; } diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/slot-resolver.integration.test.ts b/apps/mesh/src/mcp-clients/virtual-mcp/slot-resolver.integration.test.ts new file mode 100644 index 0000000000..e134c7ae09 --- /dev/null +++ b/apps/mesh/src/mcp-clients/virtual-mcp/slot-resolver.integration.test.ts @@ -0,0 +1,250 @@ +/** + * Integration tests for slot resolution at virtual MCP client construction. + * + * Exercises the wiring in `createVirtualClientFrom` end-to-end against a + * real Kysely DB and the real ConnectionStorage. The PassthroughClient is + * constructed for real but never opened — we only verify what it sees on + * its `options.connections` and `options.virtualMcp.connections` arrays, + * which is what downstream tool dispatch routes by. + * + * Scenarios: + * 1. Agent has a slot. Caller has a matching user-private connection. + * -> Slot resolves to caller's connection; createVirtualClientFrom + * succeeds and the resolved connection appears in both arrays. + * 2. Agent has a slot. Caller has only an org-shared matching connection. + * -> Slot falls back to the org-shared one. + * 3. Agent has a slot. Caller has nothing matching. + * -> createVirtualClientFrom throws SlotUnresolvedError carrying + * the missing app_id. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { trace } from "@opentelemetry/api"; +import { sql } from "kysely"; +import { ConnectionStorage } from "../../storage/connection"; +import { CredentialVault } from "../../encryption/credential-vault"; +import { VirtualMCPStorage } from "../../storage/virtual"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../../database/test-db-pg"; +import type { MeshDatabase } from "../../database"; +import { SlotUnresolvedError } from "@/core/slot-resolver"; +import type { MeshContext } from "../../core/mesh-context"; +import { createVirtualClientFrom } from "./index"; + +const USER_A = "user_test"; +const USER_B = "user_1"; +const ORG = "org_test"; + +async function insertConn( + database: MeshDatabase, + id: string, + opts: { + appId: string; + access: "user" | "org"; + createdBy?: string; + }, +): Promise { + const now = new Date().toISOString(); + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, access, status, created_at, updated_at + ) VALUES ( + ${id}, ${ORG}, ${opts.createdBy ?? USER_A}, 'test', 'HTTP', + 'https://example.com', ${opts.appId}, + ${opts.access}, 'active', ${now}, ${now} + ) + `.execute(database.db); +} + +/** + * Build a minimal MeshContext shim sufficient for createVirtualClientFrom. + * Only the fields the function actually touches are populated; everything + * else is cast through so TypeScript stays happy without us reconstructing + * the entire mesh runtime. + */ +function buildContext( + database: MeshDatabase, + invokerUserId: string | null, + connectionStorage: ConnectionStorage, +): MeshContext { + return { + auth: invokerUserId + ? { user: { id: invokerUserId } } + : { user: undefined, apiKey: undefined }, + db: database.db, + tracer: trace.getTracer("slot-resolver-integration-test"), + storage: { + connections: connectionStorage, + }, + } as unknown as MeshContext; +} + +describe("slot resolution at virtual MCP client construction", () => { + let database: MeshDatabase; + let connectionStorage: ConnectionStorage; + let virtualMcps: VirtualMCPStorage; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + const vault = new CredentialVault(CredentialVault.generateKey()); + connectionStorage = new ConnectionStorage(database.db, vault); + virtualMcps = new VirtualMCPStorage(database.db); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("resolves slot to caller's user-private connection", async () => { + // Caller has both an org-shared and a user-private connection of the + // same app_id; the user-private one wins. + await insertConn(database, "conn_shared", { + appId: "mcp-github", + access: "org", + }); + await insertConn(database, "conn_private", { + appId: "mcp-github", + access: "user", + createdBy: USER_A, + }); + + const agent = await virtualMcps.create(ORG, USER_A, { + title: "agent with slot", + status: "active", + pinned: false, + connections: [], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + const ctx = buildContext(database, USER_A, connectionStorage); + const client = await createVirtualClientFrom(agent, ctx, "passthrough"); + + // The PassthroughClient's view of children must include the resolved + // private connection on BOTH the metadata array and the entity array. + const titleMap = client.getConnectionTitleMap(); + expect(titleMap.has("conn_private")).toBe(true); + expect(titleMap.has("conn_shared")).toBe(false); + }); + + it("falls back to org-shared when caller has no private connection", async () => { + await insertConn(database, "conn_shared", { + appId: "mcp-github", + access: "org", + }); + + const agent = await virtualMcps.create(ORG, USER_A, { + title: "agent with slot", + status: "active", + pinned: false, + connections: [], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + // USER_B has no user-private mcp-github connection — must fall back + // to the org-shared one. + const ctx = buildContext(database, USER_B, connectionStorage); + const client = await createVirtualClientFrom(agent, ctx, "passthrough"); + + const titleMap = client.getConnectionTitleMap(); + expect(titleMap.has("conn_shared")).toBe(true); + }); + + it("throws SlotUnresolvedError when no matching connection exists", async () => { + // Agent declares a slot for mcp-github but no such connection exists + // in the org. + const agent = await virtualMcps.create(ORG, USER_A, { + title: "agent with unresolved slot", + status: "active", + pinned: false, + connections: [], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + const ctx = buildContext(database, USER_A, connectionStorage); + + let caught: unknown; + try { + await createVirtualClientFrom(agent, ctx, "passthrough"); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(SlotUnresolvedError); + expect((caught as SlotUnresolvedError).appIds).toContain("mcp-github"); + }); + + it("collects all unresolved slots and reports every missing app_id", async () => { + // Agent declares two slots — mcp-github and google-gmail — but the + // calling user has connections for neither. The error must list both + // app_ids so the UI can show a single card covering all missing apps. + await insertConn(database, "conn_github_other", { + appId: "mcp-github", + access: "user", + createdBy: USER_B, // owned by a different user, not the caller + }); + + const agent = await virtualMcps.create(ORG, USER_A, { + title: "multi-slot agent", + status: "active", + pinned: false, + connections: [], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + { + slot_app_id: "google-gmail", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + // USER_A has no connections for either app_id. + const ctx = buildContext(database, USER_A, connectionStorage); + + let caught: unknown; + try { + await createVirtualClientFrom(agent, ctx, "passthrough"); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(SlotUnresolvedError); + const err = caught as SlotUnresolvedError; + expect(err.appIds).toHaveLength(2); + expect(err.appIds).toContain("mcp-github"); + expect(err.appIds).toContain("google-gmail"); + }); +}); diff --git a/apps/mesh/src/shared/utils/connection-access-tab.test.ts b/apps/mesh/src/shared/utils/connection-access-tab.test.ts new file mode 100644 index 0000000000..3354e06523 --- /dev/null +++ b/apps/mesh/src/shared/utils/connection-access-tab.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { + accessTabWhereValue, + coerceConnectionAccessTab, + countConnectionsByAccess, + filterConnectionsByAccessTab, +} from "./connection-access-tab"; + +const conns = [ + { access: "org" as const }, + { access: "user" as const }, + { access: "user" as const }, +]; + +describe("coerceConnectionAccessTab", () => { + it("keeps valid tabs", () => { + expect(coerceConnectionAccessTab("all")).toBe("all"); + expect(coerceConnectionAccessTab("shared")).toBe("shared"); + expect(coerceConnectionAccessTab("personal")).toBe("personal"); + }); + + it("maps legacy and unknown values to all", () => { + expect(coerceConnectionAccessTab("connected")).toBe("all"); + expect(coerceConnectionAccessTab(undefined)).toBe("all"); + expect(coerceConnectionAccessTab("bogus")).toBe("all"); + }); +}); + +describe("accessTabWhereValue", () => { + it("maps tabs to the access column value", () => { + expect(accessTabWhereValue("all")).toBeNull(); + expect(accessTabWhereValue("shared")).toBe("org"); + expect(accessTabWhereValue("personal")).toBe("user"); + }); +}); + +describe("filterConnectionsByAccessTab", () => { + it("returns everything for the all tab", () => { + expect(filterConnectionsByAccessTab(conns, "all")).toHaveLength(3); + }); + + it("returns only org connections for shared", () => { + expect(filterConnectionsByAccessTab(conns, "shared")).toEqual([ + { access: "org" }, + ]); + }); + + it("returns only user connections for personal", () => { + expect(filterConnectionsByAccessTab(conns, "personal")).toEqual([ + { access: "user" }, + { access: "user" }, + ]); + }); +}); + +describe("countConnectionsByAccess", () => { + it("counts each bucket", () => { + expect(countConnectionsByAccess(conns)).toEqual({ + all: 3, + shared: 1, + personal: 2, + }); + }); +}); diff --git a/apps/mesh/src/shared/utils/connection-access-tab.ts b/apps/mesh/src/shared/utils/connection-access-tab.ts new file mode 100644 index 0000000000..6e3f0dc29c --- /dev/null +++ b/apps/mesh/src/shared/utils/connection-access-tab.ts @@ -0,0 +1,59 @@ +/** + * Tab model that splits connections by their `access` field. + * + * "all" → no filter (every connection the viewer can see) + * "shared" → access === "org" (shared org-wide) + * "personal" → access === "user" (private to the creator) + * + * Shared by the connections settings page and the AddConnectionDialog so the + * tab ids, labels, coercion, and filtering stay in sync across surfaces. + */ +export type ConnectionAccessTab = "all" | "shared" | "personal"; + +/** Minimal shape needed to bucket a connection by visibility. */ +type HasAccess = { access: "user" | "org" }; + +/** + * Coerce an arbitrary stored/URL value into a valid tab. Unknown values — + * including the legacy "connected" tab — fall back to "all". + */ +export function coerceConnectionAccessTab(value: unknown): ConnectionAccessTab { + return value === "shared" || value === "personal" || value === "all" + ? value + : "all"; +} + +/** + * Map a tab to the `access` column value used for server-side `where` + * filtering. "all" → null (no filter); "shared" → "org"; "personal" → "user". + */ +export function accessTabWhereValue( + tab: ConnectionAccessTab, +): "org" | "user" | null { + if (tab === "shared") return "org"; + if (tab === "personal") return "user"; + return null; +} + +/** Client-side filter of connections for a given tab. */ +export function filterConnectionsByAccessTab( + connections: T[], + tab: ConnectionAccessTab, +): T[] { + const value = accessTabWhereValue(tab); + if (value === null) return connections; + return connections.filter((c) => c.access === value); +} + +/** Count connections per access bucket. */ +export function countConnectionsByAccess( + connections: T[], +): { all: number; shared: number; personal: number } { + let shared = 0; + let personal = 0; + for (const c of connections) { + if (c.access === "org") shared++; + else if (c.access === "user") personal++; + } + return { all: connections.length, shared, personal }; +} diff --git a/apps/mesh/src/storage/connection-access.integration.test.ts b/apps/mesh/src/storage/connection-access.integration.test.ts new file mode 100644 index 0000000000..8b0d0d1153 --- /dev/null +++ b/apps/mesh/src/storage/connection-access.integration.test.ts @@ -0,0 +1,100 @@ +/** + * Verifies that ConnectionStorage.list and findById honor the access column: + * - Org-shared rows are visible to everyone. + * - User-private rows are visible only to their creator. + * - When viewer is `null`, only org-shared rows are returned. + * - When viewer is INTERNAL_VIEWER, every row is returned (trusted infra). + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { sql } from "kysely"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../database/test-db-pg"; +import type { MeshDatabase } from "../database"; +import { ConnectionStorage } from "./connection"; +import { INTERNAL_VIEWER } from "./ports"; +import { CredentialVault } from "../encryption/credential-vault"; + +const USER_A = "user_test"; +const USER_B = "user_1"; +const ORG = "org_test"; + +async function seed(database: MeshDatabase): Promise { + const now = new Date().toISOString(); + for (const [id, createdBy, access] of [ + ["conn_a_private", USER_A, "user"], + ["conn_a_org", USER_A, "org"], + ["conn_b_private", USER_B, "user"], + ] as const) { + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, access, status, created_at, updated_at + ) VALUES ( + ${id}, ${ORG}, ${createdBy}, ${id}, 'HTTP', + 'https://example.com', ${id + "-app"}, ${access}, + 'active', ${now}, ${now} + ) + `.execute(database.db); + } +} + +describe("ConnectionStorage — access filtering", () => { + let database: MeshDatabase; + let storage: ConnectionStorage; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + await seed(database); + const vault = new CredentialVault(CredentialVault.generateKey()); + storage = new ConnectionStorage(database.db, vault); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("list as USER_A: returns own private + org-shared, not USER_B's private", async () => { + const { items } = await storage.list(ORG, { viewer: USER_A }); + const ids = items.map((c) => c.id).sort(); + expect(ids).toEqual(["conn_a_org", "conn_a_private"]); + }); + + it("list as USER_B: returns own private + org-shared, not USER_A's private", async () => { + const { items } = await storage.list(ORG, { viewer: USER_B }); + const ids = items.map((c) => c.id).sort(); + expect(ids).toEqual(["conn_a_org", "conn_b_private"]); + }); + + it("list with viewer=null returns only org-shared", async () => { + const { items } = await storage.list(ORG, { viewer: null }); + expect(items.map((c) => c.id)).toEqual(["conn_a_org"]); + }); + + it("list with INTERNAL_VIEWER returns every row", async () => { + const { items } = await storage.list(ORG, { viewer: INTERNAL_VIEWER }); + const ids = items.map((c) => c.id).sort(); + expect(ids).toEqual(["conn_a_org", "conn_a_private", "conn_b_private"]); + }); + + it("findById hides another user's private connection", async () => { + const visible = await storage.findById("conn_b_private", ORG, USER_A); + expect(visible).toBeNull(); + }); + + it("findById returns own private connection", async () => { + const own = await storage.findById("conn_a_private", ORG, USER_A); + expect(own?.id).toBe("conn_a_private"); + }); + + it("findById returns org-shared connection for any viewer", async () => { + const shared = await storage.findById("conn_a_org", ORG, USER_B); + expect(shared?.id).toBe("conn_a_org"); + }); +}); diff --git a/apps/mesh/src/storage/connection.integration.test.ts b/apps/mesh/src/storage/connection.integration.test.ts index 0508efcc2d..6ac11335c2 100644 --- a/apps/mesh/src/storage/connection.integration.test.ts +++ b/apps/mesh/src/storage/connection.integration.test.ts @@ -7,6 +7,7 @@ import { } from "../database/test-db-pg"; import type { MeshDatabase } from "../database"; import { ConnectionStorage } from "./connection"; +import { INTERNAL_VIEWER } from "./ports"; import { CredentialVault } from "../encryption/credential-vault"; describe("ConnectionStorage", () => { @@ -97,13 +98,21 @@ describe("ConnectionStorage", () => { connection_url: "https://test.com", }); - const found = await storage.findById(created.id); + const found = await storage.findById( + created.id, + "org_123", + INTERNAL_VIEWER, + ); expect(found).not.toBeNull(); expect(found?.title).toBe("Find Me"); }); it("should return null for non-existent ID", async () => { - const found = await storage.findById("conn_nonexistent"); + const found = await storage.findById( + "conn_nonexistent", + "org_123", + INTERNAL_VIEWER, + ); expect(found).toBeNull(); }); }); @@ -126,7 +135,9 @@ describe("ConnectionStorage", () => { connection_url: "https://gmail.com", }); - const { items: connections } = await storage.list("org_123"); + const { items: connections } = await storage.list("org_123", { + viewer: INTERNAL_VIEWER, + }); expect(connections.length).toBeGreaterThanOrEqual(2); expect(connections.every((c) => c.organization_id === "org_123")).toBe( true, @@ -142,7 +153,9 @@ describe("ConnectionStorage", () => { connection_url: "https://other.com", }); - const { items: connections } = await storage.list("org_123"); + const { items: connections } = await storage.list("org_123", { + viewer: INTERNAL_VIEWER, + }); expect(connections.every((c) => c.organization_id === "org_123")).toBe( true, ); @@ -181,27 +194,40 @@ describe("ConnectionStorage", () => { }); it("should filter by slug (app_name present)", async () => { - const { items } = await storage.list("org_123", { slug: "alpha-app" }); + const { items } = await storage.list("org_123", { + slug: "alpha-app", + viewer: INTERNAL_VIEWER, + }); expect(items).toHaveLength(1); expect(items[0]!.title).toBe("Alpha"); }); it("should filter by slug (derived from connection_url)", async () => { - const { items: all } = await storage.list("org_123"); + const { items: all } = await storage.list("org_123", { + viewer: INTERNAL_VIEWER, + }); const beta = all.find((c) => c.title === "Beta")!; expect(beta.slug).toBeTruthy(); - const { items } = await storage.list("org_123", { slug: beta.slug! }); + const { items } = await storage.list("org_123", { + slug: beta.slug!, + viewer: INTERNAL_VIEWER, + }); expect(items).toHaveLength(1); expect(items[0]!.title).toBe("Beta"); }); it("should filter by slug (derived from connection_url when no app_name)", async () => { - const { items: all } = await storage.list("org_123"); + const { items: all } = await storage.list("org_123", { + viewer: INTERNAL_VIEWER, + }); const gamma = all.find((c) => c.title === "Gamma")!; expect(gamma.slug).toBeTruthy(); - const { items } = await storage.list("org_123", { slug: gamma.slug! }); + const { items } = await storage.list("org_123", { + slug: gamma.slug!, + viewer: INTERNAL_VIEWER, + }); expect(items).toHaveLength(1); expect(items[0]!.title).toBe("Gamma"); }); @@ -209,6 +235,7 @@ describe("ConnectionStorage", () => { it("should filter with where eq expression", async () => { const { items } = await storage.list("org_123", { where: { field: ["connection_type"], operator: "eq", value: "SSE" }, + viewer: INTERNAL_VIEWER, }); expect(items.every((c) => c.connection_type === "SSE")).toBe(true); expect(items.some((c) => c.title === "Gamma")).toBe(true); @@ -221,6 +248,7 @@ describe("ConnectionStorage", () => { operator: "like", value: "https://alpha%", }, + viewer: INTERNAL_VIEWER, }); expect(items).toHaveLength(1); expect(items[0]!.title).toBe("Alpha"); @@ -233,6 +261,7 @@ describe("ConnectionStorage", () => { operator: "contains", value: "bet", }, + viewer: INTERNAL_VIEWER, }); expect(items).toHaveLength(1); expect(items[0]!.title).toBe("Beta"); @@ -245,6 +274,7 @@ describe("ConnectionStorage", () => { operator: "in", value: ["SSE", "Websocket"], }, + viewer: INTERNAL_VIEWER, }); expect(items.every((c) => c.connection_type === "SSE")).toBe(true); }); @@ -258,6 +288,7 @@ describe("ConnectionStorage", () => { { field: ["title"], operator: "eq", value: "Gamma" }, ], }, + viewer: INTERNAL_VIEWER, }); expect(items).toHaveLength(2); const titles = items.map((c) => c.title).sort(); @@ -267,20 +298,31 @@ describe("ConnectionStorage", () => { it("should apply orderBy", async () => { const { items } = await storage.list("org_123", { orderBy: [{ field: ["title"], direction: "desc", nulls: "last" }], + viewer: INTERNAL_VIEWER, }); const titles = items.map((c) => c.title); expect(titles).toEqual([...titles].sort().reverse()); }); it("should apply pagination", async () => { - const { totalCount } = await storage.list("org_123"); + const { totalCount } = await storage.list("org_123", { + viewer: INTERNAL_VIEWER, + }); expect(totalCount).toBeGreaterThanOrEqual(3); - const page1 = await storage.list("org_123", { limit: 2, offset: 0 }); + const page1 = await storage.list("org_123", { + limit: 2, + offset: 0, + viewer: INTERNAL_VIEWER, + }); expect(page1.items).toHaveLength(2); expect(page1.totalCount).toBe(totalCount); - const page2 = await storage.list("org_123", { limit: 2, offset: 2 }); + const page2 = await storage.list("org_123", { + limit: 2, + offset: 2, + viewer: INTERNAL_VIEWER, + }); expect(page2.items.length).toBeGreaterThanOrEqual(1); expect(page2.totalCount).toBe(totalCount); @@ -292,6 +334,7 @@ describe("ConnectionStorage", () => { it("should return correct totalCount with filters", async () => { const { totalCount } = await storage.list("org_123", { where: { field: ["connection_type"], operator: "eq", value: "HTTP" }, + viewer: INTERNAL_VIEWER, }); // At least Alpha and Beta are HTTP expect(totalCount).toBeGreaterThanOrEqual(2); @@ -306,7 +349,7 @@ describe("ConnectionStorage", () => { title: "Slug Test App Name", app_name: "my-cool-app", connection_type: "HTTP", - connection_url: "https://test.com", + connection_url: "https://slug-appname.test", }); expect(conn.slug).toBe("my-cool-app"); }); @@ -329,7 +372,7 @@ describe("ConnectionStorage", () => { created_by: "user_123", title: "Slug Update Test", connection_type: "HTTP", - connection_url: "https://test.com", + connection_url: "https://slug-update.test", }); const updated = await storage.update(conn.id, { @@ -346,7 +389,7 @@ describe("ConnectionStorage", () => { created_by: "user_123", title: "Original Name", connection_type: "HTTP", - connection_url: "https://test.com", + connection_url: "https://update-title.test", }); const updated = await storage.update(created.id, { @@ -362,7 +405,7 @@ describe("ConnectionStorage", () => { created_by: "user_123", title: "Test", connection_type: "HTTP", - connection_url: "https://test.com", + connection_url: "https://update-status.test", }); const updated = await storage.update(created.id, { @@ -378,7 +421,7 @@ describe("ConnectionStorage", () => { created_by: "user_123", title: "Test", connection_type: "HTTP", - connection_url: "https://test.com", + connection_url: "https://update-metadata.test", }); const updated = await storage.update(created.id, { @@ -394,7 +437,7 @@ describe("ConnectionStorage", () => { created_by: "user_123", title: "Test", connection_type: "HTTP", - connection_url: "https://test.com", + connection_url: "https://update-bindings.test", }); const updated = await storage.update(created.id, { @@ -412,12 +455,16 @@ describe("ConnectionStorage", () => { created_by: "user_123", title: "To Delete", connection_type: "HTTP", - connection_url: "https://test.com", + connection_url: "https://delete-me.test", }); await storage.delete(created.id); - const found = await storage.findById(created.id); + const found = await storage.findById( + created.id, + "org_123", + INTERNAL_VIEWER, + ); expect(found).toBeNull(); }); }); @@ -452,7 +499,7 @@ describe("ConnectionStorage", () => { created_by: "user_123", title: "JSON Test", connection_type: "SSE", - connection_url: "https://test.com", + connection_url: "https://json-test.test", connection_headers: { headers: { "X-Test": "value" } }, metadata: { key: "value" }, }); diff --git a/apps/mesh/src/storage/connection.ts b/apps/mesh/src/storage/connection.ts index aa60f5843a..f2965b757f 100644 --- a/apps/mesh/src/storage/connection.ts +++ b/apps/mesh/src/storage/connection.ts @@ -31,8 +31,10 @@ import { isDecopilot, } from "@decocms/mesh-sdk"; import { getConnectionSlug } from "@/shared/utils/connection-slug"; -import type { ConnectionStoragePort } from "./ports"; +import { INTERNAL_VIEWER } from "./ports"; +import type { ConnectionStoragePort, ConnectionViewer } from "./ports"; import type { Database } from "./types"; +import { deriveAppId } from "./derive-app-id"; /** JSON fields that need serialization/deserialization */ const JSON_FIELDS = [ @@ -65,6 +67,7 @@ type RawConnectionRow = { metadata: string | Record | null; bindings: string | string[] | null; status: "active" | "inactive" | "error"; + access: "user" | "org"; created_at: Date | string; updated_at: Date | string; }; @@ -84,6 +87,7 @@ const TOP_LEVEL_COLUMNS = new Set([ "connection_url", // connection_token is intentionally excluded — sensitive "status", + "access", "created_at", "updated_at", ]); @@ -182,6 +186,24 @@ function applyWhereToSql(where: WhereExpression): RawBuilder { } } +/** + * Translates the Postgres unique-violation on idx_connections_user_app_unique + * into a user-facing error. Re-throws anything else untouched. + */ +function rethrowDuplicateConnectionError(err: unknown): never { + const e = err as { code?: string; constraint?: string; message?: string }; + const isDup = + e?.code === "23505" && + (e?.constraint === "idx_connections_user_app_unique" || + (e?.message ?? "").includes("idx_connections_user_app_unique")); + if (isDup) { + throw new Error( + "A private connection for this service already exists. Each service can have only one private connection per user.", + ); + } + throw err; +} + export class ConnectionStorage implements ConnectionStoragePort { constructor( private db: Kysely, @@ -192,7 +214,7 @@ export class ConnectionStorage implements ConnectionStoragePort { const id = data.id ?? generatePrefixedId("conn"); const now = new Date().toISOString(); - const existing = await this.findById(id); + const existing = await this.findById(id, undefined, INTERNAL_VIEWER); if (existing) { // Only allow update if same organization - prevent cross-org hijacking @@ -205,18 +227,23 @@ export class ConnectionStorage implements ConnectionStoragePort { const slug = getConnectionSlug(data); const serialized = await this.serializeConnection({ ...data, + app_id: deriveAppId(data), id: data.id ?? id, slug, status: "active", created_at: now, updated_at: now, }); - await this.db - .insertInto("connections") - .values(serialized as Insertable) - .execute(); + try { + await this.db + .insertInto("connections") + .values(serialized as Insertable) + .execute(); + } catch (err) { + rethrowDuplicateConnectionError(err); + } - const connection = await this.findById(id); + const connection = await this.findById(id, undefined, INTERNAL_VIEWER); if (!connection) { throw new Error(`Failed to create connection with id: ${id}`); } @@ -226,7 +253,8 @@ export class ConnectionStorage implements ConnectionStoragePort { async findById( id: string, - organizationId?: string, + organizationId: string | undefined, + viewer: ConnectionViewer, ): Promise { // Handle Decopilot ID - return Decopilot connection entity const decopilotOrgId = isDecopilot(id); @@ -245,18 +273,35 @@ export class ConnectionStorage implements ConnectionStoragePort { } const row = await query.executeTakeFirst(); - return row ? this.deserializeConnection(row as RawConnectionRow) : null; + if (!row) return null; + + const rawRow = row as RawConnectionRow; + + // Visibility filter: hide other users' user-private connections from the caller. + // `viewer` is required and explicit — callers must opt into INTERNAL_VIEWER to + // bypass the per-user filter. A string viewer is the user's id; null is an + // unauthenticated/system caller and only sees org-shared rows. + if (viewer !== INTERNAL_VIEWER) { + if (rawRow.access === "user") { + if (viewer === null || rawRow.created_by !== viewer) { + return null; + } + } + } + + return this.deserializeConnection(rawRow); } async list( organizationId: string, - options?: { + options: { includeVirtual?: boolean; slug?: string; where?: WhereExpression; orderBy?: OrderByExpression[]; limit?: number; offset?: number; + viewer: ConnectionViewer; }, ): Promise<{ items: ConnectionEntity[]; totalCount: number }> { let query = this.db @@ -264,6 +309,28 @@ export class ConnectionStorage implements ConnectionStoragePort { .selectAll() .where("organization_id", "=", organizationId); + // Per-user visibility: hide other users' user-private connections. + // `viewer` is required so callers must opt into INTERNAL_VIEWER to see + // every row. A string viewer is the user's id; null is an unauthenticated + // /system caller and only sees org-shared rows. + if (options.viewer !== INTERNAL_VIEWER) { + if (options.viewer === null) { + // Explicit null viewer (system / unauthenticated) → only org-shared rows. + query = query.where("access", "=", "org"); + } else { + const viewerUserId = options.viewer; + query = query.where((eb) => + eb.or([ + eb("access", "=", "org"), + eb.and([ + eb("access", "=", "user"), + eb("created_by", "=", viewerUserId), + ]), + ]), + ); + } + } + // By default, exclude VIRTUAL connections unless explicitly requested if (!options?.includeVirtual) { query = query.where("connection_type", "!=", "VIRTUAL"); @@ -319,27 +386,55 @@ export class ConnectionStorage implements ConnectionStoragePort { data: Partial, ): Promise { if (Object.keys(data).length === 0) { - const connection = await this.findById(id); + const connection = await this.findById(id, undefined, INTERNAL_VIEWER); if (!connection) throw new Error("Connection not found"); return connection; } - // Recompute slug if any slug-relevant field changed const slugData: Record = { ...data }; - if ( + + // Reload existing once if we need it for slug recomputation or app_id + // re-derivation. + const needsExisting = data.app_name !== undefined || data.connection_url !== undefined || - data.title !== undefined + data.title !== undefined || + data.connection_headers !== undefined || + data.connection_type !== undefined; + const existing = needsExisting + ? await this.findById(id, undefined, INTERNAL_VIEWER) + : null; + + // Recompute slug if any slug-relevant field changed + if ( + existing && + (data.app_name !== undefined || + data.connection_url !== undefined || + data.title !== undefined) ) { - const existing = await this.findById(id); - if (existing) { - slugData.slug = getConnectionSlug({ - app_name: data.app_name ?? existing.app_name, - connection_url: data.connection_url ?? existing.connection_url, - title: data.title ?? existing.title, - id, - }); - } + slugData.slug = getConnectionSlug({ + app_name: data.app_name ?? existing.app_name, + connection_url: data.connection_url ?? existing.connection_url, + title: data.title ?? existing.title, + id, + }); + } + + // Re-derive app_id when transport details change. deriveAppId preserves a + // real registry app_id and only recomputes null/synthetic ids. + if ( + existing && + (data.connection_url !== undefined || + data.connection_headers !== undefined || + data.connection_type !== undefined) + ) { + slugData.app_id = deriveAppId({ + connection_type: data.connection_type ?? existing.connection_type, + connection_url: data.connection_url ?? existing.connection_url, + connection_headers: + data.connection_headers ?? existing.connection_headers, + app_id: data.app_id ?? existing.app_id, + }); } const serialized = await this.serializeConnection({ @@ -347,13 +442,17 @@ export class ConnectionStorage implements ConnectionStoragePort { updated_at: new Date().toISOString(), }); - await this.db - .updateTable("connections") - .set(serialized) - .where("id", "=", id) - .execute(); + try { + await this.db + .updateTable("connections") + .set(serialized) + .where("id", "=", id) + .execute(); + } catch (err) { + rethrowDuplicateConnectionError(err); + } - const connection = await this.findById(id); + const connection = await this.findById(id, undefined, INTERNAL_VIEWER); if (!connection) { throw new Error("Connection not found after update"); } @@ -369,7 +468,7 @@ export class ConnectionStorage implements ConnectionStoragePort { id: string, headers?: Record, ): Promise<{ healthy: boolean; latencyMs: number }> { - const connection = await this.findById(id); + const connection = await this.findById(id, undefined, INTERNAL_VIEWER); if (!connection) { throw new Error("Connection not found"); } @@ -569,6 +668,7 @@ export class ConnectionStorage implements ConnectionStoragePort { tools: null, bindings: parseJson(row.bindings), status: row.status, + access: row.access ?? "user", created_at: row.created_at as string, updated_at: row.updated_at as string, }; diff --git a/apps/mesh/src/storage/derive-app-id-fill.integration.test.ts b/apps/mesh/src/storage/derive-app-id-fill.integration.test.ts new file mode 100644 index 0000000000..c4541d2fb6 --- /dev/null +++ b/apps/mesh/src/storage/derive-app-id-fill.integration.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../database/test-db-pg"; +import type { MeshDatabase } from "../database"; +import { ConnectionStorage } from "./connection"; +import { CredentialVault } from "../encryption/credential-vault"; + +const USER = "user_test"; +const ORG = "org_test"; + +describe("ConnectionStorage — app_id derivation", () => { + let database: MeshDatabase; + let storage: ConnectionStorage; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + const vault = new CredentialVault(CredentialVault.generateKey()); + storage = new ConnectionStorage(database.db, vault); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("fills a synthetic app_id on create when none is provided", async () => { + const conn = await storage.create({ + organization_id: ORG, + created_by: USER, + title: "custom", + connection_type: "HTTP", + connection_url: "https://API.Example.com/mcp/?token=x", + }); + expect(conn.app_id).toBe("url:api.example.com/mcp"); + }); + + it("preserves a registry app_id on create", async () => { + const conn = await storage.create({ + organization_id: ORG, + created_by: USER, + title: "gh", + connection_type: "HTTP", + connection_url: "https://api.github.com/mcp", + app_id: "deco/mcp-github", + }); + expect(conn.app_id).toBe("deco/mcp-github"); + }); + + it("re-derives a synthetic app_id when the url changes on update", async () => { + const conn = await storage.create({ + organization_id: ORG, + created_by: USER, + title: "custom", + connection_type: "HTTP", + connection_url: "https://old.com/mcp", + }); + expect(conn.app_id).toBe("url:old.com/mcp"); + + const updated = await storage.update(conn.id, { + connection_url: "https://new.com/mcp", + }); + expect(updated.app_id).toBe("url:new.com/mcp"); + }); + + it("never re-derives a real registry app_id on update", async () => { + const conn = await storage.create({ + organization_id: ORG, + created_by: USER, + title: "gh", + connection_type: "HTTP", + connection_url: "https://api.github.com/mcp", + app_id: "deco/mcp-github", + }); + + const updated = await storage.update(conn.id, { + connection_url: "https://api.github.com/v2/mcp", + }); + expect(updated.app_id).toBe("deco/mcp-github"); + }); + + it("rejects a second private connection to the same service for the same user", async () => { + await storage.create({ + organization_id: ORG, + created_by: USER, + title: "first", + connection_type: "HTTP", + connection_url: "https://dup.com/mcp", + }); + + await expect( + storage.create({ + organization_id: ORG, + created_by: USER, + title: "second", + connection_type: "HTTP", + connection_url: "https://dup.com/mcp", + }), + ).rejects.toThrow(/only one private connection per user/i); + }); +}); diff --git a/apps/mesh/src/storage/derive-app-id.test.ts b/apps/mesh/src/storage/derive-app-id.test.ts new file mode 100644 index 0000000000..da2cbdcb03 --- /dev/null +++ b/apps/mesh/src/storage/derive-app-id.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "bun:test"; +import { deriveAppId, isSyntheticAppId } from "./derive-app-id"; + +describe("deriveAppId", () => { + it("returns null for VIRTUAL agents", () => { + expect( + deriveAppId({ + connection_type: "VIRTUAL", + connection_url: "virtual://x", + }), + ).toBeNull(); + }); + + it("preserves a real registry app_id unchanged", () => { + expect( + deriveAppId({ + connection_type: "HTTP", + connection_url: "https://api.github.com/mcp", + app_id: "deco/mcp-github", + }), + ).toBe("deco/mcp-github"); + }); + + it("derives url: from an HTTP url, dropping query and trailing slash, lowercasing host", () => { + expect( + deriveAppId({ + connection_type: "HTTP", + connection_url: "https://API.Example.com:443/mcp/?token=abc", + }), + ).toBe("url:api.example.com/mcp"); + }); + + it("keeps distinct paths distinct", () => { + expect( + deriveAppId({ + connection_type: "HTTP", + connection_url: "https://x.com/a/mcp", + }), + ).toBe("url:x.com/a/mcp"); + expect( + deriveAppId({ + connection_type: "HTTP", + connection_url: "https://x.com/b/mcp", + }), + ).toBe("url:x.com/b/mcp"); + }); + + it("keeps a non-default port", () => { + expect( + deriveAppId({ + connection_type: "HTTP", + connection_url: "http://x.com:8080/mcp", + }), + ).toBe("url:x.com:8080/mcp"); + }); + + it("derives npx: from an npx STDIO connection", () => { + expect( + deriveAppId({ + connection_type: "STDIO", + connection_headers: { command: "npx", args: ["-y", "@deco/foo"] }, + }), + ).toBe("npx:@deco/foo"); + }); + + it("derives stdio: from a generic STDIO command", () => { + expect( + deriveAppId({ + connection_type: "STDIO", + connection_headers: { command: "node", args: ["server.js"] }, + }), + ).toBe("stdio:node-server-js"); + }); + + it("parses connection_headers when given as a JSON string", () => { + expect( + deriveAppId({ + connection_type: "STDIO", + connection_headers: JSON.stringify({ command: "npx", args: ["pkg"] }), + }), + ).toBe("npx:pkg"); + }); + + it("re-derives when the current app_id is synthetic", () => { + expect( + deriveAppId({ + connection_type: "HTTP", + connection_url: "https://new.com/mcp", + app_id: "url:old.com/mcp", + }), + ).toBe("url:new.com/mcp"); + }); + + it("returns null when nothing is derivable and no app_id exists", () => { + expect(deriveAppId({ connection_type: "STDIO" })).toBeNull(); + }); + + it("isSyntheticAppId recognizes synthetic prefixes only", () => { + expect(isSyntheticAppId("url:x.com")).toBe(true); + expect(isSyntheticAppId("stdio:node")).toBe(true); + expect(isSyntheticAppId("npx:pkg")).toBe(true); + expect(isSyntheticAppId("deco/mcp-github")).toBe(false); + expect(isSyntheticAppId(null)).toBe(false); + }); +}); diff --git a/apps/mesh/src/storage/derive-app-id.ts b/apps/mesh/src/storage/derive-app-id.ts new file mode 100644 index 0000000000..0641a0a22e --- /dev/null +++ b/apps/mesh/src/storage/derive-app-id.ts @@ -0,0 +1,99 @@ +/** + * Derives a stable, deterministic `app_id` for a connection so that the same + * service always yields the same id (which is what lets an agent slot resolve + * to each caller's own connection of that id). Registry connections keep their + * existing `app_id`; only connections with a null or synthetic `app_id` are + * (re)derived. + * + * Synthetic ids are prefixed `url:` / `stdio:` / `npx:`; the prefix doubles as + * the marker that distinguishes a derived id (safe to recompute) from a real + * registry id (never touch). + */ + +import { + type ConnectionParameters, + isStdioParameters, +} from "../tools/connection/schema"; + +const SYNTHETIC_PREFIXES = ["url:", "stdio:", "npx:"] as const; + +export function isSyntheticAppId(appId: string | null | undefined): boolean { + return ( + typeof appId === "string" && + SYNTHETIC_PREFIXES.some((p) => appId.startsWith(p)) + ); +} + +export interface DeriveAppIdInput { + connection_type?: string | null; + connection_url?: string | null; + connection_headers?: unknown; + app_id?: string | null; +} + +export function deriveAppId(input: DeriveAppIdInput): string | null { + // Agents are never a child / slot target. + if (input.connection_type === "VIRTUAL") return null; + + // Preserve a real registry app_id; only (re)derive null/synthetic ids. + if (input.app_id && !isSyntheticAppId(input.app_id)) return input.app_id; + + const params = parseHeaders(input.connection_headers); + if (params && isStdioParameters(params)) { + return deriveStdioAppId(params); + } + + if (input.connection_url) { + const canonical = canonicalizeUrl(input.connection_url); + if (canonical) return `url:${canonical}`; + } + + // Nothing derivable — keep whatever was there (or null). + return input.app_id ?? null; +} + +function parseHeaders(headers: unknown): ConnectionParameters | null { + if (!headers) return null; + if (typeof headers === "string") { + try { + return JSON.parse(headers) as ConnectionParameters; + } catch { + return null; + } + } + return headers as ConnectionParameters; +} + +function canonicalizeUrl(raw: string): string | null { + let u: URL; + try { + u = new URL(raw); + } catch { + return null; + } + const scheme = u.protocol.replace(":", "").toLowerCase(); + const host = u.hostname.toLowerCase(); + const defaultPort = + scheme === "https" ? "443" : scheme === "http" ? "80" : ""; + const port = u.port && u.port !== defaultPort ? `:${u.port}` : ""; + const path = u.pathname.replace(/\/+$/, ""); + return `${host}${port}${path}`; +} + +function deriveStdioAppId(params: ConnectionParameters): string { + const stdio = params as { command?: string; args?: string[] }; + const command = (stdio.command ?? "").trim(); + const args = (stdio.args ?? []).map((a) => a.trim()); + if (command === "npx" || command === "bunx") { + const pkg = args.find((a) => a.length > 0 && !a.startsWith("-")); + if (pkg) return `npx:${pkg}`; + } + return `stdio:${slug([command, ...args].join(" "))}`; +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/apps/mesh/src/storage/ports.ts b/apps/mesh/src/storage/ports.ts index eaa1065cac..381e34f88b 100644 --- a/apps/mesh/src/storage/ports.ts +++ b/apps/mesh/src/storage/ports.ts @@ -228,18 +228,47 @@ export interface AsyncResearchJobStoragePort { // Connection Storage Port // ============================================================================ +/** + * Sentinel value used by trusted internal infrastructure (background workers, + * virtual-MCP loaders, OAuth proxy plumbing) that needs to see every + * connection row in an organization regardless of the per-user `access` + * column. Callers from user-facing handlers must pass the actual user id + * (or `null` for an unauthenticated/system caller) so the storage layer + * hides other users' user-private connections. + * + * Using a Symbol guarantees the bypass cannot be triggered by accident — + * forgetting the parameter is a type error, and no user id can collide + * with this value. + */ +export const INTERNAL_VIEWER: unique symbol = Symbol("INTERNAL_VIEWER"); + +/** + * The viewer identity supplied to `ConnectionStorage.findById` / `list`. + * - `string`: a specific user id; user-private rows belonging to other + * users are hidden. + * - `null`: unauthenticated / system caller; only org-shared rows are + * returned. + * - `INTERNAL_VIEWER`: trusted internal infra; every row is returned. + */ +export type ConnectionViewer = string | null | typeof INTERNAL_VIEWER; + export interface ConnectionStoragePort { create(data: Partial): Promise; - findById(id: string): Promise; + findById( + id: string, + organizationId: string | undefined, + viewer: ConnectionViewer, + ): Promise; list( organizationId: string, - options?: { + options: { includeVirtual?: boolean; slug?: string; where?: WhereExpression; orderBy?: OrderByExpression[]; limit?: number; offset?: number; + viewer: ConnectionViewer; }, ): Promise<{ items: ConnectionEntity[]; totalCount: number }>; update( diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index 6fd3de2b9c..e187207e25 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -207,6 +207,17 @@ export interface MCPConnectionTable { metadata: JsonObject> | null; bindings: JsonArray | null; // Detected bindings (CHAT, EMAIL, etc.) + /** + * Visibility/ownership of this connection. + * - 'user': private to created_by. Only that user sees/uses it. + * - 'org': visible and usable by every member of the organization. + * Existing rows backfilled to 'org'; new rows default to 'user'. + */ + access: ColumnType< + "user" | "org", + "user" | "org" | undefined, + "user" | "org" + >; status: "active" | "inactive" | "error"; pinned: boolean; created_at: ColumnType; @@ -789,7 +800,17 @@ export type DependencyMode = "direct" | "indirect"; export interface ConnectionAggregationTable { id: string; parent_connection_id: string; // The VIRTUAL connection (agent) - child_connection_id: string; // The connection being aggregated + /** + * Concrete child connection. NULL means this row is a slot + * (see slot_app_id). XOR enforced by DB CHECK. + */ + child_connection_id: string | null; + /** + * Slot binding — resolved at runtime to the caller's connection of + * this app_id. NULL means this row uses a concrete child_connection_id. + * XOR enforced by DB CHECK. + */ + slot_app_id: string | null; selected_tools: JsonArray | null; // null = all tools selected_resources: JsonArray | null; // null = all resources, supports URI patterns with * and ** selected_prompts: JsonArray | null; // null = all prompts diff --git a/apps/mesh/src/storage/virtual-slots.integration.test.ts b/apps/mesh/src/storage/virtual-slots.integration.test.ts new file mode 100644 index 0000000000..835ef4e2ae --- /dev/null +++ b/apps/mesh/src/storage/virtual-slots.integration.test.ts @@ -0,0 +1,256 @@ +/** + * Storage-layer tests for slot rows on Virtual MCPs. + * + * Verifies that the aggregations table can store both concrete child + * connections and typed slots, and that VirtualMCPStorage round-trips + * them into the new `slots` field on VirtualMCPEntity. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { sql } from "kysely"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../database/test-db-pg"; +import type { MeshDatabase } from "../database"; +import { VirtualMCPStorage } from "./virtual"; + +const USER = "user_test"; +const ORG = "org_test"; + +async function insertChildConnection( + database: MeshDatabase, + id: string, + appId: string, +): Promise { + const now = new Date().toISOString(); + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, access, status, created_at, updated_at + ) VALUES ( + ${id}, ${ORG}, ${USER}, 'child', 'HTTP', + 'https://example.com', ${appId}, 'org', + 'active', ${now}, ${now} + ) + `.execute(database.db); +} + +describe("VirtualMCPStorage — slots", () => { + let database: MeshDatabase; + let storage: VirtualMCPStorage; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + storage = new VirtualMCPStorage(database.db); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("creates an agent with a concrete child and a slot, round-trips both", async () => { + await insertChildConnection(database, "conn_concrete", "mcp-linear"); + + const entity = await storage.create(ORG, USER, { + title: "test agent", + status: "active", + pinned: false, + connections: [ + { + connection_id: "conn_concrete", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + expect(entity.connections).toHaveLength(1); + expect(entity.connections[0]?.connection_id).toBe("conn_concrete"); + expect(entity.slots).toHaveLength(1); + expect(entity.slots[0]?.slot_app_id).toBe("mcp-github"); + + const reloaded = await storage.findById(entity.id, ORG); + expect(reloaded?.connections).toHaveLength(1); + expect(reloaded?.slots).toHaveLength(1); + expect(reloaded?.slots[0]?.slot_app_id).toBe("mcp-github"); + }); + + it("update replaces slots alongside connections", async () => { + await insertChildConnection(database, "conn_a", "mcp-linear"); + await insertChildConnection(database, "conn_b", "mcp-notion"); + + const entity = await storage.create(ORG, USER, { + title: "test agent", + status: "active", + pinned: false, + connections: [ + { + connection_id: "conn_a", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + const updated = await storage.update(entity.id, USER, { + connections: [ + { + connection_id: "conn_b", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + slots: [ + { + slot_app_id: "mcp-slack", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + expect(updated?.connections.map((c) => c.connection_id)).toEqual([ + "conn_b", + ]); + expect(updated?.slots.map((s) => s.slot_app_id)).toEqual(["mcp-slack"]); + }); + + it("update preserves slots when caller omits the slots field", async () => { + await insertChildConnection(database, "conn_a", "mcp-linear"); + await insertChildConnection(database, "conn_b", "mcp-notion"); + + const entity = await storage.create(ORG, USER, { + title: "test agent", + status: "active", + pinned: false, + connections: [ + { + connection_id: "conn_a", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + // Update ONLY connections — slots field is omitted entirely. + const updated = await storage.update(entity.id, USER, { + connections: [ + { + connection_id: "conn_b", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + expect(updated?.connections.map((c) => c.connection_id)).toEqual([ + "conn_b", + ]); + expect(updated?.slots.map((s) => s.slot_app_id)).toEqual(["mcp-github"]); + }); + + it("update preserves connections when caller omits the connections field", async () => { + await insertChildConnection(database, "conn_a", "mcp-linear"); + + const entity = await storage.create(ORG, USER, { + title: "test agent", + status: "active", + pinned: false, + connections: [ + { + connection_id: "conn_a", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + // Update ONLY slots — connections field is omitted entirely. + const updated = await storage.update(entity.id, USER, { + slots: [ + { + slot_app_id: "mcp-slack", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + + expect(updated?.connections.map((c) => c.connection_id)).toEqual([ + "conn_a", + ]); + expect(updated?.slots.map((s) => s.slot_app_id)).toEqual(["mcp-slack"]); + }); + + it("XOR enforced by DB: concrete child + slot in same agent are stored on separate rows", async () => { + await insertChildConnection(database, "conn_x", "mcp-linear"); + const entity = await storage.create(ORG, USER, { + title: "test", + status: "active", + pinned: false, + connections: [ + { + connection_id: "conn_x", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + slots: [ + { + slot_app_id: "mcp-github", + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + }); + expect(entity.connections).toHaveLength(1); + expect(entity.slots).toHaveLength(1); + }); +}); diff --git a/apps/mesh/src/storage/virtual.integration.test.ts b/apps/mesh/src/storage/virtual.integration.test.ts index e6a02cefb7..72d39f7fe0 100644 --- a/apps/mesh/src/storage/virtual.integration.test.ts +++ b/apps/mesh/src/storage/virtual.integration.test.ts @@ -1,14 +1,27 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + test, +} from "bun:test"; import { sql } from "kysely"; import { closeTestPgDatabase, connectTestPgDatabase, resetTestPgDatabase, + seedCommonTestPgFixtures, } from "../database/test-db-pg"; import type { MeshDatabase } from "../database"; import { VirtualMCPStorage } from "./virtual"; import { getDecopilotId } from "@decocms/mesh-sdk"; +const USER = "user_test"; +const ORG = "org_test"; + describe("VirtualMCPStorage.findById (Decopilot)", () => { let database: MeshDatabase; let storage: VirtualMCPStorage; @@ -79,3 +92,34 @@ describe("VirtualMCPStorage.findById (Decopilot)", () => { expect(decopilot?.connections).toEqual([]); }); }); + +describe("VirtualMCPStorage — org-scoped access", () => { + let database: MeshDatabase; + let storage: VirtualMCPStorage; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + storage = new VirtualMCPStorage(database.db); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("creates agents as org-scoped regardless of the connections default", async () => { + const entity = await storage.create(ORG, USER, { + title: "org agent", + status: "active", + pinned: false, + connections: [], + slots: [], + }); + + const row = (await sql<{ access: string }>` + SELECT access FROM connections WHERE id = ${entity.id} + `.execute(database.db)) as unknown as { rows: { access: string }[] }; + expect(row.rows[0]?.access).toBe("org"); + }); +}); diff --git a/apps/mesh/src/storage/virtual.ts b/apps/mesh/src/storage/virtual.ts index 6b155f7712..a6f687265c 100644 --- a/apps/mesh/src/storage/virtual.ts +++ b/apps/mesh/src/storage/virtual.ts @@ -26,6 +26,10 @@ import type { VirtualMCPUpdateData, } from "./ports"; import type { Database, DependencyMode } from "./types"; +import type { + VirtualMCPConnection, + VirtualMCPSlot, +} from "@decocms/mesh-sdk/types"; /** Raw database row type for connections (VIRTUAL type) */ type RawConnectionRow = { @@ -47,7 +51,8 @@ type RawConnectionRow = { type RawAggregationRow = { id: string; parent_connection_id: string; - child_connection_id: string; + child_connection_id: string | null; // null for slot rows + slot_app_id: string | null; // null for concrete rows; XOR with child_connection_id selected_tools: string | string[] | null; selected_resources: string | string[] | null; selected_prompts: string | string[] | null; @@ -80,6 +85,7 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { app_name: null, app_id: null, connection_type: "VIRTUAL", + access: "org", pinned: data.pinned ?? false, connection_url: `virtual://${id}`, connection_token: null, @@ -104,6 +110,7 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { id: generatePrefixedId("agg"), parent_connection_id: id, child_connection_id: conn.connection_id, + slot_app_id: null, selected_tools: conn.selected_tools ? JSON.stringify(conn.selected_tools) : null, @@ -120,6 +127,32 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { .execute(); } + // Insert slot aggregations (typed dependencies resolved at runtime) + if (data.slots && data.slots.length > 0) { + await this.db + .insertInto("connection_aggregations") + .values( + data.slots.map((slot) => ({ + id: generatePrefixedId("agg"), + parent_connection_id: id, + child_connection_id: null, + slot_app_id: slot.slot_app_id, + selected_tools: slot.selected_tools + ? JSON.stringify(slot.selected_tools) + : null, + selected_resources: slot.selected_resources + ? JSON.stringify(slot.selected_resources) + : null, + selected_prompts: slot.selected_prompts + ? JSON.stringify(slot.selected_prompts) + : null, + dependency_mode: "direct" as DependencyMode, + created_at: now, + })), + ) + .execute(); + } + const virtualMcp = await this.findById(id); if (!virtualMcp) { throw new Error(`Failed to create virtual MCP with id: ${id}`); @@ -144,6 +177,7 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { ...getWellKnownDecopilotVirtualMCP(resolvedOrgId), pinned: false, connections: [], + slots: [], }; } @@ -157,6 +191,7 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { ...getWellKnownBrandContextSetupVirtualMCP(resolvedOrgId), pinned: false, connections: [], + slots: [], }; } @@ -377,19 +412,36 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { .where("connection_type", "=", "VIRTUAL") .execute(); - // Update aggregations if provided - if (data.connections !== undefined) { - // Collect current direct connection IDs before removing them + // Update aggregations if either connections or slots are provided. + // Both ride the same delete/re-insert cycle for 'direct' rows so we + // preserve the existing behaviour where a single write replaces the + // agent's full direct dependency set. + if (data.connections !== undefined || data.slots !== undefined) { + // Snapshot the current direct aggregations BEFORE the delete so we can + // (a) clean up pinned views for removed connections, and (b) preserve + // the untouched field when the caller passes only one of + // connections/slots. Reading the rows AFTER the delete would return + // an empty set and silently drop the omitted field. const currentAggs = await this.db .selectFrom("connection_aggregations") - .select("child_connection_id") + .selectAll() .where("parent_connection_id", "=", id) .where("dependency_mode", "=", "direct") .execute(); + // child_connection_id is now nullable (slot rows have NULL); filter + // out nulls since slots don't have pinned views to clean up. const previousIds = new Set( - currentAggs.map((a) => a.child_connection_id), + currentAggs + .map((a) => a.child_connection_id) + .filter((cid): cid is string => cid !== null), ); + // Partition the pre-delete snapshot into concrete connections and + // slots so we can use them as the fallback when the caller omits a + // field. + const { connections: existingConnections, slots: existingSlots } = + this.partitionAggregations(currentAggs as RawAggregationRow[]); + // Only delete 'direct' dependencies - preserve 'indirect' ones from virtual tools await this.db .deleteFrom("connection_aggregations") @@ -397,14 +449,22 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { .where("dependency_mode", "=", "direct") .execute(); - if (data.connections.length > 0) { + // If the caller did not pass connections/slots explicitly, preserve + // the previous set from the pre-delete snapshot. + const connectionsToInsert = + data.connections !== undefined ? data.connections : existingConnections; + const slotsToInsert = + data.slots !== undefined ? data.slots : existingSlots; + + if (connectionsToInsert.length > 0) { await this.db .insertInto("connection_aggregations") .values( - data.connections.map((conn) => ({ + connectionsToInsert.map((conn) => ({ id: generatePrefixedId("agg"), parent_connection_id: id, child_connection_id: conn.connection_id, + slot_app_id: null, selected_tools: conn.selected_tools ? JSON.stringify(conn.selected_tools) : null, @@ -421,11 +481,39 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { .execute(); } - // Clean up pinned views for removed connections - const newIds = new Set(data.connections.map((c) => c.connection_id)); - for (const prevId of previousIds) { - if (!newIds.has(prevId)) { - await this.cleanOrphanedPinnedViews([id], prevId); + if (slotsToInsert.length > 0) { + await this.db + .insertInto("connection_aggregations") + .values( + slotsToInsert.map((slot) => ({ + id: generatePrefixedId("agg"), + parent_connection_id: id, + child_connection_id: null, + slot_app_id: slot.slot_app_id, + selected_tools: slot.selected_tools + ? JSON.stringify(slot.selected_tools) + : null, + selected_resources: slot.selected_resources + ? JSON.stringify(slot.selected_resources) + : null, + selected_prompts: slot.selected_prompts + ? JSON.stringify(slot.selected_prompts) + : null, + dependency_mode: "direct" as DependencyMode, + created_at: now, + })), + ) + .execute(); + } + + // Clean up pinned views for removed connections (slots don't have + // pinned views since they're not concrete connections). + if (data.connections !== undefined) { + const newIds = new Set(data.connections.map((c) => c.connection_id)); + for (const prevId of previousIds) { + if (!newIds.has(prevId)) { + await this.cleanOrphanedPinnedViews([id], prevId); + } } } } @@ -571,6 +659,9 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { ? normalizeSandboxMap(rawSandboxMap) : undefined; + // XOR CHECK at DB level guarantees one branch always fires per row. + const { connections, slots } = this.partitionAggregations(aggregationRows); + return { id: row.id, organization_id: row.organization_id, @@ -590,15 +681,47 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { ? { sandboxMap: normalizedSandboxMap } : {}), }, - connections: aggregationRows.map((agg) => ({ - connection_id: agg.child_connection_id, - selected_tools: this.parseJson(agg.selected_tools), - selected_resources: this.parseJson(agg.selected_resources), - selected_prompts: this.parseJson(agg.selected_prompts), - })), + connections, + slots, }; } + /** + * Partition raw aggregation rows into typed concrete connections and slots. + * Centralizes the JSON parsing and XOR branching used by both the read + * path (`deserializeVirtualMCPEntity`) and the update fallback path. + */ + private partitionAggregations(rows: RawAggregationRow[]): { + connections: VirtualMCPConnection[]; + slots: VirtualMCPSlot[]; + } { + const connections: VirtualMCPConnection[] = []; + const slots: VirtualMCPSlot[] = []; + for (const agg of rows) { + const selectedTools = this.parseJson(agg.selected_tools); + const selectedResources = this.parseJson( + agg.selected_resources, + ); + const selectedPrompts = this.parseJson(agg.selected_prompts); + if (agg.child_connection_id !== null) { + connections.push({ + connection_id: agg.child_connection_id, + selected_tools: selectedTools, + selected_resources: selectedResources, + selected_prompts: selectedPrompts, + }); + } else if (agg.slot_app_id !== null) { + slots.push({ + slot_app_id: agg.slot_app_id, + selected_tools: selectedTools, + selected_resources: selectedResources, + selected_prompts: selectedPrompts, + }); + } + } + return { connections, slots }; + } + /** * Parse JSON value safely */ diff --git a/apps/mesh/src/tools/automations/configure-trigger.ts b/apps/mesh/src/tools/automations/configure-trigger.ts index 6d1324d6ed..005adf3417 100644 --- a/apps/mesh/src/tools/automations/configure-trigger.ts +++ b/apps/mesh/src/tools/automations/configure-trigger.ts @@ -9,6 +9,7 @@ import type { MeshContext } from "@/core/mesh-context"; import { clientFromConnection } from "@/mcp-clients"; import { toServerClient } from "@/api/routes/proxy"; +import { INTERNAL_VIEWER } from "@/storage/ports"; import type { AutomationTrigger } from "@/storage/types"; import type { TriggerCallbackTokenStorage } from "@/storage/trigger-callback-tokens"; import { TriggerBinding } from "@decocms/bindings/trigger"; @@ -22,8 +23,14 @@ export async function configureTriggerOnMcp( if (trigger.type !== "event" || !trigger.connection_id) return { success: true }; + // Trigger configuration is invoked by background workers (cron, event-bus + // dispatcher) where ctx.auth doesn't reflect a user session. Visibility was + // enforced when the trigger was added (TRIGGER_ADD passes the authoring + // user id and TRIGGER_UPDATE checks ownership), so INTERNAL_VIEWER is safe. const connection = await ctx.storage.connections.findById( trigger.connection_id, + undefined, + INTERNAL_VIEWER, ); if (!connection) return { success: true }; // Connection may have been deleted diff --git a/apps/mesh/src/tools/automations/trigger-add.ts b/apps/mesh/src/tools/automations/trigger-add.ts index 86a50a71af..73250129ce 100644 --- a/apps/mesh/src/tools/automations/trigger-add.ts +++ b/apps/mesh/src/tools/automations/trigger-add.ts @@ -121,10 +121,12 @@ export const AUTOMATION_TRIGGER_ADD = defineTool({ throw new Error("event_type is required for event triggers"); } - // Validate connection belongs to this organization + // Validate connection belongs to this organization. + // viewerUserId so users can't bind a trigger to another user's private connection. const connection = await ctx.storage.connections.findById( input.connection_id, organization.id, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, ); if (!connection) { throw new Error("Connection not found"); diff --git a/apps/mesh/src/tools/connection/delete.ts b/apps/mesh/src/tools/connection/delete.ts index 3a65fc8281..c91159c575 100644 --- a/apps/mesh/src/tools/connection/delete.ts +++ b/apps/mesh/src/tools/connection/delete.ts @@ -53,8 +53,14 @@ export const COLLECTION_CONNECTIONS_DELETE = defineTool({ // Check authorization await ctx.access.check(); - // Fetch connection before deleting to return the entity - const connection = await ctx.storage.connections.findById(input.id); + // Fetch connection before deleting to return the entity. + // viewerUserId hides other users' user-private connections — the caller + // can only delete connections they're allowed to see. + const connection = await ctx.storage.connections.findById( + input.id, + organization.id, + getUserId(ctx) ?? null, + ); if (!connection) { throw new Error(`Connection not found: ${input.id}`); } diff --git a/apps/mesh/src/tools/connection/dev-assets.ts b/apps/mesh/src/tools/connection/dev-assets.ts index 460e2fd1b2..dec4d0bb58 100644 --- a/apps/mesh/src/tools/connection/dev-assets.ts +++ b/apps/mesh/src/tools/connection/dev-assets.ts @@ -86,5 +86,6 @@ export function createDevAssetsConnectionEntity( tools: DEV_ASSETS_TOOLS, bindings: ["OBJECT_STORAGE"], status: "active", + access: "org", }; } diff --git a/apps/mesh/src/tools/connection/get.ts b/apps/mesh/src/tools/connection/get.ts index d1844d7e38..f9b085dafb 100644 --- a/apps/mesh/src/tools/connection/get.ts +++ b/apps/mesh/src/tools/connection/get.ts @@ -66,7 +66,13 @@ export const COLLECTION_CONNECTIONS_GET = defineTool({ } // Get connection from database - const connection = await ctx.storage.connections.findById(input.id); + const connection = await ctx.storage.connections.findById( + input.id, + organization.id, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, + ); + // ^ viewer is the authenticated principal — `null` hides every user-private + // row, matching the behaviour for an anonymous caller. // Verify connection exists and belongs to the current organization if (!connection || connection.organization_id !== organization.id) { diff --git a/apps/mesh/src/tools/connection/index.ts b/apps/mesh/src/tools/connection/index.ts index 1263d3685f..28a7dda2cc 100644 --- a/apps/mesh/src/tools/connection/index.ts +++ b/apps/mesh/src/tools/connection/index.ts @@ -14,4 +14,7 @@ export { COLLECTION_CONNECTIONS_DELETE } from "./delete"; // Connection test tool export { CONNECTION_TEST } from "./test"; +// App-only tools +export { CONNECTION_RESOLVE_FOR_USER } from "./resolve-for-user"; + // Utility exports diff --git a/apps/mesh/src/tools/connection/list.ts b/apps/mesh/src/tools/connection/list.ts index cf26c964c6..c31f22bb00 100644 --- a/apps/mesh/src/tools/connection/list.ts +++ b/apps/mesh/src/tools/connection/list.ts @@ -159,6 +159,7 @@ export const COLLECTION_CONNECTIONS_LIST = defineTool({ // Only push pagination to SQL when no post-filtering is needed limit: needsBindingFilter ? undefined : limit, offset: needsBindingFilter ? undefined : offset, + viewer: ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, }); // Only fetch tools from MCP servers when we need them for binding filtering. diff --git a/apps/mesh/src/tools/connection/resolve-for-user.test.ts b/apps/mesh/src/tools/connection/resolve-for-user.test.ts new file mode 100644 index 0000000000..3e754f12c4 --- /dev/null +++ b/apps/mesh/src/tools/connection/resolve-for-user.test.ts @@ -0,0 +1,27 @@ +/** + * Smoke tests for CONNECTION_RESOLVE_FOR_USER. The underlying resolution + * logic is covered by slot-resolver.test.ts — this file only verifies the + * tool wrapper is shaped correctly (app-only visibility, sane schemas). + */ +import { describe, expect, it } from "bun:test"; +import { CONNECTION_RESOLVE_FOR_USER } from "./resolve-for-user"; + +describe("CONNECTION_RESOLVE_FOR_USER", () => { + it("has app-only visibility (not exposed to AI)", () => { + const ui = CONNECTION_RESOLVE_FOR_USER._meta?.ui as + | { visibility?: string } + | undefined; + expect(ui?.visibility).toBe("app"); + }); + + it("declares input and output schemas", () => { + expect(CONNECTION_RESOLVE_FOR_USER.inputSchema).toBeDefined(); + expect(CONNECTION_RESOLVE_FOR_USER.outputSchema).toBeDefined(); + }); + + it("name matches the convention used by other app-only tools", () => { + expect(CONNECTION_RESOLVE_FOR_USER.name).toBe( + "CONNECTION_RESOLVE_FOR_USER", + ); + }); +}); diff --git a/apps/mesh/src/tools/connection/resolve-for-user.ts b/apps/mesh/src/tools/connection/resolve-for-user.ts new file mode 100644 index 0000000000..6b5b329abd --- /dev/null +++ b/apps/mesh/src/tools/connection/resolve-for-user.ts @@ -0,0 +1,59 @@ +/** + * CONNECTION_RESOLVE_FOR_USER + * + * App-only tool that returns the calling user's connection for a given + * app_id, using the same resolution rules as the agent slot resolver + * (prefer user-private, fall back to org-shared). Returns null when + * nothing matches. + * + * Powers the GitHub-import picker (and any future per-user picker) so + * the UI doesn't have to do the resolution itself. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { resolveSlot } from "@/core/slot-resolver"; + +export const CONNECTION_RESOLVE_FOR_USER = defineTool({ + name: "CONNECTION_RESOLVE_FOR_USER", + description: + "Return the calling user's connection for a given app_id (user-private preferred, org-shared fallback). Returns null when nothing matches.", + annotations: { + title: "Resolve Connection For User", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + app_id: z.string().min(1).describe("app_id to resolve (e.g. 'mcp-github')"), + }), + outputSchema: z.object({ + connectionId: z.string().nullable(), + access: z.enum(["user", "org"]).nullable(), + }), + handler: async (input, ctx) => { + await ctx.access.check(); + + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error("Organization context required"); + } + + const invokerUserId = ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null; + if (!invokerUserId) { + return { connectionId: null, access: null }; + } + + const resolved = await resolveSlot(ctx.db, { + organizationId, + invokerUserId, + appId: input.app_id, + }); + return { + connectionId: resolved?.connectionId ?? null, + access: resolved?.access ?? null, + }; + }, +}); diff --git a/apps/mesh/src/tools/connection/test.ts b/apps/mesh/src/tools/connection/test.ts index b1abdf266e..6406120829 100644 --- a/apps/mesh/src/tools/connection/test.ts +++ b/apps/mesh/src/tools/connection/test.ts @@ -35,8 +35,13 @@ export const CONNECTION_TEST = defineTool({ // Check authorization await ctx.access.check(); - // Fetch connection to verify org ownership before testing - const connection = await ctx.storage.connections.findById(input.id); + // Fetch connection to verify org ownership before testing. + // viewerUserId hides other users' user-private connections. + const connection = await ctx.storage.connections.findById( + input.id, + organization.id, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, + ); if (!connection || connection.organization_id !== organization.id) { throw new Error("Connection not found"); } diff --git a/apps/mesh/src/tools/connection/update.ts b/apps/mesh/src/tools/connection/update.ts index 3b125fb07b..3cddac9131 100644 --- a/apps/mesh/src/tools/connection/update.ts +++ b/apps/mesh/src/tools/connection/update.ts @@ -103,9 +103,13 @@ async function validateConfiguration( continue; } // Verify connection exists and belongs to same organization - // Use consistent error message to prevent cross-org information disclosure - const refConnection = - await ctx.storage.connections.findById(refConnectionId); + // Use consistent error message to prevent cross-org information disclosure. + // viewerUserId so the user can't reference another user's private connection. + const refConnection = await ctx.storage.connections.findById( + refConnectionId, + organizationId, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, + ); if (!refConnection || refConnection.organization_id !== organizationId) { throw new Error(`Referenced connection not found: ${refConnectionId}`); } @@ -154,8 +158,13 @@ export const COLLECTION_CONNECTIONS_UPDATE = defineTool({ const { id, data } = input; - // First fetch the connection to verify ownership before updating - const existing = await ctx.storage.connections.findById(id); + // First fetch the connection to verify ownership before updating. + // viewerUserId hides other users' user-private connections. + const existing = await ctx.storage.connections.findById( + id, + organization.id, + ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, + ); // Verify it exists and belongs to the current organization if (!existing || existing.organization_id !== organization.id) { diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index aa81799b40..17cd1385d8 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -73,6 +73,7 @@ const CORE_TOOLS = [ ConnectionTools.COLLECTION_CONNECTIONS_UPDATE, ConnectionTools.COLLECTION_CONNECTIONS_DELETE, ConnectionTools.CONNECTION_TEST, + ConnectionTools.CONNECTION_RESOLVE_FOR_USER, // Virtual MCP collection tools VirtualMCPTools.COLLECTION_VIRTUAL_MCP_CREATE, diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index 8c5978416c..1c0c60cf25 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -74,6 +74,7 @@ const ALL_TOOL_NAMES = [ "COLLECTION_CONNECTIONS_UPDATE", "COLLECTION_CONNECTIONS_DELETE", "CONNECTION_TEST", + "CONNECTION_RESOLVE_FOR_USER", // Virtual MCP tools "COLLECTION_VIRTUAL_MCP_CREATE", "COLLECTION_VIRTUAL_MCP_LIST", @@ -401,6 +402,12 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Test connections", category: "Connections", }, + { + name: "CONNECTION_RESOLVE_FOR_USER", + description: + "Resolve the caller's connection for a given app_id (user-private preferred, org-shared fallback)", + category: "Connections", + }, { name: "DATABASES_RUN_SQL", description: "Run SQL queries", diff --git a/apps/mesh/src/tools/registry/monitor-run-start.ts b/apps/mesh/src/tools/registry/monitor-run-start.ts index a05f7264ff..8de20faacb 100644 --- a/apps/mesh/src/tools/registry/monitor-run-start.ts +++ b/apps/mesh/src/tools/registry/monitor-run-start.ts @@ -16,6 +16,7 @@ import { PLUGIN_ID, PUBLISH_REQUEST_TARGET_PREFIX, } from "./shared"; +import { INTERNAL_VIEWER } from "@/storage/ports"; import type { MonitorResultStatus, MonitorRunConfigSnapshot, @@ -768,9 +769,13 @@ export async function ensureMonitorConnection( item.id, ); if (existing) { + // Registry monitor is org-level infra running on background runs; the + // monitor connection is created and owned by the platform side, so the + // existence check uses INTERNAL_VIEWER. const found = await ctx.storage.connections.findById( existing.connection_id, organizationId, + INTERNAL_VIEWER, ); if (found) { return existing.connection_id; diff --git a/apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.integration.test.ts b/apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.integration.test.ts new file mode 100644 index 0000000000..cf3f15a9c8 --- /dev/null +++ b/apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.integration.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { sql } from "kysely"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../../database/test-db-pg"; +import type { MeshDatabase } from "../../database"; +import { ConnectionStorage } from "../../storage/connection"; +import { CredentialVault } from "../../encryption/credential-vault"; +import { assertConcreteChildrenAreOrgScoped } from "./assert-concrete-children-org-scoped"; + +const USER = "user_test"; +const ORG = "org_test"; + +async function insertConn( + database: MeshDatabase, + id: string, + access: string, +): Promise { + const now = new Date().toISOString(); + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, access, status, created_at, updated_at + ) VALUES ( + ${id}, ${ORG}, ${USER}, ${id}, 'HTTP', + 'https://example.com', ${id + "-app"}, ${access}, + 'active', ${now}, ${now} + ) + `.execute(database.db); +} + +describe("assertConcreteChildrenAreOrgScoped", () => { + let database: MeshDatabase; + let storage: ConnectionStorage; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + await insertConn(database, "conn_org", "org"); + await insertConn(database, "conn_private", "user"); + const vault = new CredentialVault(CredentialVault.generateKey()); + storage = new ConnectionStorage(database.db, vault); + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + it("allows org-scoped concrete children", async () => { + await expect( + assertConcreteChildrenAreOrgScoped( + [{ connection_id: "conn_org" }], + storage, + ORG, + ), + ).resolves.toBeUndefined(); + }); + + it("rejects a private connection used as a concrete child", async () => { + await expect( + assertConcreteChildrenAreOrgScoped( + [{ connection_id: "conn_private" }], + storage, + ORG, + ), + ).rejects.toThrow(/private and cannot be added as a concrete child/i); + }); + + it("rejects another user's private connection (the cross-user leak case)", async () => { + // Seed a private connection owned by a DIFFERENT user than the caller. + // The guard fetches with INTERNAL_VIEWER specifically so this row is + // visible and rejected — the per-user visibility filter would otherwise + // hide it and let the leak through. + const now = new Date().toISOString(); + await sql` + INSERT INTO connections ( + id, organization_id, created_by, title, connection_type, + connection_url, app_id, access, status, created_at, updated_at + ) VALUES ( + 'conn_other_private', ${ORG}, 'user_1', 'conn_other_private', 'HTTP', + 'https://other-user.example.com', 'other-user-app', 'user', + 'active', ${now}, ${now} + ) + `.execute(database.db); + + await expect( + assertConcreteChildrenAreOrgScoped( + [{ connection_id: "conn_other_private" }], + storage, + ORG, + ), + ).rejects.toThrow(/private and cannot be added as a concrete child/i); + }); + + it("is a no-op for an empty list", async () => { + await expect( + assertConcreteChildrenAreOrgScoped([], storage, ORG), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.ts b/apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.ts new file mode 100644 index 0000000000..064f482396 --- /dev/null +++ b/apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.ts @@ -0,0 +1,29 @@ +/** + * Guards the COLLECTION_VIRTUAL_MCP create/update path: a private + * (access='user') connection must not be hard-bound as a concrete child of an + * agent, because a concrete child is loaded for every caller regardless of who + * owns it. Private connections must be attached as typed slots instead, which + * resolve to each caller's own connection of the same app_id. + */ + +import type { ConnectionStoragePort } from "../../storage/ports"; +import { INTERNAL_VIEWER } from "../../storage/ports"; + +export async function assertConcreteChildrenAreOrgScoped( + connections: { connection_id: string }[], + connectionStorage: ConnectionStoragePort, + organizationId: string, +): Promise { + for (const conn of connections) { + const c = await connectionStorage.findById( + conn.connection_id, + organizationId, + INTERNAL_VIEWER, + ); + if (c && c.access === "user") { + throw new Error( + `Connection ${conn.connection_id} is private and cannot be added as a concrete child of an agent. Attach it as a slot using its app_id instead.`, + ); + } + } +} diff --git a/apps/mesh/src/tools/virtual/create.ts b/apps/mesh/src/tools/virtual/create.ts index 5fb12ffd54..477758d195 100644 --- a/apps/mesh/src/tools/virtual/create.ts +++ b/apps/mesh/src/tools/virtual/create.ts @@ -14,6 +14,7 @@ import { requireOrganization, } from "../../core/mesh-context"; import { VirtualMCPCreateDataSchema, VirtualMCPEntitySchema } from "./schema"; +import { assertConcreteChildrenAreOrgScoped } from "./assert-concrete-children-org-scoped"; /** * Random icon+color for new agents (server-side, no React deps). * Uses the same icon:// format as the client-side agent-icon module. @@ -109,6 +110,12 @@ export const COLLECTION_VIRTUAL_MCP_CREATE = defineTool({ throw new Error("User ID required to create virtual MCP"); } + await assertConcreteChildrenAreOrgScoped( + input.data.connections ?? [], + ctx.storage.connections, + organization.id, + ); + // Create the virtual MCP (input.data is already in the correct format) // Note: The facade creates a VIRTUAL connection in the connections table // Use a random icon+color if no icon is provided diff --git a/apps/mesh/src/tools/virtual/plugin-config-update.ts b/apps/mesh/src/tools/virtual/plugin-config-update.ts index d7273243cc..43199d51f5 100644 --- a/apps/mesh/src/tools/virtual/plugin-config-update.ts +++ b/apps/mesh/src/tools/virtual/plugin-config-update.ts @@ -64,14 +64,22 @@ export const VIRTUAL_MCP_PLUGIN_CONFIG_UPDATE = defineTool({ const { virtualMcpId, pluginId, connectionId, settings } = input; const userId = getUserId(ctx); - const parentConnection = - await ctx.storage.connections.findById(virtualMcpId); + const viewerUserId = ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null; + const parentConnection = await ctx.storage.connections.findById( + virtualMcpId, + undefined, + viewerUserId, + ); if (!parentConnection) { throw new Error(`Connection not found: ${virtualMcpId}`); } const connectionExists = connectionId - ? await ctx.storage.connections.findById(connectionId) + ? await ctx.storage.connections.findById( + connectionId, + undefined, + viewerUserId, + ) : null; if ( diff --git a/apps/mesh/src/tools/virtual/schema.ts b/apps/mesh/src/tools/virtual/schema.ts index aed78c9ffb..7493474984 100644 --- a/apps/mesh/src/tools/virtual/schema.ts +++ b/apps/mesh/src/tools/virtual/schema.ts @@ -14,4 +14,5 @@ export { type VirtualMCPCreateData, type VirtualMCPUpdateData, type VirtualMCPConnection, + type VirtualMCPSlot, } from "@decocms/mesh-sdk/types"; diff --git a/apps/mesh/src/tools/virtual/studio-pack/store-manager.ts b/apps/mesh/src/tools/virtual/studio-pack/store-manager.ts index fa779fbcb8..968023f72f 100644 --- a/apps/mesh/src/tools/virtual/studio-pack/store-manager.ts +++ b/apps/mesh/src/tools/virtual/studio-pack/store-manager.ts @@ -80,6 +80,10 @@ export const storeManagerAgent = { conditions: [{ field: ["id"], operator: "in", value: seededIds }], }, limit: 1, + // Viewer is the authenticated principal so the "have any user-installed + // connection yet?" check matches what this user can actually browse — + // another member's user-private install doesn't count for this user. + viewer: ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null, }); return totalCount > 0; }, diff --git a/apps/mesh/src/tools/virtual/update.ts b/apps/mesh/src/tools/virtual/update.ts index ea3253d1b8..d1d27b5a4e 100644 --- a/apps/mesh/src/tools/virtual/update.ts +++ b/apps/mesh/src/tools/virtual/update.ts @@ -12,6 +12,7 @@ import { requireOrganization, } from "../../core/mesh-context"; import { VirtualMCPEntitySchema, VirtualMCPUpdateDataSchema } from "./schema"; +import { assertConcreteChildrenAreOrgScoped } from "./assert-concrete-children-org-scoped"; /** * Input schema for updating a virtual MCP @@ -65,6 +66,12 @@ export const COLLECTION_VIRTUAL_MCP_UPDATE = defineTool({ throw new Error(`Virtual MCP not found: ${input.id}`); } + await assertConcreteChildrenAreOrgScoped( + input.data.connections ?? [], + ctx.storage.connections, + organization.id, + ); + // Shallow-merge incoming metadata with existing so that callers can send // partial metadata updates (e.g. only enabled_plugins) without wiping // other fields like instructions or ui. diff --git a/apps/mesh/src/web/components/chat/connect-card.tsx b/apps/mesh/src/web/components/chat/connect-card.tsx new file mode 100644 index 0000000000..dc24c26a24 --- /dev/null +++ b/apps/mesh/src/web/components/chat/connect-card.tsx @@ -0,0 +1,89 @@ +import { Suspense } from "react"; +import { Button } from "@deco/ui/components/button.tsx"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { ConnectSlotRow } from "@/web/components/chat/connect-slot-row"; +import { useOptionalChatStream } from "@/web/components/chat/chat-context"; +import { useSlotAppDisplays } from "@/web/hooks/use-slot-app-displays"; + +interface ConnectCardData { + agentId: string; + agentTitle: string; + appIds: string[]; +} + +/** + * Visible connect card rendered inline in the assistant message when an agent + * (parent or a delegated subagent) couldn't resolve its typed slots for the + * current user. Shows one Connect row per missing app and a Retry button that + * re-runs the last user turn once the connections are in place. + */ +export function ConnectCard({ data }: { data: ConnectCardData }) { + return ( + }> + + + ); +} + +function ConnectCardFallback({ data }: { data: ConnectCardData }) { + return ( +
+

Connect to use "{data.agentTitle}"

+

Loading connections…

+
+ ); +} + +function ConnectCardInner({ data }: { data: ConnectCardData }) { + const { org } = useProjectContext(); + const stream = useOptionalChatStream(); + const slots = data.appIds.map((appId) => ({ slot_app_id: appId })); + const displays = useSlotAppDisplays(slots); + + const handleRetry = () => { + if (!stream) return; + const lastUser = [...stream.messages] + .reverse() + .find((m) => m.role === "user"); + if (lastUser) void stream.sendMessage({ parts: lastUser.parts }); + }; + + return ( +
+
+

+ Connect to use "{data.agentTitle}" +

+

+ This agent needs your personal connections before it can run. +

+
+
+ {slots.map((slot) => ( + + ))} +
+ {stream ? ( + + ) : null} +
+ ); +} diff --git a/apps/mesh/src/web/components/chat/connect-slot-row.tsx b/apps/mesh/src/web/components/chat/connect-slot-row.tsx new file mode 100644 index 0000000000..74a094f06b --- /dev/null +++ b/apps/mesh/src/web/components/chat/connect-slot-row.tsx @@ -0,0 +1,80 @@ +import { Link } from "@tanstack/react-router"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Loading01 } from "@untitledui/icons"; +import { IntegrationIcon } from "@/web/components/integration-icon"; +import { useConnectApp } from "@/web/hooks/use-connect-app"; +import type { ResolvedSlotAppDisplay } from "@/web/hooks/use-slot-app-displays"; + +/** + * One row inside a ConnectCard. Registry apps show their icon + friendly name + * and connect inline (OAuth in place); non-registry / synthetic slots show the + * raw app_id and deep-link to the connections page. + */ +export function ConnectSlotRow({ + display, + orgSlug, +}: { + display: ResolvedSlotAppDisplay; + orgSlug: string; +}) { + const { connect, status, error } = useConnectApp(); + const registryItem = + display.kind === "registry" ? display.registryItem : null; + const busy = + status === "connecting" || + status === "authenticating" || + status === "ready"; + + return ( +
+ +
+

{display.title}

+ {status === "error" && error ? ( +

{error}

+ ) : null} +
+ {registryItem ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/mesh/src/web/components/chat/message/assistant.tsx b/apps/mesh/src/web/components/chat/message/assistant.tsx index 9f22921501..b4effd8440 100644 --- a/apps/mesh/src/web/components/chat/message/assistant.tsx +++ b/apps/mesh/src/web/components/chat/message/assistant.tsx @@ -38,6 +38,7 @@ import { addUsage, emptyUsageStats } from "@decocms/mesh-sdk"; import { useOptionalChatStream, useOptionalChatTask } from "../context.tsx"; import { LiveTimer } from "../../live-timer.tsx"; import { GridLoader } from "../../grid-loader.tsx"; +import { ConnectCard } from "@/web/components/chat/connect-card"; import { formatDuration } from "../../../lib/format-time.ts"; type ThinkingStage = "planning" | "thinking"; @@ -488,6 +489,8 @@ function MessagePart({ case "source-url": case "source-document": return null; + case "data-connect-required": + return ; case "data-tool-metadata": case "data-tool-subtask-metadata": case "data-generate-image": diff --git a/apps/mesh/src/web/components/chat/message/use-filter-parts.ts b/apps/mesh/src/web/components/chat/message/use-filter-parts.ts index 876fd52b94..b7f38f8bf8 100644 --- a/apps/mesh/src/web/components/chat/message/use-filter-parts.ts +++ b/apps/mesh/src/web/components/chat/message/use-filter-parts.ts @@ -173,7 +173,9 @@ export function useFilterParts(message: ChatMessage | null) { // Including them in renderOrder would let them claim // `isLastVisiblePart` and steal the message-bottom usage stats from // the real last visible part. - if (p.type.startsWith("data-")) { + // data-* parts are metadata side-channels and never render — EXCEPT + // data-connect-required, which is a visible connect card. + if (p.type.startsWith("data-") && p.type !== "data-connect-required") { continue; } diff --git a/apps/mesh/src/web/components/connections/create-connection-dialog.tsx b/apps/mesh/src/web/components/connections/create-connection-dialog.tsx index 8d83dcb4b8..5eb925e939 100644 --- a/apps/mesh/src/web/components/connections/create-connection-dialog.tsx +++ b/apps/mesh/src/web/components/connections/create-connection-dialog.tsx @@ -69,8 +69,8 @@ import { interface CreateConnectionDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - /** Called with the new connection id after successful creation. */ - onCreated?: (id: string) => void; + /** Called with the new connection id and title after successful creation. */ + onCreated?: (id: string, title: string) => void; } export function CreateConnectionDialog({ @@ -271,7 +271,7 @@ export function CreateConnectionDialog({ form.reset(); onOpenChange(false); - onCreated?.(newId); + onCreated?.(newId, data.title); } catch { toast.error("Failed to create connection"); } diff --git a/apps/mesh/src/web/components/create-agent-dropdown.tsx b/apps/mesh/src/web/components/create-agent-dropdown.tsx index 7677300c3b..a5f85e687b 100644 --- a/apps/mesh/src/web/components/create-agent-dropdown.tsx +++ b/apps/mesh/src/web/components/create-agent-dropdown.tsx @@ -5,7 +5,6 @@ import { import { Globe02, Users03 } from "@untitledui/icons"; import { GitHubIcon } from "@/web/components/icons/github-icon"; import { SHOPIFY_HYDROGEN_ICON } from "@/web/hooks/use-create-website-agent"; -import { usePreferences } from "@/web/hooks/use-preferences.ts"; interface CreateAgentDropdownContentProps { onCreateFromScratch: () => void; @@ -30,8 +29,6 @@ export function CreateAgentDropdownContent({ side, showBetaBadge, }: CreateAgentDropdownContentProps) { - const [preferences] = usePreferences(); - return ( @@ -50,17 +47,15 @@ export function CreateAgentDropdownContent({ /> Create Shopify Headless Store - {preferences.experimental_vibecode && ( - - - Import from GitHub - {showBetaBadge && ( - - Beta - - )} - - )} + + + Import from GitHub + {showBetaBadge && ( + + Beta + + )} + deco.cx Import from deco.cx diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index 548f4b57bc..bbf5d8c477 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -15,6 +15,7 @@ import { authenticateMcp, isConnectionAuthenticated, } from "@/web/lib/mcp-oauth"; +import { persistDownstreamToken } from "@/web/lib/connect-app"; import { KEYS } from "@/web/lib/query-keys"; import { ConnectionInstancesPanel } from "./connection-instances-panel.tsx"; import { Button } from "@deco/ui/components/button.tsx"; @@ -303,7 +304,10 @@ function ConnectionInspectorViewWithConnection({ form.reset(connectionToFormValues(configureInstance ?? connection)); }; - const handleAuthenticateForId = async (connId: string) => { + const handleAuthenticateForId = async ( + connId: string, + currentTitle?: string | null, + ) => { const { token, tokenInfo, error } = await authenticateMcp({ connectionId: connId, orgSlug: projectOrg.slug, @@ -314,59 +318,14 @@ function ConnectionInspectorViewWithConnection({ return; } - if (tokenInfo) { - try { - const response = await fetch( - `/api/${projectOrg.slug}/connections/${connId}/oauth-token`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ - accessToken: tokenInfo.accessToken, - refreshToken: tokenInfo.refreshToken, - expiresIn: tokenInfo.expiresIn, - scope: tokenInfo.scope, - clientId: tokenInfo.clientId, - clientSecret: tokenInfo.clientSecret, - tokenEndpoint: tokenInfo.tokenEndpoint, - }), - }, - ); - if (!response.ok) { - console.error("Failed to save OAuth token:", await response.text()); - await connectionActions.update.mutateAsync({ - id: connId, - data: { connection_token: token }, - }); - } else { - try { - await connectionActions.update.mutateAsync({ - id: connId, - data: {}, - }); - } catch (err) { - console.warn( - "Failed to refresh connection tools after OAuth:", - err, - ); - } - } - } catch (err) { - console.error("Error saving OAuth token:", err); - await connectionActions.update.mutateAsync({ - id: connId, - data: { connection_token: token }, - }); - } - } else { - await connectionActions.update.mutateAsync({ - id: connId, - data: { connection_token: token }, - }); - } + await persistDownstreamToken({ + orgSlug: projectOrg.slug, + connectionId: connId, + token, + tokenInfo, + connectionActions, + currentTitle, + }); const mcpProxyUrl = new URL( `/api/${projectOrg.slug}/mcp/${connId}`, @@ -382,7 +341,8 @@ function ConnectionInspectorViewWithConnection({ toast.success("Authentication successful"); }; - const handleAuthenticate = () => handleAuthenticateForId(connection.id); + const handleAuthenticate = () => + handleAuthenticateForId(connection.id, connection.title); const handleRemoveOAuth = async () => { try { @@ -559,7 +519,9 @@ function ConnectionInspectorViewWithConnection({ setConfigureInstance(inst)} - onAuthenticate={(inst) => handleAuthenticateForId(inst.id)} + onAuthenticate={(inst) => + handleAuthenticateForId(inst.id, inst.title) + } onDelete={(inst) => deleteConnection.requestDelete(inst)} isAdding={isAddingInstance} onAdd={async () => { @@ -605,7 +567,7 @@ function ConnectionInspectorViewWithConnection({ authStatus.supportsOAuth && !authStatus.isAuthenticated ) { - await handleAuthenticateForId(newId); + await handleAuthenticateForId(newId, newTitle); } // New instance shares the same app slug — no navigation needed // The page will re-render with the new sibling diff --git a/apps/mesh/src/web/components/github-repo-picker.tsx b/apps/mesh/src/web/components/github-repo-picker.tsx index c05968b495..194e4e12c3 100644 --- a/apps/mesh/src/web/components/github-repo-picker.tsx +++ b/apps/mesh/src/web/components/github-repo-picker.tsx @@ -19,10 +19,8 @@ import { invalidateVirtualMcpQueries } from "@/web/lib/query-keys"; import { useProjectContext, useMCPClient, - useConnections, SELF_MCP_ALIAS_ID, } from "@decocms/mesh-sdk"; -import type { ConnectionEntity } from "@decocms/mesh-sdk"; import { KEYS } from "@/web/lib/query-keys"; import { toast } from "sonner"; import { @@ -33,7 +31,9 @@ import { } from "@untitledui/icons"; import { useAutoInstallGitHub } from "@/web/hooks/use-auto-install-github"; import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; +import { useResolveConnectionForUser } from "@/web/hooks/use-resolve-connection-for-user"; import { GitHubIcon } from "@/web/components/icons/github-icon"; +import { GITHUB_APP_ID } from "@/web/utils/constants"; import { STOREFRONT_GITHUB_AUTOMATIONS, setupStorefrontGithubAutomations, @@ -154,8 +154,6 @@ function PickerContent({ const { org } = useProjectContext(); const queryClient = useQueryClient(); const navigateToAgent = useNavigateToAgent(); - const [selectedConnection, setSelectedConnection] = - useState(null); const [autoRespondEnabled, setAutoRespondEnabled] = useState(true); const [selectedAutomationKeys, setSelectedAutomationKeys] = useState< Set @@ -176,24 +174,32 @@ function PickerContent({ ? selectedAutomationKeys : new Set(); - const githubConnections = useConnections({ slug: "mcp-github" }); + const selfClient = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + orgSlug: org.slug, + }); + + // Resolve the caller's own mcp-github connection (user-private preferred, + // org-shared fallback). Avoids the cross-user leak that + // useConnections({ slug: 'mcp-github' }) had: that query could surface a + // teammate's connection and end up listing their personal installations. + // Resolution matches on connections.app_id (the scoped registry id, + // GITHUB_APP_ID), NOT the bare slug. + const resolveQuery = useResolveConnectionForUser( + org.id, + org.slug, + GITHUB_APP_ID, + ); + + const resolvedConnectionId = resolveQuery.data?.connectionId ?? null; const autoInstall = useAutoInstallGitHub({ - enabled: githubConnections.length === 0, + enabled: resolveQuery.isSuccess && resolvedConnectionId === null, }); - const effectiveConnection = - githubConnections.length === 1 - ? (githubConnections[0] ?? null) - : selectedConnection; - const githubClient = useMCPClient({ - connectionId: effectiveConnection?.id ?? "", - orgId: org.id, - orgSlug: org.slug, - }); - const selfClient = useMCPClient({ - connectionId: SELF_MCP_ALIAS_ID, + connectionId: resolvedConnectionId ?? "", orgId: org.id, orgSlug: org.slug, }); @@ -283,11 +289,11 @@ function PickerContent({ const importMutation = useMutation({ mutationFn: async (repo: Repo) => { - if (!effectiveConnection || !selectedInstallation) { + if (!resolvedConnectionId || !selectedInstallation) { throw new Error("No GitHub connection or installation"); } - const connectionId = effectiveConnection.id; + const connectionId = resolvedConnectionId; const result = (await selfClient.callTool({ name: "COLLECTION_VIRTUAL_MCP_CREATE", @@ -319,7 +325,11 @@ function PickerContent({ }, }, }, - connections: [{ connection_id: connectionId }], + // The GitHub connection is user-private, so it must be attached as + // a typed slot (resolved per-caller to each user's own GitHub + // connection of this app_id), not a concrete child. + connections: [], + slots: [{ slot_app_id: GITHUB_APP_ID }], }, }, })) as { structuredContent?: unknown }; @@ -407,67 +417,45 @@ function PickerContent({ ); } - if (githubConnections.length === 0 && autoInstall.status === "idle") { + if (resolveQuery.isError) { return ( { + resolveQuery.refetch(); + }} /> ); } - if (githubConnections.length > 1 && !effectiveConnection) { + if (resolveQuery.isLoading || resolvedConnectionId === null) { return ( -
-
-

- Select a connection -

-
- {githubConnections.map((conn) => ( - - ))} -
+ ); } - if (!effectiveConnection) return null; - if (!selectedInstallation) { return ( 1} - onBack={() => setSelectedConnection(null)} /> ); } return ( void; - showBackButton: boolean; - onBack: () => void; }) { const selfClient = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, @@ -503,12 +485,28 @@ function InstallationPicker({ orgSlug, }); + // Resolve the caller's own mcp-github connection. Each user gets only + // their installations — the cross-user leak via useConnections() is gone. + // Matches on connections.app_id (GITHUB_APP_ID), not the bare slug. + const resolveQuery = useResolveConnectionForUser( + orgId, + orgSlug, + GITHUB_APP_ID, + ); + + const resolvedConnectionId = resolveQuery.data?.connectionId ?? null; + + const autoInstall = useAutoInstallGitHub({ + enabled: resolveQuery.isSuccess && resolvedConnectionId === null, + }); + const installationsQuery = useQuery({ - queryKey: KEYS.githubUserOrgs(orgId, connectionId), + queryKey: KEYS.githubUserOrgs(orgId, resolvedConnectionId ?? ""), + enabled: resolvedConnectionId !== null, queryFn: async () => { const result = await selfClient.callTool({ name: "GITHUB_LIST_USER_ORGS", - arguments: { connectionId }, + arguments: { connectionId: resolvedConnectionId }, }); const content = (result as { content?: Array<{ text?: string }> }) .content?.[0]?.text; @@ -520,7 +518,39 @@ function InstallationPicker({ }, }); - if (installationsQuery.isLoading) { + // Defensive: if the user has no GitHub connection, render the inline + // install flow rather than crashing or showing an empty list. Normally + // the parent PickerContent intercepts this case first, but this guard + // makes InstallationPicker safe to render standalone. + if (resolveQuery.isSuccess && resolvedConnectionId === null) { + return ( + + ); + } + + if (resolveQuery.isError) { + return ( + { + resolveQuery.refetch(); + }} + /> + ); + } + + if (installationsQuery.isLoading || resolveQuery.isLoading) { return (
@@ -543,19 +573,6 @@ function InstallationPicker({ return (
- {showBackButton && ( -
- -
- )} -
{data.installations.map((inst) => ( - {preferences.experimental_vibecode && ( - - )} + {isDecoUser && (
- { - const next = id as ConnectionTab; - if (next !== activeTab) { - track("connections_page_tab_changed", { to_tab: next }); - } - setActiveTab(next); - }} - /> + + } + > + + diff --git a/apps/mesh/src/web/utils/constants.ts b/apps/mesh/src/web/utils/constants.ts index 6a854921e7..428abc3ff7 100644 --- a/apps/mesh/src/web/utils/constants.ts +++ b/apps/mesh/src/web/utils/constants.ts @@ -5,6 +5,18 @@ import { z } from "zod"; // Re-export from core for backwards compatibility export { MCP_MESH_KEY as MCP_MESH_DECOCMS_KEY } from "@/core/constants"; +/** + * Canonical app_id of the GitHub MCP connection in the registry. + * + * This is the value stored in `connections.app_id` for installed GitHub + * connections (registry apps are scoped as `/`). It is NOT + * the same as the connection `slug` (`mcp-github`). Connection resolution + * (CONNECTION_RESOLVE_FOR_USER / resolveSlot) matches on `app_id`, so passing + * the bare slug here resolves nothing and leaves the GitHub picker stuck on + * "Installing the GitHub connection…". + */ +export const GITHUB_APP_ID = "deco/mcp-github"; + export type { JsonSchema }; /** diff --git a/apps/mesh/src/web/utils/extract-connection-data.ts b/apps/mesh/src/web/utils/extract-connection-data.ts index 693a260f5f..e3f91dd868 100644 --- a/apps/mesh/src/web/utils/extract-connection-data.ts +++ b/apps/mesh/src/web/utils/extract-connection-data.ts @@ -218,5 +218,6 @@ export function extractConnectionData( tools: null, bindings: null, status: "inactive" as const, + access: "user" as const, }; } diff --git a/apps/mesh/src/web/views/settings/profile-preferences.tsx b/apps/mesh/src/web/views/settings/profile-preferences.tsx index 6d0240f55d..b91af695f6 100644 --- a/apps/mesh/src/web/views/settings/profile-preferences.tsx +++ b/apps/mesh/src/web/views/settings/profile-preferences.tsx @@ -354,44 +354,6 @@ function PreferencesSection() { ); } -function ExperimentalSection() { - const [preferences, setPreferences] = usePreferences(); - - return ( - - - { - track("preferences_experimental_vibecode_toggled", { - enabled: !preferences.experimental_vibecode, - }); - setPreferences((prev) => ({ - ...prev, - experimental_vibecode: !prev.experimental_vibecode, - })); - }} - action={ - { - track("preferences_experimental_vibecode_toggled", { - enabled: checked, - }); - setPreferences((prev) => ({ - ...prev, - experimental_vibecode: checked, - })); - }} - /> - } - /> - - - ); -} - export function ProfilePreferencesPage() { return ( @@ -401,7 +363,6 @@ export function ProfilePreferencesPage() { Profile & Preferences - diff --git a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx index 29b8800136..3b4c2f9eb3 100644 --- a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx @@ -1,3 +1,9 @@ +import { + type ConnectionAccessTab, + accessTabWhereValue, + coerceConnectionAccessTab, + filterConnectionsByAccessTab, +} from "@/shared/utils/connection-access-tab"; import { getConnectionSlug } from "@/shared/utils/connection-slug"; import { groupConnections } from "@/shared/utils/group-connections"; import { CollectionSearch } from "@/web/components/collections/collection-search.tsx"; @@ -7,6 +13,7 @@ import { ConnectionCard } from "@/web/components/connections/connection-card.tsx import type { RegistryItem } from "@/web/components/store/types"; import { useInfiniteScroll } from "@/web/hooks/use-infinite-scroll"; import { useLocalStorage } from "@/web/hooks/use-local-storage"; +import { connectApp, persistDownstreamToken } from "@/web/lib/connect-app"; import { LOCALSTORAGE_KEYS } from "@/web/lib/localstorage-keys"; import { authenticateMcp, @@ -14,10 +21,7 @@ import { } from "@/web/lib/mcp-oauth"; import { KEYS } from "@/web/lib/query-keys"; import { authClient } from "@/web/lib/auth-client"; -import { - extractConnectionData, - getRegistryItemAppName, -} from "@/web/utils/extract-connection-data"; +import { getRegistryItemAppName } from "@/web/utils/extract-connection-data"; import { getGitHubAvatarUrl } from "@/web/utils/github"; import { useEnabledRegistries } from "@/web/hooks/use-enabled-registries"; import { useMergedStoreDiscovery } from "@/web/hooks/use-merged-store-discovery"; @@ -70,7 +74,7 @@ type AttachMode = "existing" | "clone" | "new" | "custom"; type ConnectionDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; - defaultTab?: "all" | "connected"; + defaultTab?: ConnectionAccessTab; initialSearch?: string; } & ( | { @@ -92,8 +96,6 @@ type ConnectionDialogProps = { // Dialog content (needs Suspense boundary above it) // --------------------------------------------------------------------------- -type ConnectionTab = "all" | "connected"; - function ConnectionDialogContent({ mode = "add", agentId, @@ -105,7 +107,7 @@ function ConnectionDialogContent({ search, onCreateConnection, onBrowseNavigate, - defaultTab = "connected", + defaultTab = "all", }: { mode?: ConnectionDialogMode; agentId?: string; @@ -117,20 +119,22 @@ function ConnectionDialogContent({ search: string; onCreateConnection: () => void; onBrowseNavigate?: (slug: string) => void; - defaultTab?: "all" | "connected"; + defaultTab?: ConnectionAccessTab; }) { const { org } = useProjectContext(); const deferredSearch = useDeferredValue(search); const isSearchStale = search !== deferredSearch; const searchLower = deferredSearch.trim().toLowerCase(); - const [activeTab, setActiveTab] = useLocalStorage( + // Key on `mode` (browse = home sidebar, add = agent) rather than defaultTab, + // which now defaults to "all" for both. Legacy "connected" values coerce away. + const [activeTab, setActiveTab] = useLocalStorage( LOCALSTORAGE_KEYS.connectionsTab(org.slug) + - (defaultTab === "all" ? ":home-modal" : ":agent-modal"), - (existing) => existing ?? defaultTab, + (mode === "browse" ? ":home-modal" : ":agent-modal"), + (existing) => coerceConnectionAccessTab(existing ?? defaultTab), ); - const handleTabChange = (nextTab: ConnectionTab) => { + const handleTabChange = (nextTab: ConnectionAccessTab) => { if (nextTab !== activeTab) { track("connections_dialog_tab_changed", { to_tab: nextTab }); } @@ -145,7 +149,7 @@ function ConnectionDialogContent({ orgSlug: org.slug, }); - const where = deferredSearch?.trim() + const searchWhere = deferredSearch?.trim() ? { operator: "or" as const, conditions: [ @@ -163,6 +167,22 @@ function ConnectionDialogContent({ } : undefined; + // Tabs are hidden while searching, so search spans every access bucket; + // otherwise filter by the active tab server-side to keep pagination correct. + const accessValue = searchLower ? null : accessTabWhereValue(activeTab); + const accessWhere = accessValue + ? { + field: ["access"], + operator: "eq" as const, + value: accessValue, + } + : undefined; + + const where = + searchWhere && accessWhere + ? { operator: "and" as const, conditions: [searchWhere, accessWhere] } + : (searchWhere ?? accessWhere); + const toolArguments = { ...(where && { where }), orderBy: [{ field: ["updated_at"], direction: "asc" as const }], @@ -215,7 +235,14 @@ function ConnectionDialogContent({ connectionsData?.pages.flatMap( (p: CollectionListOutput) => p?.items ?? [], ) ?? []; - const grouped = groupConnections(allConnections); + // The server-side `where` filters real rows by access, but well-known + // connections (e.g. the dev-assets "Local Files") are injected after the SQL + // filter and would otherwise leak into Shared/Personal. Guard client-side + // too. While searching, tabs are hidden so the search spans every bucket. + const visibleConnections = searchLower + ? allConnections + : filterConnectionsByAccessTab(allConnections, activeTab); + const grouped = groupConnections(visibleConnections); // Build set of connected app names to deduplicate catalog items const connectedAppNames = new Set( @@ -494,10 +521,11 @@ function ConnectionDialogContent({ handleTabChange(id as ConnectionTab)} + onTabChange={(id) => handleTabChange(id as ConnectionAccessTab)} />
)}
@@ -714,47 +742,14 @@ export function AddConnectionDialog({ connection_id: id, flow: "clone", }); - if (tokenInfo) { - try { - const response = await fetch( - `/api/${org.slug}/connections/${id}/oauth-token`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ - accessToken: tokenInfo.accessToken, - refreshToken: tokenInfo.refreshToken, - expiresIn: tokenInfo.expiresIn, - scope: tokenInfo.scope, - clientId: tokenInfo.clientId, - clientSecret: tokenInfo.clientSecret, - tokenEndpoint: tokenInfo.tokenEndpoint, - }), - }, - ); - if (!response.ok) { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } else { - await connectionActions.update.mutateAsync({ id, data: {} }); - } - } catch { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } - } else { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } + await persistDownstreamToken({ + orgSlug: org.slug, + connectionId: id, + token, + tokenInfo, + connectionActions, + currentTitle: newTitle, + }); await queryClient.invalidateQueries({ queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), }); @@ -776,117 +771,53 @@ export function AddConnectionDialog({ setConnectingItemId(item.id); try { - const connectionData = extractConnectionData( - item, - org.id, - session.user.id, - { remoteIndex: 0 }, - ); - - const isStdioConnection = connectionData.connection_type === "STDIO"; - const hasUrl = Boolean(connectionData.connection_url); - const hasStdioConfig = - isStdioConnection && - connectionData.connection_headers && - typeof connectionData.connection_headers === "object" && - "command" in connectionData.connection_headers; + const result = await connectApp(item, { + org: { id: org.id, slug: org.slug }, + userId: session.user.id, + connectionActions, + queryClient, + }); - if (!hasUrl && !hasStdioConfig) { + if (result.error === "no-connection-method") { toast.error( "This MCP Server cannot be connected: no connection method available", ); - setConnectingItemId(null); return; } - const { id } = await connectionActions.create.mutateAsync(connectionData); + const id = result.id; + if (!id) { + toast.error("Failed to connect"); + return; + } - // Handle OAuth flow - const mcpProxyUrl = new URL( - `/api/${org.slug}/mcp/${id}`, - window.location.origin, - ); - const authStatus = await isConnectionAuthenticated({ - url: mcpProxyUrl.href, - token: null, - orgId: org.id, - }); + const appName = getRegistryItemAppName(item); - if (authStatus.supportsOAuth && !authStatus.isAuthenticated) { - const { token, tokenInfo, error } = await authenticateMcp({ - connectionId: id, - orgSlug: org.slug, - scope: "offline_access", - }); - if (error || !token) { - track("connection_oauth_failed", { - connection_id: id, - flow: "connect_new", - error: error ?? "no_token", - }); - toast.warning("Couldn't sign in to this connection", { - description: `It was added to your agent, but its sign-in setup looks off. You can try authenticating again later from the connection's settings. (${error ?? "no token received"})`, - }); - trackAttach(id, connectionData.app_name ?? null, "new"); - onAdd(id); - return; - } - track("connection_oauth_succeeded", { + if (result.oauth === "failed") { + track("connection_oauth_failed", { connection_id: id, flow: "connect_new", + error: result.error ?? "no_token", }); + toast.warning("Couldn't sign in to this connection", { + description: `It was added to your agent, but its sign-in setup looks off. You can try authenticating again later from the connection's settings. (${result.error ?? "no token received"})`, + }); + trackAttach(id, appName, "new"); + onAdd(id); + return; + } - if (tokenInfo) { - try { - const response = await fetch( - `/api/${org.slug}/connections/${id}/oauth-token`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ - accessToken: tokenInfo.accessToken, - refreshToken: tokenInfo.refreshToken, - expiresIn: tokenInfo.expiresIn, - scope: tokenInfo.scope, - clientId: tokenInfo.clientId, - clientSecret: tokenInfo.clientSecret, - tokenEndpoint: tokenInfo.tokenEndpoint, - }), - }, - ); - if (!response.ok) { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } else { - await connectionActions.update.mutateAsync({ id, data: {} }); - } - } catch { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } - } else { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } - - await queryClient.invalidateQueries({ - queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), + if (result.oauth === "succeeded") { + track("connection_oauth_succeeded", { + connection_id: id, + flow: "connect_new", }); toast.success("Connected and authenticated"); } else { toast.success("Connected"); } - trackAttach(id, connectionData.app_name ?? null, "new"); + trackAttach(id, appName, "new"); onAdd(id); } catch (err) { console.error("Failed to connect:", err); @@ -942,7 +873,7 @@ export function AddConnectionDialog({ { + onCreated={async (id, title) => { setCreateOpen(false); // Handle OAuth if needed (same flow as handleConnectAndAdd) @@ -980,50 +911,14 @@ export function AddConnectionDialog({ connection_id: id, flow: "custom_create", }); - if (tokenInfo) { - try { - const response = await fetch( - `/api/${org.slug}/connections/${id}/oauth-token`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ - accessToken: tokenInfo.accessToken, - refreshToken: tokenInfo.refreshToken, - expiresIn: tokenInfo.expiresIn, - scope: tokenInfo.scope, - clientId: tokenInfo.clientId, - clientSecret: tokenInfo.clientSecret, - tokenEndpoint: tokenInfo.tokenEndpoint, - }), - }, - ); - if (!response.ok) { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } else { - await connectionActions.update.mutateAsync({ - id, - data: {}, - }); - } - } catch { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } - } else { - await connectionActions.update.mutateAsync({ - id, - data: { connection_token: token }, - }); - } + await persistDownstreamToken({ + orgSlug: org.slug, + connectionId: id, + token, + tokenInfo, + connectionActions, + currentTitle: title, + }); await queryClient.invalidateQueries({ queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), }); diff --git a/apps/mesh/src/web/views/virtual-mcp/connection-attach.test.ts b/apps/mesh/src/web/views/virtual-mcp/connection-attach.test.ts new file mode 100644 index 0000000000..68b0e736e0 --- /dev/null +++ b/apps/mesh/src/web/views/virtual-mcp/connection-attach.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { connectionAttachTarget } from "./connection-attach"; + +describe("connectionAttachTarget", () => { + it("attaches an org-scoped connection as a concrete child", () => { + expect( + connectionAttachTarget({ id: "conn_1", access: "org", app_id: "mcp-x" }), + ).toEqual({ kind: "connection", connectionId: "conn_1" }); + }); + + it("attaches a user-private connection as a typed slot keyed by app_id", () => { + expect( + connectionAttachTarget({ + id: "conn_2", + access: "user", + app_id: "mcp-github", + }), + ).toEqual({ kind: "slot", slotAppId: "mcp-github" }); + }); + + it("skips a user-private connection that has no app_id", () => { + expect( + connectionAttachTarget({ id: "conn_3", access: "user", app_id: null }), + ).toEqual({ kind: "skip-no-app-id" }); + }); + + it("treats an undefined app_id on a user connection as unslottable", () => { + expect(connectionAttachTarget({ id: "conn_4", access: "user" })).toEqual({ + kind: "skip-no-app-id", + }); + }); + + it("attaches an org connection without an app_id as a concrete child", () => { + expect( + connectionAttachTarget({ id: "conn_5", access: "org", app_id: null }), + ).toEqual({ kind: "connection", connectionId: "conn_5" }); + }); +}); diff --git a/apps/mesh/src/web/views/virtual-mcp/connection-attach.ts b/apps/mesh/src/web/views/virtual-mcp/connection-attach.ts new file mode 100644 index 0000000000..4184bdcebb --- /dev/null +++ b/apps/mesh/src/web/views/virtual-mcp/connection-attach.ts @@ -0,0 +1,30 @@ +/** + * Decide how a connection should be attached to a Virtual MCP / agent based on + * its visibility (`access`). + * + * Org-scoped connections (`access === "org"`) attach as concrete children — the + * agent references the exact connection id. User-private connections + * (`access === "user"`) must attach as typed *slots*: at runtime the slot is + * resolved per-caller to that caller's own connection of the matching app_id + * (falling back to an org-shared one). A private connection without an `app_id` + * has no type to slot on, so it can't be attached. + * + * Shared by the agent settings tab (`handleAddConnection`) and the bulk + * "add to agent" flow so the slot-vs-child decision stays in one place. + */ +export type AttachTarget = + | { kind: "connection"; connectionId: string } + | { kind: "slot"; slotAppId: string } + | { kind: "skip-no-app-id" }; + +export function connectionAttachTarget(conn: { + id: string; + access: "user" | "org"; + app_id?: string | null; +}): AttachTarget { + if (conn.access === "user") { + if (!conn.app_id) return { kind: "skip-no-app-id" }; + return { kind: "slot", slotAppId: conn.app_id }; + } + return { kind: "connection", connectionId: conn.id }; +} diff --git a/apps/mesh/src/web/views/virtual-mcp/dependency-selection-dialog.tsx b/apps/mesh/src/web/views/virtual-mcp/dependency-selection-dialog.tsx deleted file mode 100644 index 77feebd29b..0000000000 --- a/apps/mesh/src/web/views/virtual-mcp/dependency-selection-dialog.tsx +++ /dev/null @@ -1,681 +0,0 @@ -import { CollectionTabs } from "@/web/components/collections/collection-tabs.tsx"; -import { ToolAnnotationBadges } from "@/web/components/tools"; -import { ErrorBoundary } from "@/web/components/error-boundary"; -import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; -import { useMCPAuthStatus } from "@/web/hooks/use-mcp-auth-status"; -import { Button } from "@deco/ui/components/button.tsx"; -import { Checkbox } from "@deco/ui/components/checkbox.tsx"; -import { - Dialog, - DialogContent, - DialogFooter, -} from "@deco/ui/components/dialog.tsx"; -import { cn } from "@deco/ui/lib/utils.ts"; -import { - useConnection, - useMCPClient, - useMCPPromptsList, - useMCPResourcesList, - useMCPToolsList, - useProjectContext, -} from "@decocms/mesh-sdk"; -import { AlertTriangle, Loading01, LockUnlocked01 } from "@untitledui/icons"; -import type { ReactNode } from "react"; -import { Suspense, useReducer } from "react"; -import type { VirtualMCPConnection } from "@decocms/mesh-sdk/types"; -import { - type ConnectionFormValue, - type SelectionValue, -} from "./selection-utils"; -import type { VirtualMcpFormReturn } from "./types"; - -// Form types -type FormData = Record; - -// Generic item type for selections -interface SelectableItem { - id: string; - name: string; - description?: string; - tags?: ReactNode; -} - -// Loading spinner component -function LoadingSpinner() { - return ( -
- -
- ); -} - -// Error fallback factory for method not found errors -function createMethodNotFoundFallback(notSupportedMessage: string) { - return ({ error }: { error: Error | null }) => { - // Check for "Method not found" error (code -32601) - const isMethodNotFound = - error?.message?.includes("Method not found") || - (error as any)?.code === -32601; - - if (isMethodNotFound) { - return ( -
-
- {notSupportedMessage} -
-
- ); - } - - // Default error fallback - return ( -
-
- -
-
-

Something went wrong

-

- {error?.message - ? error.message.length > 200 - ? `${error.message.slice(0, 200)}...` - : error.message - : "An unexpected error occurred"} -

-
-
- ); - }; -} - -// Generic Selection Item Component -function SelectionItem({ - item, - isSelected, - onToggle, - disabled, -}: { - item: SelectableItem; - isSelected: boolean; - onToggle: () => void; - disabled?: boolean; -}) { - return ( - - ); -} - -// Generic Selection Tab Component -function SelectionTab({ - items, - selections, - onToggle, - emptyMessage, - disabled, -}: { - items: SelectableItem[]; - selections: SelectionValue; - onToggle: (itemId: string, allItemIds: string[]) => void; - emptyMessage: string; - disabled?: boolean; -}) { - const allItemIds = items.map((item) => item.id); - - // Early return for empty state - if (items.length === 0) { - return ( -
-
- {emptyMessage} -
-
- ); - } - - return ( -
- {items.map((item) => ( - onToggle(item.id, allItemIds)} - disabled={disabled} - /> - ))} -
- ); -} - -// Tools Tab Wrapper -function ToolsTab({ - connectionId, - selections, - onToggle, - disabled, -}: { - connectionId: string; - selections: SelectionValue; - onToggle: (toolName: string, allToolNames: string[]) => void; - disabled?: boolean; -}) { - const { org } = useProjectContext(); - const client = useMCPClient({ - connectionId, - orgId: org.id, - orgSlug: org.slug, - }); - const { data } = useMCPToolsList({ client }); - - const items: SelectableItem[] = data.tools.map((tool) => ({ - id: tool.name, - name: tool.name, - description: tool.description, - tags: ( - | undefined} - /> - ), - })); - - return ( - - ); -} - -// Resources Tab Wrapper -function ResourcesTab({ - connectionId, - selections, - onToggle, - disabled, -}: { - connectionId: string; - selections: SelectionValue; - onToggle: (name: string, allResourceNames: string[]) => void; - disabled?: boolean; -}) { - const { org } = useProjectContext(); - const client = useMCPClient({ - connectionId, - orgId: org.id, - orgSlug: org.slug, - }); - const { data } = useMCPResourcesList({ client }); - - const items: SelectableItem[] = data.resources.map((resource) => ({ - id: resource.name || resource.uri, - name: resource.name || resource.uri, - description: resource.description, - })); - - return ( - - ); -} - -// Prompts Tab Wrapper -function PromptsTab({ - connectionId, - selections, - onToggle, - disabled, -}: { - connectionId: string; - selections: SelectionValue; - onToggle: (name: string, allPromptNames: string[]) => void; - disabled?: boolean; -}) { - const { org } = useProjectContext(); - const client = useMCPClient({ - connectionId, - orgId: org.id, - orgSlug: org.slug, - }); - const { data } = useMCPPromptsList({ client }); - - const items: SelectableItem[] = data.prompts.map((prompt) => ({ - id: prompt.name, - name: prompt.name, - description: prompt.description, - })); - - return ( - - ); -} - -// Connection Details Content Component -function ConnectionDetailsContent({ - currentConnection, - activeTab, - selectedId, - formData, - toggleTool, - toggleResource, - togglePrompt, - toggleAllTools, - toggleAllResources, - toggleAllPrompts, - onTabChange, -}: { - currentConnection: { - id: string; - title: string; - description?: string | null; - icon?: string | null; - }; - activeTab: "tools" | "resources" | "prompts"; - selectedId: string; - formData: FormData; - toggleTool: ( - connId: string, - toolName: string, - allToolNames: string[], - ) => void; - toggleResource: ( - connId: string, - name: string, - allResourceNames: string[], - ) => void; - togglePrompt: ( - connId: string, - name: string, - allPromptNames: string[], - ) => void; - toggleAllTools: (connId: string) => void; - toggleAllResources: (connId: string) => void; - toggleAllPrompts: (connId: string) => void; - onTabChange: (value: "tools" | "resources" | "prompts") => void; -}) { - const sel = formData[selectedId]; - const isAllSelected = - activeTab === "tools" - ? sel?.tools === null - : activeTab === "resources" - ? sel?.resources === null - : sel?.prompts === null; - - const handleSelectAll = () => { - if (activeTab === "tools") toggleAllTools(selectedId); - else if (activeTab === "resources") toggleAllResources(selectedId); - else toggleAllPrompts(selectedId); - }; - - return ( - <> - {/* Header */} -
-
- -
-

- {currentConnection.title} -

- {currentConnection.description && ( -

- {currentConnection.description} -

- )} -
-
-
- - {/* Tabs row + Select all */} -
- - onTabChange(id as "tools" | "resources" | "prompts") - } - /> - -
- - {/* Tab content */} -
- {activeTab === "tools" && ( - - }> - - toggleTool(selectedId, toolName, allToolNames) - } - /> - - - )} - {activeTab === "resources" && ( - - }> - - toggleResource(selectedId, name, allResourceNames) - } - /> - - - )} - {activeTab === "prompts" && ( - - }> - - togglePrompt(selectedId, name, allPromptNames) - } - /> - - - )} -
- - ); -} - -interface DependencySelectionDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedId: string | null; - form: VirtualMcpFormReturn; - connections: VirtualMCPConnection[]; - onAuthenticate?: (connectionId: string) => void; -} - -// Auth check — renders auth prompt if the connection needs authorization -function AuthGate({ - connectionId, - onAuthenticate, - children, -}: { - connectionId: string; - onAuthenticate?: (connectionId: string) => void; - children: ReactNode; -}) { - const authStatus = useMCPAuthStatus({ connectionId }); - const needsAuth = authStatus.supportsOAuth && !authStatus.isAuthenticated; - - if (needsAuth) { - return ( -
-
- -
-
-

Authorization required

-

- This connection needs to be authorized before you can configure its - tools and resources. -

-
- {onAuthenticate && ( - - )} -
- ); - } - - return <>{children}; -} - -// Helper: Convert connections array to Record for easier manipulation -function connectionsToRecord(connections: VirtualMCPConnection[]): FormData { - const formData: FormData = {}; - for (const conn of connections) { - formData[conn.connection_id] = { - tools: conn.selected_tools, - resources: conn.selected_resources ?? null, - prompts: conn.selected_prompts ?? null, - }; - } - return formData; -} - -// Helper: Convert Record back to connections array -function recordToConnections(formData: FormData): VirtualMCPConnection[] { - return Object.entries(formData).map(([connId, sel]) => ({ - connection_id: connId, - selected_tools: sel.tools, - selected_resources: sel.resources, - selected_prompts: sel.prompts, - })); -} - -// Dialog state reducer -interface DialogState { - activeTab: "tools" | "resources" | "prompts"; -} - -type DialogAction = { - type: "SET_ACTIVE_TAB"; - payload: "tools" | "resources" | "prompts"; -}; - -function dialogReducer(state: DialogState, action: DialogAction): DialogState { - switch (action.type) { - case "SET_ACTIVE_TAB": - return { ...state, activeTab: action.payload }; - default: - return state; - } -} - -export function DependencySelectionDialog({ - open, - onOpenChange, - selectedId, - form, - connections, - onAuthenticate, -}: DependencySelectionDialogProps) { - const [dialogState, dispatch] = useReducer(dialogReducer, { - activeTab: "tools", - }); - - const currentConnection = useConnection(selectedId ?? ""); - - // Convert connections array to Record for local use - const formData = connectionsToRecord(connections ?? []); - - const toggleItem = ( - connId: string, - field: "tools" | "resources" | "prompts", - itemId: string, - allItemIds: string[], - ) => { - const currentSelection = formData[connId]?.[field]; - let newSelection: SelectionValue; - - if (currentSelection === null) { - newSelection = allItemIds.filter((id) => id !== itemId); - } else if (currentSelection?.includes(itemId)) { - newSelection = currentSelection.filter((id) => id !== itemId); - } else { - newSelection = [...(currentSelection ?? []), itemId]; - if (newSelection.length === allItemIds.length) { - newSelection = null; - } - } - - const updatedFormData: FormData = { ...formData }; - if (!updatedFormData[connId]) { - updatedFormData[connId] = { tools: null, resources: null, prompts: null }; - } else { - updatedFormData[connId] = { ...updatedFormData[connId] }; - } - updatedFormData[connId][field] = newSelection; - - form.setValue("connections", recordToConnections(updatedFormData), { - shouldDirty: true, - shouldTouch: true, - }); - }; - - const toggleAll = ( - connId: string, - field: "tools" | "resources" | "prompts", - ) => { - const current = formData[connId]?.[field]; - const newSelection = current === null ? [] : null; - - const updatedFormData: FormData = { ...formData }; - if (!updatedFormData[connId]) { - updatedFormData[connId] = { tools: null, resources: null, prompts: null }; - } else { - updatedFormData[connId] = { ...updatedFormData[connId] }; - } - updatedFormData[connId][field] = newSelection; - - form.setValue("connections", recordToConnections(updatedFormData), { - shouldDirty: true, - shouldTouch: true, - }); - }; - - const toggleTool = ( - connId: string, - toolName: string, - allToolNames: string[], - ) => toggleItem(connId, "tools", toolName, allToolNames); - const toggleResource = ( - connId: string, - name: string, - allResourceNames: string[], - ) => toggleItem(connId, "resources", name, allResourceNames); - const togglePrompt = ( - connId: string, - promptName: string, - allPromptNames: string[], - ) => toggleItem(connId, "prompts", promptName, allPromptNames); - - const toggleAllTools = (connId: string) => toggleAll(connId, "tools"); - const toggleAllResources = (connId: string) => toggleAll(connId, "resources"); - const toggleAllPrompts = (connId: string) => toggleAll(connId, "prompts"); - - if (!selectedId || !currentConnection) return null; - - return ( - - - }> - - - dispatch({ type: "SET_ACTIVE_TAB", payload: value }) - } - /> - - - - - - - - - ); -} diff --git a/apps/mesh/src/web/views/virtual-mcp/enable-toggle.tsx b/apps/mesh/src/web/views/virtual-mcp/enable-toggle.tsx new file mode 100644 index 0000000000..9bccf819b1 --- /dev/null +++ b/apps/mesh/src/web/views/virtual-mcp/enable-toggle.tsx @@ -0,0 +1,41 @@ +import { Switch } from "@deco/ui/components/switch.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; + +/** + * Enable/disable toggle for a connection or slot card. Wraps the design-system + * Switch with a visible border and a stronger off-state track so it stands out + * against the muted/tinted card footers — the default switch's `bg-input` + * off-state blends into them. + */ +export function EnableToggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: (enabled: boolean) => void; +}) { + return ( + + {/* Wrap in a span: TooltipTrigger asChild would otherwise merge the + tooltip's own data-state onto the Switch and clobber Radix Switch's + checked/unchecked data-state, breaking all state-based styling. */} + + + + + + + {enabled ? "Enabled" : "Disabled"} + + + ); +} diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 9542fd7a45..0e33b8d6d5 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -10,6 +10,7 @@ import { usePanelActions } from "@/web/layouts/shell-layout"; import { User } from "@/web/components/user/user"; import { useMCPAuthStatus } from "@/web/hooks/use-mcp-auth-status"; +import { persistDownstreamToken } from "@/web/lib/connect-app"; import { authenticateMcp, isConnectionAuthenticated, @@ -17,6 +18,7 @@ import { import { KEYS } from "@/web/lib/query-keys"; import { unwrapToolResult } from "@/web/lib/unwrap-tool-result"; import { getConnectionSlug } from "@/shared/utils/connection-slug"; +import { SlotItem } from "./slot-item"; import { AlertDialog, AlertDialogAction, @@ -70,7 +72,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { Settings02, - Settings04, Maximize01, Play, Plus, @@ -86,9 +87,15 @@ import { IconPicker } from "../../components/icon-picker"; import { SimpleIconPicker } from "../../components/simple-icon-picker"; import { Page } from "@/web/components/page"; import { AddConnectionDialog } from "./add-connection-dialog"; +import { connectionAttachTarget } from "./connection-attach"; +import { EnableToggle } from "./enable-toggle"; import { track } from "@/web/lib/posthog-client"; -import { DependencySelectionDialog } from "./dependency-selection-dialog"; -import { ALL_ITEMS_SELECTED } from "./selection-utils"; +import { + ALL_ITEMS_SELECTED, + disabledSelection, + enabledSelection, + isSelectionEnabled, +} from "./selection-utils"; import { VirtualMcpFormSchema, type VirtualMcpFormData, @@ -109,15 +116,11 @@ import { RuntimeFields } from "@/web/components/sandbox/runtime-card/runtime-fie type DialogState = { shareDialogOpen: boolean; addDialogOpen: boolean; - settingsDialogOpen: boolean; - settingsConnectionId: string | null; }; type DialogAction = | { type: "SET_SHARE_DIALOG_OPEN"; payload: boolean } - | { type: "SET_ADD_DIALOG_OPEN"; payload: boolean } - | { type: "OPEN_SETTINGS"; payload: string } - | { type: "CLOSE_SETTINGS" }; + | { type: "SET_ADD_DIALOG_OPEN"; payload: boolean }; function dialogReducer(state: DialogState, action: DialogAction): DialogState { switch (action.type) { @@ -125,18 +128,6 @@ function dialogReducer(state: DialogState, action: DialogAction): DialogState { return { ...state, shareDialogOpen: action.payload }; case "SET_ADD_DIALOG_OPEN": return { ...state, addDialogOpen: action.payload }; - case "OPEN_SETTINGS": - return { - ...state, - settingsDialogOpen: true, - settingsConnectionId: action.payload, - }; - case "CLOSE_SETTINGS": - return { - ...state, - settingsDialogOpen: false, - settingsConnectionId: null, - }; default: return state; } @@ -229,7 +220,8 @@ function stripIncompleteEnvEntries( function ConnectionItem({ connection_id, usedConnectionIds, - onOpenSettings, + enabled, + onToggleEnabled, onRemove, onAuthenticate, onSwitchInstance, @@ -237,7 +229,8 @@ function ConnectionItem({ }: { connection_id: string; usedConnectionIds: Set; - onOpenSettings: () => void; + enabled: boolean; + onToggleEnabled: (enabled: boolean) => void; onRemove: () => void; onAuthenticate: (connectionId: string) => void; onSwitchInstance: (oldId: string, newId: string) => void; @@ -264,7 +257,8 @@ function ConnectionItem({ orgSlug={org.slug} appName={connection.app_name} usedConnectionIds={usedConnectionIds} - onOpenSettings={onOpenSettings} + enabled={enabled} + onToggleEnabled={onToggleEnabled} onRemove={onRemove} onAuthenticate={onAuthenticate} onSwitchInstance={onSwitchInstance} @@ -276,67 +270,6 @@ function ConnectionItem({ const NEW_INSTANCE_VALUE = "__new_instance__"; -async function extractEmailFromTokenInfo( - tokenInfo: { - idToken: string | null; - userinfoEndpoint: string | null; - accessToken: string; - } | null, - accessToken: string, -): Promise { - // 1. Try to decode the OIDC id_token JWT (fastest, no extra request) - const jwtToTry = tokenInfo?.idToken ?? null; - if (jwtToTry) { - const email = decodeJwtEmail(jwtToTry); - if (email) return email; - } - - // 2. Call the OIDC userinfo endpoint if available (works for Google Drive which returns opaque access tokens) - const userinfoEndpoint = tokenInfo?.userinfoEndpoint ?? null; - if (userinfoEndpoint) { - try { - const res = await fetch(userinfoEndpoint, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (res.ok) { - const userinfo = (await res.json()) as Record; - const email = - typeof userinfo.email === "string" - ? userinfo.email - : typeof userinfo.upn === "string" - ? userinfo.upn - : typeof userinfo.preferred_username === "string" - ? userinfo.preferred_username - : null; - if (email) return email; - } - } catch { - // Ignore — userinfo endpoint unavailable or CORS blocked - } - } - - // 3. Last resort: try to decode the access token itself as a JWT - return decodeJwtEmail(accessToken); -} - -function decodeJwtEmail(token: string): string | null { - try { - const parts = token.split("."); - if (parts.length === 3 && parts[1]) { - const payload = JSON.parse( - atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")), - ) as Record; - if (typeof payload.email === "string") return payload.email; - if (typeof payload.upn === "string") return payload.upn; - if (typeof payload.preferred_username === "string") - return payload.preferred_username; - } - } catch { - // Not a decodable JWT - } - return null; -} - function SiblingInstanceSelector({ appName, connectionId, @@ -407,7 +340,8 @@ function ConnectionItemWithAuth({ orgSlug, appName, usedConnectionIds, - onOpenSettings, + enabled, + onToggleEnabled, onRemove, onAuthenticate, onSwitchInstance, @@ -422,7 +356,8 @@ function ConnectionItemWithAuth({ orgSlug: string; appName?: string | null; usedConnectionIds: Set; - onOpenSettings: () => void; + enabled: boolean; + onToggleEnabled: (enabled: boolean) => void; onRemove: () => void; onAuthenticate: (connectionId: string) => void; onSwitchInstance: (oldId: string, newId: string) => void; @@ -453,9 +388,9 @@ function ConnectionItemWithAuth({ icon={connectionIcon} name={connectionTitle} size="sm" - className="shrink-0" + className={cn("shrink-0", !enabled && "opacity-50")} /> -
+

{connectionTitle}

{needsAuth ? ( @@ -494,7 +429,7 @@ function ConnectionItemWithAuth({ )} - {/* Footer — instance selector + resources summary + edit + remove */} + {/* Footer — instance selector + enable/disable + remove */}
{/* Instance selector */} {appName && ( @@ -507,21 +442,8 @@ function ConnectionItemWithAuth({ /> )} -
- - - - - Configure resources - +
+
@@ -1955,19 +1931,6 @@ Define step-by-step how the agent should handle requests. onAdd={handleAddConnection} /> - { - if (!open) { - dispatch({ type: "CLOSE_SETTINGS" }); - } - }} - selectedId={dialogState.settingsConnectionId} - form={form} - connections={connections} - onAuthenticate={handleAuthenticate} - /> - diff --git a/apps/mesh/src/web/views/virtual-mcp/selection-utils.test.ts b/apps/mesh/src/web/views/virtual-mcp/selection-utils.test.ts new file mode 100644 index 0000000000..bdf5236772 --- /dev/null +++ b/apps/mesh/src/web/views/virtual-mcp/selection-utils.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test"; +import { + disabledSelection, + enabledSelection, + isSelectionEnabled, +} from "./selection-utils"; + +describe("isSelectionEnabled", () => { + it("treats all-null (everything exposed) as enabled", () => { + expect( + isSelectionEnabled({ + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }), + ).toBe(true); + }); + + it("treats all-empty-arrays (nothing exposed) as disabled", () => { + expect( + isSelectionEnabled({ + selected_tools: [], + selected_resources: [], + selected_prompts: [], + }), + ).toBe(false); + }); + + it("treats a legacy subset selection as enabled", () => { + expect( + isSelectionEnabled({ + selected_tools: ["a"], + selected_resources: [], + selected_prompts: [], + }), + ).toBe(true); + }); + + it("treats a resources-only entry (tools [], resources null) as enabled", () => { + expect( + isSelectionEnabled({ + selected_tools: [], + selected_resources: null, + selected_prompts: [], + }), + ).toBe(true); + }); + + it("treats missing resources/prompts as enabled even when tools is empty", () => { + expect(isSelectionEnabled({ selected_tools: [] })).toBe(true); + }); +}); + +describe("enabledSelection / disabledSelection", () => { + it("enabledSelection exposes everything (all null)", () => { + expect(enabledSelection()).toEqual({ + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }); + }); + + it("disabledSelection exposes nothing (all empty arrays)", () => { + expect(disabledSelection()).toEqual({ + selected_tools: [], + selected_resources: [], + selected_prompts: [], + }); + }); + + it("round-trips through isSelectionEnabled", () => { + expect(isSelectionEnabled(enabledSelection())).toBe(true); + expect(isSelectionEnabled(disabledSelection())).toBe(false); + }); +}); diff --git a/apps/mesh/src/web/views/virtual-mcp/selection-utils.ts b/apps/mesh/src/web/views/virtual-mcp/selection-utils.ts index beb372da16..d6aba58a41 100644 --- a/apps/mesh/src/web/views/virtual-mcp/selection-utils.ts +++ b/apps/mesh/src/web/views/virtual-mcp/selection-utils.ts @@ -37,3 +37,47 @@ export const ALL_ITEMS_SELECTED: ConnectionFormValue = { resources: null, prompts: null, } as const; + +/** + * Selection fields as stored on a Virtual MCP connection/slot form entry. + * `null` means "all exposed", `[]` means "none exposed", `[...]` a subset. + */ +export interface ToolSelectionFields { + selected_tools: SelectionValue; + selected_resources?: SelectionValue; + selected_prompts?: SelectionValue; +} + +const isEmptyArray = (value: SelectionValue | undefined): boolean => + Array.isArray(value) && value.length === 0; + +/** + * A connection/slot is "disabled" only when it exposes nothing at all — i.e. + * every selection field is an explicit empty array. Anything else (all-null = + * everything, or a non-empty subset, or an unset field) counts as enabled. + */ +export function isSelectionEnabled(sel: ToolSelectionFields): boolean { + return !( + isEmptyArray(sel.selected_tools) && + isEmptyArray(sel.selected_resources) && + isEmptyArray(sel.selected_prompts) + ); +} + +/** Selection fields exposing everything (the enabled state). */ +export function enabledSelection(): Required { + return { + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }; +} + +/** Selection fields exposing nothing (the disabled state). */ +export function disabledSelection(): Required { + return { + selected_tools: [], + selected_resources: [], + selected_prompts: [], + }; +} diff --git a/apps/mesh/src/web/views/virtual-mcp/slot-display.test.ts b/apps/mesh/src/web/views/virtual-mcp/slot-display.test.ts new file mode 100644 index 0000000000..b4923c9eea --- /dev/null +++ b/apps/mesh/src/web/views/virtual-mcp/slot-display.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { slotDisplayState } from "./slot-display"; + +describe("slotDisplayState", () => { + it("uses the resolved connection's title and icon when resolved", () => { + expect( + slotDisplayState("deco/mcp-github", { + title: "GitHub", + icon: "https://example.com/gh.png", + }), + ).toEqual({ + state: "resolved", + title: "GitHub", + icon: "https://example.com/gh.png", + }); + }); + + it("falls back to the app_id with no icon when unresolved", () => { + expect(slotDisplayState("deco/mcp-github", null)).toEqual({ + state: "unresolved", + title: "deco/mcp-github", + icon: null, + }); + }); + + it("preserves a null resolved icon", () => { + expect( + slotDisplayState("some/app", { title: "Some App", icon: null }), + ).toEqual({ state: "resolved", title: "Some App", icon: null }); + }); +}); diff --git a/apps/mesh/src/web/views/virtual-mcp/slot-display.ts b/apps/mesh/src/web/views/virtual-mcp/slot-display.ts new file mode 100644 index 0000000000..0c2ac10000 --- /dev/null +++ b/apps/mesh/src/web/views/virtual-mcp/slot-display.ts @@ -0,0 +1,21 @@ +/** + * Decides how to display a typed slot. A slot carries only a `slot_app_id`; + * when it resolves to one of the caller's connections we show that connection's + * title/icon, otherwise we fall back to the raw app_id (the "not connected for + * you" state). + */ +export interface SlotDisplay { + state: "resolved" | "unresolved"; + title: string; + icon: string | null; +} + +export function slotDisplayState( + slotAppId: string, + resolved: { title: string; icon: string | null } | null, +): SlotDisplay { + if (resolved) { + return { state: "resolved", title: resolved.title, icon: resolved.icon }; + } + return { state: "unresolved", title: slotAppId, icon: null }; +} diff --git a/apps/mesh/src/web/views/virtual-mcp/slot-item.tsx b/apps/mesh/src/web/views/virtual-mcp/slot-item.tsx new file mode 100644 index 0000000000..7f04694dac --- /dev/null +++ b/apps/mesh/src/web/views/virtual-mcp/slot-item.tsx @@ -0,0 +1,173 @@ +import { Suspense } from "react"; +import { Link } from "@tanstack/react-router"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { Settings02, XClose } from "@untitledui/icons"; +import { useConnection } from "@decocms/mesh-sdk"; +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { getConnectionSlug } from "@/shared/utils/connection-slug"; +import { useResolveConnectionForUser } from "@/web/hooks/use-resolve-connection-for-user"; +import { EnableToggle } from "./enable-toggle"; +import { slotDisplayState } from "./slot-display"; + +function SlotItemSkeleton() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +/** + * Renders one typed slot in the agent settings connections list. Mirrors the + * concrete connection card — same body + footer layout — differing only in the + * violet `special` tint and the inline "Personal" pill. Resolves the slot's + * app_id to the caller's own connection: resolved slots get an enable/disable + * switch (matching concrete cards). Settings is only reachable once every slot + * resolves (the agent-view connect gate handles connecting), so the unresolved + * branch is a defensive "not connected" fallback with only a remove action. + */ +export function SlotItem({ + slotAppId, + orgId, + orgSlug, + enabled, + onToggleEnabled, + onRemove, +}: { + slotAppId: string; + orgId: string; + orgSlug: string; + enabled: boolean; + onToggleEnabled: (enabled: boolean) => void; + onRemove: () => void; +}) { + const resolveQuery = useResolveConnectionForUser(orgId, orgSlug, slotAppId); + if (resolveQuery.isLoading) return ; + const resolvedId = resolveQuery.data?.connectionId ?? null; + return ( + }> + + + ); +} + +function SlotItemInner({ + slotAppId, + resolvedId, + orgSlug, + enabled, + onToggleEnabled, + onRemove, +}: { + slotAppId: string; + resolvedId: string | null; + orgSlug: string; + enabled: boolean; + onToggleEnabled: (enabled: boolean) => void; + onRemove: () => void; +}) { + // useConnection tolerates undefined (returns null without suspending); when a + // resolvedId is present it suspends until loaded (caught by the parent). + const connection = useConnection(resolvedId ?? undefined); + const resolved = + resolvedId && connection + ? { title: connection.title, icon: connection.icon } + : null; + const display = slotDisplayState(slotAppId, resolved); + const detailSlug = connection ? getConnectionSlug(connection) : null; + const isResolved = display.state === "resolved" && detailSlug !== null; + + return ( +
+ {isResolved ? ( + + +
+

{display.title}

+ {connection?.description && ( +

+ {connection.description} +

+ )} +
+ + + + + + + Connection settings + + + ) : ( +
+ +
+

{display.title}

+

+ Not connected for you +

+
+
+ )} + +
+ + Personal + +
+ {isResolved && ( + + )} + + + + + Remove + +
+
+
+ ); +} diff --git a/apps/mesh/src/web/views/virtual-mcp/types.ts b/apps/mesh/src/web/views/virtual-mcp/types.ts index 54487d563d..8f208b11ee 100644 --- a/apps/mesh/src/web/views/virtual-mcp/types.ts +++ b/apps/mesh/src/web/views/virtual-mcp/types.ts @@ -8,6 +8,14 @@ import type { UseFormReturn } from "react-hook-form"; /** * Form validation schema for Virtual MCP + * + * `slots` is picked from VirtualMCPEntitySchema but overridden to `optional()` + * here because the entity schema uses `.default([])` which splits `z.input` + * (slots?: …) from `z.output` (slots: …). react-hook-form's `useForm` + + * `zodResolver` requires identical input/output types — so we keep slots + * optional throughout the form layer. At runtime the value is never actually + * undefined: `VirtualMCPStorage.findById` always returns `slots: []`, which + * seeds the form's default values. */ export const VirtualMcpFormSchema = VirtualMCPEntitySchema.pick({ status: true, @@ -16,6 +24,9 @@ export const VirtualMcpFormSchema = VirtualMCPEntitySchema.pick({ icon: true, metadata: true, connections: true, + slots: true, +}).extend({ + slots: VirtualMCPEntitySchema.shape.slots.optional(), }); /** diff --git a/docs/superpowers/plans/2026-05-29-hoist-connect-gate.md b/docs/superpowers/plans/2026-05-29-hoist-connect-gate.md new file mode 100644 index 0000000000..d23a425e3d --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-hoist-connect-gate.md @@ -0,0 +1,254 @@ +# Hoist the connect gate to the whole agent view — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the "connect to use this agent" gate from the chat panel up to the agent-view container (`AgentInsetProvider`), so that an agent with unresolved slots walls its entire view (tab bar + all tabs + chat) for the current user (owner included), and remove the now-redundant chat-panel gate and the settings-tab slot Connect button. + +**Architecture:** `AgentInsetProvider` already fetches `entity = useVirtualMCP(id)` and has `org`. Add a `useUnresolvedSlots` call and an early return that renders the existing `ConnectAgentGate` filling the inset (before the desktop/mobile layout split, covering both). Remove the duplicate gate from `ChatPanelContent`. Simplify `SlotItem`'s unresolved branch (drop the Connect button) since settings is now only reachable when all slots resolve. + +**Tech Stack:** React 19, TanStack Router/Query, mesh-sdk hooks, Tailwind v4, Bun. + +**Spec:** `docs/superpowers/specs/2026-05-29-hoist-connect-gate-design.md` + +--- + +## File Structure + +- **Modify** `apps/mesh/src/web/layouts/agent-shell-layout/index.tsx` — compute unresolved slots, early-return the gate. +- **Modify** `apps/mesh/src/web/components/chat/side-panel-chat.tsx` — remove the chat-panel gate. +- **Modify** `apps/mesh/src/web/views/virtual-mcp/slot-item.tsx` — drop the unresolved Connect button. + +No new files; reuses `ConnectAgentGate` and `useUnresolvedSlots`. No new unit tests (the `unresolvedSlots` logic is unchanged and already covered); UI verified by type-check + manual. + +--- + +## Task 1: Gate the whole agent view in `AgentInsetProvider` + +**Files:** +- Modify: `apps/mesh/src/web/layouts/agent-shell-layout/index.tsx` + +- [ ] **Step 1: Add imports** + +Add these imports alongside the existing imports in `apps/mesh/src/web/layouts/agent-shell-layout/index.tsx`: + +```typescript +import { ConnectAgentGate } from "@/web/components/chat/connect-agent-gate"; +import { useUnresolvedSlots } from "@/web/hooks/use-unresolved-slots"; +``` + +- [ ] **Step 2: Compute unresolved slots (unconditional hook, before any return)** + +In `AgentInsetProvider`, immediately after the existing line `const entity = useVirtualMCP(virtualMcpId);` (≈line 310), add: + +```typescript + const { unresolved, isLoading: slotsLoading } = useUnresolvedSlots( + org.id, + org.slug, + entity?.slots ?? [], + ); + const showConnectGate = !slotsLoading && unresolved.length > 0; +``` + +(`org` is in scope via `useProjectContext()`. This hook is added before all early returns, so hook order stays stable.) + +- [ ] **Step 3: Early-return the gate** + +In `AgentInsetProvider`, immediately AFTER the `const insetContextValue: InsetContextValue = { virtualMcpId, entity };` declaration (≈line 350) and BEFORE the existing `if (ensureState.status === "creating" || ...)` return, insert: + +```tsx + if (showConnectGate) { + return ( + +
+ +
+
+ ); + } +``` + +This sits before the desktop/mobile layout split, so it walls both. `InsetContext` is already imported (used by the sibling `ensureState` return). `ConnectAgentGate` centers itself (`h-full w-full flex items-center justify-center`). + +- [ ] **Step 4: Type-check + lint** + +Run: +```bash +cd /Users/gimenes/conductor/workspaces/mesh/stuttgart-v3 +bun run --cwd=apps/mesh check +bun run lint +``` +Expected: `check` exit 0; `lint` 0 errors (pre-existing warnings in unrelated files are fine). + +- [ ] **Step 5: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/layouts/agent-shell-layout/index.tsx +git commit -m "feat(agents): gate the whole agent view on unresolved connections" +``` + +--- + +## Task 2: Remove the redundant chat-panel gate + +**Files:** +- Modify: `apps/mesh/src/web/components/chat/side-panel-chat.tsx` + +- [ ] **Step 1: Remove the gate imports** + +In `apps/mesh/src/web/components/chat/side-panel-chat.tsx`, delete these two import lines (≈lines 24-25): + +```typescript +import { ConnectAgentGate } from "./connect-agent-gate"; +import { useUnresolvedSlots } from "@/web/hooks/use-unresolved-slots"; +``` + +- [ ] **Step 2: Remove the hook call + `showConnectGate`** + +In `ChatPanelContent`, delete this block (≈lines 73-78), which currently sits between `const fullVm = useVirtualMCP(displayAgent.id);` and `const link = useCurrentLink();`: + +```typescript + const { unresolved, isLoading: slotsLoading } = useUnresolvedSlots( + org.id, + org.slug, + fullVm?.slots ?? [], + ); + const showConnectGate = !slotsLoading && unresolved.length > 0; +``` + +(Leave `const fullVm = useVirtualMCP(displayAgent.id);` — it's still used by `isClonableAgent = agentHasClonableSource(fullVm?.metadata)` below.) + +- [ ] **Step 3: Remove the gate early return** + +Delete this block (≈lines 101-114), which sits between the `showProviderEmptyState` return and the `showCreditsModal` comment: + +```tsx + if (showConnectGate) { + return ( + + + + + + ); + } +``` + +- [ ] **Step 4: Type-check + lint** + +Run: +```bash +cd /Users/gimenes/conductor/workspaces/mesh/stuttgart-v3 +bun run --cwd=apps/mesh check +bun run lint +``` +Expected: `check` exit 0 (no unused-import or undefined-symbol errors); `lint` 0 errors. + +- [ ] **Step 5: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/chat/side-panel-chat.tsx +git commit -m "refactor(agents): drop chat-panel connect gate in favor of the view-level gate" +``` + +--- + +## Task 3: Drop the Connect button from `SlotItem`'s unresolved branch + +**Files:** +- Modify: `apps/mesh/src/web/views/virtual-mcp/slot-item.tsx` + +- [ ] **Step 1: Remove the Connect button** + +In `apps/mesh/src/web/views/virtual-mcp/slot-item.tsx`, in the unresolved branch (the `: (` branch of `isResolved`), delete the Connect button block so the row keeps only the icon + title + "Not connected for you" text. Replace: + +```tsx +

+ Not connected for you +

+
+ +
+ )} +``` + +with: + +```tsx +

+ Not connected for you +

+
+
+ )} +``` + +(`Button` is still used by the footer's Remove control and `Link` by the resolved branch's card link, so no imports are removed.) + +- [ ] **Step 2: Type-check + lint** + +Run: +```bash +cd /Users/gimenes/conductor/workspaces/mesh/stuttgart-v3 +bun run --cwd=apps/mesh check +bun run lint +``` +Expected: `check` exit 0 (no unused-import errors for `Button`/`Link` — both still used); `lint` 0 errors. + +- [ ] **Step 3: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/views/virtual-mcp/slot-item.tsx +git commit -m "refactor(agents): remove Connect button from settings slot rows" +``` + +--- + +## Task 4: Verification + +**Files:** none (verification only) + +- [ ] **Step 1: Unit test (unchanged logic) + type-check + lint** + +```bash +cd /Users/gimenes/conductor/workspaces/mesh/stuttgart-v3 +bun test apps/mesh/src/web/hooks/unresolved-slots.test.ts +bun run --cwd=apps/mesh check +bun run lint +``` +Expected: unit test PASS (4); `check` exit 0; `lint` 0 errors. + +- [ ] **Step 2: Manual check (dev server)** + +Start the app (`bun run dev`). As a user **without** the agent's required connection: +- Open a GitHub-imported agent at `?virtualmcpid=...&main=settings` (and `main=preview`, `main=automations`, and the chat) → in every case the entire agent view is replaced by the connect gate (no tab bar, no chat). Click Connect → Connections page; connect and return → the gate clears (refetch-on-focus) and the normal view appears. +- As a user **with** the connection (or the agent owner who has it): the agent view renders normally; the settings tab shows the slot(s) with the violet "Personal" chip and **no** Connect button. +- A slotless agent (e.g. the default Decopilot agent) is never gated. + +--- + +## Out of scope (future) + +- Inline/in-place connect inside the gate (still deep-links to Connections). +- Moving `ConnectAgentGate` out of `components/chat/` now that chat no longer owns it. diff --git a/docs/superpowers/plans/2026-05-29-registry-aware-connect-gate.md b/docs/superpowers/plans/2026-05-29-registry-aware-connect-gate.md new file mode 100644 index 0000000000..60526418fa --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-registry-aware-connect-gate.md @@ -0,0 +1,842 @@ +# Registry-aware connect gate with inline connect — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the "Connect to use this agent" gate show each unresolved slot's real MCP icon + friendly name (from the registry) and let the user connect the app inline (OAuth in place) without leaving the gate. + +**Architecture:** A new suspending hook (`useSlotAppDisplays`) batches one `COLLECTION_REGISTRY_APP_GET` per slot so the gate renders fully-formed. A shared async `connectApp()` core (factored out of `add-connection-dialog.tsx`'s `handleConnectAndAdd`) performs create → OAuth → token-persist. A `useConnectApp()` hook wraps it with per-row status, and a new `ConnectSlotRow` component renders each slot — registry rows get inline connect, non-registry/synthetic rows keep today's deep-link. + +**Tech Stack:** React 19 (no `useEffect`/`useMemo` per repo rules), TanStack Query (`useSuspenseQuery`), `@decocms/mesh-sdk` MCP client, Bun test runner, Biome formatting. + +--- + +## File Structure + +- Create `apps/mesh/src/web/hooks/slot-app-display.ts` — pure `slotAppDisplay(slotAppId, registryItem | null)` mapper + types. **Unit-tested.** +- Create `apps/mesh/src/web/hooks/slot-app-display.test.ts` — unit tests for the mapper. +- Create `apps/mesh/src/web/hooks/use-slot-app-displays.ts` — batched, suspending registry-metadata hook. +- Create `apps/mesh/src/web/lib/connect-app.ts` — shared async inline-connect pipeline (no React). +- Create `apps/mesh/src/web/hooks/use-connect-app.ts` — React hook wrapping `connectApp` with status/error. +- Create `apps/mesh/src/web/components/chat/connect-slot-row.tsx` — one gate row (registry vs fallback). +- Modify `apps/mesh/src/web/components/chat/connect-agent-gate.tsx` — use `useSlotAppDisplays`, render `ConnectSlotRow`. +- Modify `apps/mesh/src/web/lib/query-keys.ts` — add `slotAppDisplays` key. +- Modify `apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx` — `handleConnectAndAdd` delegates to `connectApp` (DRY). + +--- + +## Task 1: Pure `slotAppDisplay` mapper + unit tests + +**Files:** +- Create: `apps/mesh/src/web/hooks/slot-app-display.ts` +- Test: `apps/mesh/src/web/hooks/slot-app-display.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/mesh/src/web/hooks/slot-app-display.test.ts`: + +```ts +import { describe, expect, it } from "bun:test"; +import type { RegistryItem } from "@/web/components/store/types"; +import { slotAppDisplay } from "./slot-app-display"; + +describe("slotAppDisplay", () => { + it("falls back to the raw app_id when there is no registry item", () => { + expect(slotAppDisplay("url:api.acme.com/mcp", null)).toEqual({ + kind: "fallback", + title: "url:api.acme.com/mcp", + icon: null, + }); + }); + + it("uses the registry friendly name and icon when present", () => { + const item = { + _meta: { "mcp.mesh": { friendlyName: "GitHub" } }, + server: { icons: [{ src: "https://cdn/github.png" }] }, + } as unknown as RegistryItem; + expect(slotAppDisplay("deco/mcp-github", item)).toEqual({ + kind: "registry", + title: "GitHub", + icon: "https://cdn/github.png", + }); + }); + + it("falls through to server.title and null icon when friendly name/icon are missing", () => { + const item = { + _meta: {}, + server: { title: "Linear MCP" }, + } as unknown as RegistryItem; + expect(slotAppDisplay("deco/mcp-linear", item)).toEqual({ + kind: "registry", + title: "Linear MCP", + icon: null, + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test apps/mesh/src/web/hooks/slot-app-display.test.ts` +Expected: FAIL — `Cannot find module './slot-app-display'`. + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/mesh/src/web/hooks/slot-app-display.ts`: + +```ts +/** + * Decides how to display one of an agent's unresolved typed slots in the + * connect gate. A slot carries only `slot_app_id`; when the registry knows the + * app we show its friendly name + icon and allow inline connect, otherwise we + * fall back to the raw app_id (synthetic `url:`/`stdio:`/`npx:` ids, or unknown + * apps), which can only be connected via the connections page. + */ +import type { RegistryItem } from "@/web/components/store/types"; +import { MCP_MESH_DECOCMS_KEY } from "@/web/utils/constants"; + +export interface SlotAppDisplay { + kind: "registry" | "fallback"; + title: string; + icon: string | null; +} + +export function slotAppDisplay( + slotAppId: string, + item: RegistryItem | null, +): SlotAppDisplay { + if (!item) { + return { kind: "fallback", title: slotAppId, icon: null }; + } + const meshMeta = item._meta?.[MCP_MESH_DECOCMS_KEY]; + const title = + meshMeta?.friendlyName || + meshMeta?.friendly_name || + item.title || + item.server?.title || + item.server?.name || + slotAppId; + const icon = item.server?.icons?.[0]?.src ?? null; + return { kind: "registry", title, icon }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test apps/mesh/src/web/hooks/slot-app-display.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/mesh/src/web/hooks/slot-app-display.ts apps/mesh/src/web/hooks/slot-app-display.test.ts +git commit -m "feat(chat): add slotAppDisplay registry-metadata mapper for the connect gate" +``` + +--- + +## Task 2: `slotAppDisplays` query key + `useSlotAppDisplays` suspending hook + +**Files:** +- Modify: `apps/mesh/src/web/lib/query-keys.ts` (after the `unresolvedSlots` key, ~line 357) +- Create: `apps/mesh/src/web/hooks/use-slot-app-displays.ts` + +- [ ] **Step 1: Add the query key** + +In `apps/mesh/src/web/lib/query-keys.ts`, directly after the `unresolvedSlots` entry (the block ending at line 357), add: + +```ts + // Batched registry-metadata lookup for an agent's unresolved slots (powers + // the connect gate's icon/name + registry-vs-fallback decision). appIds must + // be sorted by the caller so the key is stable regardless of slot ordering. + slotAppDisplays: (orgId: string, sortedAppIds: string[]) => + ["slot-app-displays", orgId, ...sortedAppIds] as const, +``` + +- [ ] **Step 2: Create the hook** + +Create `apps/mesh/src/web/hooks/use-slot-app-displays.ts`: + +```ts +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + useMCPClient, + useProjectContext, + WellKnownOrgMCPId, +} from "@decocms/mesh-sdk"; +import type { RegistryItem } from "@/web/components/store/types"; +import { KEYS } from "@/web/lib/query-keys"; +import { type SlotAppDisplay, slotAppDisplay } from "./slot-app-display"; +import type { SlotLike } from "./unresolved-slots"; + +export interface ResolvedSlotAppDisplay extends SlotAppDisplay { + registryItem: RegistryItem | null; +} + +/** + * Resolves each unresolved slot's `app_id` to its registry display metadata + * (icon + friendly name) in a single suspending query — one + * COLLECTION_REGISTRY_APP_GET per app_id via Promise.all. Suspends (like + * `useUnresolvedSlots`) so the gate appears fully-formed with no icon/name + * flash. An app the registry doesn't know (synthetic id, or a failed lookup) + * maps to a `fallback` display. Returns a map keyed by `slot_app_id`. + */ +export function useSlotAppDisplays( + slots: T[], +): Record { + const { org } = useProjectContext(); + const registryClient = useMCPClient({ + connectionId: WellKnownOrgMCPId.REGISTRY(org.id), + orgId: org.id, + orgSlug: org.slug, + }); + const appIds = slots.map((s) => s.slot_app_id); + const sortedAppIds = [...appIds].sort(); + + const query = useSuspenseQuery({ + queryKey: KEYS.slotAppDisplays(org.id, sortedAppIds), + staleTime: 5 * 60 * 1000, + queryFn: async (): Promise> => { + const entries = await Promise.all( + appIds.map(async (appId) => { + let item: RegistryItem | null = null; + try { + const result = await registryClient.callTool({ + name: "COLLECTION_REGISTRY_APP_GET", + arguments: { name: appId }, + }); + const structured = ( + result as { structuredContent?: { item?: RegistryItem } } + ).structuredContent; + item = structured?.item ?? null; + } catch { + // Unknown app / registry error → fallback row (deep-link), never + // fail the whole gate. + item = null; + } + return [ + appId, + { ...slotAppDisplay(appId, item), registryItem: item }, + ] as const; + }), + ); + return Object.fromEntries(entries); + }, + }); + + return query.data; +} +``` + +- [ ] **Step 3: Type-check** + +Run: `bun run --cwd=apps/mesh check` +Expected: PASS (no type errors introduced). + +- [ ] **Step 4: Commit** + +```bash +git add apps/mesh/src/web/lib/query-keys.ts apps/mesh/src/web/hooks/use-slot-app-displays.ts +git commit -m "feat(chat): add useSlotAppDisplays suspending registry-metadata hook" +``` + +--- + +## Task 3: Shared `connectApp` inline-connect core + +**Files:** +- Create: `apps/mesh/src/web/lib/connect-app.ts` + +This factors the create → OAuth → token-persist pipeline out of `add-connection-dialog.tsx`'s `handleConnectAndAdd` (currently ~L804-913) so the gate and the dialog share one implementation. It does **no** UI side effects (no toasts, tracking, or agent-attach) — callers layer those on. + +- [ ] **Step 1: Create the module** + +Create `apps/mesh/src/web/lib/connect-app.ts`: + +```ts +/** + * Shared inline-connect pipeline: turn a registry item into a created (and, if + * needed, OAuth-authenticated) connection. No UI side effects — callers handle + * toasts/tracking/navigation. Used by the connect gate (`useConnectApp`) and + * the add-connection dialog. + */ +import type { QueryClient } from "@tanstack/react-query"; +import type { useConnectionActions } from "@decocms/mesh-sdk"; +import type { RegistryItem } from "@/web/components/store/types"; +import { + authenticateMcp, + isConnectionAuthenticated, +} from "@/web/lib/mcp-oauth"; +import { KEYS } from "@/web/lib/query-keys"; +import { extractConnectionData } from "@/web/utils/extract-connection-data"; + +export interface ConnectAppDeps { + org: { id: string; slug: string }; + userId: string; + connectionActions: ReturnType; + queryClient: QueryClient; + /** Reports pipeline progress so callers can show per-phase UI. */ + onPhase?: (phase: "connecting" | "authenticating") => void; +} + +export interface ConnectAppResult { + /** The created connection id, or null if creation never happened. */ + id: string | null; + oauth: "not-needed" | "succeeded" | "failed"; + /** + * `"no-connection-method"` when the item has no URL/STDIO command (nothing + * created), an OAuth error string when `oauth === "failed"`, else null. + */ + error: string | null; +} + +export async function connectApp( + item: RegistryItem, + deps: ConnectAppDeps, +): Promise { + const { org, userId, connectionActions, queryClient, onPhase } = deps; + + const connectionData = extractConnectionData(item, org.id, userId, { + remoteIndex: 0, + }); + + const isStdio = connectionData.connection_type === "STDIO"; + const hasUrl = Boolean(connectionData.connection_url); + const hasStdioConfig = + isStdio && + connectionData.connection_headers && + typeof connectionData.connection_headers === "object" && + "command" in connectionData.connection_headers; + if (!hasUrl && !hasStdioConfig) { + return { id: null, oauth: "not-needed", error: "no-connection-method" }; + } + + onPhase?.("connecting"); + const { id } = await connectionActions.create.mutateAsync(connectionData); + + const mcpProxyUrl = new URL( + `/api/${org.slug}/mcp/${id}`, + window.location.origin, + ); + const authStatus = await isConnectionAuthenticated({ + url: mcpProxyUrl.href, + token: null, + orgId: org.id, + }); + + if (!(authStatus.supportsOAuth && !authStatus.isAuthenticated)) { + return { id, oauth: "not-needed", error: null }; + } + + onPhase?.("authenticating"); + const { token, tokenInfo, error } = await authenticateMcp({ + connectionId: id, + orgSlug: org.slug, + scope: "offline_access", + }); + if (error || !token) { + return { id, oauth: "failed", error: error ?? "no token received" }; + } + + if (tokenInfo) { + try { + const response = await fetch( + `/api/${org.slug}/connections/${id}/oauth-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + accessToken: tokenInfo.accessToken, + refreshToken: tokenInfo.refreshToken, + expiresIn: tokenInfo.expiresIn, + scope: tokenInfo.scope, + clientId: tokenInfo.clientId, + clientSecret: tokenInfo.clientSecret, + tokenEndpoint: tokenInfo.tokenEndpoint, + }), + }, + ); + if (!response.ok) { + await connectionActions.update.mutateAsync({ + id, + data: { connection_token: token }, + }); + } else { + await connectionActions.update.mutateAsync({ id, data: {} }); + } + } catch { + await connectionActions.update.mutateAsync({ + id, + data: { connection_token: token }, + }); + } + } else { + await connectionActions.update.mutateAsync({ + id, + data: { connection_token: token }, + }); + } + + await queryClient.invalidateQueries({ + queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), + }); + + return { id, oauth: "succeeded", error: null }; +} +``` + +- [ ] **Step 2: Type-check** + +Run: `bun run --cwd=apps/mesh check` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/mesh/src/web/lib/connect-app.ts +git commit -m "feat(connections): extract shared connectApp inline-connect pipeline" +``` + +--- + +## Task 4: `useConnectApp` hook + +**Files:** +- Create: `apps/mesh/src/web/hooks/use-connect-app.ts` + +- [ ] **Step 1: Create the hook** + +Create `apps/mesh/src/web/hooks/use-connect-app.ts`: + +```ts +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useConnectionActions, useProjectContext } from "@decocms/mesh-sdk"; +import type { RegistryItem } from "@/web/components/store/types"; +import { authClient } from "@/web/lib/auth-client"; +import { connectApp } from "@/web/lib/connect-app"; + +export type ConnectAppStatus = + | "idle" + | "connecting" + | "authenticating" + | "ready" + | "error"; + +/** + * Drives inline connect for a single connect-gate row. `connect(item)` runs the + * shared `connectApp` pipeline and exposes a per-row status/error. On success it + * invalidates the slot-resolution queries so the gate re-resolves and the row + * drops (a background refetch on the gate's suspense query — no re-suspend). + */ +export function useConnectApp(): { + connect: (item: RegistryItem) => Promise; + status: ConnectAppStatus; + error: string | null; +} { + const { org } = useProjectContext(); + const { data: session } = authClient.useSession(); + const connectionActions = useConnectionActions(); + const queryClient = useQueryClient(); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + + const connect = async (item: RegistryItem) => { + if (!session?.user?.id) return; + setError(null); + setStatus("connecting"); + try { + const result = await connectApp(item, { + org: { id: org.id, slug: org.slug }, + userId: session.user.id, + connectionActions, + queryClient, + onPhase: (phase) => setStatus(phase), + }); + if (result.error) { + setStatus("error"); + setError( + result.error === "no-connection-method" + ? "This app can't be connected automatically." + : "Couldn't connect. Try again.", + ); + return; + } + // Re-resolve the gate (and settings slot rows) so this slot drops. + await queryClient.invalidateQueries({ queryKey: ["unresolved-slots"] }); + await queryClient.invalidateQueries({ + queryKey: ["connection-resolve-for-user"], + }); + setStatus("ready"); + } catch (err) { + console.error("Inline connect failed:", err); + setStatus("error"); + setError("Couldn't connect. Try again."); + } + }; + + return { connect, status, error }; +} +``` + +- [ ] **Step 2: Type-check** + +Run: `bun run --cwd=apps/mesh check` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/mesh/src/web/hooks/use-connect-app.ts +git commit -m "feat(chat): add useConnectApp hook for inline connect-gate connect" +``` + +--- + +## Task 5: `ConnectSlotRow` component + +**Files:** +- Create: `apps/mesh/src/web/components/chat/connect-slot-row.tsx` + +- [ ] **Step 1: Create the component** + +Create `apps/mesh/src/web/components/chat/connect-slot-row.tsx`: + +```tsx +import { Link } from "@tanstack/react-router"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Loading01 } from "@untitledui/icons"; +import { IntegrationIcon } from "@/web/components/integration-icon"; +import { useConnectApp } from "@/web/hooks/use-connect-app"; +import type { ResolvedSlotAppDisplay } from "@/web/hooks/use-slot-app-displays"; + +/** + * One row of the connect gate. Registry apps show their icon + friendly name and + * connect inline (OAuth in place); non-registry / synthetic slots show the raw + * app_id and deep-link to the connections page. + */ +export function ConnectSlotRow({ + display, + orgSlug, +}: { + display: ResolvedSlotAppDisplay; + orgSlug: string; +}) { + const { connect, status, error } = useConnectApp(); + const registryItem = + display.kind === "registry" ? display.registryItem : null; + const busy = status === "connecting" || status === "authenticating"; + + return ( +
+ +
+

{display.title}

+ {status === "error" && error ? ( +

{error}

+ ) : null} +
+ {registryItem ? ( + + ) : ( + + )} +
+ ); +} +``` + +- [ ] **Step 2: Type-check** + +Run: `bun run --cwd=apps/mesh check` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/mesh/src/web/components/chat/connect-slot-row.tsx +git commit -m "feat(chat): add ConnectSlotRow with inline connect / deep-link fallback" +``` + +--- + +## Task 6: Wire `ConnectAgentGate` to use registry displays + `ConnectSlotRow` + +**Files:** +- Modify: `apps/mesh/src/web/components/chat/connect-agent-gate.tsx` (whole file) + +- [ ] **Step 1: Replace the file contents** + +Replace the entire contents of `apps/mesh/src/web/components/chat/connect-agent-gate.tsx` with: + +```tsx +import { IntegrationIcon } from "@/web/components/integration-icon"; +import { ConnectSlotRow } from "@/web/components/chat/connect-slot-row"; +import { useSlotAppDisplays } from "@/web/hooks/use-slot-app-displays"; +import type { SlotLike } from "@/web/hooks/unresolved-slots"; + +/** + * Shown when the current user is missing one or more of the agent's required + * personal connections (typed slots). Each row shows the app's registry icon + + * friendly name and a Connect button: registry apps connect inline (OAuth in + * place); synthetic / unknown apps deep-link to the Connections page. When the + * last slot resolves, the surrounding view re-resolves and replaces this gate. + */ +export function ConnectAgentGate({ + agentTitle, + agentIcon, + slots, + orgSlug, +}: { + agentTitle: string; + agentIcon: string | null; + slots: SlotLike[]; + orgSlug: string; +}) { + const displays = useSlotAppDisplays(slots); + + return ( +
+
+ +

+ Connect to use this agent +

+

+ "{agentTitle}" needs your personal connections before it can run. +

+
+
+ {slots.map((slot) => ( + + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Type-check** + +Run: `bun run --cwd=apps/mesh check` +Expected: PASS. (No more `Button`/`Link` imports in this file — they moved to `ConnectSlotRow`.) + +- [ ] **Step 3: Commit** + +```bash +git add apps/mesh/src/web/components/chat/connect-agent-gate.tsx +git commit -m "feat(chat): render registry icon/name + inline connect in the connect gate" +``` + +--- + +## Task 7: Refactor `handleConnectAndAdd` in the dialog to use `connectApp` (DRY) + +**Files:** +- Modify: `apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx` (`handleConnectAndAdd`, ~L804-927; imports near top) + +Goal: delete the duplicated create/OAuth/persist body and call the shared `connectApp`, preserving the dialog's existing behavior exactly (tracking, toasts, `onAdd`, and `connectingItemId` spinner state). On OAuth failure the dialog still attaches the connection and warns (unchanged). + +- [ ] **Step 1: Add the import** + +In `apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx`, add to the imports (next to the other `@/web/lib` / `@/web/utils` imports): + +```ts +import { connectApp } from "@/web/lib/connect-app"; +``` + +- [ ] **Step 2: Replace `handleConnectAndAdd`** + +Replace the entire `handleConnectAndAdd` function (the `const handleConnectAndAdd = async (item: RegistryItem) => { ... };` block, currently ~L804-927) with: + +```ts + // For catalog items with no instances: create connection + add to agent + const handleConnectAndAdd = async (item: RegistryItem) => { + if (!org || !session?.user?.id) return; + setConnectingItemId(item.id); + + try { + const result = await connectApp(item, { + org: { id: org.id, slug: org.slug }, + userId: session.user.id, + connectionActions, + queryClient, + }); + + if (result.error === "no-connection-method") { + toast.error( + "This MCP Server cannot be connected: no connection method available", + ); + return; + } + + const id = result.id; + if (!id) { + toast.error("Failed to connect"); + return; + } + + const appName = getRegistryItemAppName(item); + + if (result.oauth === "failed") { + track("connection_oauth_failed", { + connection_id: id, + flow: "connect_new", + error: result.error ?? "no_token", + }); + toast.warning("Couldn't sign in to this connection", { + description: `It was added to your agent, but its sign-in setup looks off. You can try authenticating again later from the connection's settings. (${result.error ?? "no token received"})`, + }); + trackAttach(id, appName, "new"); + onAdd(id); + return; + } + + if (result.oauth === "succeeded") { + track("connection_oauth_succeeded", { + connection_id: id, + flow: "connect_new", + }); + toast.success("Connected and authenticated"); + } else { + toast.success("Connected"); + } + + trackAttach(id, appName, "new"); + onAdd(id); + } catch (err) { + console.error("Failed to connect:", err); + toast.error("Failed to connect"); + } finally { + setConnectingItemId(null); + } + }; +``` + +Notes for the implementer: +- `getRegistryItemAppName` is already imported in this file (line 25). The previous code derived `app_name` from `connectionData.app_name`; `getRegistryItemAppName(item)` yields the same canonical value (`extractConnectionData` sets `app_name` from it), so tracking is unchanged. +- `trackAttach`, `track`, `connectionActions`, `queryClient`, `org`, `session` are all already in scope in this component (defined ~L681-683 and used by the surrounding handlers). +- Do **not** touch `handleCloneAndAdd` (the other handler ~L700) — it is out of scope. +- After the edit, `authenticateMcp` / `isConnectionAuthenticated` may become unused **in this file** only if no other handler uses them. `handleCloneAndAdd` still uses both, so keep those imports. Run the lint/check in the next step to confirm no unused-import errors. + +- [ ] **Step 3: Type-check and lint** + +Run: `bun run --cwd=apps/mesh check` +Expected: PASS. + +Run: `bun run lint` +Expected: 0 errors (pre-existing warnings unrelated to these files are acceptable). + +- [ ] **Step 4: Commit** + +```bash +git add apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx +git commit -m "refactor(connections): route dialog handleConnectAndAdd through shared connectApp" +``` + +--- + +## Task 8: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the unit test** + +Run: `bun test apps/mesh/src/web/hooks/slot-app-display.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 2: Type-check all workspaces** + +Run: `bun run check` +Expected: clean across all workspaces. + +- [ ] **Step 3: Lint** + +Run: `bun run lint` +Expected: 0 errors (pre-existing warnings in untouched files are acceptable). + +- [ ] **Step 4: Format** + +Run: `bun run fmt` +Expected: no remaining changes after running (commit any formatting fixes). + +- [ ] **Step 5: Manual verification notes (record in the PR / hand back to user)** + +Document for the user to verify in the running app (per `TESTING.md`, this is e2e/manual territory, not a unit test): +- Open a GitHub-imported agent as a user **without** GitHub connected → the gate shows the **GitHub icon + "GitHub"** (not `deco/mcp-github`) with an inline `[ Connect ]`. +- Click Connect → button shows `Connecting…` then `Authenticating…` → OAuth popup → on return the row clears; when GitHub was the only slot, the agent view appears. No full-gate flash. +- A member who already has the connection → no gate (resolved). +- An agent with a synthetic-`app_id` slot (e.g. a custom HTTP connection wired as a slot) → fallback row: raw `app_id` + a Connect that deep-links to the connections page. +- The add-connection dialog's "Connect" on a catalog item still works as before (connect + attach + toast). + +- [ ] **Step 6: Commit any formatting-only changes** + +```bash +git add -A +git commit -m "chore(chat): formatting for registry-aware connect gate" || echo "nothing to commit" +``` + +--- + +## Self-Review + +- **Spec coverage:** + - §1 metadata resolution (suspending, batched; registry vs fallback) → Tasks 1 (mapper) + 2 (hook). + - §2 `useConnectApp` + factor `handleConnectAndAdd` + invalidate gate resolve query → Tasks 3 (`connectApp`), 4 (`useConnectApp`), 7 (dialog refactor). + - §3 `ConnectSlotRow` per-row states + gate wiring → Tasks 5 + 6. + - Testing (unit for the mapper; manual/e2e) → Tasks 1 + 8. +- **Placeholder scan:** none — every code step is complete. +- **Type consistency:** `SlotAppDisplay`/`slotAppDisplay` (Task 1) ↔ `ResolvedSlotAppDisplay`/`useSlotAppDisplays` (Task 2) ↔ `ConnectSlotRow` `display` prop (Task 5) ↔ gate fallback literal (Task 6) all share `{ kind, title, icon, registryItem }`. `ConnectAppResult` `{ id, oauth, error }` (Task 3) is consumed identically by `useConnectApp` (Task 4) and the dialog (Task 7). `KEYS.slotAppDisplays` (Task 2) matches the hook's `queryKey`. + diff --git a/docs/superpowers/plans/2026-05-31-just-in-time-connection-gate.md b/docs/superpowers/plans/2026-05-31-just-in-time-connection-gate.md new file mode 100644 index 0000000000..d1ecdac916 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-just-in-time-connection-gate.md @@ -0,0 +1,799 @@ +# Just-in-Time Connection Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the upfront composer gate with one runtime mechanism: when an agent (parent) or a delegated subagent can't resolve all its typed slots for the invoking user, the run emits a structured `data-connect-required` chunk that the chat renders as a single connect card (Connect buttons + Retry). + +**Architecture:** Both the parent and subagent assemble their MCP client through the same `createVirtualClientFrom`, which already throws `SlotUnresolvedError`. We (1) make that error collect *all* missing app_ids and carry the agent identity, (2) catch it at the two boundaries (`assembleDecopilotTools` for the parent, `subtask.ts` for the subagent) and emit one typed stream chunk, (3) render that chunk as a visible `ConnectCard` reusing the existing `ConnectSlotRow`, and (4) delete the now-redundant upfront gate. + +**Tech Stack:** TypeScript, Bun test, Hono server, React 19 (Vite), AI SDK `UIMessageStreamWriter` data chunks, Kysely/Postgres. + +**Spec:** `docs/superpowers/specs/2026-05-31-just-in-time-connection-gate-design.md` + +--- + +## File structure + +**Server (modify):** +- `apps/mesh/src/core/slot-resolver.ts` — `SlotUnresolvedError` carries `appIds[] + agentId + agentTitle`. +- `apps/mesh/src/mcp-clients/virtual-mcp/index.ts` — slot loop collects all unresolved, throws once. +- `apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.ts` — catch + emit chunk + model text (subagent boundary). +- `apps/mesh/src/harnesses/decopilot/index.ts` — catch + emit chunk + return (parent boundary). +- `apps/mesh/src/api/routes/decopilot/types.ts` — register the `connect-required` data part. + +**Frontend (create/modify):** +- `apps/mesh/src/web/components/chat/connect-card.tsx` — NEW visible card (reuses `ConnectSlotRow`). +- `apps/mesh/src/web/components/chat/message/use-filter-parts.ts` — let `data-connect-required` reach renderOrder. +- `apps/mesh/src/web/components/chat/message/assistant.tsx` — render the card for the new part. +- `apps/mesh/src/web/layouts/agent-shell-layout/index.tsx` — remove the upfront gate. + +**Frontend (delete — dead after gate removal):** +- `apps/mesh/src/web/components/chat/connect-agent-gate.tsx` +- `apps/mesh/src/web/hooks/use-unresolved-slots.ts` +- `apps/mesh/src/web/hooks/unresolved-slots.ts` +- `apps/mesh/src/web/hooks/unresolved-slots.test.ts` + +**Keep (now consumed by `ConnectCard`):** `connect-slot-row.tsx`, `use-slot-app-displays.ts`. + +**Tests:** +- `apps/mesh/src/core/slot-resolver.test.ts` (modify) — new error shape. +- `apps/mesh/src/mcp-clients/virtual-mcp/slot-resolver.integration.test.ts` (modify) — `.appIds` assertion. + +--- + +## Task 1: Restructure `SlotUnresolvedError` to carry all app_ids + agent identity + +**Files:** +- Modify: `apps/mesh/src/core/slot-resolver.ts:36-45` +- Test: `apps/mesh/src/core/slot-resolver.test.ts:70-77` + +- [ ] **Step 1: Update the failing unit test first** + +Replace the existing `describe("SlotUnresolvedError", …)` block (lines 70-77) with: + +```ts +describe("SlotUnresolvedError", () => { + it("carries all app_ids and agent identity for the UI to surface", () => { + const err = new SlotUnresolvedError( + ["mcp-github", "google-gmail"], + "vmcp_123", + "My Agent", + ); + expect(err.appIds).toEqual(["mcp-github", "google-gmail"]); + expect(err.agentId).toBe("vmcp_123"); + expect(err.agentTitle).toBe("My Agent"); + expect(err.name).toBe("SlotUnresolvedError"); + expect(err.message).toContain("mcp-github"); + expect(err.message).toContain("google-gmail"); + expect(err.message).toContain("My Agent"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test apps/mesh/src/core/slot-resolver.test.ts` +Expected: FAIL — `SlotUnresolvedError` constructor still takes a single string; `.appIds`/`.agentId`/`.agentTitle` undefined. + +- [ ] **Step 3: Rewrite the class** + +Replace lines 36-45 of `slot-resolver.ts` with: + +```ts +export class SlotUnresolvedError extends Error { + readonly appIds: string[]; + readonly agentId: string; + readonly agentTitle: string; + constructor(appIds: string[], agentId: string, agentTitle: string) { + super( + `Agent '${agentTitle}' (${agentId}) has unresolved slots for app_ids: ${appIds.join(", ")} — the caller has no matching connection.`, + ); + this.name = "SlotUnresolvedError"; + this.appIds = appIds; + this.agentId = agentId; + this.agentTitle = agentTitle; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `bun test apps/mesh/src/core/slot-resolver.test.ts` +Expected: PASS. (Note: `apps/mesh/src/mcp-clients/virtual-mcp/index.ts` will now have type errors at its 3 throw sites — fixed in Task 2. Do NOT run `bun run check` yet.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/mesh/src/core/slot-resolver.ts apps/mesh/src/core/slot-resolver.test.ts +git commit -m "refactor(slots): SlotUnresolvedError carries all app_ids + agent identity" +``` + +--- + +## Task 2: Collect all unresolved slots in `createVirtualClientFrom` (throw once) + +**Files:** +- Modify: `apps/mesh/src/mcp-clients/virtual-mcp/index.ts:149-211` +- Test: `apps/mesh/src/mcp-clients/virtual-mcp/slot-resolver.integration.test.ts:199-200` + +The current loop throws on the first unresolved slot. Change it to accumulate every unresolved `app_id` and throw once after the loop, so the card lists all missing apps. Preserve the single `SlotResolutionCache` instance and the resolved-connection aggregation for the slots that DO resolve. + +- [ ] **Step 1: Replace the slot-resolution loop** + +Current code (index.ts:149-211) — the `if (virtualMcp.slots.length > 0) { … }` block. Replace its body with: + +```ts + if (virtualMcp.slots.length > 0) { + const invokerUserId = ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null; + const slotCache = new SlotResolutionCache(); + const activeSpan = trace.getActiveSpan(); + const unresolvedAppIds: string[] = []; + + for (const slot of virtualMcp.slots) { + if (!invokerUserId) { + unresolvedAppIds.push(slot.slot_app_id); + continue; + } + + const resolved = await slotCache.resolve( + invokerUserId, + slot.slot_app_id, + () => + resolveSlot(ctx.db, { + organizationId: virtualMcp.organization_id, + invokerUserId, + appId: slot.slot_app_id, + }), + ); + if (!resolved) { + unresolvedAppIds.push(slot.slot_app_id); + continue; + } + + // Slot resolver already enforces per-user access by looking up the + // invoker's own slot row; once resolved, the connection lookup itself + // is just an entity hydration step, so INTERNAL_VIEWER is appropriate. + const resolvedEntity = await ctx.storage.connections.findById( + resolved.connectionId, + virtualMcp.organization_id, + INTERNAL_VIEWER, + ); + if (!resolvedEntity) { + // Defensive: resolver pointed at a row that disappeared (e.g. + // deleted between the resolveSlot SELECT and this findById). Treat + // as unresolved so the UI prompts the user to reconnect. + unresolvedAppIds.push(slot.slot_app_id); + continue; + } + + resolvedConnections.push(resolvedEntity); + resolvedVMCPConnections.push({ + connection_id: resolved.connectionId, + selected_tools: slot.selected_tools, + selected_resources: slot.selected_resources, + selected_prompts: slot.selected_prompts, + }); + + if (activeSpan) { + activeSpan.setAttribute( + `slot.${slot.slot_app_id}.app_id`, + slot.slot_app_id, + ); + activeSpan.setAttribute( + `slot.${slot.slot_app_id}.connection_id`, + resolved.connectionId, + ); + activeSpan.setAttribute( + `slot.${slot.slot_app_id}.access`, + resolved.access, + ); + } + } + + if (unresolvedAppIds.length > 0) { + // De-dupe in case two slots reference the same app_id. + const uniqueAppIds = [...new Set(unresolvedAppIds)]; + throw new SlotUnresolvedError( + uniqueAppIds, + virtualMcp.id ?? "", + virtualMcp.title, + ); + } + } +``` + +- [ ] **Step 2: Update the integration test assertion** + +In `apps/mesh/src/mcp-clients/virtual-mcp/slot-resolver.integration.test.ts`, find lines 199-200: + +```ts + expect(caught).toBeInstanceOf(SlotUnresolvedError); + expect((caught as SlotUnresolvedError).appId).toBe("mcp-github"); +``` + +Replace with: + +```ts + expect(caught).toBeInstanceOf(SlotUnresolvedError); + expect((caught as SlotUnresolvedError).appIds).toContain("mcp-github"); +``` + +- [ ] **Step 3: Type-check** + +Run: `bun run --cwd apps/mesh check` +Expected: PASS (the 3 former throw sites are gone; the single throw uses the new signature). + +- [ ] **Step 4: Run the unit test suite for the resolver** + +Run: `bun test apps/mesh/src/core/slot-resolver.test.ts` +Expected: PASS. (The integration test requires the Docker DB harness per TESTING.md; if available run `bun test apps/mesh/src/mcp-clients/virtual-mcp/slot-resolver.integration.test.ts`, otherwise note it for the e2e pass in Task 10.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/mesh/src/mcp-clients/virtual-mcp/index.ts apps/mesh/src/mcp-clients/virtual-mcp/slot-resolver.integration.test.ts +git commit -m "feat(slots): collect all unresolved slots and throw once" +``` + +--- + +## Task 3: Register the `connect-required` data part type + +**Files:** +- Modify: `apps/mesh/src/api/routes/decopilot/types.ts:26-55` + +The `ChatMessage` type's second generic is the data-parts map. Each key `K` becomes the runtime part type `data-K`. Add a `connect-required` entry so both server `writer.write` and the frontend switch are typed. + +- [ ] **Step 1: Add the data-part entry** + +In the `ChatMessage` data-parts object (the second generic argument), add this key alongside `"thread-title"`: + +```ts + "connect-required": { + agentId: string; + agentTitle: string; + appIds: string[]; + }; +``` + +- [ ] **Step 2: Type-check** + +Run: `bun run --cwd apps/mesh check` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/mesh/src/api/routes/decopilot/types.ts +git commit -m "feat(chat): register data-connect-required part type" +``` + +--- + +## Task 4: Subagent boundary — catch in `subtask.ts`, emit chunk + model text + +**Files:** +- Modify: `apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.ts:13-15,96-101,188-190` + +`createVirtualClientFrom` (line 97) sits OUTSIDE the existing `try { … } finally { mcpClient.close() }` (lines 103-190). Wrap it in its own try/catch so a `SlotUnresolvedError` emits the chunk and yields a model-facing result instead of crashing the tool. + +- [ ] **Step 1: Add the import** + +Add near the other imports (after line 13): + +```ts +import { SlotUnresolvedError } from "@/core/slot-resolver"; +``` + +- [ ] **Step 2: Wrap `createVirtualClientFrom` (current lines 96-101)** + +Current: + +```ts + // 2. Create MCP client for the target. + const mcpClient = await createVirtualClientFrom( + virtualMcp, + ctx, + "passthrough", + ); + + try { +``` + +Replace with: + +```ts + // 2. Create MCP client for the target. If the target agent has typed + // slots the invoking user hasn't connected, createVirtualClientFrom + // throws SlotUnresolvedError — surface it as a connect card to the + // user (data chunk) and a clear instruction to the model, instead of + // a generic "tool failed". + let mcpClient: Awaited>; + try { + mcpClient = await createVirtualClientFrom(virtualMcp, ctx, "passthrough"); + } catch (err) { + if (err instanceof SlotUnresolvedError) { + writer.write({ + type: "data-connect-required", + id: toolCallId, + data: { + agentId: err.agentId, + agentTitle: err.agentTitle, + appIds: err.appIds, + }, + }); + yield { + text: "", + error: `Cannot run subagent "${err.agentTitle}": the user must connect ${err.appIds.join(", ")}. A connect card was shown — ask the user to connect, then retry.`, + finishReason: "stop", + }; + return; + } + throw err; + } + + try { +``` + +- [ ] **Step 3: Make the `finally` null-safe** + +The closing `finally` (current line 188-190) references `mcpClient`. It is now always assigned before the inner `try`, so it is unchanged: + +```ts + } finally { + mcpClient.close().catch(() => {}); + } +``` + +Leave it as-is (no change needed — `mcpClient` is guaranteed assigned when the inner `try` is entered). + +- [ ] **Step 4: Type-check** + +Run: `bun run --cwd apps/mesh check` +Expected: PASS. + +- [ ] **Step 5: Run the subtask unit test** + +Run: `bun test apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.test.ts` +Expected: PASS (existing behavior unchanged for the happy path). + +- [ ] **Step 6: Commit** + +```bash +git add apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.ts +git commit -m "feat(subtask): emit connect-required card when a subagent slot is unresolved" +``` + +--- + +## Task 5: Parent boundary — catch in `index.ts`, emit chunk + end run + +**Files:** +- Modify: `apps/mesh/src/harnesses/decopilot/index.ts` (imports + lines 167-176) + +The parent's tools (and its `createVirtualClientFrom`) are assembled by `assembleDecopilotTools` at `index.ts:167`, where `pl.writer` is in scope. A `SlotUnresolvedError` here means the parent's own slots are unresolved (previously caught by the upfront gate). Catch it, emit the same chunk, and `return` to end the run cleanly. + +- [ ] **Step 1: Add the import** + +Add to the imports at the top of `index.ts`: + +```ts +import { SlotUnresolvedError } from "@/core/slot-resolver"; +``` + +- [ ] **Step 2: Wrap `assembleDecopilotTools` (current lines 167-176)** + +Current: + +```ts + const tools = await assembleDecopilotTools(effectiveInput, ctx, { + writer: pl.writer, + toolOutputMap: pl.toolOutputMap, + pendingImages: pl.pendingImages, + threadId: pl.threadId, + provider: pl.provider, + imageProvider: pl.imageProvider ?? pl.provider, + deepResearchProvider: pl.deepResearchProvider ?? pl.provider, + htmlPageBuffer: pl.htmlPageBuffer, + }); +``` + +Replace with: + +```ts + let tools: Awaited>; + try { + tools = await assembleDecopilotTools(effectiveInput, ctx, { + writer: pl.writer, + toolOutputMap: pl.toolOutputMap, + pendingImages: pl.pendingImages, + threadId: pl.threadId, + provider: pl.provider, + imageProvider: pl.imageProvider ?? pl.provider, + deepResearchProvider: pl.deepResearchProvider ?? pl.provider, + htmlPageBuffer: pl.htmlPageBuffer, + }); + } catch (err) { + if (err instanceof SlotUnresolvedError) { + // The parent agent's own slots are unresolved for this user. + // Surface the connect card and end the run cleanly (no model + // call happens) instead of a generic stream error. + pl.writer.write({ + type: "data-connect-required", + data: { + agentId: err.agentId, + agentTitle: err.agentTitle, + appIds: err.appIds, + }, + }); + return; + } + throw err; + } +``` + +The existing `try { … } finally { await tools.close().catch(() => {}); }` block (current lines 178-231) continues unchanged and now refers to the `tools` declared above. + +- [ ] **Step 3: Type-check** + +Run: `bun run --cwd apps/mesh check` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/mesh/src/harnesses/decopilot/index.ts +git commit -m "feat(harness): emit connect-required card when the parent agent's slots are unresolved" +``` + +--- + +## Task 6: Let `data-connect-required` reach the render order (frontend) + +**Files:** +- Modify: `apps/mesh/src/web/components/chat/message/use-filter-parts.ts` (the renderOrder skip, ~line 176) + +All `data-*` parts are normally skipped from `renderOrder` (so they never render). `data-connect-required` is a *visible* card, so it must pass through. Do NOT add an extraction handler for it (it is not keyed metadata). + +- [ ] **Step 1: Find the renderOrder skip** + +Search for the line that skips data parts: + +Run: `rg -n 'startsWith\("data-"\)' apps/mesh/src/web/components/chat/message/use-filter-parts.ts` +Expected: a line like `if (p.type.startsWith("data-")) continue;` + +- [ ] **Step 2: Add the exception** + +Change that line to keep `data-connect-required` visible: + +```ts + // data-* parts are metadata side-channels and never render — EXCEPT + // data-connect-required, which is a visible connect card. + if (p.type.startsWith("data-") && p.type !== "data-connect-required") { + continue; + } +``` + +- [ ] **Step 3: Type-check** + +Run: `bun run --cwd apps/mesh check` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/mesh/src/web/components/chat/message/use-filter-parts.ts +git commit -m "feat(chat): keep data-connect-required parts in render order" +``` + +--- + +## Task 7: Create the `ConnectCard` component (frontend) + +**Files:** +- Create: `apps/mesh/src/web/components/chat/connect-card.tsx` + +Reuses `ConnectSlotRow` + `useSlotAppDisplays` (the same building blocks the old gate used) and adds a Retry button that re-runs the last user turn. + +- [ ] **Step 1: Confirm the chat-stream hook name and shape** + +Run: `rg -n 'export function useChatStream|sendMessage:' apps/mesh/src/web/components/chat/chat-context.tsx` +Expected: a `useChatStream` (or similarly named) hook exposing `sendMessage` and `messages`. Use whatever the exact exported name is in Step 2's import. + +- [ ] **Step 2: Write the component** + +```tsx +import { Suspense } from "react"; +import { Button } from "@deco/ui/components/button.tsx"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { ConnectSlotRow } from "@/web/components/chat/connect-slot-row"; +import { useChatStream } from "@/web/components/chat/chat-context"; +import { useSlotAppDisplays } from "@/web/hooks/use-slot-app-displays"; + +interface ConnectCardData { + agentId: string; + agentTitle: string; + appIds: string[]; +} + +/** + * Visible connect card rendered inline in the assistant message when an agent + * (parent or a delegated subagent) couldn't resolve its typed slots for the + * current user. Shows one Connect row per missing app and a Retry button that + * re-runs the last user turn once the connections are in place. + */ +export function ConnectCard({ data }: { data: ConnectCardData }) { + return ( + }> + + + ); +} + +function ConnectCardFallback({ data }: { data: ConnectCardData }) { + return ( +
+

+ Connect to use “{data.agentTitle}” +

+

Loading connections…

+
+ ); +} + +function ConnectCardInner({ data }: { data: ConnectCardData }) { + const { org } = useProjectContext(); + const { sendMessage, messages } = useChatStream(); + const slots = data.appIds.map((appId) => ({ slot_app_id: appId })); + const displays = useSlotAppDisplays(slots); + + const handleRetry = () => { + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + if (lastUser) void sendMessage({ parts: lastUser.parts }); + }; + + return ( +
+
+

+ Connect to use “{data.agentTitle}” +

+

+ This agent needs your personal connections before it can run. +

+
+
+ {slots.map((slot) => ( + + ))} +
+ +
+ ); +} +``` + +- [ ] **Step 3: Type-check** + +Run: `bun run --cwd apps/mesh check` +Expected: PASS. If `useChatStream` is exported under a different name (Step 1) or `sendMessage`'s param differs, adjust the import/call accordingly. `sendMessage({ parts })` matches `SendMessageParams` in `chat-context.tsx`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/mesh/src/web/components/chat/connect-card.tsx +git commit -m "feat(chat): add ConnectCard rendered from data-connect-required" +``` + +--- + +## Task 8: Render the card in the message switch (frontend) + +**Files:** +- Modify: `apps/mesh/src/web/components/chat/message/assistant.tsx` (the `MessagePart` switch, data-* cases ~491-495) + +- [ ] **Step 1: Import the card** + +Add to the imports in `assistant.tsx`: + +```ts +import { ConnectCard } from "@/web/components/chat/connect-card"; +``` + +- [ ] **Step 2: Add a render case BEFORE the null data-* group** + +Find the grouped null cases (around lines 491-495): + +```ts + case "data-tool-metadata": + case "data-tool-subtask-metadata": + case "data-generate-image": + case "data-web-search": + return null; +``` + +Insert this case immediately ABOVE them: + +```ts + case "data-connect-required": + return ; +``` + +(`part.data` is typed `{ agentId, agentTitle, appIds }` via the `connect-required` registration from Task 3.) + +- [ ] **Step 3: Type-check** + +Run: `bun run --cwd apps/mesh check` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/mesh/src/web/components/chat/message/assistant.tsx +git commit -m "feat(chat): render ConnectCard for data-connect-required parts" +``` + +--- + +## Task 9: Remove the upfront gate and delete dead code + +**Files:** +- Modify: `apps/mesh/src/web/layouts/agent-shell-layout/index.tsx` (remove lines 314-321, 363-378, and imports at 79-80) +- Delete: `connect-agent-gate.tsx`, `use-unresolved-slots.ts`, `unresolved-slots.ts`, `unresolved-slots.test.ts` + +- [ ] **Step 1: Remove the gate check (current lines 314-321)** + +Delete this block (including its leading comment): + +```ts + // Suspense-based: AgentInsetProvider suspends until slot resolution settles, + // so the gate decision is made before any panel renders (no flash). + const { unresolved } = useUnresolvedSlots( + org.id, + org.slug, + entity?.slots ?? [], + ); + const showConnectGate = unresolved.length > 0; +``` + +- [ ] **Step 2: Remove the gate render (current lines 363-378)** + +Delete this entire block: + +```ts + if (showConnectGate) { + return ( + +
+
+ +
+
+
+ ); + } +``` + +- [ ] **Step 3: Remove the now-unused imports** + +Delete the import lines for `ConnectAgentGate` and `useUnresolvedSlots` (around lines 79-80). Find them: + +Run: `rg -n 'ConnectAgentGate|useUnresolvedSlots' apps/mesh/src/web/layouts/agent-shell-layout/index.tsx` +Then delete those two import statements. (If `InsetContext` becomes unused after removing the block, delete its import too — let the type-check in Step 5 tell you.) + +- [ ] **Step 4: Delete the dead files** + +```bash +git rm apps/mesh/src/web/components/chat/connect-agent-gate.tsx \ + apps/mesh/src/web/hooks/use-unresolved-slots.ts \ + apps/mesh/src/web/hooks/unresolved-slots.ts \ + apps/mesh/src/web/hooks/unresolved-slots.test.ts +``` + +- [ ] **Step 5: Type-check + lint + knip** + +Run: `bun run check` +Expected: PASS. +Run: `bun run lint` +Expected: no NEW errors (pre-existing `useEffect`/memoization warnings in unrelated chat code are acceptable). +Run: `bunx knip` (or the repo's knip script) and confirm no dead-code warnings for `use-slot-app-displays.ts` / `connect-slot-row.tsx` — they are now imported by `ConnectCard`. If knip flags anything you removed an importer of, fix by removing the truly-dead export. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(chat): remove upfront connect gate in favor of inline connect card" +``` + +--- + +## Task 10: End-to-end tests + +**Files:** +- Create: `apps/mesh/e2e/tests/connect-card.spec.ts` (follow the structure of an existing slot/agent e2e spec — find one first) + +- [ ] **Step 1: Find an existing e2e to model after** + +Run: `rg -l 'slot|virtualMcp|subtask' apps/mesh/e2e/tests` +Read the closest match to mirror its setup helpers (org + user + agent creation, OAuth/connection seeding). + +- [ ] **Step 2: Write the parent-gate scenario** + +Author a test that: creates an agent with a typed slot for an app the user has NOT connected; opens the agent chat; sends a message; asserts a connect card renders (locator for the “Connect to use” heading + a Connect button) and the composer was NOT blocked beforehand. Then simulate the connection existing and click Retry; assert the run proceeds (no card on the new turn). Use the real Postgres/NATS harness per TESTING.md. Mirror the seeding approach from the spec found in Step 1 — do not stub `MeshContext`. + +- [ ] **Step 3: Write the subagent scenario** + +Author a test that: creates a parent agent able to delegate, and a subagent with an unresolved slot; drives the parent to call `subtask` (seed a deterministic prompt/agent so the model delegates, or invoke the subtask path directly via the harness e2e entry); assert an inline connect card appears under the subtask call and the parent run does not hard-fail. + +- [ ] **Step 4: Run the e2e** + +Run: `bun run --cwd apps/mesh test:e2e connect-card` (use the repo's actual e2e command — check `apps/mesh/package.json`). +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/mesh/e2e/tests/connect-card.spec.ts +git commit -m "test(e2e): connect card for unresolved parent and subagent slots" +``` + +--- + +## Task 11: Final verification + +- [ ] **Step 1: Full type-check** + +Run: `bun run check` +Expected: PASS across all workspaces. + +- [ ] **Step 2: Lint + format** + +Run: `bun run lint` (no new errors) then `bun run fmt`. + +- [ ] **Step 3: Unit tests** + +Run: `bun test apps/mesh/src/core/slot-resolver.test.ts apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.test.ts` +Expected: PASS. + +- [ ] **Step 4: Dead-code check** + +Run the repo's knip command; expected clean (no orphaned exports from the gate removal). + +- [ ] **Step 5: Commit any formatting** + +```bash +git add -A +git commit -m "chore: fmt for just-in-time connect gate" || echo "nothing to format" +``` + +--- + +## Self-review notes (for the implementer) + +- **Spec coverage:** §1 goal → Tasks 4/5/7/8; §2 collect-all error → Tasks 1/2; §3 typed chunk at both boundaries → Tasks 3/4/5; §4 model text → Task 4 (subagent) + Task 5 (parent returns, model sees no tool failure); §5 one card renderer → Tasks 6/7/8; §5 gate removal → Task 9; §6 edge cases (parallel subtasks keyed by `toolCallId`, mid-session disconnect via the same throw) → covered by Tasks 4 + 2; §7 testing → Tasks 1/2/10. +- **Type consistency:** `SlotUnresolvedError(appIds: string[], agentId: string, agentTitle: string)` and `.appIds/.agentId/.agentTitle` are used identically in Tasks 1, 2, 4, 5. The chunk `data` shape `{ agentId, agentTitle, appIds }` matches the `connect-required` registration (Task 3) and `ConnectCardData` (Task 7) and `part.data` (Task 8). +- **Parent vs subagent rendering:** the parent emits the chunk with no `id` (one appended visible part); the subagent emits with `id: toolCallId`. Both render via the same `data-connect-required` switch case — rendering does NOT depend on `id`, so both work. +- **Risk to verify during execution:** that `pl.writer.write(...)` followed by `return` in `index.ts` (Task 5) flushes the card chunk to the client before the run ends. `writer` is the `createUIMessageStream` writer (same one `subtask.ts` uses successfully), so it should; confirm in the Task 10 parent e2e. If it does not flush, fall back to writing the chunk and then yielding a terminal finish chunk rather than bare `return`. diff --git a/docs/superpowers/specs/2026-05-29-hoist-connect-gate-design.md b/docs/superpowers/specs/2026-05-29-hoist-connect-gate-design.md new file mode 100644 index 0000000000..a616429b91 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-hoist-connect-gate-design.md @@ -0,0 +1,128 @@ +# Hoist the "connect to use this agent" gate to the whole agent view + +**Date:** 2026-05-29 +**Status:** Approved design, ready for implementation planning + +## Problem + +The connect gate (shown when the current user is missing an agent's required personal +connections / typed slots) currently lives only in the chat panel (`side-panel-chat.tsx`). +Navigating to the other agent tabs — preview, settings, automations — bypasses it: preview +hits the same raw `SlotUnresolvedError`, and the experience is inconsistent. The gate should +be the single "connect first" wall for the whole agent, placed above the tab/panel rendering. + +## Goal + +Hoist the gate to the agent-view container so that, when the current user has any unresolved +slot for the agent, the **entire agent view** (tab bar, all main tabs, and the chat panel) is +replaced by the connect gate. The gate becomes the one place to connect. This applies to +**everyone, including the agent owner** ("connect to the agent beforehand"). + +A consequence: the settings tab is then only reachable when all slots resolve, so the +settings-tab slot rows no longer need a Connect button. + +## Non-goals + +- Inline/in-place connect inside the gate — still deep-links to the Connections page (a + future enhancement). +- Owner exemption — the owner is gated like everyone else. +- Per-tab gating — it's a single top-level wall (Option A), not run-surface-only gating. + +## Background (current code) + +- `AgentInsetProvider` (`apps/mesh/src/web/layouts/agent-shell-layout/index.tsx`) is the + agent-view container. It reads `virtualmcpid` from the route search params and already + calls `entity = useVirtualMCP(virtualMcpId)` (a Suspense query; `entity` has `.slots`, + `.title`, `.icon`). It renders the toolbar, the tab bar (`MainPanelTabsBar`), the main tab + content (`MainPanelContent` → preview/settings/automations/git), and the chat panel + (`ChatMainPanelGroup`), in both a desktop and a mobile layout branch. +- `useUnresolvedSlots(orgId, orgSlug, slots)` (`apps/mesh/src/web/hooks/use-unresolved-slots.ts`) + returns `{ unresolved, isLoading }`; `refetchOnWindowFocus: "always"` so it re-resolves when + the user returns from connecting. +- `ConnectAgentGate` (`apps/mesh/src/web/components/chat/connect-agent-gate.tsx`) renders the + panel (agent icon/title + per-slot rows with a Connect deep-link to + `/$org/settings/connections`). +- The chat-panel gate lives in `ChatPanelContent` (`side-panel-chat.tsx`): a `useUnresolvedSlots` + call + `showConnectGate` + an early return rendering `ConnectAgentGate`. +- `SlotItem` (`apps/mesh/src/web/views/virtual-mcp/slot-item.tsx`) renders an agent-settings + slot row: a resolved branch (violet "Personal" chip) and an unresolved branch ("Not + connected for you" + a Connect button). + +## Design + +### 1. Gate the whole agent view at `AgentInsetProvider` + +After `entity` is fetched and alongside the other hooks (before the desktop/mobile layout +returns, so the check is unconditional and covers both layouts): + +```ts +const { unresolved, isLoading: slotsLoading } = useUnresolvedSlots( + org.id, + org.slug, + entity?.slots ?? [], +); +const showConnectGate = !slotsLoading && unresolved.length > 0; +``` + +Then an early return (before the desktop and mobile layout returns) that fills the agent +inset with only the gate — no toolbar, no tab bar, no chat (a full brick): + +```tsx +if (showConnectGate) { + return ( +
+ +
+ ); +} +``` + +The app sidebar (outside the inset) still lets the user navigate away. Agents with no slots +(e.g. the default Decopilot agent → `entity` null or empty slots) never gate. + +### 2. Remove the redundant chat-panel gate + +In `ChatPanelContent` (`side-panel-chat.tsx`), delete the `useUnresolvedSlots` call, the +`showConnectGate` computation, and the `ConnectAgentGate` early return. The chat panel returns +to its pre-gate behavior — the higher wall supersedes it, so there is no double-gating and no +dead code. Remove the now-unused imports (`useUnresolvedSlots`, and `ConnectAgentGate` if it's +no longer referenced from this file). + +### 3. Simplify `SlotItem` — drop the unresolved Connect button + +Settings is now reachable only when all slots resolve, so `SlotItem`'s unresolved branch is +effectively unreachable in normal flow. Remove the **Connect** button (and its `Link`) from +the unresolved branch, leaving a minimal no-action "Not connected for you" display as a +defensive fallback (covers the rare race where a connection is deleted while the user is on +settings). The resolved branch (violet "Personal" chip + link to the connection) is unchanged. +Drop any imports that become unused as a result (e.g. `Button`/`Link` only if no longer used +elsewhere in the file — the resolved branch still uses `Link`, so keep that). + +### Component location (minor) + +`ConnectAgentGate` stays in `apps/mesh/src/web/components/chat/` to minimize churn (only the +import path changes consumer). Moving it to `layouts/agent-shell-layout/` is an optional +follow-up now that chat no longer owns it. + +## Edge cases + +- Default Decopilot / slotless agent → no gate. +- All slots resolved → no gate; settings shows resolved `SlotItem`s (Personal chip). +- `isLoading` → no gate flash (render the normal view until resolution settles). +- Owner with unresolved slots → gated (must connect before configuring), per Option A. + +## Testing + +- Existing `unresolvedSlots` unit tests cover detection (unchanged). +- Manual: + - Agent with an unresolved slot, as a user without the connection → the whole agent view + (tab bar + preview/settings/automations + chat) is replaced by the connect wall. + - Connect (deep-link) and return → wall clears (refetch-on-focus), normal view appears. + - Agent with all slots resolved → normal view; settings shows the `SlotItem` "Personal" + chip and no Connect button. + - Slotless agent → normal view, never gated. diff --git a/docs/superpowers/specs/2026-05-29-registry-aware-connect-gate-design.md b/docs/superpowers/specs/2026-05-29-registry-aware-connect-gate-design.md new file mode 100644 index 0000000000..7428ede165 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-registry-aware-connect-gate-design.md @@ -0,0 +1,198 @@ +# Registry-aware connect gate with inline connect + +**Date:** 2026-05-29 +**Status:** Approved design, ready for implementation planning + +## Problem + +The "Connect to use this agent" gate (`ConnectAgentGate`, +`apps/mesh/src/web/components/chat/connect-agent-gate.tsx`) lists each unresolved slot as a +generic icon (`IntegrationIcon icon={null}`) + the raw `slot_app_id` string (e.g. +`deco/mcp-github`) + a Connect button that deep-links to `/$org/settings/connections`. Two +weaknesses: + +1. It shows the raw `app_id` and a generic icon instead of the app's real icon and friendly + name, so the user can't tell at a glance which integration they need. +2. Connecting kicks the user out of the gate to the Connections page; they have to find the + app, install it, and come back. The gate should let them connect **in place**. + +## Goal + +Tighten the gate's integration with the registry: + +1. **Render the real MCP icon + friendly name** for each unresolved slot, sourced from the + registry (`COLLECTION_REGISTRY_APP_GET`). +2. **Inline connect** — the Connect button installs/authenticates the app without leaving the + gate; on success the slot resolves and its row disappears. When the last slot resolves, the + gate gives way to the agent view. + +## Non-goals + +- No new install/OAuth logic — inline connect is the existing `handleConnectAndAdd` pipeline + (`add-connection-dialog.tsx`) factored into a reusable hook. +- Synthetic / non-registry slots (`url:` / `stdio:` / `npx:` from `deriveAppId`, or any + `app_id` the registry doesn't know) are **not** inline-installable — they keep today's + generic icon + raw `app_id` + deep-link Connect. +- No auto-firing connect on mount (would surprise the user with an OAuth popup); connect is + always on click. +- No change to slot detection (`useUnresolvedSlots`) or to where the gate is mounted + (`AgentInsetProvider`, per the hoist work). + +## Background (current code) + +- `ConnectAgentGate` receives `{ agentTitle, agentIcon, slots: SlotLike[], orgSlug }` and maps + `slots` to rows. `SlotLike = { slot_app_id: string }`. It is mounted by `AgentInsetProvider` + (`apps/mesh/src/web/layouts/agent-shell-layout/index.tsx`) with `slots={unresolved}`. +- `useUnresolvedSlots(orgId, orgSlug, slots)` + (`apps/mesh/src/web/hooks/use-unresolved-slots.ts`) is a **`useSuspenseQuery`** that resolves + every slot's `app_id` via `CONNECTION_RESOLVE_FOR_USER` and returns `{ unresolved: T[] }` + (the slots that didn't resolve). `staleTime: 0` + `refetchOnWindowFocus: "always"` so it + re-resolves (background, no re-suspend) when the user returns from connecting. +- `useRegistryApp(appId, { enabled })` (`apps/mesh/src/web/hooks/use-registry-app.ts`) calls + `COLLECTION_REGISTRY_APP_GET` with `{ name: appId }` and returns `RegistryItem | null` + (5-min stale time). A `RegistryItem` exposes a friendly title (`_meta["mcp.mesh"].friendlyName` + / `friendly_name` → `server.title` → `title` → `server.name`) and an icon + (`server.icons[0].src`). +- `extractConnectionData(item, orgId, userId, { remoteIndex })` + (`apps/mesh/src/web/utils/extract-connection-data.ts`) turns a `RegistryItem` into connection + create input (`title`, `icon`, `app_id`, `app_name`, `connection_type`, `connection_url`, + `oauth_config`, …). +- `handleConnectAndAdd(item)` (`add-connection-dialog.tsx`, ~L804-927) is the inline-connect + pipeline we will factor out: validate connection method → `connectionActions.create` → + build `mcpProxyUrl` → `isConnectionAuthenticated` → if OAuth needed, `authenticateMcp` + (popup) → persist token via `POST /api/:org/connections/:id/oauth-token` (fallback to + `connectionActions.update` with `connection_token`) → invalidate `isMCPAuthenticated`. +- `connectionActions` come from the connections mutation hooks; `org`/`session` from context + (`useProjectContext` / session). `useRegistryApp` reads `org` from `useProjectContext` + internally. + +## Design + +### 1. Display-metadata resolution (suspending, batched) + +For each unresolved slot, resolve `{ kind, title, icon, registryItem }` from the registry: + +- **Registry app found** → `{ kind: "registry", title: , icon: + server.icons[0].src ?? null, registryItem }`. +- **Not found** (`null` — synthetic id or unknown app) → `{ kind: "fallback", title: + slot_app_id, icon: null, registryItem: null }`. + +This lookup **suspends** so the gate appears fully-formed (icons, names, buttons all settled) +with no progressive text→name / generic→icon swap — consistent with the no-flash decision that +made `useUnresolvedSlots` a suspense query. + +Implementation: a new hook `useSlotAppDisplays(slots)` that batches one +`COLLECTION_REGISTRY_APP_GET` per `slot_app_id` (Promise.all in a single `useSuspenseQuery`, +mirroring the structure of `useUnresolvedSlots`), returning +`Record`. Batching into one suspense query (rather than +calling `useRegistryApp` per row) keeps the hook count stable and lets the whole gate settle +in one boundary. A pure helper `slotAppDisplay(slotAppId, registryItem | null)` does the +RegistryItem → `{ kind, title, icon }` mapping and is unit-tested. + +### 2. `useConnectApp` — generalized inline connect + +Factor the `handleConnectAndAdd` body into a reusable hook in +`apps/mesh/src/web/hooks/use-connect-app.ts`: + +```ts +function useConnectApp(): { + connect: (item: RegistryItem) => Promise; + status: "idle" | "connecting" | "authenticating" | "ready" | "error"; + error: string | null; +}; +``` + +Pipeline (unchanged from `handleConnectAndAdd`, minus the agent-attach `onAdd` step — the gate +doesn't attach anything; the connection just needs to *exist and resolve* for the slot): + +1. `extractConnectionData(item, org.id, session.user.id, { remoteIndex: 0 })`; reject if no + URL and no STDIO command (`status: "error"`). +2. `connectionActions.create.mutateAsync(...)` → `id` (`status: "connecting"`). +3. `isConnectionAuthenticated`; if `supportsOAuth && !isAuthenticated` → + `authenticateMcp({ connectionId: id, orgSlug, scope: "offline_access" })` + (`status: "authenticating"`). On OAuth failure, surface `error` (the connection still + exists but unauthenticated; do **not** auto-delete — keep parity with `handleConnectAndAdd`, + which leaves it and warns). +4. Persist token (`POST .../oauth-token`, fallback to `update` with `connection_token`). +5. **Invalidate so the gate re-resolves**: `KEYS.unresolvedSlots(...)` (the gate's resolve + query) and the connections list, plus `KEYS.isMCPAuthenticated(...)`. The refetch is a + background refetch on the suspense query → no re-suspend / no full-gate flash; the resolved + row simply drops. + +`add-connection-dialog.tsx`'s `handleConnectAndAdd` is refactored to call this hook (keeping +its extra `onAdd`/tracking around the shared core) so there's a single inline-connect +implementation rather than a copy. + +> Note on the resolve key: the gate's resolve query is keyed +> `KEYS.unresolvedSlots(orgId, sortedAppIds)`. After connecting, invalidating that exact key +> (the gate's mounted instance) triggers the background re-resolve. If invalidating by the full +> key is awkward from the hook, invalidate the `unresolvedSlots` key prefix so any mounted gate +> re-resolves. (Pin the exact invalidation target during planning against `KEYS`.) + +### 3. Gate row rendering + +Extract a `ConnectSlotRow` component (one per slot) so each row owns its own `useConnectApp` +state without hooks-in-a-loop. `ConnectAgentGate` maps `slots` → ``. + +Row behavior by `display.kind`: + +- **`registry`** — real `icon` + friendly `title`. Connect button calls + `connect(display.registryItem)` on click: + - `idle` → `[ Connect ]` + - `connecting` → `[ Connecting… ]` (disabled, spinner) + - `authenticating` → `[ Authenticating… ]` (disabled, spinner) + - `error` → inline error text (e.g. "Couldn't connect") + `[ Try again ]` + - on success the slot resolves and the row disappears (gate re-resolves). + Rows are independent — one can be connecting while another is idle. +- **`fallback`** — generic icon (`IntegrationIcon icon={null}`) + raw `slot_app_id` + the + current deep-link Connect (`Link to="/$org/settings/connections"`). Unchanged behavior. + +``` +┌──────────────────────────────────────────────────────────┐ +│ 🔌 Connect to use this agent │ +│ "storefront" needs your personal connections to run. │ +│ │ +│ 🐙 GitHub [ Connect ] │ registry: real icon+name, inline connect +│ 🟦 Linear [ Connecting… ] │ in-flight: spinner + label +│ 🟥 Sentry couldn't connect [ Try again ] │ error: inline message + retry +│ ▢ url:api.acme.com/mcp [ Connect ] │ fallback: generic icon, raw id, deep-link +└──────────────────────────────────────────────────────────┘ +``` + +### Component locations + +- `useSlotAppDisplays` → `apps/mesh/src/web/hooks/use-slot-app-displays.ts`; pure helper + `slotAppDisplay` → co-located (e.g. `apps/mesh/src/web/hooks/slot-app-display.ts`) with a + `*.test.ts`. +- `useConnectApp` → `apps/mesh/src/web/hooks/use-connect-app.ts`. +- `ConnectSlotRow` → co-located with `ConnectAgentGate` (same `components/chat/` dir, or a new + file beside it). `ConnectAgentGate` stays where it is. + +## Edge cases + +- Slot whose registry lookup returns `null` → `fallback` row (deep-link), never an inline + connect attempt. +- OAuth popup blocked / cancelled → `status: "error"`, inline "Try again"; the orphaned + connection is left (parity with `handleConnectAndAdd`), and a subsequent retry reuses the + normal create/auth path (a duplicate private connection to the same service would hit the + per-service uniqueness rule — surfaced as the row error). +- Connecting one slot must not re-suspend the whole gate: invalidation triggers a **background** + refetch of the (already-loaded) suspense queries, so only the resolved row drops. +- Agent with mixed registry + fallback slots → rows render independently by kind. +- Registry temporarily unreachable → the metadata suspense query errors to the surrounding + boundary (same boundary `useVirtualMCP` / `useUnresolvedSlots` already use). Acceptable; the + registry is a reliable org connection. + +## Testing + +- **Unit:** `slotAppDisplay(slotAppId, registryItem | null)` → asserts the registry-found case + (friendly title precedence + icon) and the `null` → fallback case (raw `app_id`, null icon). +- **Manual / E2E:** + - Open a GitHub-imported agent as a user without GitHub connected → gate shows the **GitHub + icon + "GitHub"** (not `deco/mcp-github`) and an inline `[ Connect ]`. + - Click Connect → OAuth popup → on return the row clears; when it was the only slot, the agent + view appears. No full-gate flash. + - A second member who already has GitHub → no gate (resolved). + - An agent with a synthetic-`app_id` slot → fallback row (raw id + deep-link), unchanged. + diff --git a/docs/superpowers/specs/2026-05-31-just-in-time-connection-gate-design.md b/docs/superpowers/specs/2026-05-31-just-in-time-connection-gate-design.md new file mode 100644 index 0000000000..d03a36246b --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-just-in-time-connection-gate-design.md @@ -0,0 +1,209 @@ +# Just-in-Time Connection Gate for Agents & Subagents — Design + +**Date:** 2026-05-31 +**Status:** Approved (brainstorm) — pending spec review +**Topic:** Replace the upfront slot gate with a single runtime "connect card" mechanism shared by agent (parent) and subagent (subtask) flows. + +## Problem + +Agents declare typed **slots** (by `app_id`) that resolve at runtime to the +invoking user's own connection (`access='user' AND created_by=invoker`), falling +back to an org-shared connection (`access='org'`). Private connections can only +ever be slots — concrete child connections are required to be org-scoped and are +always present — so the per-user gap is **exclusively about slots**. + +Today the gate that guarantees a user has the connections an agent needs is +enforced in two places, and **both only consider the *current* agent's own +slots**: + +- **Client:** `AgentInsetProvider` (`agent-shell-layout/index.tsx:316-378`) calls + `useUnresolvedSlots` and, if any slot is unresolved, renders `ConnectAgentGate` + instead of the chat — blocking the composer before the user can type. +- **Server preflight:** `createVirtualClientFrom` + (`mcp-clients/virtual-mcp/index.ts:149-211`) throws `SlotUnresolvedError` on + the first unresolved slot when assembling the agent's client. + +Subtask delegation is **unbounded**: a parent can delegate to *any* active +virtual MCP in the org (the model is shown the full org agent list via +`agents-block`); there is no parent→subagent relationship in the schema, and +recursion is capped at one level (subagents can't subtask). Because the gate +only checks the parent's own slots, a subagent reached via `subtask` may declare +slots the user hasn't satisfied. Today that throws `SlotUnresolvedError` inside +`subtask.ts:97` (no try/catch), which the AI SDK surfaces to the model as a +generic `"Subtask failed: …"` mid-conversation — a poor, opaque experience. + +Gating the full transitive closure upfront is infeasible given unbounded +delegation (it would require the union of every org agent's slots before the +first message). We therefore move to **just-in-time** gating. + +## Decisions (from brainstorm) + +1. **Just-in-time, not upfront.** Delegation stays unbounded. We check slots at + run time, asking only for the apps actually needed by the agent being run. +2. **One mechanism for both flows.** Parent and subagent both assemble their + client through the same `createVirtualClientFrom`, which already throws + `SlotUnresolvedError`. We upgrade that error and give it a single frontend + renderer rather than building two paths. +3. **Consolidate into the in-run card (option B).** The upfront composer-blocking + gate is removed. The run is the single source of truth: an unresolved slot + (parent's own *or* a delegated subagent's) throws the structured error, which + the chat renders as one connect card inline in the thread. +4. **Manual Retry (option A).** The card has a **Retry** button that re-runs the + **last user turn**. No in-flight pause/resume; no auto-watching. + +## Design + +### 1. Structured, collect-all error (`slot-resolver.ts` + `createVirtualClientFrom`) + +The slot loop currently throws on the **first** unresolved slot, so a card could +only ever show one app. Change it to resolve every slot, accumulate all +unresolved `app_id`s, and throw once at the end: + +```ts +export class SlotUnresolvedError extends Error { + readonly appIds: string[]; // was: appId: string + readonly agentId: string; + readonly agentTitle: string; + constructor(appIds: string[], agentId: string, agentTitle: string) { … } +} +``` + +- Resolution rules unchanged (user-private preferred → org fallback). +- All-or-nothing preserved: any unresolved slot ⇒ throw (the agent cannot run + with a partial toolset). +- This is the shared chokepoint: both the parent path + (`harnesses/decopilot/tools.ts:136`) and the subagent path + (`built-in-tools/subtask.ts:97`) inherit collect-all for free. + +`agentId`/`agentTitle` come from the `VirtualMCPEntity` being assembled, so the +card can name which agent (parent or which subagent) needs the connections. + +### 2. Catch at both boundaries, emit one typed chunk + +A raw throw is swallowed as a generic failure, so each boundary catches +`SlotUnresolvedError` and writes **one** typed stream chunk: + +```ts +writer.write({ + type: "data-connect-required", + id, // toolCallId (subagent) or run/message id (parent) + data: { agentId, agentTitle, appIds }, +}); +``` + +- **Subagent boundary** (`subtask.ts`): wrap `createVirtualClientFrom` in + try/catch. On `SlotUnresolvedError`, write the chunk keyed by `toolCallId` and + `yield` a model-facing result (see §3) instead of letting the tool throw. +- **Parent boundary** (run setup around `runAgentLoop` / run-stream, where + `tools.ts` assembles the client): catch the same error, write the chunk for the + run, and end the stream cleanly (no crash). + +### 3. Model-facing text (single source of truth) + +From the same error the model receives plain text so it doesn't blindly retry: + +> "Couldn't run **{agentTitle}** — the user must connect: GitHub, Gmail. A +> connect card was shown to the user." + +- Subagent: returned via `toModelOutput`. +- Parent: included in the run's terminal assistant text. + +The card (user-facing) and the text (model-facing) derive from the same +structured error, so they never drift. + +### 4. Frontend — remove upfront gate, add one card renderer + +- **Remove** the composer-blocking branch in `agent-shell-layout/index.tsx` (the + `ConnectAgentGate` render path) and its use of `useUnresolvedSlots` for gating. + Chat always renders. +- **Add** a `data-connect-required` part to the `derive-parts` pipeline + (`components/chat/derive-parts.ts`) and a single `ConnectCard` renderer. +- The `ConnectCard` **reuses the existing building blocks**: `ConnectSlotRow` + (per-app OAuth Connect button / connections deep-link) and `useSlotAppDisplays` + (registry icon + friendly name). These are the same components the old + `ConnectAgentGate` used — that reuse is the "single frontend treatment." +- The card carries a **Retry** button that re-runs the last user turn. After the + user connects all listed apps and clicks Retry, the parent's slots resolve and + any subtask re-issues and preflights clean. + +### 5. Edge cases + +- **Parallel subtasks:** each failing subtask emits its own chunk keyed by its + `toolCallId`, so multiple cards render distinctly under their respective calls. +- **Mid-session disconnect** (org connection removed, token revoked): the next + run throws → same card. This safety net is the upside of removing the pre-gate. +- **Non-UI / API callers:** they don't consume the chunk, but + `SlotUnresolvedError` remains a clean structured error for them. +- **Self-fallback to org:** unchanged — a slot resolved by an org connection is + not "unresolved" and produces no card. + +### 6. Dead code + +Removing the upfront gate likely leaves `ConnectAgentGate` and/or +`useUnresolvedSlots` unused. Per repo policy (knip), delete what becomes dead or +fold it into the new `ConnectCard` — do not leave orphaned exports. + +## Components & boundaries + +| Unit | Responsibility | Depends on | +|---|---|---| +| `SlotUnresolvedError` (`slot-resolver.ts`) | Carry `{appIds, agentId, agentTitle}` | — | +| `createVirtualClientFrom` (collect-all) | Resolve all slots; throw structured error if any unresolved | `resolveSlot` | +| subtask boundary (`subtask.ts`) | Catch error → write `data-connect-required` + model text | writer, error | +| parent boundary (run setup) | Catch error → write `data-connect-required` + end stream | writer, error | +| `derive-parts` mapping | Turn chunk into a `connect-required` part | chunk shape | +| `ConnectCard` (frontend) | Render apps + Connect + Retry | `ConnectSlotRow`, `useSlotAppDisplays` | + +## Acceptance criteria + +1. A parent agent with an unsatisfied slot: the user can type and send; the run + produces a connect card (not a blocked composer, not a generic error) listing + **all** missing apps; connecting them + Retry runs the agent successfully. +2. A subagent reached via `subtask` with a missing connection: an inline connect + card appears under that subtask call naming the subagent; connecting + Retry + makes the delegation succeed. +3. Multiple parallel subtasks each missing different apps render separate cards. +4. The model receives clear text and does not loop retrying the same failing + subtask. +5. A connection removed mid-session surfaces the same card on the next run. +6. No orphaned `ConnectAgentGate` / `useUnresolvedSlots` exports (knip clean); + `bun run check` and `bun run lint` pass. + +## Testing + +- **Unit (`bun test`, pure logic only):** + - collect-all resolution: multiple unresolved slots → all present in `appIds`; + a mix of resolved/unresolved → only unresolved reported. + - the pure error→chunk mapping (`SlotUnresolvedError` → `data-connect-required` + payload). + - updated `slot-resolver.test.ts` for the new error shape. +- **E2E (Playwright):** + - parent: unsatisfied slot → card on first message → connect → Retry → runs. + - subagent: missing connection mid-run → inline card → connect → Retry → + delegation succeeds. + - parallel subtasks each show their own card. + +## Out of scope (possible follow-ups) + +- **Auto-retry on connect (option B from Q5):** the card already knows the + `app_id`s, so re-checking resolution after each connect and auto-running when + the last clears is a natural follow-up. +- **In-flight pause/resume of the subtask (option C from Q3).** +- **Bounding delegation / declared subagents (the upfront-gate alternative).** + +## Key files (reference) + +- `apps/mesh/src/core/slot-resolver.ts` — `SlotUnresolvedError`, `resolveSlot`. +- `apps/mesh/src/mcp-clients/virtual-mcp/index.ts:133-211` — slot resolution loop + (collect-all change). +- `apps/mesh/src/harnesses/decopilot/built-in-tools/subtask.ts:97` — subagent + boundary. +- `apps/mesh/src/harnesses/decopilot/tools.ts:136` — parent client assembly. +- `apps/mesh/src/harnesses/decopilot/run-agent-loop.ts` / run-stream — parent + boundary for catch + chunk emission. +- `apps/mesh/src/web/layouts/agent-shell-layout/index.tsx:316-378` — gate removal. +- `apps/mesh/src/web/components/chat/connect-agent-gate.tsx`, + `connect-slot-row.tsx` — reuse / consolidation source. +- `apps/mesh/src/web/components/chat/derive-parts.ts` — add the new part. +- `apps/mesh/src/web/hooks/use-slot-app-displays.ts`, + `use-unresolved-slots.ts` — display helpers / dead-code review. diff --git a/packages/mesh-sdk/src/index.ts b/packages/mesh-sdk/src/index.ts index 75426cf467..7e44a07a6a 100644 --- a/packages/mesh-sdk/src/index.ts +++ b/packages/mesh-sdk/src/index.ts @@ -114,6 +114,7 @@ export { type VirtualMCPCreateData, type VirtualMCPUpdateData, type VirtualMCPConnection, + type VirtualMCPSlot, type VirtualMcpUILayout, type VirtualMcpUILayoutTab, SandboxMapSchema, diff --git a/packages/mesh-sdk/src/lib/constants.test.ts b/packages/mesh-sdk/src/lib/constants.test.ts index 84b4eb50f6..99b7fcae20 100644 --- a/packages/mesh-sdk/src/lib/constants.test.ts +++ b/packages/mesh-sdk/src/lib/constants.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { StudioPackAgentId, isStudioPackAgent } from "./constants"; +import { + StudioPackAgentId, + getWellKnownCommunityRegistryConnection, + getWellKnownRegistryConnection, + getWellKnownSelfConnection, + isStudioPackAgent, +} from "./constants"; describe("StudioPackAgentId", () => { test("generates the Store Manager id with the org suffix", () => { @@ -24,3 +30,13 @@ describe("isStudioPackAgent", () => { expect(isStudioPackAgent("decopilot_org_xyz")).toBe(false); }); }); + +describe("auto-seeded org connections", () => { + test("well-known Self / Deco Store / Community Registry connections are org-scoped", () => { + expect( + getWellKnownSelfConnection("http://localhost:3000", "org_xyz").access, + ).toBe("org"); + expect(getWellKnownRegistryConnection("org_xyz").access).toBe("org"); + expect(getWellKnownCommunityRegistryConnection().access).toBe("org"); + }); +}); diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index ccb26d17bd..fffef313d9 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -71,6 +71,7 @@ export function getWellKnownRegistryConnection( isDefault: true, type: "registry", }, + access: "org", }; } @@ -99,6 +100,7 @@ export function getWellKnownCommunityRegistryConnection(): ConnectionCreateData isDefault: true, type: "registry", }, + access: "org", }; } @@ -131,6 +133,7 @@ export function getWellKnownSelfConnection( isDefault: true, type: "self", }, + access: "org", }; } @@ -247,6 +250,7 @@ function defineWellKnownAgentVMCP(opts: { metadata: { instructions: opts.instructions ?? null }, pinned: false, connections: [], + slots: [], }; } @@ -357,5 +361,6 @@ export function getWellKnownDecopilotConnection( }, tools: [], bindings: [], + access: "org", }; } diff --git a/packages/mesh-sdk/src/types/connection.ts b/packages/mesh-sdk/src/types/connection.ts index e1d4bdaeb3..14eefda6f6 100644 --- a/packages/mesh-sdk/src/types/connection.ts +++ b/packages/mesh-sdk/src/types/connection.ts @@ -152,6 +152,13 @@ export const ConnectionEntitySchema = z.object({ bindings: z.array(z.string()).nullable().describe("Detected bindings"), status: z.enum(["active", "inactive", "error"]).describe("Current status"), + + access: z + .enum(["user", "org"]) + .default("user") + .describe( + "Visibility/ownership. 'user' = private to created_by; 'org' = shared with everyone in the organization.", + ), }); /** @@ -185,6 +192,7 @@ export const ConnectionCreateDataSchema = ConnectionEntitySchema.omit({ configuration_state: true, configuration_scopes: true, metadata: true, + access: true, }) .extend({ icon: z.string().nullish(), diff --git a/packages/mesh-sdk/src/types/index.ts b/packages/mesh-sdk/src/types/index.ts index 2c4d929216..a60017cbe9 100644 --- a/packages/mesh-sdk/src/types/index.ts +++ b/packages/mesh-sdk/src/types/index.ts @@ -25,6 +25,7 @@ export { type VirtualMCPCreateData, type VirtualMCPUpdateData, type VirtualMCPConnection, + type VirtualMCPSlot, type VirtualMcpUILayout, type VirtualMcpUILayoutTab, type GithubRepo, diff --git a/packages/mesh-sdk/src/types/virtual-mcp.ts b/packages/mesh-sdk/src/types/virtual-mcp.ts index 67dbe06709..eee21cebe7 100644 --- a/packages/mesh-sdk/src/types/virtual-mcp.ts +++ b/packages/mesh-sdk/src/types/virtual-mcp.ts @@ -8,10 +8,11 @@ import { z } from "zod"; /** - * Virtual MCP connection schema - defines which connection and tools/resources/prompts are included + * Selection filter fields shared by connection rows and typed slots. + * `null` means "include everything"; an array narrows to the listed items. + * Wildcards (`*`, `**`) are honored by the resource matcher. */ -const VirtualMCPConnectionSchema = z.object({ - connection_id: z.string().describe("Connection ID"), +const SelectionFilterFieldsShape = { selected_tools: z .array(z.string()) .nullable() @@ -30,6 +31,25 @@ const VirtualMCPConnectionSchema = z.object({ .describe( "Selected prompt names. null = all prompts included, array = only these prompts included", ), +} as const; + +/** + * Same fields as `SelectionFilterFieldsShape` but marked optional. Used by + * Create/Update input schemas where the client may omit any filter and have + * the server default it to `null` (include everything). + */ +const SelectionFilterInputFieldsShape = { + selected_tools: SelectionFilterFieldsShape.selected_tools.optional(), + selected_resources: SelectionFilterFieldsShape.selected_resources.optional(), + selected_prompts: SelectionFilterFieldsShape.selected_prompts.optional(), +} as const; + +/** + * Virtual MCP connection schema - defines which connection and tools/resources/prompts are included + */ +const VirtualMCPConnectionSchema = z.object({ + connection_id: z.string().describe("Connection ID"), + ...SelectionFilterFieldsShape, }); export type VirtualMCPConnection = z.infer; @@ -37,14 +57,31 @@ export type VirtualMCPConnection = z.infer; /** * Virtual MCP connection schema for input (Create/Update) - fields can be optional */ -const VirtualMCPConnectionInputSchema = VirtualMCPConnectionSchema.extend({ - selected_tools: VirtualMCPConnectionSchema.shape.selected_tools.optional(), - selected_resources: - VirtualMCPConnectionSchema.shape.selected_resources.optional(), - selected_prompts: - VirtualMCPConnectionSchema.shape.selected_prompts.optional(), +const VirtualMCPConnectionInputSchema = VirtualMCPConnectionSchema.extend( + SelectionFilterInputFieldsShape, +); + +/** + * Virtual MCP slot schema — a typed dependency declared without binding to a + * specific connection. Resolved at runtime to the caller's user-private + * connection of the matching app_id (falling back to an org-shared one). + * + * Slot uniqueness within a single agent is enforced by a partial unique index + * on (parent_connection_id, slot_app_id) WHERE slot_app_id IS NOT NULL. + */ +const VirtualMCPSlotSchema = z.object({ + slot_app_id: z + .string() + .describe("app_id this slot is typed by (e.g. 'mcp-github')"), + ...SelectionFilterFieldsShape, }); +export type VirtualMCPSlot = z.infer; + +const VirtualMCPSlotInputSchema = VirtualMCPSlotSchema.extend( + SelectionFilterInputFieldsShape, +); + /** * Pinned view schema - a tool view pinned to a virtual MCP */ @@ -471,6 +508,12 @@ export const VirtualMCPEntitySchema = z.object({ connections: z .array(VirtualMCPConnectionSchema) .describe("Connections with their selected tools, resources, and prompts"), + slots: z + .array(VirtualMCPSlotSchema) + .default([]) + .describe( + "Typed slots — resolved to the caller's connection of the matching app_id at runtime.", + ), }); /** @@ -531,6 +574,10 @@ export const VirtualMCPCreateDataSchema = z.object({ .describe( "Connections to include/exclude (can be empty for exclusion mode)", ), + slots: z + .array(VirtualMCPSlotInputSchema) + .optional() + .describe("Typed slots to declare on the new agent."), }); export type VirtualMCPCreateData = z.infer; @@ -583,6 +630,10 @@ export const VirtualMCPUpdateDataSchema = z.object({ .array(VirtualMCPConnectionInputSchema) .optional() .describe("New connections (replaces existing)"), + slots: z + .array(VirtualMCPSlotInputSchema) + .optional() + .describe("New slots (replaces existing slots if provided)."), }); export type VirtualMCPUpdateData = z.infer; diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index 476a3ae8cc..5405f11c1a 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -19,6 +19,8 @@ const badgeVariants = cva( "border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90 focus-visible:ring-success/20 dark:focus-visible:ring-success/40", warning: "border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40", + special: + "border-transparent bg-special text-special-foreground [a&]:hover:bg-special/90 focus-visible:ring-special/20 dark:focus-visible:ring-special/40", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", },