mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
Represent direct Composio MCP with the existing events; drop the retired composio_sync label
Master retired the legacy Composio broker and toolkit child sync (#13758); direct Composio MCP (catalog slug `composio`, method `mcp`) is the supported flow, set up from the Apps gallery or inline task cards (#13879) through the same server paths every other gallery connector uses. No per-app child rows exist any more, so nothing cascades. - Remove `composio_sync` from `setup_flow` / `change_source`: after the rebase it had no emission site left. No other label changed. - Direct Composio MCP needs no new event, label, or identity rule: `connectorKeyForConnection` already yields `composio` from the validated catalog slug, and the session URL / headers never reach telemetry. - Wire two committed lifecycle writes the trace found uncovered, both on the direct-MCP OAuth path and both with existing labels: `finalizeOAuthAccess` (draft -> active; Apps gallery and connection-intent task cards) as `gallery`, and `completePaperclipCloudConnectorCallback` as `oauth_callback`. - Replace the child-sync integration tests with direct Composio MCP tests (gallery connect + finish, pause/resume, OAuth access finalization) and a retained-legacy-record removal test proving the raw toolkit slug and account id never leave the process. Proposal-only: markers stay; nothing is registered or sent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb60a5ee53
commit
0f614f9abf
4 files changed
+315
-187
No files matched your search
@@ -159,7 +159,7 @@ export function trackConnectionCreated(
|
|||||||
connector_key: RawDimension<"custom">;
|
connector_key: RawDimension<"custom">;
|
||||||
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
|
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
|
||||||
auth_kind: RawDimension<"oauth" | "api_key" | "none">;
|
auth_kind: RawDimension<"oauth" | "api_key" | "none">;
|
||||||
setup_flow: RawDimension<"gallery" | "api" | "example" | "composio_sync">;
|
setup_flow: RawDimension<"gallery" | "api" | "example">;
|
||||||
status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
},
|
},
|
||||||
@@ -184,7 +184,6 @@ export function trackConnectionUpdated(
|
|||||||
| "credential_refresh"
|
| "credential_refresh"
|
||||||
| "archive"
|
| "archive"
|
||||||
| "example"
|
| "example"
|
||||||
| "composio_sync"
|
|
||||||
>;
|
>;
|
||||||
previous_status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
previous_status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
||||||
status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
status: RawDimension<"draft" | "active" | "disabled" | "archived">;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
companies,
|
companies,
|
||||||
|
companyMemberships,
|
||||||
companySecretBindings,
|
companySecretBindings,
|
||||||
companySecretVersions,
|
companySecretVersions,
|
||||||
companySecrets,
|
companySecrets,
|
||||||
@@ -12,12 +16,12 @@ import {
|
|||||||
secretAccessEvents,
|
secretAccessEvents,
|
||||||
toolApplications,
|
toolApplications,
|
||||||
toolCatalogEntries,
|
toolCatalogEntries,
|
||||||
|
toolConnectionInstalls,
|
||||||
toolConnections,
|
toolConnections,
|
||||||
toolProfileBindings,
|
toolProfileBindings,
|
||||||
toolProfileEntries,
|
toolProfileEntries,
|
||||||
toolProfiles,
|
toolProfiles,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import type { ComposioClient } from "../services/composio.js";
|
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
startEmbeddedPostgresTestDatabase,
|
startEmbeddedPostgresTestDatabase,
|
||||||
@@ -31,6 +35,7 @@ vi.mock("../telemetry.js", () => ({
|
|||||||
|
|
||||||
const { toolAccessService } = await import("../services/tool-access.js");
|
const { toolAccessService } = await import("../services/tool-access.js");
|
||||||
const { secretService } = await import("../services/secrets.js");
|
const { secretService } = await import("../services/secrets.js");
|
||||||
|
const { instanceSettingsService } = await import("../services/instance-settings.js");
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
||||||
@@ -48,7 +53,7 @@ function updatedEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createCompany(db: Db) {
|
async function createCompany(db: Db) {
|
||||||
return db
|
const company = await db
|
||||||
.insert(companies)
|
.insert(companies)
|
||||||
.values({
|
.values({
|
||||||
name: `Lifecycle telemetry ${randomUUID()}`,
|
name: `Lifecycle telemetry ${randomUUID()}`,
|
||||||
@@ -56,16 +61,66 @@ async function createCompany(db: Db) {
|
|||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]!);
|
.then((rows) => rows[0]!);
|
||||||
|
await db.insert(companyMemberships).values({
|
||||||
|
companyId: company.id,
|
||||||
|
principalType: "user",
|
||||||
|
principalId: actor.actorId,
|
||||||
|
status: "active",
|
||||||
|
membershipRole: "admin",
|
||||||
|
});
|
||||||
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createComposioParentAndChild(db: Db, companyId: string) {
|
const actor = {
|
||||||
const secrets = secretService(db);
|
actorType: "user" as const,
|
||||||
const apiKey = await secrets.create(companyId, {
|
actorId: "lifecycle-telemetry-user",
|
||||||
name: `Composio test key ${randomUUID().slice(0, 8)}`,
|
actorSource: "local_implicit" as const,
|
||||||
key: `tool_app.${randomUUID()}.credentials_apiKey`,
|
};
|
||||||
provider: "local_encrypted",
|
|
||||||
value: "composio-test-key",
|
const mcpTool = (name: string) => ({
|
||||||
});
|
name,
|
||||||
|
description: name,
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal remote MCP server: answers initialize, the initialized notification,
|
||||||
|
* and tools/list. Mirrors the fixture the remote-connector lifecycle tests use
|
||||||
|
* so the direct Composio MCP path here is the real gallery path, not a stub.
|
||||||
|
*/
|
||||||
|
function remoteMcpFixture() {
|
||||||
|
const requests: { url: string; headers: Headers }[] = [];
|
||||||
|
const remoteHttpRequest = async (url: string, init: RequestInit) => {
|
||||||
|
const body = JSON.parse(String(init.body)) as { id: number; method: string };
|
||||||
|
requests.push({ url, headers: new Headers(init.headers) });
|
||||||
|
if (body.method === "initialize") {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
id: body.id,
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
result: { protocolVersion: "2025-06-18", capabilities: {} },
|
||||||
|
},
|
||||||
|
{ headers: { "Mcp-Session-Id": "session" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.method === "notifications/initialized")
|
||||||
|
return new Response(null, { status: 202 });
|
||||||
|
return Response.json({
|
||||||
|
id: body.id,
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
result: { tools: [mcpTool("read"), mcpTool("ask")] },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return { requests, remoteHttpRequest };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retained legacy broker records (#13758): a `rest_api` parent keyed to the
|
||||||
|
* catalog slug and a `provider: composio` child. Master keeps such rows only
|
||||||
|
* for inspection and explicit removal; they cannot execute or reconnect.
|
||||||
|
*/
|
||||||
|
async function insertRetiredComposioRecords(db: Db, companyId: string) {
|
||||||
const [application] = await db
|
const [application] = await db
|
||||||
.insert(toolApplications)
|
.insert(toolApplications)
|
||||||
.values({ companyId, name: "Composio", type: "rest_api", status: "active" })
|
.values({ companyId, name: "Composio", type: "rest_api", status: "active" })
|
||||||
@@ -75,7 +130,7 @@ async function createComposioParentAndChild(db: Db, companyId: string) {
|
|||||||
.values({
|
.values({
|
||||||
companyId,
|
companyId,
|
||||||
applicationId: application!.id,
|
applicationId: application!.id,
|
||||||
name: "Composio",
|
name: "Composio (legacy broker)",
|
||||||
uid: `composio/${randomUUID()}`,
|
uid: `composio/${randomUUID()}`,
|
||||||
transport: "rest_api",
|
transport: "rest_api",
|
||||||
authKind: "api_key",
|
authKind: "api_key",
|
||||||
@@ -83,34 +138,8 @@ async function createComposioParentAndChild(db: Db, companyId: string) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
config: { sourceTemplateKey: "composio" },
|
config: { sourceTemplateKey: "composio" },
|
||||||
transportConfig: { sourceTemplateKey: "composio" },
|
transportConfig: { sourceTemplateKey: "composio" },
|
||||||
credentialRefs: [
|
|
||||||
{
|
|
||||||
name: "credentials.apiKey",
|
|
||||||
secretId: apiKey.id,
|
|
||||||
version: "latest",
|
|
||||||
placement: "header",
|
|
||||||
key: "x-api-key",
|
|
||||||
prefix: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
credentialSecretRefs: [
|
|
||||||
{
|
|
||||||
secretId: apiKey.id,
|
|
||||||
versionSelector: "latest",
|
|
||||||
configPath: "credentials.apiKey",
|
|
||||||
required: true,
|
|
||||||
label: "Composio API key",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
await db.insert(companySecretBindings).values({
|
|
||||||
companyId,
|
|
||||||
secretId: apiKey.id,
|
|
||||||
targetType: "tool_connection",
|
|
||||||
targetId: parent!.id,
|
|
||||||
configPath: "credentials.apiKey",
|
|
||||||
});
|
|
||||||
const [child] = await db
|
const [child] = await db
|
||||||
.insert(toolConnections)
|
.insert(toolConnections)
|
||||||
.values({
|
.values({
|
||||||
@@ -134,55 +163,22 @@ async function createComposioParentAndChild(db: Db, companyId: string) {
|
|||||||
return { parent: parent!, child: child! };
|
return { parent: parent!, child: child! };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeComposioClient(accountStatus: () => string): ComposioClient {
|
|
||||||
return {
|
|
||||||
validateApiKey: vi.fn(async () => undefined),
|
|
||||||
listToolkits: vi.fn(async () => ({
|
|
||||||
items: [{ slug: "github", name: "GitHub" }],
|
|
||||||
})),
|
|
||||||
listAuthConfigs: vi.fn(async () => ({ items: [] })),
|
|
||||||
createConnectLink: vi.fn(async () => ({
|
|
||||||
link_token: "link",
|
|
||||||
redirect_url: "https://composio.test/link",
|
|
||||||
expires_at: new Date().toISOString(),
|
|
||||||
})),
|
|
||||||
listConnectedAccounts: vi.fn(async () => ({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: "account-github",
|
|
||||||
user_id: "paperclip:test",
|
|
||||||
status: accountStatus(),
|
|
||||||
toolkit: { slug: "github" },
|
|
||||||
auth_config: {
|
|
||||||
id: "auth-github",
|
|
||||||
auth_scheme: "OAUTH2",
|
|
||||||
is_composio_managed: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
deleteConnectedAccount: vi.fn(async () => undefined),
|
|
||||||
createSession: vi.fn(async () => ({
|
|
||||||
session_id: "session",
|
|
||||||
mcp: { url: "https://composio.test/mcp" },
|
|
||||||
})),
|
|
||||||
resumeSession: vi.fn(async () => ({
|
|
||||||
session_id: "session",
|
|
||||||
mcp: { url: "https://composio.test/mcp" },
|
|
||||||
})),
|
|
||||||
} as unknown as ComposioClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
|
describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
|
||||||
let db!: Db;
|
let db!: Db;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null =
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null =
|
||||||
null;
|
null;
|
||||||
|
let keyDir: string | null = null;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
keyDir = await mkdtemp(join(tmpdir(), "lifecycle-telemetry-secrets-"));
|
||||||
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(keyDir, "key"));
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase(
|
tempDb = await startEmbeddedPostgresTestDatabase(
|
||||||
"paperclip-lifecycle-telemetry-",
|
"paperclip-lifecycle-telemetry-",
|
||||||
);
|
);
|
||||||
db = createDb(tempDb.connectionString);
|
db = createDb(tempDb.connectionString);
|
||||||
|
await instanceSettingsService(db).updateExperimental({
|
||||||
|
enableMcpAggregators: true,
|
||||||
|
});
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -193,17 +189,21 @@ describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
|
|||||||
await db.delete(toolProfiles);
|
await db.delete(toolProfiles);
|
||||||
await db.delete(toolCatalogEntries);
|
await db.delete(toolCatalogEntries);
|
||||||
await db.delete(connectionGrants);
|
await db.delete(connectionGrants);
|
||||||
|
await db.delete(toolConnectionInstalls);
|
||||||
await db.delete(toolConnections);
|
await db.delete(toolConnections);
|
||||||
await db.delete(toolApplications);
|
await db.delete(toolApplications);
|
||||||
await db.delete(secretAccessEvents);
|
await db.delete(secretAccessEvents);
|
||||||
await db.delete(companySecretBindings);
|
await db.delete(companySecretBindings);
|
||||||
await db.delete(companySecretVersions);
|
await db.delete(companySecretVersions);
|
||||||
await db.delete(companySecrets);
|
await db.delete(companySecrets);
|
||||||
|
await db.delete(companyMemberships);
|
||||||
await db.delete(companies);
|
await db.delete(companies);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await tempDb?.cleanup();
|
await tempDb?.cleanup();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
if (keyDir) await rm(keyDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
function service(options: Parameters<typeof toolAccessService>[1] = {}) {
|
function service(options: Parameters<typeof toolAccessService>[1] = {}) {
|
||||||
@@ -263,119 +263,231 @@ describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
|
|||||||
expect(updatedEvents()).toHaveLength(0);
|
expect(updatedEvents()).toHaveLength(0);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it("composio parent pause emits child transitions from committed rows and stays silent on the no-op repeat", async () => {
|
it("direct Composio MCP setup from the gallery emits catalog identity only, never the session URL", async () => {
|
||||||
const company = await createCompany(db);
|
const company = await createCompany(db);
|
||||||
const { parent, child } = await createComposioParentAndChild(db, company.id);
|
const remote = remoteMcpFixture();
|
||||||
const svc = service({
|
const svc = service({
|
||||||
composioClientFactory: () => fakeComposioClient(() => "ACTIVE"),
|
deploymentMode: "local_trusted",
|
||||||
|
deploymentExposure: "private",
|
||||||
|
remoteHttpRequest: remote.remoteHttpRequest,
|
||||||
});
|
});
|
||||||
|
// The same server path the Apps gallery and inline task cards call
|
||||||
await svc.updateConnection(parent.id, { enabled: false });
|
// (POST /companies/:companyId/tools/apps/connect): Composio Connect with an
|
||||||
let events = updatedEvents();
|
// externally configured MCP session URL.
|
||||||
// Parent transition (api) + child cascade (composio_sync).
|
const connected = await svc.connectGalleryApp(
|
||||||
expect(events).toHaveLength(2);
|
company.id,
|
||||||
const childEvent = events.find(
|
{
|
||||||
([, dims]) => (dims as { change_source: string }).change_source === "composio_sync",
|
galleryKey: "composio",
|
||||||
);
|
connectionMethodKey: "mcp",
|
||||||
expect(childEvent?.[1]).toMatchObject({
|
link: "https://mcp.composio.dev/session/fixture?token=fixture-secret",
|
||||||
connector_key: "custom",
|
authMode: "none",
|
||||||
previous_enabled: true,
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
db.select().from(toolConnections).then((rows) =>
|
|
||||||
rows.find((row) => row.id === child.id),
|
|
||||||
),
|
|
||||||
).resolves.toMatchObject({ enabled: false });
|
|
||||||
|
|
||||||
track.mockClear();
|
|
||||||
await svc.updateConnection(parent.id, { enabled: false });
|
|
||||||
// Parent and child both already disabled: nothing changed, nothing emits.
|
|
||||||
expect(updatedEvents()).toHaveLength(0);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it("a child deleted between listing and write produces no event", async () => {
|
|
||||||
const company = await createCompany(db);
|
|
||||||
const { parent, child } = await createComposioParentAndChild(db, company.id);
|
|
||||||
const svc = service({
|
|
||||||
composioClientFactory: () => fakeComposioClient(() => "ACTIVE"),
|
|
||||||
beforeComposioChildLifecycleWrite: async (childId) => {
|
|
||||||
await db.delete(toolConnections).where(eq(toolConnections.id, childId));
|
|
||||||
},
|
},
|
||||||
});
|
actor,
|
||||||
|
|
||||||
await svc.updateConnection(parent.id, { enabled: false });
|
|
||||||
const events = updatedEvents();
|
|
||||||
// Only the parent's own committed transition may report.
|
|
||||||
expect(events).toHaveLength(1);
|
|
||||||
expect(
|
|
||||||
events.every(
|
|
||||||
([, dims]) =>
|
|
||||||
(dims as { change_source: string }).change_source !== "composio_sync",
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
const remaining = await db.select().from(toolConnections);
|
|
||||||
expect(remaining.find((row) => row.id === child.id)).toBeUndefined();
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it("a failed child lifecycle write emits nothing for that child", async () => {
|
|
||||||
const company = await createCompany(db);
|
|
||||||
const { parent } = await createComposioParentAndChild(db, company.id);
|
|
||||||
const svc = service({
|
|
||||||
composioClientFactory: () => fakeComposioClient(() => "ACTIVE"),
|
|
||||||
beforeComposioChildLifecycleWrite: async () => {
|
|
||||||
throw new Error("forced child write failure");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
svc.updateConnection(parent.id, { enabled: false }),
|
|
||||||
).rejects.toThrow(/forced child write failure/);
|
|
||||||
expect(
|
|
||||||
updatedEvents().filter(
|
|
||||||
([, dims]) =>
|
|
||||||
(dims as { change_source: string }).change_source === "composio_sync",
|
|
||||||
),
|
|
||||||
).toHaveLength(0);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it("restore emits only for children whose committed enabled state changed", async () => {
|
|
||||||
const company = await createCompany(db);
|
|
||||||
const { parent, child } = await createComposioParentAndChild(db, company.id);
|
|
||||||
let accountStatus = "ACTIVE";
|
|
||||||
const svc = service({
|
|
||||||
composioClientFactory: () => fakeComposioClient(() => accountStatus),
|
|
||||||
});
|
|
||||||
|
|
||||||
await svc.updateConnection(parent.id, { enabled: false });
|
|
||||||
track.mockClear();
|
|
||||||
|
|
||||||
// Inactive account: the child stays disabled, so no transition may report.
|
|
||||||
accountStatus = "INACTIVE";
|
|
||||||
await svc.updateConnection(parent.id, { enabled: true });
|
|
||||||
expect(
|
|
||||||
updatedEvents().filter(
|
|
||||||
([, dims]) =>
|
|
||||||
(dims as { change_source: string }).change_source === "composio_sync",
|
|
||||||
),
|
|
||||||
).toHaveLength(0);
|
|
||||||
|
|
||||||
track.mockClear();
|
|
||||||
accountStatus = "ACTIVE";
|
|
||||||
await svc.updateConnection(parent.id, { enabled: true });
|
|
||||||
const events = updatedEvents().filter(
|
|
||||||
([, dims]) =>
|
|
||||||
(dims as { change_source: string }).change_source === "composio_sync",
|
|
||||||
);
|
);
|
||||||
expect(events).toHaveLength(1);
|
expect(remote.requests.length).toBeGreaterThan(0);
|
||||||
expect(events[0]?.[1]).toMatchObject({
|
const created = createdEvents();
|
||||||
|
expect(created).toHaveLength(1);
|
||||||
|
expect(created[0]?.[1]).toMatchObject({
|
||||||
|
connector_key: "composio",
|
||||||
|
transport: "mcp_remote",
|
||||||
|
auth_kind: "none",
|
||||||
|
setup_flow: "gallery",
|
||||||
|
status: connected.connection.status,
|
||||||
|
enabled: connected.connection.enabled,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(track.mock.calls)).not.toContain("fixture-secret");
|
||||||
|
expect(JSON.stringify(track.mock.calls)).not.toContain("mcp.composio.dev");
|
||||||
|
|
||||||
|
track.mockClear();
|
||||||
|
const finished = await svc.finishGalleryAppConnection(
|
||||||
|
company.id,
|
||||||
|
connected.connectionId,
|
||||||
|
{
|
||||||
|
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
|
||||||
|
askFirstCatalogEntryIds: [],
|
||||||
|
access: "all_agents",
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
// Connect commits a draft; the finish step is the committed draft -> active
|
||||||
|
// transition, so it is the one `gallery` update this setup reports.
|
||||||
|
expect(connected.connection).toMatchObject({ status: "draft", enabled: false });
|
||||||
|
expect(finished.connection).toMatchObject({ status: "active", enabled: true });
|
||||||
|
const updated = updatedEvents();
|
||||||
|
expect(updated).toHaveLength(1);
|
||||||
|
expect(updated[0]?.[1]).toMatchObject({
|
||||||
|
connector_key: "composio",
|
||||||
|
transport: "mcp_remote",
|
||||||
|
change_source: "gallery",
|
||||||
|
previous_status: "draft",
|
||||||
|
status: "active",
|
||||||
previous_enabled: false,
|
previous_enabled: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
await expect(
|
expect(createdEvents()).toHaveLength(0);
|
||||||
db.select().from(toolConnections).then((rows) =>
|
}, 30_000);
|
||||||
rows.find((row) => row.id === child.id),
|
|
||||||
),
|
it("pausing and resuming a direct Composio MCP connection reports api transitions with the catalog key", async () => {
|
||||||
).resolves.toMatchObject({ enabled: true });
|
const company = await createCompany(db);
|
||||||
|
const remote = remoteMcpFixture();
|
||||||
|
const svc = service({
|
||||||
|
deploymentMode: "local_trusted",
|
||||||
|
deploymentExposure: "private",
|
||||||
|
remoteHttpRequest: remote.remoteHttpRequest,
|
||||||
|
});
|
||||||
|
const connected = await svc.connectGalleryApp(
|
||||||
|
company.id,
|
||||||
|
{
|
||||||
|
galleryKey: "composio",
|
||||||
|
connectionMethodKey: "mcp",
|
||||||
|
link: "https://mcp.composio.dev/session/fixture?token=fixture-secret",
|
||||||
|
authMode: "none",
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
await svc.finishGalleryAppConnection(
|
||||||
|
company.id,
|
||||||
|
connected.connectionId,
|
||||||
|
{
|
||||||
|
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
|
||||||
|
askFirstCatalogEntryIds: [],
|
||||||
|
access: "all_agents",
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
track.mockClear();
|
||||||
|
|
||||||
|
await svc.updateConnection(connected.connectionId, { enabled: false });
|
||||||
|
await svc.updateConnection(connected.connectionId, { enabled: true });
|
||||||
|
const updated = updatedEvents();
|
||||||
|
expect(updated).toHaveLength(2);
|
||||||
|
expect(updated.map(([, dims]) => dims)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
connector_key: "composio",
|
||||||
|
transport: "mcp_remote",
|
||||||
|
change_source: "api",
|
||||||
|
previous_enabled: true,
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
connector_key: "composio",
|
||||||
|
change_source: "api",
|
||||||
|
previous_enabled: false,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
// No per-app child rows exist for direct MCP, so nothing else cascades.
|
||||||
|
expect(createdEvents()).toHaveLength(0);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("finalizing OAuth access reports the committed draft -> active transition as a gallery step", async () => {
|
||||||
|
// Direct MCP aggregator OAuth (Composio Connect via DCR) from the Apps
|
||||||
|
// gallery or an inline task card: the callback stores a personal grant,
|
||||||
|
// then finalizeOAuthAccess — POST .../apps/:id/finalize-oauth-access, also
|
||||||
|
// called by connection-intent completion — activates the draft row itself
|
||||||
|
// before handing off to the finish step.
|
||||||
|
const company = await createCompany(db);
|
||||||
|
const svc = service();
|
||||||
|
const [application] = await db
|
||||||
|
.insert(toolApplications)
|
||||||
|
.values({ companyId: company.id, name: "Composio", type: "mcp_remote", status: "active" })
|
||||||
|
.returning();
|
||||||
|
const [connection] = await db
|
||||||
|
.insert(toolConnections)
|
||||||
|
.values({
|
||||||
|
companyId: company.id,
|
||||||
|
applicationId: application!.id,
|
||||||
|
name: "Composio",
|
||||||
|
uid: `composio/${randomUUID()}`,
|
||||||
|
transport: "mcp_remote",
|
||||||
|
authKind: "oauth",
|
||||||
|
credentialPolicy: "per_user",
|
||||||
|
status: "draft",
|
||||||
|
enabled: false,
|
||||||
|
config: {
|
||||||
|
sourceTemplateKey: "composio",
|
||||||
|
connectionMethodKey: "mcp",
|
||||||
|
url: "https://connect.composio.dev/mcp",
|
||||||
|
},
|
||||||
|
transportConfig: { url: "https://connect.composio.dev/mcp" },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const accessToken = await secretService(db).create(company.id, {
|
||||||
|
name: `OAuth access token ${randomUUID().slice(0, 8)}`,
|
||||||
|
key: `tool_app.${randomUUID()}.oauth_access_token`,
|
||||||
|
provider: "local_encrypted",
|
||||||
|
value: "fixture-access-token",
|
||||||
|
});
|
||||||
|
await db.insert(connectionGrants).values({
|
||||||
|
companyId: company.id,
|
||||||
|
connectionId: connection!.id,
|
||||||
|
kind: "user",
|
||||||
|
subjectUserId: actor.actorId,
|
||||||
|
status: "active",
|
||||||
|
credentialSecretRefs: [
|
||||||
|
{
|
||||||
|
secretId: accessToken.id,
|
||||||
|
versionSelector: "latest",
|
||||||
|
configPath: "oauth.access_token",
|
||||||
|
required: true,
|
||||||
|
label: "OAuth access token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
track.mockClear();
|
||||||
|
|
||||||
|
const finished = await svc.finalizeOAuthAccess(
|
||||||
|
company.id,
|
||||||
|
connection!.id,
|
||||||
|
{ grantKind: "user" },
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
expect(finished.connection).toMatchObject({ status: "active", enabled: true });
|
||||||
|
const updated = updatedEvents();
|
||||||
|
// Exactly one transition: finalize's own write. The finish step it calls
|
||||||
|
// afterwards re-reads an already-active row and stays silent.
|
||||||
|
expect(updated).toHaveLength(1);
|
||||||
|
expect(updated[0]?.[1]).toMatchObject({
|
||||||
|
connector_key: "composio",
|
||||||
|
transport: "mcp_remote",
|
||||||
|
auth_kind: "oauth",
|
||||||
|
change_source: "gallery",
|
||||||
|
previous_status: "draft",
|
||||||
|
status: "active",
|
||||||
|
previous_enabled: false,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(createdEvents()).toHaveLength(0);
|
||||||
|
expect(JSON.stringify(track.mock.calls)).not.toContain("fixture-access-token");
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("retained legacy broker records emit only their explicit removal, distinguishable by transport", async () => {
|
||||||
|
const company = await createCompany(db);
|
||||||
|
const { parent, child } = await insertRetiredComposioRecords(db, company.id);
|
||||||
|
const svc = service();
|
||||||
|
track.mockClear();
|
||||||
|
|
||||||
|
await svc.archiveConnection(parent.id);
|
||||||
|
await svc.archiveConnection(child.id);
|
||||||
|
const updated = updatedEvents();
|
||||||
|
expect(updated).toHaveLength(2);
|
||||||
|
expect(updated[0]?.[1]).toMatchObject({
|
||||||
|
// Legacy parent: catalog slug survives, but `rest_api` marks it as the
|
||||||
|
// retired broker rather than a direct MCP connection.
|
||||||
|
connector_key: "composio",
|
||||||
|
transport: "rest_api",
|
||||||
|
change_source: "archive",
|
||||||
|
status: "archived",
|
||||||
|
});
|
||||||
|
expect(updated[1]?.[1]).toMatchObject({
|
||||||
|
// Legacy child: no catalog key; the raw toolkit slug never leaves.
|
||||||
|
connector_key: "custom",
|
||||||
|
transport: "mcp_remote",
|
||||||
|
change_source: "archive",
|
||||||
|
status: "archived",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(track.mock.calls)).not.toContain("github");
|
||||||
|
expect(JSON.stringify(track.mock.calls)).not.toContain("account-github");
|
||||||
|
expect(createdEvents()).toHaveLength(0);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
});
|
});
|
||||||
@@ -12,15 +12,14 @@ import { getTelemetryClient } from "../telemetry.js";
|
|||||||
type ToolConnectionRow = typeof toolConnections.$inferSelect;
|
type ToolConnectionRow = typeof toolConnections.$inferSelect;
|
||||||
type ToolInvocationRow = typeof toolInvocations.$inferSelect;
|
type ToolInvocationRow = typeof toolInvocations.$inferSelect;
|
||||||
|
|
||||||
export type ConnectorSetupFlow = "gallery" | "api" | "example" | "composio_sync";
|
export type ConnectorSetupFlow = "gallery" | "api" | "example";
|
||||||
export type ConnectorChangeSource =
|
export type ConnectorChangeSource =
|
||||||
| "api"
|
| "api"
|
||||||
| "gallery"
|
| "gallery"
|
||||||
| "oauth_callback"
|
| "oauth_callback"
|
||||||
| "credential_refresh"
|
| "credential_refresh"
|
||||||
| "archive"
|
| "archive"
|
||||||
| "example"
|
| "example";
|
||||||
| "composio_sync";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminal `tool_invocations.status` values. `pending`, `authorized`,
|
* Terminal `tool_invocations.status` values. `pending`, `authorized`,
|
||||||
|
|||||||
@@ -14883,6 +14883,10 @@ export function toolAccessService(
|
|||||||
stateRow.connectionId,
|
stateRow.connectionId,
|
||||||
stateRow.companyId,
|
stateRow.companyId,
|
||||||
);
|
);
|
||||||
|
const preCloudCallbackLifecycle = {
|
||||||
|
status: connection.status,
|
||||||
|
enabled: connection.enabled,
|
||||||
|
};
|
||||||
// The connection lifecycle, not the incidental presence of its app profile,
|
// The connection lifecycle, not the incidental presence of its app profile,
|
||||||
// distinguishes setup from reauthorization. New connections and connections
|
// distinguishes setup from reauthorization. New connections and connections
|
||||||
// revived after removal are drafts until this callback completes. A profile
|
// revived after removal are drafts until this callback completes. A profile
|
||||||
@@ -15244,6 +15248,10 @@ export function toolAccessService(
|
|||||||
.where(eq(issueThreadInteractions.id, stateRow.interactionId));
|
.where(eq(issueThreadInteractions.id, stateRow.interactionId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// The transaction above committed the connection's lifecycle write; a
|
||||||
|
// Paperclip Cloud connector callback is the managed variant of an OAuth
|
||||||
|
// callback completion.
|
||||||
|
emitConnectionUpdated(connection, preCloudCallbackLifecycle, "oauth_callback");
|
||||||
if (githubMetadata) {
|
if (githubMetadata) {
|
||||||
const [githubGrant] = await db
|
const [githubGrant] = await db
|
||||||
.select({ id: connectionGrants.id })
|
.select({ id: connectionGrants.id })
|
||||||
@@ -16155,6 +16163,10 @@ export function toolAccessService(
|
|||||||
if (requestingAgentId)
|
if (requestingAgentId)
|
||||||
await assertAgentsInCompany(companyId, [requestingAgentId]);
|
await assertAgentsInCompany(companyId, [requestingAgentId]);
|
||||||
let connection = await getConnectionRow(connectionId, companyId);
|
let connection = await getConnectionRow(connectionId, companyId);
|
||||||
|
const preFinalizeLifecycle = {
|
||||||
|
status: connection.status,
|
||||||
|
enabled: connection.enabled,
|
||||||
|
};
|
||||||
if (connection.authKind !== "oauth")
|
if (connection.authKind !== "oauth")
|
||||||
throw badRequest("This connection does not use browser sign-in");
|
throw badRequest("This connection does not use browser sign-in");
|
||||||
if (connection.status === "archived")
|
if (connection.status === "archived")
|
||||||
@@ -16464,6 +16476,12 @@ export function toolAccessService(
|
|||||||
for (const secretId of personalSecretIds) await secrets.remove(secretId);
|
for (const secretId of personalSecretIds) await secrets.remove(secretId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both identity branches above commit their own lifecycle write (draft ->
|
||||||
|
// active) before `finishGalleryAppConnection` re-reads the row, so that
|
||||||
|
// step alone would never observe this transition. This is the OAuth
|
||||||
|
// access-finalization step of the same gallery setup flow (Apps and inline
|
||||||
|
// task cards both reach it), hence `gallery`.
|
||||||
|
emitConnectionUpdated(connection, preFinalizeLifecycle, "gallery");
|
||||||
const catalog = await db
|
const catalog = await db
|
||||||
.select()
|
.select()
|
||||||
.from(toolCatalogEntries)
|
.from(toolCatalogEntries)
|
||||||
|
|||||||
Reference in new issue
Block a user