diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index ec7cc6aee8..a8024b5798 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -38,7 +38,7 @@ Classify the vendor before writing metadata. Use the [PAP-2432](/PAP/issues/PAP- | Reuse path | Use when | Typical transport | Examples from the matrix | | --- | --- | --- | --- | -| MCP-direct | The vendor exposes an official or stable MCP server whose tools map cleanly to Paperclip grants. | `remote_http`; `local_stdio` only for approved trusted templates. | Linear, Notion, Sentry, Vercel, Exa, Apify, Context7. | +| MCP-direct | The vendor exposes an official or stable MCP server whose tools map cleanly to Paperclip grants. | `mcp_remote`; `local_stdio` only for approved trusted templates. | Linear, Notion, Sentry, Vercel, Exa, Apify, Context7. | | OpenAPI-shim | The vendor has a documented REST/OpenAPI surface but no stable MCP server, and a generated/thin shim can expose safe actions. | Shim service or approved template that presents an MCP-compatible catalog to Paperclip. | Datadog, Apollo, QuickBooks, Ramp/Brex, Zendesk. | | Vendor-deep-wrapper | The vendor boundary depends on app-installation tokens, event validation, rich domain semantics, resource grants, or high-risk writes. | Vendor-specific wrapper behind the same connection model. | GitHub, Slack, Google Workspace writes, Atlassian, Microsoft 365, Cloudflare, Figma, Stripe, Salesforce, HubSpot, Intercom, PagerDuty. | @@ -78,23 +78,23 @@ Credentials always live in `company_secrets` with redacted metadata and versione Do not add durable vendor credentials to agent env, project env, runtime env, adapter config, issue comments, screenshots, logs, fixture JSON, or plugin config. Agents receive a run-scoped gateway token; Paperclip resolves the vendor credential server-side and audits the call. -## Step 4: Define Manifest Metadata +## Step 4: Author The AppDefinition -The manifest must explain what the operator gets without exposing protocol details in prosumer surfaces. Developer docs can mention transport, MCP, shim, and gateway terms; the Apps gallery copy should use plain app/action language. +Author an `AppDefinition` as the canonical data record for the app and every supported connection method. It must explain what the operator gets without exposing protocol details in prosumer surfaces. Developer docs can mention transport, MCP, shim, and gateway terms; the Apps gallery copy should use plain app/action language. Capture: - `key`: stable lowercase app key, e.g. `linear`. - `name`, `logoUrl`, `tagline`, `description`: user-facing metadata. -- `authKind`: `oauth`, `api_key`, or `none`. -- `transportTemplate`: `remote_http` URL, approved `local_stdio` template key, or shim template key once available. +- `methods`: explicit combinations of `transport` (`mcp_remote`, `rest_api`, `local_stdio`), `authKind` (`oauth`, `api_key`, `none`), and `ownership` (`platform_shared`, `platform_provisioned`, `customer`, `dcr`). +- Stable connection UID namespace used to form `{namespace}/{slug}` addresses. - `credentialFields`: labels, vendor-call placement, header key, prefix, help URL, and required state. User-facing labels should be sanitized by the Apps UI copy layer. The saved value is always a `company_secrets` ref, not an env entry. - `oauth`: provider key, scopes, authorization URL, token URL, metadata URL if applicable. - `urlPatterns`: URLs that can identify this app during paste/import flows. - `recommendedDefaults`: access and risk defaults, especially ask-first risk levels. - `availability`: whether the connector is generally available, gated by deployment config, or needs vendor registration. -Keep manifest metadata deterministic and company-scoped at install time. Global catalog data names capabilities; company connection rows hold the configured instance, secret refs, resource filters, status, health, and audit history. +Keep `AppDefinition` metadata deterministic and company-scoped at install time. Global catalog data names capabilities; company connection and grant rows hold the configured instance, subject/provider tenant, secret refs, resource filters, status, health, and audit history. ## Step 5: Model Resource Filters @@ -274,7 +274,7 @@ This dry run applies the template to Linear, one of the [PAP-2432](/PAP/issues/P ### Transport And Auth -- Transport: `remote_http` +- Transport: `mcp_remote` - Endpoint: `https://mcp.linear.app/mcp` - Auth mode: OAuth - OAuth scopes: `read` and `write` initially, with writes governed by profiles and ask-first policies. @@ -298,7 +298,7 @@ This dry run applies the template to Linear, one of the [PAP-2432](/PAP/issues/P "tagline": "Create, update and read tickets.", "authKind": "oauth", "transportTemplate": { - "transport": "remote_http", + "transport": "mcp_remote", "url": "https://mcp.linear.app/mcp" }, "credentialFields": [], diff --git a/doc/connections/GLOSSARY.md b/doc/connections/GLOSSARY.md index 2e45a021b1..53c260de62 100644 --- a/doc/connections/GLOSSARY.md +++ b/doc/connections/GLOSSARY.md @@ -12,7 +12,14 @@ v1, plugin, skill, MCP, and gateway language onto Apps v2. | Term | Definition | It is NOT | | --- | --- | --- | | App | Catalog entry for an external or first-party system: metadata, supported transports, auth modes, and action catalog. The unit of the store. | A running thing; a plugin. | +| AppDefinition | Versioned, data-only authoring record for an App: identity, copy, supported methods, ownership options, fields, and setup guidance. | A company connection or executable plugin. | | Connection | A configured, credentialed instance of an app for this company, possibly per-user account. Carries status and health. | A plugin install; an MCP server config file. | +| uid | Stable company-scoped connection address in `{namespace}/{slug}` form. | A database UUID or display name. | +| Ownership | Who supplies and controls the OAuth client: platform-shared, platform-provisioned, customer, or DCR. | Connection kind or credential ownership. | +| Subject | The app/workspace or Paperclip user on whose behalf a credential is requested. | The calling agent. | +| Grant | Credential-bearing authorization for one connection subject and provider tenant. | A profile, rule, or permission bypass. | +| Trigger | Provider-origin event definition that starts governed Paperclip work. | An unauthenticated webhook handler. | +| Connector service | Paperclip-operated relay for managed OAuth callbacks, credential custody, and webhook intake at `connect.paperclip.ing`. | Paperclip ID or the per-company broker. | | Action / Tool | One invokable capability of a connection, risk-classified and quarantined when new or changed. | A free-form shell command or permission grant. | | Profile | Curated allowlist of actions bound to a scope such as company, project, agent, routine, or issue. | A permission system of its own. | | Rule | Allow, ask-first, or block per action. Ask-first lands in the Review queue. | A profile or catalog entry. | @@ -51,7 +58,9 @@ Keep protocol and implementation terms behind Developer or Advanced surfaces: | Vocabulary term | Apps v2 object or surface | | --- | --- | | App | `tool_applications`, provider gallery cards, app detail metadata. | +| AppDefinition | Catalog registry source used to author and seed Apps and setup methods. | | Connection | `tool_connections`, connection detail status/health, setup/configure flows. | +| Grant | `connection_grants`, provider tenant and subject-specific credential refs. | | Action / Tool | Catalog entries discovered from MCP/OpenAPI/vendor wrappers. | | Profile | Access profiles and bindings. | | Rule | Policy rules such as allow, ask-first, block, rate limit, and trust rules. | diff --git a/doc/connections/SECURITY-THREAT-MODEL.md b/doc/connections/SECURITY-THREAT-MODEL.md index 1e2977dbb5..c8cce179fc 100644 --- a/doc/connections/SECURITY-THREAT-MODEL.md +++ b/doc/connections/SECURITY-THREAT-MODEL.md @@ -12,6 +12,14 @@ Apps v2 object model accepted in [PAP-13211](/PAP/issues/PAP-13211). The securit decisions survived the Connections v1 retirement; the v1 implementation details did not. +Connections v3 adds forward security surfaces that later phases must threat-model +in detail: subject-bound token requests, workspace/user grants, provider triggers, +and the managed connector-service callback/webhook relay. Until those phases +land, treat all four as untrusted boundaries: bind subjects and grants to the +company, fail closed on revocation, authenticate and deduplicate triggers, and +never trust relay-supplied tenant or connection identifiers without signed +context and server-side ownership checks. + ## Required Security Decisions 1. **Credentials live only in `company_secrets`.** Connections store secret refs @@ -47,6 +55,8 @@ did not. signing secrets, and remote MCP auth material. - Connection metadata: provider, workspace/account ids, resource filters, health state, transport config, and status. +- Subject/grant metadata, provider tenant identifiers, trigger registrations, + and connector-service relay routing state. - Governance state: catalog entries, risk classes, quarantine state, profiles, bindings, policies, action requests, and trust rules. - Paperclip objects mutated by integrations: issues, comments, documents, @@ -78,7 +88,10 @@ Trust boundaries: trust perimeter. 5. Webhook boundary: inbound requests are attacker-controlled until signature validation and dedupe pass. -6. External-content boundary: provider data is untrusted even when fetched +6. Connector-service relay boundary: callbacks and trigger deliveries remain + untrusted until signed context resolves the company, connection, grant, and + subject server-side. +7. External-content boundary: provider data is untrusted even when fetched through an authenticated connection. ## Flow Requirements diff --git a/packages/db/src/connections-v3-schema-core-migration.test.ts b/packages/db/src/connections-v3-schema-core-migration.test.ts new file mode 100644 index 0000000000..d08b7e16ec --- /dev/null +++ b/packages/db/src/connections-v3-schema-core-migration.test.ts @@ -0,0 +1,90 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import postgres from "postgres"; +import { applyPendingMigrations } from "./client.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js"; + +const MIGRATION_FILE = "0182_connections_v3_schema_core.sql"; +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function migrationHash() { + const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8"); + return createHash("sha256").update(content).digest("hex"); +} + +describeEmbeddedPostgres("connections v3 schema core migration", () => { + afterEach(async () => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + + it("backfills a workspace grant and rolls back without losing the connection", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-connections-v3-migration-"); + cleanups.push(database.cleanup); + const sql = postgres(database.connectionString, { max: 1 }); + cleanups.push(async () => sql.end()); + + await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`; + await sql`DROP TABLE IF EXISTS "connection_grants"`; + await sql`DROP INDEX IF EXISTS "tool_connections_company_uid_uq"`; + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_company_id_uq"`; + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_ownership_check"`; + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_transport_check"`; + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_auth_kind_check"`; + await sql`ALTER TABLE "tool_connections" DROP COLUMN IF EXISTS "uid"`; + await sql`ALTER TABLE "tool_connections" DROP COLUMN IF EXISTS "ownership"`; + await sql`ALTER TABLE "tool_connections" DROP COLUMN IF EXISTS "auth_kind"`; + + const companyId = randomUUID(); + const applicationId = randomUUID(); + const connectionId = randomUUID(); + const secretId = randomUUID(); + await sql`INSERT INTO "companies" ("id", "name", "issue_prefix") VALUES (${companyId}, 'Paperclip', 'PAP')`; + await sql`INSERT INTO "tool_applications" ("id", "company_id", "application_key", "name", "type") VALUES (${applicationId}, ${companyId}, 'linear', 'Linear', 'mcp_http')`; + await sql` + INSERT INTO "tool_connections" ("id", "company_id", "application_id", "name", "connection_kind", "transport", "config", "credential_secret_refs") + VALUES (${connectionId}, ${companyId}, ${applicationId}, 'Production', 'managed', 'remote_http', '{"oauth":{"provider":"linear"}}'::jsonb, ${sql.json([{ secretId, configPath: "oauth.refresh_token" }])}) + `; + + await applyPendingMigrations(database.connectionString); + + const [connection] = await sql<{ uid: string; ownership: string; transport: string; auth_kind: string }[]>` + SELECT "uid", "ownership", "transport", "auth_kind" FROM "tool_connections" WHERE "id" = ${connectionId} + `; + expect(connection).toMatchObject({ ownership: "customer", transport: "mcp_remote", auth_kind: "oauth" }); + expect(connection?.uid).toMatch(/^linear\/production-[0-9a-f]{8}$/); + + const [grant] = await sql<{ kind: string; is_default: boolean; credential_secret_refs: unknown[] }[]>` + SELECT "kind", "is_default", "credential_secret_refs" FROM "connection_grants" WHERE "connection_id" = ${connectionId} + `; + expect(grant).toMatchObject({ kind: "workspace", is_default: true }); + expect(grant?.credential_secret_refs).toEqual([{ secretId, configPath: "oauth.refresh_token" }]); + + const otherCompanyId = randomUUID(); + await sql`INSERT INTO "companies" ("id", "name", "issue_prefix") VALUES (${otherCompanyId}, 'Other', 'OTH')`; + await expect(sql` + INSERT INTO "connection_grants" ("company_id", "connection_id", "kind") + VALUES (${otherCompanyId}, ${connectionId}, 'workspace') + `).rejects.toMatchObject({ code: "23503" }); + await expect(sql` + INSERT INTO "connection_grants" ("company_id", "connection_id", "kind", "subject_user_id", "is_default") + VALUES (${companyId}, ${connectionId}, 'user', 'user-1', true) + `).rejects.toMatchObject({ code: "23514" }); + + await sql`DROP TABLE "connection_grants"`; + await sql`DROP INDEX "tool_connections_company_uid_uq"`; + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_company_id_uq"`; + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_ownership_check"`; + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_transport_check"`; + await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_auth_kind_check"`; + await sql`UPDATE "tool_connections" SET "transport" = 'remote_http' WHERE "transport" = 'mcp_remote'`; + await sql`ALTER TABLE "tool_connections" DROP COLUMN "uid"`; + await sql`ALTER TABLE "tool_connections" DROP COLUMN "ownership"`; + await sql`ALTER TABLE "tool_connections" DROP COLUMN "auth_kind"`; + + const [rolledBack] = await sql<{ id: string; transport: string }[]>` + SELECT "id", "transport" FROM "tool_connections" WHERE "id" = ${connectionId} + `; + expect(rolledBack).toEqual({ id: connectionId, transport: "remote_http" }); + }, 30_000); +}); diff --git a/packages/db/src/migrations/0182_connections_v3_schema_core.sql b/packages/db/src/migrations/0182_connections_v3_schema_core.sql new file mode 100644 index 0000000000..de67fee62b --- /dev/null +++ b/packages/db/src/migrations/0182_connections_v3_schema_core.sql @@ -0,0 +1,74 @@ +ALTER TABLE "tool_connections" ADD COLUMN "uid" text; +ALTER TABLE "tool_connections" ADD COLUMN "ownership" text DEFAULT 'customer' NOT NULL; +ALTER TABLE "tool_connections" ADD COLUMN "auth_kind" text DEFAULT 'none' NOT NULL; + +UPDATE "tool_connections" AS c +SET "uid" = concat( + coalesce(nullif(regexp_replace(lower(a."application_key"), '[^a-z0-9]+', '-', 'g'), ''), 'app'), + '/', + coalesce(nullif(trim(both '-' from regexp_replace(lower(c."name"), '[^a-z0-9]+', '-', 'g')), ''), 'connection'), + '-', + left(c."id"::text, 8) +) +FROM "tool_applications" AS a +WHERE a."id" = c."application_id"; + +UPDATE "tool_connections" +SET "transport" = 'mcp_remote' +WHERE "transport" = 'remote_http'; + +UPDATE "tool_connections" +SET "auth_kind" = CASE + WHEN jsonb_typeof("config" -> 'oauth') = 'object' THEN 'oauth' + WHEN jsonb_array_length(coalesce("credential_secret_refs", '[]'::jsonb)) > 0 + OR jsonb_array_length(coalesce("credential_refs", '[]'::jsonb)) > 0 THEN 'api_key' + ELSE 'none' +END; + +ALTER TABLE "tool_connections" ALTER COLUMN "uid" SET NOT NULL; +CREATE UNIQUE INDEX "tool_connections_company_uid_uq" ON "tool_connections" USING btree ("company_id", "uid"); +ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_company_id_uq" UNIQUE ("company_id", "id"); +ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_ownership_check" CHECK ("ownership" in ('platform_shared', 'platform_provisioned', 'customer', 'dcr')); +ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_transport_check" CHECK ("transport" in ('mcp_remote', 'rest_api', 'local_stdio')); +ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_auth_kind_check" CHECK ("auth_kind" in ('oauth', 'api_key', 'none')); + +CREATE TABLE "connection_grants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "kind" text NOT NULL, + "subject_user_id" text, + "provider_tenant" jsonb, + "credential_secret_refs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "is_default" boolean DEFAULT false NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "revoked_at" timestamp with time zone, + "revoked_by_agent_id" uuid, + "revoked_by_user_id" text, + "last_used_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "connection_grants_kind_check" CHECK ("kind" in ('workspace', 'user')), + CONSTRAINT "connection_grants_status_check" CHECK ("status" in ('active', 'revoked', 'expired', 'needs_reauthorization')), + CONSTRAINT "connection_grants_subject_check" CHECK (("kind" = 'user' and "subject_user_id" is not null) or ("kind" = 'workspace' and "subject_user_id" is null)), + CONSTRAINT "connection_grants_default_check" CHECK ("is_default" = false or "kind" = 'workspace') +); +ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_company_connection_fk" FOREIGN KEY ("company_id", "connection_id") REFERENCES "public"."tool_connections"("company_id", "id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_revoked_by_agent_id_agents_id_fk" FOREIGN KEY ("revoked_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +CREATE INDEX "connection_grants_company_connection_idx" ON "connection_grants" USING btree ("company_id", "connection_id"); +CREATE INDEX "connection_grants_subject_user_idx" ON "connection_grants" USING btree ("company_id", "subject_user_id"); +CREATE UNIQUE INDEX "connection_grants_user_uq" ON "connection_grants" USING btree ("connection_id", "subject_user_id"); +CREATE UNIQUE INDEX "connection_grants_default_uq" ON "connection_grants" USING btree ("connection_id") WHERE "is_default" = true AND "kind" = 'workspace'; + +INSERT INTO "connection_grants" ( + "company_id", "connection_id", "kind", "credential_secret_refs", "status", "is_default", + "created_by_agent_id", "created_by_user_id", "created_at", "updated_at" +) +SELECT + "company_id", "id", 'workspace', "credential_secret_refs", 'active', true, + "created_by_agent_id", "created_by_user_id", "created_at", "updated_at" +FROM "tool_connections"; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 428657b853..0a06c52f30 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1228,7 +1228,7 @@ "breakpoints": true }, { - "idx": 177, + "idx": 178, "version": "7", "when": 1784241826832, "tag": "0177_activity_log_responsible_user", @@ -1261,6 +1261,13 @@ "when": 1784231633059, "tag": "0181_decision_training_retention_policy", "breakpoints": true + }, + { + "idx": 182, + "version": "7", + "when": 1784592000000, + "tag": "0182_connections_v3_schema_core", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index a5dec1a996..a5fe607663 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -109,6 +109,7 @@ export { secretAccessEvents } from "./secret_access_events.js"; export { toolApplications, toolConnections, + connectionGrants, toolConnectionInstalls, toolOauthStates, toolCatalogEntries, diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts index 91d2355836..5c3e4213fa 100644 --- a/packages/db/src/schema/tool_access.ts +++ b/packages/db/src/schema/tool_access.ts @@ -4,12 +4,14 @@ import { import { boolean, check, + foreignKey, index, integer, jsonb, pgTable, text, timestamp, + unique, uniqueIndex, uuid, } from "drizzle-orm/pg-core"; @@ -25,10 +27,14 @@ import type { ToolCatalogEntryKind, ToolCatalogEntryStatus, ToolConnectionHealthStatus, + ToolConnectionAuthKind, ToolConnectionKind, + ToolConnectionOwnership, ToolConnectionInstallTargetType, ToolConnectionStatus, ToolConnectionTransport, + ConnectionGrantKind, + ConnectionGrantStatus, ToolCredentialSecretRef, ToolInvocationApprovalState, ToolInvocationStatus, @@ -106,8 +112,11 @@ export const toolConnections = pgTable( // and the connections are gone by the time this constraint is checked. applicationId: uuid("application_id").notNull().references(() => toolApplications.id, { onDelete: "no action" }), name: text("name").notNull(), + uid: text("uid").notNull(), connectionKind: text("connection_kind").$type().notNull().default("managed"), + ownership: text("ownership").$type().notNull().default("customer"), transport: text("transport").$type().notNull(), + authKind: text("auth_kind").$type().notNull().default("none"), status: text("status").$type().notNull().default("draft"), enabled: boolean("enabled").notNull().default(false), config: jsonb("config").$type>().notNull().default({}), @@ -126,10 +135,53 @@ export const toolConnections = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ + check("tool_connections_ownership_check", sql`${table.ownership} in ('platform_shared', 'platform_provisioned', 'customer', 'dcr')`), + check("tool_connections_transport_check", sql`${table.transport} in ('mcp_remote', 'rest_api', 'local_stdio')`), + check("tool_connections_auth_kind_check", sql`${table.authKind} in ('oauth', 'api_key', 'none')`), index("tool_connections_company_idx").on(table.companyId), index("tool_connections_application_idx").on(table.applicationId), index("tool_connections_company_enabled_idx").on(table.companyId, table.enabled), uniqueIndex("tool_connections_company_name_uq").on(table.companyId, table.name), + uniqueIndex("tool_connections_company_uid_uq").on(table.companyId, table.uid), + unique("tool_connections_company_id_uq").on(table.companyId, table.id), + ], +); + +export const connectionGrants = pgTable( + "connection_grants", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id").notNull(), + kind: text("kind").$type().notNull(), + subjectUserId: text("subject_user_id"), + providerTenant: jsonb("provider_tenant").$type<{ name?: string; externalId?: string }>(), + credentialSecretRefs: jsonb("credential_secret_refs").$type().notNull().default([]), + status: text("status").$type().notNull().default("active"), + isDefault: boolean("is_default").notNull().default(false), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + revokedByAgentId: uuid("revoked_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + revokedByUserId: text("revoked_by_user_id"), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + check("connection_grants_kind_check", sql`${table.kind} in ('workspace', 'user')`), + check("connection_grants_status_check", sql`${table.status} in ('active', 'revoked', 'expired', 'needs_reauthorization')`), + check("connection_grants_subject_check", sql`(${table.kind} = 'user' and ${table.subjectUserId} is not null) or (${table.kind} = 'workspace' and ${table.subjectUserId} is null)`), + check("connection_grants_default_check", sql`${table.isDefault} = false or ${table.kind} = 'workspace'`), + foreignKey({ + columns: [table.companyId, table.connectionId], + foreignColumns: [toolConnections.companyId, toolConnections.id], + name: "connection_grants_company_connection_fk", + }).onDelete("cascade"), + index("connection_grants_company_connection_idx").on(table.companyId, table.connectionId), + index("connection_grants_subject_user_idx").on(table.companyId, table.subjectUserId), + uniqueIndex("connection_grants_user_uq").on(table.connectionId, table.subjectUserId), + uniqueIndex("connection_grants_default_uq").on(table.connectionId).where(sql`${table.isDefault} = true and ${table.kind} = 'workspace'`), ], ); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 779fa9565a..5ca9d606b6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1108,6 +1108,8 @@ export type { ToolCallEvent, ToolCatalogEntryKind, ToolConnectionHealthStatus, + ToolConnectionAuthKind, + ToolConnectionOwnership, ToolConnectionTransport, ToolConnectionStatus, ToolConnectionKind, @@ -1131,6 +1133,9 @@ export type { ToolConnectionInstall, ToolConnectionInstallSnapshot, ToolConnectionInstallTargetType, + ConnectionGrant, + ConnectionGrantKind, + ConnectionGrantStatus, ConnectionTokenScope, ConnectionTokenRequest, ConnectionTokenAttribution, diff --git a/packages/shared/src/tool-app-gallery.ts b/packages/shared/src/tool-app-gallery.ts index 6caf86fa4c..bb54fcb360 100644 --- a/packages/shared/src/tool-app-gallery.ts +++ b/packages/shared/src/tool-app-gallery.ts @@ -14,7 +14,7 @@ export interface AppGalleryCredentialField { export type AppGalleryTransportTemplate = | { - transport: Extract; + transport: Extract; url: string; } | { @@ -58,7 +58,7 @@ export const TOOL_APP_GALLERY = [ description: "Let agents use Zapier automations across the apps your business already runs. Good for handoffs, lightweight operations, and cross-app updates that should stay visible in Paperclip.", authKind: "api_key", transportTemplate: { - transport: "remote_http", + transport: "mcp_remote", url: "https://mcp.zapier.com/api/mcp", }, credentialFields: [ @@ -86,7 +86,7 @@ export const TOOL_APP_GALLERY = [ description: "Give agents a governed way to inspect repositories, issues, and pull requests. Useful when engineering work needs GitHub context or small updates without leaving Paperclip.", authKind: "api_key", transportTemplate: { - transport: "remote_http", + transport: "mcp_remote", url: "https://api.githubcopilot.com/mcp/", }, credentialFields: [ @@ -114,7 +114,7 @@ export const TOOL_APP_GALLERY = [ description: "Let agents search workspace conversations and coordinate in Slack when work needs team context. Message-sending actions can still ask a human first.", authKind: "oauth", transportTemplate: { - transport: "remote_http", + transport: "mcp_remote", url: "https://mcp.slack.com/mcp", }, credentialFields: [], @@ -138,7 +138,7 @@ export const TOOL_APP_GALLERY = [ description: "Connect Notion so agents can find docs, read project notes, and update workspace pages. Use it for company memory that lives outside Paperclip.", authKind: "oauth", transportTemplate: { - transport: "remote_http", + transport: "mcp_remote", url: "https://mcp.notion.com/mcp", }, credentialFields: [], @@ -162,7 +162,7 @@ export const TOOL_APP_GALLERY = [ description: "Let agents look up Linear work and make issue updates when their Paperclip tasks depend on your existing product queue.", authKind: "oauth", transportTemplate: { - transport: "remote_http", + transport: "mcp_remote", url: "https://mcp.linear.app/mcp", }, credentialFields: [], @@ -204,7 +204,7 @@ export const TOOL_APP_GALLERY = [ description: "Let agents pull current library documentation while they work. It is a low-risk reference app for coding and research tasks.", authKind: "none", transportTemplate: { - transport: "remote_http", + transport: "mcp_remote", url: "https://mcp.context7.com/mcp", }, credentialFields: [], diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 17e9526756..d891d65779 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -408,9 +408,14 @@ export type { ConnectionTokenUseEnvLeaseResponse, ToolConnection, ToolConnectionHealthStatus, + ToolConnectionAuthKind, + ToolConnectionOwnership, ToolConnectionTransport, ToolConnectionStatus, ToolConnectionKind, + ConnectionGrant, + ConnectionGrantKind, + ConnectionGrantStatus, ToolCredentialSecretRef, ToolInvocation, ToolInvocationApprovalState, diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index 72f33259cd..8dd42f5a9c 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -67,9 +67,13 @@ export type { }; export type ToolActorType = "agent" | "user" | "system" | "plugin"; -export type ToolConnectionTransport = "remote_http" | "local_stdio"; +export type ToolConnectionTransport = "mcp_remote" | "rest_api" | "local_stdio"; +export type ToolConnectionAuthKind = "oauth" | "api_key" | "none"; +export type ToolConnectionOwnership = "platform_shared" | "platform_provisioned" | "customer" | "dcr"; export type ToolConnectionStatus = "draft" | "active" | "disabled" | "archived"; export type ToolConnectionInstallTargetType = "company" | "agent"; +export type ConnectionGrantKind = "workspace" | "user"; +export type ConnectionGrantStatus = "active" | "revoked" | "expired" | "needs_reauthorization"; export type ToolCredentialPlacement = "header" | "env"; export interface McpConnectionCredentialRef { @@ -89,6 +93,8 @@ export interface ToolCredentialSecretRef { label?: string | null; projectionClass?: SecretProjectionClass; projectionAllowlistKey?: string | null; + keyScope?: string; + expiresAt?: string; } export interface ToolRedactedValueSummary { @@ -121,8 +127,11 @@ export interface ToolConnection { companyId: string; applicationId: string; name: string; + uid: string; connectionKind: ToolConnectionKind; - transport?: ToolConnectionTransport; + ownership: ToolConnectionOwnership; + transport: ToolConnectionTransport; + authKind: ToolConnectionAuthKind; status?: ToolConnectionStatus; transportConfig: Record; config?: Record; @@ -142,6 +151,27 @@ export interface ToolConnection { createdAt: Date; updatedAt: Date; installs?: ToolConnectionInstall[]; + grants?: ConnectionGrant[]; +} + +export interface ConnectionGrant { + id: string; + companyId: string; + connectionId: string; + kind: ConnectionGrantKind; + subjectUserId: string | null; + providerTenant: { name?: string; externalId?: string } | null; + credentialSecretRefs: ToolCredentialSecretRef[]; + status: ConnectionGrantStatus; + isDefault: boolean; + createdByAgentId: string | null; + createdByUserId: string | null; + revokedAt: Date | null; + revokedByAgentId: string | null; + revokedByUserId: string | null; + lastUsedAt: Date | null; + createdAt: Date; + updatedAt: Date; } export interface ToolConnectionInstall { diff --git a/packages/shared/src/validators/tool-access.test.ts b/packages/shared/src/validators/tool-access.test.ts index dc8f2f0056..ad8763f0af 100644 --- a/packages/shared/src/validators/tool-access.test.ts +++ b/packages/shared/src/validators/tool-access.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from "vitest"; import { createToolConnectionSchema, + toolCredentialSecretRefSchema, toolRedactedValueSummarySchema, toolTransportConfigSchema, } from "./tool-access.js"; describe("tool access validators", () => { + it("accepts multi-key credential annotations", () => { + const parsed = toolCredentialSecretRefSchema.parse({ + secretId: "11111111-1111-4111-8111-111111111111", + configPath: "credentials.apiKey", + keyScope: "production", + expiresAt: "2027-01-01T00:00:00Z", + }); + expect(parsed.keyScope).toBe("production"); + }); it("rejects raw credential-looking fields in transport config", () => { const parsed = toolTransportConfigSchema.safeParse({ url: "https://example.test/mcp", diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index 7f5b0e811a..83e3b86e69 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -34,7 +34,11 @@ import { jsonSchemaSchema } from "./plugin.js"; export const toolApplicationTypeSchema = z.enum(TOOL_APPLICATION_TYPES); export const toolApplicationStatusSchema = z.enum(TOOL_APPLICATION_STATUSES); -export const toolConnectionTransportSchema = z.enum(["remote_http", "local_stdio"]); +export const toolConnectionTransportSchema = z.enum(["mcp_remote", "rest_api", "local_stdio"]); +export const toolConnectionAuthKindSchema = z.enum(["oauth", "api_key", "none"]); +export const toolConnectionOwnershipSchema = z.enum(["platform_shared", "platform_provisioned", "customer", "dcr"]); +export const connectionGrantKindSchema = z.enum(["workspace", "user"]); +export const connectionGrantStatusSchema = z.enum(["active", "revoked", "expired", "needs_reauthorization"]); export const toolConnectionStatusSchema = z.enum(["draft", "active", "disabled", "archived"]); export const toolConnectionInstallTargetTypeSchema = z.enum(["company", "agent"]); export const toolCredentialPlacementSchema = z.enum(["header", "env"]); @@ -94,6 +98,8 @@ export const toolCredentialSecretRefSchema = z.object({ label: z.string().trim().max(120).optional().nullable(), projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(), projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(), + keyScope: z.string().trim().min(1).max(160).optional(), + expiresAt: z.string().datetime({ offset: true }).optional(), }); export const mcpConnectionCredentialRefSchema = z.object({ @@ -141,6 +147,8 @@ export const createToolConnectionSchema = z.object({ applicationName: z.string().trim().min(1).max(160).optional(), name: z.string().trim().min(1).max(160), transport: toolConnectionTransportSchema.optional(), + authKind: toolConnectionAuthKindSchema.default("none"), + ownership: toolConnectionOwnershipSchema.default("customer"), status: toolConnectionStatusSchema.optional(), connectionKind: toolConnectionKindSchema.default("managed"), config: toolTransportConfigSchema.optional(), @@ -159,6 +167,33 @@ export const updateToolConnectionSchema = createToolConnectionSchema.omit({ appl export type UpdateToolConnection = z.infer; +export const connectionGrantSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + connectionId: z.string().uuid(), + kind: connectionGrantKindSchema, + subjectUserId: z.string().nullable(), + providerTenant: z.object({ + name: z.string().trim().min(1).max(200).optional(), + externalId: z.string().trim().min(1).max(400).optional(), + }).nullable(), + credentialSecretRefs: z.array(toolCredentialSecretRefSchema), + status: connectionGrantStatusSchema, + isDefault: z.boolean(), + createdByAgentId: z.string().uuid().nullable(), + createdByUserId: z.string().nullable(), + revokedAt: z.coerce.date().nullable(), + revokedByAgentId: z.string().uuid().nullable(), + revokedByUserId: z.string().nullable(), + lastUsedAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}).superRefine((grant, ctx) => { + if ((grant.kind === "user") !== Boolean(grant.subjectUserId)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectUserId"], message: "User grants require a subject user; workspace grants must not have one" }); + } +}); + export const putToolConnectionInstallsSchema = z.object({ installs: z.array(z.object({ targetType: toolConnectionInstallTargetTypeSchema, diff --git a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts index ad122095b6..fe16ee5b62 100644 --- a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts +++ b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts @@ -83,7 +83,8 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => { companyId: company!.id, applicationId: application!.id, name: "Installed MCP", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://installed.example.test/mcp" }, @@ -92,7 +93,8 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => { companyId: company!.id, applicationId: application!.id, name: "Uninstalled MCP", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://uninstalled.example.test/mcp" }, @@ -176,7 +178,8 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => { companyId: company!.id, applicationId: application!.id, name: "Zapier", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://zapier.example.test/mcp" }, diff --git a/server/src/__tests__/heartbeat-runtime-skills.test.ts b/server/src/__tests__/heartbeat-runtime-skills.test.ts index 4df0a59944..daa03af27a 100644 --- a/server/src/__tests__/heartbeat-runtime-skills.test.ts +++ b/server/src/__tests__/heartbeat-runtime-skills.test.ts @@ -311,7 +311,8 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => { companyId, applicationId: application!.id, name: "Installed Runtime MCP", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://installed.example.test/mcp" }, @@ -320,7 +321,8 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => { companyId, applicationId: application!.id, name: "Uninstalled Runtime MCP", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://uninstalled.example.test/mcp" }, diff --git a/server/src/__tests__/tool-access-policy-service.test.ts b/server/src/__tests__/tool-access-policy-service.test.ts index d3e4858c81..1f6e683bec 100644 --- a/server/src/__tests__/tool-access-policy-service.test.ts +++ b/server/src/__tests__/tool-access-policy-service.test.ts @@ -93,7 +93,8 @@ async function createTool(db: ReturnType, companyId: string) { companyId, applicationId: application.id, name: `Connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://example.invalid/mcp" }, @@ -1658,7 +1659,7 @@ describeEmbeddedPostgres("tool access policy service", () => { await expect(svc.createConnection(company.id, { name: "Wrong secret", - transport: "remote_http", + transport: "mcp_remote", transportConfig: { url: "https://example.invalid/mcp" }, credentialSecretRefs: [{ secretId: otherSecret.id, diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 1ceb199350..10fbd18f8b 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -271,7 +271,8 @@ async function createBrokerConnection( companyId, applicationId: application!.id, name: `Pages connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, healthStatus: input.healthStatus ?? "ok", @@ -329,7 +330,8 @@ async function createOAuthConnection( companyId, applicationId: application!.id, name: `OAuth connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, healthStatus: "ok", @@ -378,7 +380,8 @@ async function createRemoteToolFixture( companyId, applicationId: application!.id, name: `Fixture Connection ${randomUUID()}`, - transport: "remote_http", + uid: `fixture/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example.test/mcp" }, @@ -746,7 +749,7 @@ describeEmbeddedPostgres("tool access service", () => { const connection = await service.createConnection(company.id, { name: "Remote fixture", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp", quarantineNewEntries: true }, enabled: true, status: "active", @@ -835,7 +838,7 @@ describeEmbeddedPostgres("tool access service", () => { const connection = await service.createConnection(company.id, { name: "Streamable HTTP fixture", - transport: "remote_http", + transport: "mcp_remote", config: { url: "http://127.0.0.1:8848/mcp" }, enabled: true, status: "active", @@ -1018,7 +1021,7 @@ describeEmbeddedPostgres("tool access service", () => { await expect(service.createConnection(company.id, { name: "Metadata endpoint", - transport: "remote_http", + transport: "mcp_remote", config: { url: "http://169.254.169.254/latest/meta-data" }, enabled: true, status: "active", @@ -1049,7 +1052,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: `Profile Connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, @@ -1676,7 +1680,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application!.id, name: "Summary connection", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, @@ -1773,7 +1778,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application!.id, name: "Preview connection", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, @@ -1836,7 +1842,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application!.id, name: "Allow connection", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, @@ -2335,7 +2342,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(res.body.drafts).toEqual([ expect.objectContaining({ name: "secure", - transport: "remote_http", + transport: "mcp_remote", status: "draft", config: { url: "https://secure.example/mcp" }, credentialFields: [ @@ -2404,7 +2411,7 @@ describeEmbeddedPostgres("tool access service", () => { const connection = await service.createConnection(company.id, { applicationName: "Discord", name: "Discord bot token", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://discord.example.test/mcp" }, enabled: false, status: "draft", @@ -2461,7 +2468,7 @@ describeEmbeddedPostgres("tool access service", () => { await expect(service.createConnection(company.id, { applicationId: application!.id, name: "Blocked class-3 token", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://blocked.example.test/mcp" }, enabled: false, status: "draft", @@ -2639,7 +2646,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "Sheets", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://sheets.example/mcp" }, @@ -3082,7 +3090,7 @@ describeEmbeddedPostgres("tool access service", () => { const service = toolAccessService(db); const connection = await service.createConnection(company.id, { name: "Machine OAuth", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://m2m.example.test/mcp", oauth: { @@ -3238,7 +3246,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: `Attention connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, @@ -3250,7 +3259,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: `Healthy connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://healthy.example/mcp" }, @@ -3333,7 +3343,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: `Action review connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, @@ -3428,7 +3439,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: `Review connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://review.example/mcp" }, @@ -3588,7 +3600,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "Auto connection", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://auto.example/mcp" }, @@ -3648,7 +3661,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "Attention review connection", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://attention-review.example/mcp" }, @@ -4068,7 +4082,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application!.id, name: "Smoke OAuth masquerade connection", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: false, healthStatus: "unchecked", @@ -4138,7 +4153,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application!.id, name: "Smoke Lab HTTP MCP fixture", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, healthStatus: "ok", @@ -4771,7 +4787,7 @@ describeEmbeddedPostgres("tool access service", () => { const connection = await service.createConnection(company.id, { applicationId: application.id, name: "Viewer guarded connection", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://viewer-guard.example/mcp" }, status: "active", enabled: true, @@ -4784,7 +4800,7 @@ describeEmbeddedPostgres("tool access service", () => { .send({ name: "Viewer create app", type: "mcp_http" }), await request(viewerApp) .post(`/api/companies/${company.id}/tools/connections`) - .send({ name: "Viewer create connection", transport: "remote_http", config: { url: "https://viewer-create.example/mcp" } }), + .send({ name: "Viewer create connection", transport: "mcp_remote", config: { url: "https://viewer-create.example/mcp" } }), await request(viewerApp) .patch(`/api/tool-applications/${application.id}`) .send({ name: "Viewer edited app" }), @@ -4930,7 +4946,7 @@ describeEmbeddedPostgres("tool access service", () => { const app = createRouteApp(db); const connection = await service.createConnection(company.id, { name: "Guarded connection", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, }); @@ -4951,7 +4967,7 @@ describeEmbeddedPostgres("tool access service", () => { const app = createRouteApp(db); const connection = await service.createConnection(company.id, { name: "Single connection", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, status: "active", enabled: true, @@ -4996,7 +5012,7 @@ describeEmbeddedPostgres("tool access service", () => { const first = await service.createConnection(company.id, { applicationId: application.id, name: "First connection", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://one.example/mcp" }, status: "active", enabled: true, @@ -5004,7 +5020,7 @@ describeEmbeddedPostgres("tool access service", () => { await service.createConnection(company.id, { applicationId: application.id, name: "Second connection", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://two.example/mcp" }, status: "active", enabled: true, @@ -5025,12 +5041,42 @@ describeEmbeddedPostgres("tool access service", () => { expect(activities.some((activity) => activity.action === "tool_application.archived")).toBe(false); }); + it("keeps normalized connection UIDs unique", async () => { + const company = await createCompany(db); + const service = toolAccessService(db); + const firstApplication = await service.createApplication(company.id, { + name: "First UID app", + type: "mcp_http", + }); + const secondApplication = await service.createApplication(company.id, { + name: "Second UID app", + type: "mcp_http", + }); + + const first = await service.createConnection(company.id, { + applicationId: firstApplication.id, + name: "Foo Bar", + transport: "mcp_remote", + config: { url: "https://one.example/mcp" }, + }); + const second = await service.createConnection(company.id, { + applicationId: secondApplication.id, + name: "foo-bar", + transport: "mcp_remote", + config: { url: "https://two.example/mcp" }, + }); + + expect(first.uid).not.toBe(second.uid); + expect(first.uid).toMatch(/\/foo-bar-[0-9a-f]{8}$/); + expect(second.uid).toMatch(/\/foo-bar-[0-9a-f]{8}$/); + }); + it("fails closed at the database when a connection races an application delete (no silent cascade)", async () => { const company = await createCompany(db); const service = toolAccessService(db); const connection = await service.createConnection(company.id, { name: "Racy connection", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, }); @@ -5060,7 +5106,7 @@ describeEmbeddedPostgres("tool access service", () => { const service = toolAccessService(db); const connection = await service.createConnection(company.id, { name: "Company-scoped connection", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, }); @@ -5149,7 +5195,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "Remote MCP", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://example.invalid/mcp" }, @@ -5273,7 +5320,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "GitHub", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://github.example/mcp" }, @@ -5414,7 +5462,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "Google Sheets (stdio smoke)", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://sheets.example/mcp" }, @@ -5478,7 +5527,7 @@ describeEmbeddedPostgres("tool access service", () => { action: "tool_connection.archived", entityType: "tool_connection", entityId: connection.id, - details: { transport: "remote_http" }, + details: { transport: "mcp_remote" }, createdAt: new Date("2026-06-12T10:04:00Z"), }, ]); @@ -5536,7 +5585,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "Remote runtime", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, @@ -5549,7 +5599,7 @@ describeEmbeddedPostgres("tool access service", () => { slotKey: `${connection.id}:remote`, ownerScopeType: "connection", ownerScopeId: connection.id, - runtimeKind: "remote_http", + runtimeKind: "mcp_remote", status: "running", reuseKey: connection.id, provider: "paperclip", @@ -5562,7 +5612,7 @@ describeEmbeddedPostgres("tool access service", () => { status: 422, details: expect.objectContaining({ code: "runtime_control_unsupported", - runtimeKind: "remote_http", + runtimeKind: "mcp_remote", }), }); }); @@ -5586,6 +5636,7 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "Degraded local stdio", + uid: `test/${randomUUID()}`, transport: "local_stdio", status: "active", enabled: true, @@ -5715,7 +5766,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "Imported draft", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "draft", enabled: false, config: { url: "https://draft.example/mcp" }, @@ -5727,7 +5779,8 @@ describeEmbeddedPostgres("tool access service", () => { companyId: company.id, applicationId: application.id, name: "OAuth connected, not enabled", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: false, config: { url: "https://not-enabled.example/mcp" }, @@ -5812,7 +5865,7 @@ describeEmbeddedPostgres("tool access service", () => { expect.arrayContaining([ expect.objectContaining({ name: "github", - transport: "remote_http", + transport: "mcp_remote", status: "draft", config: { url: "https://mcp.example/github" }, warnings: [expect.stringContaining("Paperclip secret")], @@ -5833,7 +5886,7 @@ describeEmbeddedPostgres("tool access service", () => { const service = toolAccessService(db); const connection = await service.createConnection(company.id, { name: "Secret-backed remote", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, enabled: true, status: "active", @@ -5872,7 +5925,7 @@ describeEmbeddedPostgres("tool access service", () => { action: "tool_connection.health_check", outcome: "failure", reasonCode: "secret_missing", - details: { status: "missing_secret", transport: "remote_http" }, + details: { status: "missing_secret", transport: "mcp_remote" }, }); expect(JSON.stringify(audit)).not.toContain("Bearer "); expect(JSON.stringify(audit)).not.toContain("Authorization"); @@ -5884,7 +5937,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("revoked token")); const connection = await service.createConnection(company.id, { name: "Swept remote", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, enabled: true, status: "active", @@ -5912,14 +5965,14 @@ describeEmbeddedPostgres("tool access service", () => { const used = await service.createConnection(company.id, { name: "Used remote", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://used.example/mcp" }, enabled: true, status: "active", }); const unused = await service.createConnection(company.id, { name: "Unused remote", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://unused.example/mcp" }, enabled: true, status: "active", diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index fb2e31b799..2ce5d2f126 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -86,7 +86,8 @@ async function createRemoteMcpToolFixture(db: ReturnType, compa companyId, applicationId: application.id, name: "Remote connection", - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, healthStatus: "ok", diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 9597a0736a..7ed7d47ed8 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -204,7 +204,8 @@ async function createRemoteMcpTool( companyId, applicationId: application.id, name: input.connectionName ?? `Remote connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: input.connectionStatus ?? "active", enabled: input.connectionEnabled ?? true, healthStatus: input.healthStatus ?? "ok", @@ -327,6 +328,7 @@ rl.on("line", (line) => { companyId, applicationId: application!.id, name: input.connectionName ?? `Local stdio connection ${randomUUID()}`, + uid: `test/${randomUUID()}`, transport: "local_stdio", status: input.connectionStatus ?? "active", enabled: input.connectionEnabled ?? true, @@ -1162,7 +1164,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { applicationKey: "kv-demo", connectionId: remoteTool.connection.id, catalogEntryId: remoteTool.catalogEntry.id, - transport: "remote_http", + transport: "mcp_remote", upstreamToolName: "kv_set", annotations: { readOnlyHint: false }, risk: expect.objectContaining({ level: "write", isWrite: true }), @@ -1985,7 +1987,7 @@ rl.on("line", (line) => { const access = toolAccessService(db); const connection = await access.createConnection(company.id, { name: "KV demo SDK fixture", - transport: "remote_http", + transport: "mcp_remote", config: { url: `http://127.0.0.1:${port}/mcp` }, enabled: true, status: "active", @@ -2241,7 +2243,7 @@ rl.on("line", (line) => { summary: expect.stringContaining('"value":"original"'), }, execution: { - transport: "remote_http", + transport: "mcp_remote", request: { protocol: "MCP JSON-RPC 2.0", httpMethod: "POST", @@ -2616,13 +2618,13 @@ rl.on("line", (line) => { const remoteFailureCases = [ { name: "HTTP status", - reasonCode: "remote_http_status", + reasonCode: "mcp_remote_status", status: 502, response: () => ({ status: 503, body: { error: "unavailable" } }), }, { name: "invalid JSON", - reasonCode: "remote_http_invalid_json", + reasonCode: "mcp_remote_invalid_json", status: 502, response: () => ({ rawBody: "not json" }), }, @@ -2634,7 +2636,7 @@ rl.on("line", (line) => { }, { name: "response size", - reasonCode: "remote_http_response_too_large", + reasonCode: "mcp_remote_response_too_large", status: 502, response: () => { const rawBody = "x".repeat(1_000_001); @@ -2695,7 +2697,7 @@ rl.on("line", (line) => { summary: expect.stringContaining('"key":"alpha"'), }, execution: { - transport: "remote_http", + transport: "mcp_remote", request: { endpoint: fake.url, mcpMethod: "tools/call", @@ -2703,7 +2705,7 @@ rl.on("line", (line) => { }, }, }); - if (scenario.reasonCode === "remote_http_status") { + if (scenario.reasonCode === "mcp_remote_status") { expect(failureAudit.details).toMatchObject({ execution: { response: { httpStatus: 503 } }, }); @@ -3114,6 +3116,7 @@ rl.on("line", (line) => { companyId: company.id, applicationId: application!.id, name: "Plugin: acme.plugin-mail", + uid: `test/${randomUUID()}`, transport: "local_stdio", status: "active", enabled: true, diff --git a/server/src/__tests__/tool-oauth-legacy-backfill.test.ts b/server/src/__tests__/tool-oauth-legacy-backfill.test.ts index cff14521bd..1384541092 100644 --- a/server/src/__tests__/tool-oauth-legacy-backfill.test.ts +++ b/server/src/__tests__/tool-oauth-legacy-backfill.test.ts @@ -71,7 +71,8 @@ describeEmbeddedPostgres("tool OAuth legacy backfill", () => { companyId: company.id, applicationId: application!.id, name: `Legacy OAuth Connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { @@ -181,7 +182,8 @@ describeEmbeddedPostgres("tool OAuth legacy backfill", () => { companyId: company.id, applicationId: application!.id, name: `Legacy OAuth AWS Connection ${randomUUID()}`, - transport: "remote_http", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", status: "active", enabled: true, config: { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 1705abd635..c5bef010b0 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2162,14 +2162,14 @@ export async function buildPaperclipRuntimeMcpServers(input: { )) : []; const permittedNotInstalledConnections = permittedConnections - .filter((connection) => connection.transport === "remote_http" && !installedConnectionIds.has(connection.id)) + .filter((connection) => connection.transport === "mcp_remote" && !installedConnectionIds.has(connection.id)) .map(({ id, name }) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name)); const uniqueConnections = effective.installedConnections.filter((connection) => permittedConnectionIds.has(connection.id) && connection.status === "active" && connection.enabled - && connection.transport === "remote_http" + && connection.transport === "mcp_remote" ); const service = createToolGatewayService(input.db); if (uniqueConnections.length === 0) { diff --git a/server/src/services/remote-http-endpoint-guard.ts b/server/src/services/remote-http-endpoint-guard.ts index 48f40c76f7..4ed1fdb30e 100644 --- a/server/src/services/remote-http-endpoint-guard.ts +++ b/server/src/services/remote-http-endpoint-guard.ts @@ -20,16 +20,16 @@ export function parseRemoteHttpEndpoint( error: RemoteHttpEndpointErrorFactory, ): URL { if (typeof value !== "string" || value.trim().length === 0) { - throw error("Remote MCP connection requires config.url", "remote_http_url_missing"); + throw error("Remote MCP connection requires config.url", "mcp_remote_url_missing"); } let parsed: URL; try { parsed = new URL(value); } catch { - throw error("Remote MCP connection URL is invalid", "remote_http_url_invalid"); + throw error("Remote MCP connection URL is invalid", "mcp_remote_url_invalid"); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw error("Remote MCP connection URL must use http or https", "remote_http_url_invalid"); + throw error("Remote MCP connection URL must use http or https", "mcp_remote_url_invalid"); } return parsed; } diff --git a/server/src/services/smoke-lab.ts b/server/src/services/smoke-lab.ts index dfbcd364c4..5c308ce261 100644 --- a/server/src/services/smoke-lab.ts +++ b/server/src/services/smoke-lab.ts @@ -717,7 +717,7 @@ export function smokeLabService(db: Db, options: { companyId: string; applicationId: string; name: string; - transport: "local_stdio" | "remote_http"; + transport: "local_stdio" | "mcp_remote"; config: Record; transportConfig?: Record; actor?: SmokeLabActorInfo; @@ -748,6 +748,7 @@ export function smokeLabService(db: Db, options: { const [created] = await db.insert(toolConnections).values({ companyId: input.companyId, name: input.name, + uid: `smoke-lab/${input.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`, ...values, createdByAgentId: input.actor?.actorType === "agent" ? input.actor.agentId : null, createdByUserId: input.actor?.actorType === "user" ? input.actor.actorId : null, @@ -1089,7 +1090,7 @@ export function smokeLabService(db: Db, options: { companyId, applicationId: httpApp.row.id, name: HTTP_CONNECTION_NAME, - transport: "remote_http", + transport: "mcp_remote", config: { smokeLabFixture: "oauth-http", service: "smoke-lab.http-mcp-fixture", diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index d762cf22df..ae5f9a46db 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -964,7 +964,7 @@ export function toolAccessPolicyService(db: Db) { applicationType = application.type; } providerType = providerType - ?? (applicationType === "mcp_http" && connectionTransport === "remote_http" + ?? (applicationType === "mcp_http" && connectionTransport === "mcp_remote" ? "mcp_remote_http" : applicationType === "mcp_stdio" && connectionTransport === "local_stdio" ? "mcp_local_stdio" diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index c3a69bccc2..80589d2c0b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -505,6 +505,10 @@ function normalizeKey(input: string) { .slice(0, 160) || "tool"; } +function connectionUid(namespace: string, name: string, connectionId: string) { + return `${normalizeKey(namespace)}/${normalizeKey(name)}-${connectionId.slice(0, 8)}`; +} + function actorBinding(actor: ActorInfo | undefined) { return { actorType: actor?.actorType ?? null, @@ -589,8 +593,11 @@ function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection companyId: row.companyId, applicationId: row.applicationId, name: row.name, + uid: row.uid, connectionKind: row.connectionKind, + ownership: row.ownership, transport: row.transport, + authKind: row.authKind, status: row.status, enabled: row.enabled, config: row.config ?? {}, @@ -2070,7 +2077,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} activeConnections, disabledConnections, degradedConnections, - remoteHttpConnections: connections.filter((connection) => connection.status !== "archived" && connection.transport === "remote_http").length, + remoteHttpConnections: connections.filter((connection) => connection.status !== "archived" && connection.transport === "mcp_remote").length, localStdioConnections: connections.filter((connection) => connection.status !== "archived" && connection.transport === "local_stdio").length, }; const recommendations = buildRuntimeAlerts({ @@ -2105,13 +2112,13 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} supportMatrix: { remoteHttp: { supported: true, - note: "remote_http MCP connections are supported in hosted cloud and local deployments.", + note: "mcp_remote MCP connections are supported in hosted cloud and local deployments.", }, localStdio: { supported: localStdioSupported, note: localStdioSupported ? "local_stdio is available for local trusted mode or through the configured trusted MCP runtime host." - : `local_stdio should stay disabled for ${deploymentMode}/${deploymentExposure}; use remote_http or configure a trusted runtime worker.`, + : `local_stdio should stay disabled for ${deploymentMode}/${deploymentExposure}; use mcp_remote or configure a trusted runtime worker.`, }, }, alerts: firing, @@ -2779,7 +2786,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } async function discoverTools(connection: typeof toolConnections.$inferSelect): Promise { - if (connection.transport === "remote_http") return remoteTools(connection); + if (connection.transport === "mcp_remote") return remoteTools(connection); await resolveCredentialHeaders(connection); return localTools(connection); } @@ -2814,7 +2821,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} async function checkConnectionHealth(connectionId: string, actor?: ActorInfo): Promise { const connection = await getConnectionRow(connectionId); try { - if (connection.transport === "remote_http") { + if (connection.transport === "mcp_remote") { await remoteTools(connection); } else { await resolveCredentialHeaders(connection); @@ -3304,10 +3311,13 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} await ensureRuntimeSlot(updated); return { row: updated, created: false }; } + const connectionId = randomUUID(); const [created] = await db.insert(toolConnections).values({ + id: connectionId, companyId, applicationId, name: definition.connectionName, + uid: connectionUid("paperclip", definition.connectionName, connectionId), connectionKind: "managed", transport: "local_stdio", status: "active", @@ -4092,11 +4102,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); const transportTemplate = galleryEntry?.transportTemplate ?? { - transport: "remote_http" as const, + transport: "mcp_remote" as const, url: input.link ?? "", }; const transport = transportTemplate.transport; - const baseConfig = transport === "remote_http" + const baseConfig = transport === "mcp_remote" ? { url: transportTemplate.url } : { templateId: transportTemplate.templateKey }; let config: Record = galleryEntry @@ -4119,7 +4129,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} config = normalizeGoogleSheetsConnectionConfig(config); await assertGoogleSheetsSpreadsheetOwnership(companyId, config); } - if (transport === "remote_http") await assertRemoteEndpointAllowed(config); + if (transport === "mcp_remote") await assertRemoteEndpointAllowed(config); if (transport === "local_stdio") await stdioTemplateId(companyId, config); assertLocalStdioCanBeEnabled(transport, false); @@ -4181,7 +4191,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} applicationKey: `app-gallery:${galleryEntry?.key ?? "link"}:${randomUUID()}`, name, description: galleryEntry?.tagline ?? `Connected app at ${input.link}`, - type: transport === "remote_http" ? "mcp_http" : "mcp_stdio", + type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", status: "draft", metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.key, galleryKey: galleryEntry.key } : { source: "link" }, }).returning(); @@ -4217,11 +4227,15 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} updatedAt: new Date(), }).where(eq(toolConnections.id, revivedConnectionPrevious.id)).returning(); } else { + const connectionId = randomUUID(); [connectionRow] = await db.insert(toolConnections).values({ + id: connectionId, companyId, applicationId: applicationRow.id, name, + uid: connectionUid(applicationRow.applicationKey ?? applicationRow.name, name, connectionId), connectionKind: "managed", + authKind: galleryEntry?.authKind ?? "none", transport, status: "draft", enabled: false, @@ -5348,16 +5362,18 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} createConnection: async (companyId: string, input: CreateToolConnection): Promise => { let applicationId = input.applicationId; + let applicationNamespace = input.applicationName ?? input.name; const transport = input.transport; if (!transport) throw badRequest("Tool connection transport is required"); const config = normalizeGoogleSheetsConnectionConfig(input.config ?? input.transportConfig ?? {}); - if (transport === "remote_http") await assertRemoteEndpointAllowed(config); + if (transport === "mcp_remote") await assertRemoteEndpointAllowed(config); if (transport === "local_stdio") await stdioTemplateId(companyId, config); assertLocalStdioCanBeEnabled(transport, input.enabled ?? false); await assertGoogleSheetsSpreadsheetOwnership(companyId, config); if (applicationId) { const app = await assertApplication(companyId, applicationId); - if ((transport === "remote_http" && app.type !== "mcp_http") || (transport === "local_stdio" && app.type !== "mcp_stdio")) { + applicationNamespace = app.applicationKey ?? app.name; + if ((transport === "mcp_remote" && app.type !== "mcp_http") || (transport === "local_stdio" && app.type !== "mcp_stdio")) { throw unprocessable("Connection transport must match application type"); } } else { @@ -5365,19 +5381,24 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} companyId, applicationKey: normalizeKey(input.applicationName ?? input.name), name: input.applicationName ?? input.name, - type: transport === "remote_http" ? "mcp_http" : "mcp_stdio", + type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", status: "active", metadata: {}, }).returning(); applicationId = app.id; } await assertSecretRefs(companyId, [...(input.credentialRefs ?? []), ...(input.credentialSecretRefs ?? [])]); + const connectionId = randomUUID(); const [row] = await db.insert(toolConnections).values({ + id: connectionId, companyId, applicationId, name: input.name, + uid: connectionUid(applicationNamespace, input.name, connectionId), connectionKind: input.connectionKind ?? "managed", + ownership: input.ownership ?? "customer", transport, + authKind: input.authKind ?? "none", status: input.status ?? "draft", enabled: input.enabled ?? false, config, @@ -5475,7 +5496,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} updateConnection: async (connectionId: string, input: UpdateToolConnection): Promise => { const existing = await getConnectionRow(connectionId); const config = normalizeGoogleSheetsConnectionConfig(input.config ?? input.transportConfig ?? existing.config); - if (existing.transport === "remote_http") await assertRemoteEndpointAllowed(config); + if (existing.transport === "mcp_remote") await assertRemoteEndpointAllowed(config); if (existing.transport === "local_stdio") await stdioTemplateId(existing.companyId, config); assertLocalStdioCanBeEnabled(existing.transport, input.enabled ?? existing.enabled); await assertGoogleSheetsSpreadsheetOwnership(existing.companyId, config, { excludeConnectionId: existing.id }); @@ -6543,7 +6564,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} }); return { name, - transport: "remote_http" as const, + transport: "mcp_remote" as const, status: "draft" as const, config: { url: server.url ?? server.endpoint }, credentialRefs: [] as McpConnectionCredentialRef[], @@ -6566,7 +6587,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} warnings.push("Unsupported MCP server entry."); return { name, - transport: "remote_http" as const, + transport: "mcp_remote" as const, status: "draft" as const, config: {}, credentialRefs: [], diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 7ee58056d2..9ec481000a 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -123,7 +123,7 @@ export interface ConnectedMcpGatewayMetadata { applicationDisplayName: string; connectionId: string; catalogEntryId: string; - transport: "remote_http" | "local_stdio"; + transport: "mcp_remote" | "local_stdio"; gatewayToolName: string; upstreamToolName: string; catalogName: string; @@ -234,7 +234,7 @@ type RemoteHttpExecutionResult = { }; type RemoteHttpExecutionAudit = { - transport: "remote_http"; + transport: "mcp_remote"; request: { protocol: "MCP JSON-RPC 2.0"; httpMethod: "POST"; @@ -859,7 +859,7 @@ export function createToolGatewayService( eq(toolCatalogEntries.status, "active"), isNull(toolCatalogEntries.quarantinedAt), eq(toolConnections.companyId, companyId), - inArray(toolConnections.transport, ["remote_http", "local_stdio"]), + inArray(toolConnections.transport, ["mcp_remote", "local_stdio"]), eq(toolConnections.status, "active"), eq(toolConnections.enabled, true), inArray(toolConnections.healthStatus, ["ok", "healthy"]), @@ -870,7 +870,7 @@ export function createToolGatewayService( .orderBy(toolConnections.name, toolCatalogEntries.name); const eligibleRows = rows.filter(({ connection, application }) => - (connection.transport === "remote_http" && application.type === "mcp_http") + (connection.transport === "mcp_remote" && application.type === "mcp_http") || (connection.transport === "local_stdio" && application.type === "mcp_stdio") ); const baseNames = eligibleRows.map(({ catalogEntry, connection, application }) => { @@ -885,6 +885,9 @@ export function createToolGatewayService( }, new Map()); return eligibleRows.map(({ catalogEntry, connection, application }, index) => { + if (connection.transport === "rest_api") { + throw new Error(`REST API connection ${connection.id} cannot be exposed through the MCP gateway`); + } const baseName = baseNames[index]!; const gatewayToolName = baseNameCounts.get(baseName)! > 1 ? `${baseName}-${shortStableId(catalogEntry.id)}` @@ -2355,7 +2358,7 @@ export function createToolGatewayService( throw new ToolGatewayHttpError( 422, "A configured credential secret could not be resolved.", - "remote_http_missing_secret", + "mcp_remote_missing_secret", { connectionId: connection.id, credential: ref.name }, ); } @@ -2396,7 +2399,7 @@ export function createToolGatewayService( throw new ToolGatewayHttpError( 422, "A configured credential secret could not be resolved.", - "remote_http_missing_secret", + "mcp_remote_missing_secret", { connectionId: connection.id, credential: input.configPath }, ); } @@ -2482,11 +2485,11 @@ export function createToolGatewayService( eq(toolConnections.companyId, session.companyId), )) .limit(1); - if (!connection || connection.transport !== "remote_http") { + if (!connection || connection.transport !== "mcp_remote") { throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found"); } if (!connection.enabled || connection.status !== "active") { - throw new ToolGatewayHttpError(403, "Connection is disabled.", "remote_http_connection_disabled", { + throw new ToolGatewayHttpError(403, "Connection is disabled.", "mcp_remote_connection_disabled", { connectionId: connection.id, }); } @@ -2784,7 +2787,7 @@ export function createToolGatewayService( return new ToolGatewayHttpError( 502, "Remote MCP response exceeded the gateway size limit", - "remote_http_response_too_large", + "mcp_remote_response_too_large", { maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES }, ); } @@ -3017,7 +3020,7 @@ export function createToolGatewayService( }); const requestId = `paperclip-tool-${randomUUID()}`; const execution: RemoteHttpExecutionAudit = { - transport: "remote_http", + transport: "mcp_remote", request: { protocol: "MCP JSON-RPC 2.0", httpMethod: "POST", @@ -3061,7 +3064,7 @@ export function createToolGatewayService( }; if (!response.ok) { await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error."); - throw new ToolGatewayHttpError(502, "Remote MCP server returned an HTTP error", "remote_http_status", { + throw new ToolGatewayHttpError(502, "Remote MCP server returned an HTTP error", "mcp_remote_status", { status: response.status, connectionId: connection.id, catalogEntryId: entry.id, @@ -3073,7 +3076,7 @@ export function createToolGatewayService( payload = parseMcpHttpResponseBody(body, response.headers.get("content-type")); } catch { await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned invalid JSON."); - throw new ToolGatewayHttpError(502, "Remote MCP server returned invalid JSON", "remote_http_invalid_json", { + throw new ToolGatewayHttpError(502, "Remote MCP server returned invalid JSON", "mcp_remote_invalid_json", { connectionId: connection.id, catalogEntryId: entry.id, execution, @@ -3121,7 +3124,7 @@ export function createToolGatewayService( }); } await markRemoteConnectionHealth(connection, "error", "Remote MCP tool call failed."); - throw new ToolGatewayHttpError(502, "Remote MCP tool call failed", "remote_http_fetch_failed", { + throw new ToolGatewayHttpError(502, "Remote MCP tool call failed", "mcp_remote_fetch_failed", { connectionId: connection.id, catalogEntryId: entry.id, execution, diff --git a/tests/e2e/application-delete-screenshot.spec.ts b/tests/e2e/application-delete-screenshot.spec.ts index 6100c9c96f..0202971fcd 100644 --- a/tests/e2e/application-delete-screenshot.spec.ts +++ b/tests/e2e/application-delete-screenshot.spec.ts @@ -32,7 +32,7 @@ test("captures the current app removal confirmations", async ({ page }) => { data: { applicationName: "Guarded MCP", name: "Primary connection", - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, }, }); diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts index e093ca6c58..7a4f87a7e2 100644 --- a/tests/e2e/applications-crud.spec.ts +++ b/tests/e2e/applications-crud.spec.ts @@ -45,7 +45,7 @@ async function createConnection( ): Promise<{ id: string; applicationId: string; name: string }> { const res = await request.post(`/api/companies/${companyId}/tools/connections`, { data: { - transport: "remote_http", + transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, enabled: true, status: "active", diff --git a/tests/e2e/smoke-lab-browser-runner.mts b/tests/e2e/smoke-lab-browser-runner.mts index fa617afd88..9ffe4cb5ea 100644 --- a/tests/e2e/smoke-lab-browser-runner.mts +++ b/tests/e2e/smoke-lab-browser-runner.mts @@ -164,7 +164,7 @@ async function main() { const oauthUrl = services.find((s: Json) => s.id === "fake-oauth")?.url as string; const fx = await api("POST", `/api/companies/${companyId}/smoke-lab/install-fixtures`); const preferStdio = scenario.transport === "local_stdio" || scenario.transport === "plugin"; - const wantTransport = preferStdio ? "local_stdio" : "remote_http"; + const wantTransport = preferStdio ? "local_stdio" : "mcp_remote"; const conn = fx.connections.find((c: Json) => c.transport === wantTransport); assert(conn, `${wantTransport} connection for ${scenario.path}`); @@ -263,7 +263,7 @@ async function main() { // schema-change-quarantine await doStep(scenario, "schema-change-quarantine", async () => { - if (conn.transport !== "remote_http") { + if (conn.transport !== "mcp_remote") { await page.goto(`${BASE}/${prefix}/apps/${conn.id}/activity`, { waitUntil: "networkidle" }); return "Non-HTTP path records governance/quarantine evidence through fixture metadata."; } diff --git a/tests/e2e/smoke-lab.catalog.ts b/tests/e2e/smoke-lab.catalog.ts index 0749df71f6..c72d5fa84d 100644 --- a/tests/e2e/smoke-lab.catalog.ts +++ b/tests/e2e/smoke-lab.catalog.ts @@ -2,7 +2,7 @@ export type SmokeRunStepPath = "P1" | "P2" | "P3" | "P4" | "P5" | "P6" | "P7"; export type SmokeLabScenarioStatus = "ci_safe" | "headed_full"; -export type SmokeLabTransport = "remote_http" | "local_stdio" | "plugin" | "prosumer_import" | "gateway_session" | "governance"; +export type SmokeLabTransport = "mcp_remote" | "local_stdio" | "plugin" | "prosumer_import" | "gateway_session" | "governance"; export interface SmokeLabLifecycleTool { name: string; @@ -48,7 +48,7 @@ export const smokeLabScenarios: SmokeLabScenario[] = [ { path: "P1", title: "Remote HTTP MCP connection, OAuth", - transport: "remote_http", + transport: "mcp_remote", authMode: "oauth", smokeService: "HTTP MCP fixture + fake OAuth provider", status: "ci_safe", @@ -65,7 +65,7 @@ export const smokeLabScenarios: SmokeLabScenario[] = [ { path: "P2", title: "Remote HTTP MCP connection, API key", - transport: "remote_http", + transport: "mcp_remote", authMode: "api_key", smokeService: "HTTP MCP fixture with static fixture credential", status: "ci_safe", diff --git a/tests/e2e/smoke-lab.spec.ts b/tests/e2e/smoke-lab.spec.ts index bcb612fd16..09ae358164 100644 --- a/tests/e2e/smoke-lab.spec.ts +++ b/tests/e2e/smoke-lab.spec.ts @@ -16,7 +16,7 @@ type SmokeRunStepResult = { type ToolConnection = { id: string; name: string; - transport: "remote_http" | "local_stdio"; + transport: "mcp_remote" | "local_stdio"; applicationId: string; enabled: boolean; status?: string; @@ -214,7 +214,7 @@ async function startAndInstallFixtures(request: APIRequestContext, companyId: st function connectionForScenario(fixtures: FixtureInstall, scenario: SmokeLabScenario): ToolConnection { const preferStdio = scenario.transport === "local_stdio" || scenario.transport === "plugin"; - const transport = preferStdio ? "local_stdio" : "remote_http"; + const transport = preferStdio ? "local_stdio" : "mcp_remote"; const connection = fixtures.connections.find((candidate) => candidate.transport === transport); if (!connection) throw new Error(`Missing ${transport} fixture connection for ${scenario.path}`); return connection; @@ -371,7 +371,7 @@ test.describe.serial("Smoke Lab scenario catalog mirror", () => { }); await runRecordedStep(page, request, seed, smokeRun.id, scenario, "schema-change-quarantine", async () => { - if (connection.transport !== "remote_http") { + if (connection.transport !== "mcp_remote") { await page.goto(`/${seed.prefix}/apps/${connection.id}/activity`); return "Non-HTTP path records governance/quarantine evidence through fixture metadata."; } diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx index a3eefa4e2e..68fe3f7c9e 100644 --- a/ui/src/components/AppConnectionSidebar.test.tsx +++ b/ui/src/components/AppConnectionSidebar.test.tsx @@ -94,7 +94,7 @@ function connection(overrides: Record = {}) { return { id: "conn-1", name: "GitHub", - transport: "remote_http", + transport: "mcp_remote", status: "active", healthStatus: "healthy", enabled: true, diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 240822b524..2aa7bd2199 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -114,7 +114,7 @@ function connection(overrides: Record = {}) { applicationId: "app-1", name: "GitHub", connectionKind: "managed", - transport: "remote_http", + transport: "mcp_remote", status: "active", transportConfig: { url: "https://github.example/mcp" }, config: { url: "https://github.example/mcp" }, @@ -179,7 +179,7 @@ describe("AppDetail", () => { tagline: "GitHub tagline", description: "Give agents a governed way to inspect repositories and pull requests.", authKind: "api_key", - transportTemplate: { transport: "remote_http", url: "https://github.example/mcp" }, + transportTemplate: { transport: "mcp_remote", url: "https://github.example/mcp" }, credentialFields: [], recommendedDefaults: {}, urlPatterns: [], diff --git a/ui/src/pages/apps/AppNotConnected.test.tsx b/ui/src/pages/apps/AppNotConnected.test.tsx index e2037ad12c..38b2a5de1c 100644 --- a/ui/src/pages/apps/AppNotConnected.test.tsx +++ b/ui/src/pages/apps/AppNotConnected.test.tsx @@ -114,7 +114,7 @@ function connection(overrides: Record = {}) { applicationId: "app-1", name: "GitHub", connectionKind: "managed", - transport: "remote_http", + transport: "mcp_remote", status: "archived", transportConfig: { url: "https://github.example/mcp" }, config: { url: "https://github.example/mcp" }, diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 8bb4562b65..f663920ccc 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -58,7 +58,7 @@ function galleryEntry(overrides: Record) { logoUrl: "https://example.com/github.png", tagline: "Let agents open PRs and issues.", authKind: "oauth", - transportTemplate: { transport: "remote_http", url: "https://api.github.com/mcp" }, + transportTemplate: { transport: "mcp_remote", url: "https://api.github.com/mcp" }, credentialFields: [], recommendedDefaults: {}, urlPatterns: [], diff --git a/ui/src/pages/apps/Connections.test.tsx b/ui/src/pages/apps/Connections.test.tsx index 3f80ba4bf5..88a11e6b55 100644 --- a/ui/src/pages/apps/Connections.test.tsx +++ b/ui/src/pages/apps/Connections.test.tsx @@ -88,7 +88,7 @@ function connection(overrides: Record) { applicationId: "app-x", name: "GitHub", connectionKind: "managed", - transport: "remote_http", + transport: "mcp_remote", status: "active", transportConfig: {}, config: {}, diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index 21407a9f24..994120eecc 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -268,7 +268,7 @@ export function connectionAddress(connection: ToolConnection): string { } export function connectionTransportLabel(transport: ToolConnection["transport"]): string { - if (transport === "remote_http") return "Remote HTTP"; + if (transport === "mcp_remote") return "Remote HTTP"; if (transport === "local_stdio") return "Local command"; return "Unknown"; } diff --git a/ui/src/pages/tools/AuditTab.test.tsx b/ui/src/pages/tools/AuditTab.test.tsx index 6f19c0bb48..cd5e5cf8f3 100644 --- a/ui/src/pages/tools/AuditTab.test.tsx +++ b/ui/src/pages/tools/AuditTab.test.tsx @@ -170,7 +170,7 @@ describe("AuditTab", () => { summary: JSON.stringify({ email: "person@example.com", apiToken: "***REDACTED***" }), }, execution: { - transport: "remote_http", + transport: "mcp_remote", request: { httpMethod: "POST", endpoint: "https://mcp.zapier.com/api/mcp", diff --git a/ui/src/pages/tools/PasteConfigTab.test.tsx b/ui/src/pages/tools/PasteConfigTab.test.tsx index 73cd2fda28..a91c7320bd 100644 --- a/ui/src/pages/tools/PasteConfigTab.test.tsx +++ b/ui/src/pages/tools/PasteConfigTab.test.tsx @@ -82,8 +82,11 @@ function connectResult(overrides: Partial = {}): ConnectTo companyId: "company-1", applicationId: "app-1", name: "kv-demo", + uid: "app-gallery-link-test/kv-demo", connectionKind: "managed", - transport: "remote_http", + ownership: "customer", + transport: "mcp_remote", + authKind: "none", status: "draft", enabled: false, config: { url: "http://127.0.0.1:8848/mcp" }, @@ -210,7 +213,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => { drafts: [ { name: "kv-demo", - transport: "remote_http", + transport: "mcp_remote", status: "draft", config: { url: "http://127.0.0.1:8848/mcp" }, credentialRefs: [], @@ -249,7 +252,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => { drafts: [ { name: "secure-demo", - transport: "remote_http", + transport: "mcp_remote", status: "draft", config: { url: "https://secure.example/mcp" }, credentialRefs: [], diff --git a/ui/src/pages/tools/PasteConfigTab.tsx b/ui/src/pages/tools/PasteConfigTab.tsx index c6c8d815da..2b4e54b709 100644 --- a/ui/src/pages/tools/PasteConfigTab.tsx +++ b/ui/src/pages/tools/PasteConfigTab.tsx @@ -45,7 +45,7 @@ function draftSummary(draft: McpJsonImportDraft): string { * approved Paperclip template), so they get no hand-off here. */ function draftConnectUrl(draft: McpJsonImportDraft): string | null { - if (draft.transport !== "remote_http") return null; + if (draft.transport !== "mcp_remote") return null; const raw = draft.config?.url; if (typeof raw !== "string") return null; try { diff --git a/ui/src/pages/tools/connection-dialogs.tsx b/ui/src/pages/tools/connection-dialogs.tsx index 1548f82d1d..f176102e87 100644 --- a/ui/src/pages/tools/connection-dialogs.tsx +++ b/ui/src/pages/tools/connection-dialogs.tsx @@ -40,7 +40,7 @@ import { } from "./shared"; export const TRANSPORT_LABEL: Record = { - remote_http: "remote http", + mcp_remote: "remote http", local_stdio: "local stdio", }; @@ -158,7 +158,7 @@ export function AddConnectionDialog({ const [applicationId, setApplicationId] = useState(defaultApplicationId ?? ""); const [applicationName, setApplicationName] = useState(""); const [name, setName] = useState(""); - const [transport, setTransport] = useState<"remote_http" | "local_stdio">("remote_http"); + const [transport, setTransport] = useState<"mcp_remote" | "local_stdio">("mcp_remote"); const [endpointUrl, setEndpointUrl] = useState(""); const [templateId, setTemplateId] = useState(""); const [creds, setCreds] = useState([]); @@ -208,7 +208,7 @@ export function AddConnectionDialog({ const create = useMutation({ mutationFn: () => { const config: Record = - transport === "remote_http" ? { url: endpointUrl.trim() } : { templateId }; + transport === "mcp_remote" ? { url: endpointUrl.trim() } : { templateId }; const input: CreateToolConnectionInput = { ...(applicationMode === "existing" ? { applicationId } : { applicationName: applicationName.trim() }), name: name.trim(), @@ -269,11 +269,11 @@ export function AddConnectionDialog({ }; const transportConfigValid = - transport === "remote_http" ? endpointUrl.trim().length > 0 : templateId.length > 0; + transport === "mcp_remote" ? endpointUrl.trim().length > 0 : templateId.length > 0; const appChoiceValid = applicationMode === "existing" ? !!applicationId : applicationName.trim().length > 0; const canCreate = appChoiceValid && name.trim().length > 0 && transportConfigValid && !create.isPending; const locked = !!draft; - const inferredType = transport === "remote_http" ? "MCP HTTP" : "MCP stdio"; + const inferredType = transport === "mcp_remote" ? "MCP HTTP" : "MCP stdio"; return ( !o && onClose()}> @@ -365,20 +365,20 @@ export function AddConnectionDialog({ - {transport === "remote_http" ? ( + {transport === "mcp_remote" ? (