Files
PaperClipAI/server/src/__tests__/tool-access-service.test.ts
T
2026-08-20 21:20:01 +00:00

7200 lines
278 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
authUsers,
companies,
companyMemberships,
companySecretBindings,
connectionGrants,
connectionTokenIssuances,
companySecrets,
companySecretVersions,
createDb,
heartbeatRuns,
issueThreadInteractions,
issues,
principalPermissionGrants,
secretAccessEvents,
toolAccessAuditEvents,
toolActionRequests,
toolApplications,
toolCallEvents,
toolCatalogEntries,
toolConnectionInstalls,
toolConnections,
toolOauthStates,
toolInvocations,
toolPolicies,
toolProfileBindings,
toolProfileEntries,
toolProfiles,
toolRuntimeMetricCounters,
toolRuntimeSlots,
toolStdioCommandTemplates,
} from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { getConnectableAppDefinition } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService } from "../services/tool-access.js";
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { secretService } from "../services/secrets.js";
import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js";
import { createToolGatewayService, type ToolGatewayService } from "../services/tool-gateway.js";
import { toolAccessRoutes } from "../routes/tool-access.js";
import { errorHandler } from "../middleware/index.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function createCompany(db: ReturnType<typeof createDb>) {
return db
.insert(companies)
.values({
name: `Tool Access CRUD ${randomUUID()}`,
issuePrefix: `TC${randomUUID().slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
}
// Build a Response-like object that mirrors what `fetch` returns for an MCP
// Streamable HTTP JSON response: `text()`, `json()`, and a `content-type`
// header. Production now reads the body via `text()` + content-type so it can
// also decode SSE-framed responses, so test doubles must supply both.
function mcpHttpResponse(
payload: unknown,
opts: { contentType?: string; body?: string } = {},
): Response {
const contentType = opts.contentType ?? "application/json";
const body = opts.body ?? JSON.stringify(payload);
return {
ok: true,
status: 200,
headers: { get: (name: string) => (name.toLowerCase() === "content-type" ? contentType : null) },
text: async () => body,
json: async () => payload,
} as unknown as Response;
}
// Build an SSE-framed (`event: message\ndata: {…}`) MCP Streamable HTTP
// response, the shape a spec-compliant server returns once the request carries
// the `Accept: application/json, text/event-stream` header.
function mcpSseResponse(payload: unknown): Response {
return mcpHttpResponse(payload, {
contentType: "text/event-stream",
body: `event: message\ndata: ${JSON.stringify(payload)}\n\n`,
});
}
function mockToolsList(tools: unknown[]) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue(
mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools } }),
);
}
function createRouteApp(
db: ReturnType<typeof createDb>,
actor?: Express.Request["actor"],
toolGateway?: ToolGatewayService,
deployment?: { deploymentMode: "authenticated"; deploymentExposure: "public" },
) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor ?? {
type: "board",
userId: "board-user",
userName: "Board User",
userEmail: null,
isInstanceAdmin: true,
source: "local_implicit",
};
next();
});
app.use("/api", toolAccessRoutes(db, { toolGateway, ...deployment }));
app.use(errorHandler);
return app;
}
function boardSessionActor(
companyId: string,
membershipRole: "owner" | "admin" | "operator" | "member" | "viewer",
userId = `${membershipRole}-${randomUUID()}`,
sessionId = `session-${randomUUID()}`,
): Express.Request["actor"] {
return {
type: "board",
userId,
sessionId,
userName: `${membershipRole} user`,
userEmail: null,
isInstanceAdmin: false,
source: "session",
companyIds: [companyId],
memberships: [{ companyId, membershipRole, status: "active" }],
};
}
async function grantBoardUser(
db: ReturnType<typeof createDb>,
companyId: string,
userId: string,
permissionKeys: string[],
) {
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "operator",
});
if (permissionKeys.length > 0) {
await db.insert(principalPermissionGrants).values(permissionKeys.map((permissionKey) => ({
companyId,
principalType: "user",
principalId: userId,
permissionKey,
scope: null,
grantedByUserId: "owner",
})));
}
}
async function createAgent(db: ReturnType<typeof createDb>, companyId: string, status = "active") {
return db.insert(agents).values({
companyId,
name: `Test Agent ${randomUUID()}`,
role: "engineer",
status,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning().then((rows) => rows[0]!);
}
async function createIssueAndRun(db: ReturnType<typeof createDb>, companyId: string, agentId: string) {
const [issue] = await db.insert(issues).values({
companyId,
title: `Broker issue ${randomUUID()}`,
status: "in_progress",
assigneeAgentId: agentId,
}).returning();
const [run] = await db.insert(heartbeatRuns).values({
companyId,
agentId,
invocationSource: "assignment",
status: "running",
contextSnapshot: { issueId: issue!.id, responsibleUserId: "user-for-run" },
}).returning();
return { issue: issue!, run: run! };
}
function agentJwtActor(companyId: string, agentId: string, runId: string): Express.Request["actor"] {
return {
type: "agent",
companyId,
agentId,
runId,
source: "agent_jwt",
};
}
async function allowConnectionForAgent(
db: ReturnType<typeof createDb>,
companyId: string,
agentId: string,
connectionId: string,
input: { brokerMint?: boolean } = {},
) {
const [profile] = await db.insert(toolProfiles).values({
companyId,
profileKey: `broker-${randomUUID()}`,
name: `Broker profile ${randomUUID()}`,
defaultAction: "deny",
}).returning();
await db.insert(toolProfileBindings).values({
companyId,
profileId: profile!.id,
targetType: "agent",
targetId: agentId,
});
await db.insert(toolProfileEntries).values({
companyId,
profileId: profile!.id,
selectorType: "connection",
effect: "include",
connectionId,
});
if (input.brokerMint ?? true) {
await db.insert(toolProfileEntries).values({
companyId,
profileId: profile!.id,
selectorType: "tool_name",
effect: "include",
toolName: "connection_token.mint",
});
}
return profile!;
}
async function createBrokerConnection(
db: ReturnType<typeof createDb>,
companyId: string,
input: {
path?: "exchange" | "static";
parentScopes?: string[];
defaultScopes?: string[];
rateLimitPerHour?: number;
healthStatus?: "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret";
tokenUrl?: string;
} = {},
) {
const secret = await secretService(db).create(companyId, {
provider: "local_encrypted",
name: `Broker parent ${randomUUID()}`,
key: `broker.parent.${randomUUID()}`,
value: "parent-deploy-token",
});
const [application] = await db.insert(toolApplications).values({
companyId,
applicationKey: "paperclip-pages",
name: `Paperclip Pages ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId,
applicationId: application!.id,
name: `Pages connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
healthStatus: input.healthStatus ?? "ok",
config: {
service: "pages",
namespaceAllowlist: ["dotta"],
tokenBroker: {
enabled: true,
path: input.path ?? "exchange",
tokenUrl: input.tokenUrl ?? "https://pages.example.test/v1/tokens/exchange",
parentCredentialConfigPath: "credentials.deploy_token",
parentScopes: input.parentScopes ?? ["pages:publish:ns/dotta"],
defaultScopes: input.defaultScopes ?? [],
...(input.rateLimitPerHour !== undefined ? { rateLimitPerHour: input.rateLimitPerHour } : {}),
},
},
transportConfig: {},
credentialSecretRefs: [{
secretId: secret.id,
versionSelector: "latest",
configPath: "credentials.deploy_token",
required: true,
label: "Pages deploy token",
}],
}).returning();
await db.insert(companySecretBindings).values({
companyId,
secretId: secret.id,
targetType: "tool_connection",
targetId: connection!.id,
configPath: "credentials.deploy_token",
});
return { application: application!, connection: connection!, secret };
}
async function createOAuthConnection(
db: ReturnType<typeof createDb>,
companyId: string,
input: { tokenBroker?: Record<string, unknown> } = {},
) {
const accessSecret = await secretService(db).create(companyId, {
provider: "local_encrypted",
name: `OAuth access ${randomUUID()}`,
key: `oauth.access.${randomUUID()}`,
value: "stored-upstream-oauth-access-token",
});
const [application] = await db.insert(toolApplications).values({
companyId,
applicationKey: `oauth-fixture-${randomUUID()}`,
name: `OAuth fixture ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId,
applicationId: application!.id,
name: `OAuth connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
healthStatus: "ok",
config: {
url: "https://oauth-app.example.test/mcp",
oauth: {
provider: "slack",
tokenUrl: "https://oauth-app.example.test/oauth/token",
scopes: ["channels:write"],
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
},
...(input.tokenBroker ? { tokenBroker: input.tokenBroker } : {}),
},
transportConfig: { url: "https://oauth-app.example.test/mcp" },
credentialSecretRefs: [{
secretId: accessSecret.id,
versionSelector: "latest",
configPath: "oauth.access_token",
required: true,
label: "OAuth access token",
}],
}).returning();
await db.insert(companySecretBindings).values({
companyId,
secretId: accessSecret.id,
targetType: "tool_connection",
targetId: connection!.id,
configPath: "oauth.access_token",
});
return { application: application!, connection: connection!, accessSecret };
}
async function createRemoteToolFixture(
db: ReturnType<typeof createDb>,
companyId: string,
input: { riskLevel?: "read" | "write" | "destructive"; quarantined?: boolean } = {},
) {
const [application] = await db.insert(toolApplications).values({
companyId,
applicationKey: `fixture-${randomUUID()}`,
name: `Fixture App ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId,
applicationId: application!.id,
name: `Fixture Connection ${randomUUID()}`,
uid: `fixture/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example.test/mcp" },
transportConfig: { url: "https://fixture.example.test/mcp" },
healthStatus: "ok",
}).returning();
const riskLevel = input.riskLevel ?? "write";
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
companyId,
applicationId: application!.id,
connectionId: connection!.id,
entryKind: "tool",
name: `send_email-${randomUUID()}`,
toolName: "send_email",
title: "Send email",
description: "Send a fixture email.",
inputSchema: {
type: "object",
properties: { to: { type: "string" }, body: { type: "string" } },
required: ["to"],
additionalProperties: true,
},
annotations: { readOnlyHint: riskLevel === "read" },
riskLevel,
isReadOnly: riskLevel === "read",
isWrite: riskLevel === "write",
isDestructive: riskLevel === "destructive",
status: "active",
versionHash: randomUUID(),
schemaHash: randomUUID(),
quarantinedAt: input.quarantined ? new Date() : null,
quarantineReason: input.quarantined ? "pending_review" : null,
}).returning();
return { application: application!, connection: connection!, catalogEntry: catalogEntry! };
}
describeEmbeddedPostgres("tool access service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-access-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
await db.delete(toolOauthStates);
await db.delete(connectionTokenIssuances);
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(companySecrets);
await db.delete(activityLog);
await db.delete(toolCallEvents);
await db.delete(toolActionRequests);
await db.delete(toolInvocations);
await db.delete(toolAccessAuditEvents);
await db.delete(issueThreadInteractions);
await db.delete(toolRuntimeMetricCounters);
await db.delete(toolRuntimeSlots);
await db.delete(toolStdioCommandTemplates);
await db.delete(toolConnectionInstalls);
await db.delete(toolProfileBindings);
await db.delete(toolProfileEntries);
await db.delete(toolProfiles);
await db.delete(toolCatalogEntries);
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(companies);
await db.delete(authUsers);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("mints generic exchange connection tokens through the agent route and stores only hashes", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id);
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
expect(String(url)).toBe("https://pages.example.test/v1/tokens/exchange");
expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer parent-deploy-token" }));
const body = JSON.parse(String(init?.body));
expect(body).toMatchObject({
namespace: "dotta",
ttlSeconds: 900,
actions: ["publish"],
actor: { type: "agent", id: agent.id, runId: run.id, onBehalfOf: "user:user-for-run" },
});
return {
ok: true,
status: 201,
json: async () => ({
token: "child-pages-token",
expiresAt: new Date(Date.now() + 900_000).toISOString(),
scope: "pages:publish:ns/dotta",
token_type: "Bearer",
}),
} as Response;
});
const res = await request(app)
.post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`)
.set("X-Paperclip-Run-Id", run.id)
.send({ scope: "pages:publish:ns/dotta", requestedTtlSeconds: 5000 });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: "minted",
connectionId: connection.id,
connection: { id: connection.id, uid: connection.uid },
grantId: expect.any(String),
path: "exchange",
token: "child-pages-token",
tokenType: "Bearer",
ttlSeconds: expect.any(Number),
scope: ["pages:publish:ns/dotta"],
attribution: { agentId: agent.id, runId: run.id, issueId: expect.any(String), responsibleUserId: "user-for-run" },
});
expect(res.body.ttlSeconds).toBeLessThanOrEqual(900);
expect(fetchMock).toHaveBeenCalledTimes(1);
const issuances = await db.select().from(connectionTokenIssuances);
expect(issuances).toHaveLength(1);
expect(issuances[0]).toMatchObject({
companyId: company.id,
connectionId: connection.id,
agentId: agent.id,
runId: run.id,
path: "exchange",
outcome: "success",
tokenHash: createHash("sha256").update("child-pages-token").digest("hex"),
});
expect(JSON.stringify(issuances)).not.toContain("child-pages-token");
expect(JSON.stringify(issuances)).not.toContain("parent-deploy-token");
const secretEvents = await db.select().from(secretAccessEvents).where(eq(secretAccessEvents.consumerId, connection.id));
expect(secretEvents).toEqual(expect.arrayContaining([
expect.objectContaining({
actorType: "agent",
actorId: agent.id,
configPath: "credentials.deploy_token",
heartbeatRunId: run.id,
outcome: "success",
}),
]));
});
it("selects scoped credentials for array scopes and fails closed for unknown selectors", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id, {
parentScopes: ["staging", "production"],
});
const productionSecret = await secretService(db).create(company.id, {
provider: "local_encrypted",
name: `Production broker parent ${randomUUID()}`,
key: `broker.production.${randomUUID()}`,
value: "production-deploy-token",
});
await db.update(toolConnections).set({
config: {
...connection.config,
tokenBroker: {
...(connection.config.tokenBroker as Record<string, unknown>),
parentCredentialConfigPath: "credentials.production_token",
},
},
credentialSecretRefs: [
...connection.credentialSecretRefs,
{
secretId: productionSecret.id,
versionSelector: "latest",
configPath: "credentials.production_token",
required: true,
label: "Production deploy token",
keyScope: "production",
},
],
updatedAt: new Date(),
}).where(eq(toolConnections.id, connection.id));
await db.insert(companySecretBindings).values({
companyId: company.id,
secretId: productionSecret.id,
targetType: "tool_connection",
targetId: connection.id,
configPath: "credentials.production_token",
});
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 201,
json: async () => ({
token: "unexpected-production-token",
expiresAt: new Date(Date.now() + 900_000).toISOString(),
scope: "staging",
}),
} as Response);
const res = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "staging" });
expect(res.status).toBe(422);
expect(res.body).toMatchObject({ code: "parent_credential_missing" });
expect(fetchMock).not.toHaveBeenCalled();
const productionSecretEvents = await db.select().from(secretAccessEvents).where(and(
eq(secretAccessEvents.consumerId, connection.id),
eq(secretAccessEvents.configPath, "credentials.production_token"),
));
expect(productionSecretEvents).toHaveLength(0);
fetchMock.mockClear();
fetchMock.mockImplementation(async (_url, init) => {
expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer production-deploy-token" }));
return {
ok: true,
status: 201,
json: async () => ({
token: "production-child-token",
expiresAt: new Date(Date.now() + 900_000).toISOString(),
scope: "production",
}),
} as Response;
});
const productionRes = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: ["production"] });
expect(productionRes.status).toBe(200);
expect(productionRes.body).toMatchObject({ token: "production-child-token", scope: ["production"] });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("returns typed subject errors and rejects revoked grants immediately", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id);
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const denied = await request(app)
.post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`)
.send({ subject: { type: "user", userId: "someone-else" } });
expect(denied.status).toBe(403);
expect(denied.body).toMatchObject({
code: "subject_not_permitted",
connection: { uid: connection.uid },
subject: { type: "user", userId: "someone-else" },
});
const missing = await request(app)
.post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`)
.send({ subject: { type: "user", userId: "user-for-run" } });
expect(missing.status).toBe(409);
expect(missing.body).toMatchObject({ code: "user_authorization_required", remediation: { action: "start_authorization" } });
const service = toolAccessService(db);
const grant = await service.addConnectionInstallation(connection.id, { isDefault: false });
await service.revokeConnectionGrant(connection.id, grant.id);
const revoked = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ grantId: grant.id });
expect(revoked.status).toBe(409);
expect(revoked.body).toMatchObject({ code: "grant_revoked", grantId: grant.id });
});
it("returns daily connection usage buckets", async () => {
const company = await createCompany(db);
const { connection } = await createBrokerConnection(db, company.id);
const service = toolAccessService(db);
await db.insert(connectionTokenIssuances).values({
companyId: company.id,
applicationId: connection.applicationId,
connectionId: connection.id,
agentId: (await createAgent(db, company.id)).id,
path: "exchange",
requestedScope: [],
issuedScope: [],
outcome: "success",
});
await db.insert(toolInvocations).values({
companyId: company.id,
connectionId: connection.id,
toolName: "fixture",
riskLevel: "write",
});
const usage = await service.getConnectionUsage(connection.uid, "7d", company.id);
expect(usage.connection).toEqual({ id: connection.id, uid: connection.uid });
expect(usage.buckets.at(-1)).toMatchObject({
issuances: { total: 1, byOutcome: { success: 1 }, byPath: { exchange: 1 } },
invocations: { total: 1, byRiskLevel: { write: 1 } },
});
});
it("rejects connection token minting after the heartbeat run completes", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id);
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id));
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("inactive runs must not call upstream"));
const res = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.set("X-Paperclip-Run-Id", run.id)
.send({ scope: "pages:publish:ns/dotta" });
expect(res.status).toBe(403);
expect(res.body.error).toBe("Agent run is not active");
expect(fetchMock).not.toHaveBeenCalled();
});
it("denies broker minting when the agent only has a generic connection profile grant", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id);
await allowConnectionForAgent(db, company.id, agent.id, connection.id, { brokerMint: false });
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("broker mint should not call upstream"));
const res = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "pages:publish:ns/dotta" });
expect(res.status).toBe(403);
expect(res.body).toMatchObject({ code: "broker_mint_not_granted" });
expect(fetchMock).not.toHaveBeenCalled();
const [issuance] = await db.select().from(connectionTokenIssuances);
expect(issuance).toMatchObject({
connectionId: connection.id,
path: "exchange",
outcome: "denied",
errorCode: "broker_mint_not_granted",
tokenHash: null,
});
});
it("does not infer oauth_access for OAuth-backed connections without broker opt-in", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createOAuthConnection(db, company.id);
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("oauth broker refusal should not call upstream"));
const res = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "channels:write" });
expect(res.status).toBe(403);
expect(res.body).toMatchObject({ code: "broker_not_enabled" });
expect(JSON.stringify(res.body)).not.toContain("stored-upstream-oauth-access-token");
expect(fetchMock).not.toHaveBeenCalled();
const [issuance] = await db.select().from(connectionTokenIssuances);
expect(issuance).toMatchObject({
connectionId: connection.id,
path: "static",
outcome: "denied",
errorCode: "broker_not_enabled",
tokenHash: null,
});
expect(issuance?.path).not.toBe("oauth_access");
const secretEvents = await db.select().from(secretAccessEvents).where(eq(secretAccessEvents.consumerId, connection.id));
expect(secretEvents).toHaveLength(0);
});
it("refuses explicit oauth_access broker paths without projecting stored OAuth bearers", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createOAuthConnection(db, company.id, {
tokenBroker: {
enabled: true,
path: "oauth_access",
parentScopes: ["channels:write"],
defaultScopes: ["channels:write"],
},
});
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("oauth_access refusal should not call upstream"));
const res = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "channels:write" });
expect(res.status).toBe(422);
expect(res.body).toMatchObject({ code: "oauth_access_projection_disabled" });
expect(JSON.stringify(res.body)).not.toContain("stored-upstream-oauth-access-token");
expect(fetchMock).not.toHaveBeenCalled();
const [issuance] = await db.select().from(connectionTokenIssuances);
expect(issuance).toMatchObject({
connectionId: connection.id,
path: "oauth_access",
outcome: "denied",
errorCode: "oauth_access_projection_disabled",
tokenHash: null,
});
const secretEvents = await db.select().from(secretAccessEvents).where(eq(secretAccessEvents.consumerId, connection.id));
expect(secretEvents).toHaveLength(0);
});
it("returns a typed use_env_lease refusal for static credential delivery", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id, { path: "static" });
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const res = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "pages:publish:ns/dotta" });
expect(res.status).toBe(409);
expect(res.body).toMatchObject({
status: "use_env_lease",
code: "use_env_lease",
path: "static",
connectionId: connection.id,
});
const [issuance] = await db.select().from(connectionTokenIssuances);
expect(issuance).toMatchObject({
connectionId: connection.id,
path: "static",
outcome: "use_env_lease",
errorCode: "use_env_lease",
tokenHash: null,
});
});
it("denies token scopes outside the parent scope before minting", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id, { parentScopes: ["pages:publish:ns/dotta"] });
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch");
const res = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "pages:publish:ns/other" });
expect(res.status).toBe(403);
expect(res.body).toMatchObject({ code: "scope_exceeds_parent" });
expect(fetchMock).not.toHaveBeenCalled();
const [issuance] = await db.select().from(connectionTokenIssuances);
expect(issuance).toMatchObject({ outcome: "denied", errorCode: "scope_exceeds_parent", tokenHash: null });
});
it("rate limits connection token minting per agent and connection", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id, { rateLimitPerHour: 1 });
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ token: `child-${randomUUID()}`, expires_in: 600, scope: "pages:publish:ns/dotta" }),
} as Response);
await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "pages:publish:ns/dotta" })
.expect(200);
const limited = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "pages:publish:ns/dotta" });
expect(limited.status).toBe(429);
expect(limited.body).toMatchObject({ code: "rate_limited" });
const issuances = await db.select().from(connectionTokenIssuances).where(eq(connectionTokenIssuances.connectionId, connection.id));
expect(issuances.map((row) => row.outcome).sort()).toEqual(["rate_limited", "success"]);
});
it("quarantines new or changed catalog entries during active opt-in catalog refresh", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const fetchMock = mockToolsList([
{
name: "search_notes",
description: "Search notes.",
inputSchema: { type: "object", properties: { q: { type: "string" } } },
annotations: { readOnlyHint: true },
},
{
name: "send_email",
description: "Send an email.",
inputSchema: { type: "object", properties: { to: { type: "string" } } },
annotations: { readOnlyHint: false },
},
]);
const connection = await service.createConnection(company.id, {
name: "Remote fixture",
transport: "mcp_remote",
config: { url: "https://fixture.example/mcp", quarantineNewEntries: true },
enabled: true,
status: "active",
});
const firstRefresh = await service.refreshCatalog(connection.id, { actorType: "user", actorId: "board" });
expect(fetchMock).toHaveBeenCalledWith(
"https://fixture.example/mcp",
expect.objectContaining({ method: "POST" }),
);
expect(firstRefresh.discoveredCount).toBe(2);
expect(firstRefresh.quarantinedCount).toBe(2);
expect(firstRefresh.catalog).toEqual(
expect.arrayContaining([
expect.objectContaining({ toolName: "search_notes", status: "quarantined", riskLevel: "read" }),
expect.objectContaining({
toolName: "send_email",
status: "quarantined",
riskLevel: "write",
quarantineReason: "pending_review",
}),
]),
);
await db
.update(toolCatalogEntries)
.set({ status: "active", reviewedAt: new Date(), quarantineReason: null, quarantinedAt: null })
.where(eq(toolCatalogEntries.toolName, "send_email"));
fetchMock.mockResolvedValueOnce(mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: {
tools: [
{
name: "send_email",
description: "Send an email with attachments.",
inputSchema: { type: "object", properties: { to: { type: "string" }, attachment: { type: "string" } } },
annotations: { readOnlyHint: false },
},
],
},
}));
const secondRefresh = await service.refreshCatalog(connection.id);
expect(secondRefresh.quarantinedCount).toBe(1);
expect(secondRefresh.catalog).toEqual(
expect.arrayContaining([
expect.objectContaining({
toolName: "send_email",
status: "quarantined",
quarantineReason: "pending_review",
}),
]),
);
});
it("sends the MCP Streamable HTTP Accept header and decodes an SSE catalog response", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
// Emulate a spec-compliant Streamable HTTP server: 406 unless the request
// advertises `Accept: application/json, text/event-stream`, and an
// SSE-framed body in response. Regression guard for PAP-11096.
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
const accept = headers.accept ?? headers.Accept ?? "";
if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
return {
ok: false,
status: 406,
headers: { get: () => null },
text: async () => JSON.stringify({
jsonrpc: "2.0",
error: { code: -32000, message: "Not Acceptable: Client must accept both application/json and text/event-stream" },
id: null,
}),
} as unknown as Response;
}
return mcpSseResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "kv_get", description: "Read a value.", annotations: { readOnlyHint: true } }] },
});
});
const connection = await service.createConnection(company.id, {
name: "Streamable HTTP fixture",
transport: "mcp_remote",
config: { url: "http://127.0.0.1:8848/mcp" },
enabled: true,
status: "active",
});
const refresh = await service.refreshCatalog(connection.id, { actorType: "user", actorId: "board" });
expect(refresh.discoveredCount).toBe(1);
expect(refresh.catalog).toEqual(
expect.arrayContaining([expect.objectContaining({ toolName: "kv_get", riskLevel: "read" })]),
);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:8848/mcp",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ accept: "application/json, text/event-stream" }),
}),
);
// The same probe backs the periodic health sweep, so it must also pass.
const health = await service.checkHealth(connection.id);
expect(health.connection.healthStatus).toBe("ok");
});
it("registers an approved local stdio template and exposes its runtime slot", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const connection = await service.createConnection(company.id, {
name: "Local echo fixture",
transport: "local_stdio",
config: { templateId: "paperclip.echo-calculator-time" },
enabled: true,
status: "active",
});
const health = await service.checkHealth(connection.id);
const refresh = await service.refreshCatalog(connection.id);
const runtimeSlots = await service.listRuntimeSlots(company.id);
expect(health.runtimeSlot).toMatchObject({
connectionId: connection.id,
runtimeKind: "local_stdio",
status: "stopped",
commandTemplateKey: "paperclip.echo-calculator-time",
});
expect(refresh.catalog.map((entry) => entry.toolName).sort()).toEqual(["add", "echo", "fail_with_code", "now"]);
expect(runtimeSlots).toEqual([
expect.objectContaining({
connectionId: connection.id,
providerRef: "template:paperclip.echo-calculator-time",
healthStatus: "ok",
}),
]);
});
it("requires tools:admin to create, list, and disable stdio command templates", async () => {
const company = await createCompany(db);
const userId = `tool-admin-${randomUUID()}`;
const actor: Express.Request["actor"] = {
type: "board",
userId,
userName: "Tool Admin",
userEmail: null,
isInstanceAdmin: false,
source: "session",
companyIds: [company.id],
memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }],
};
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "operator",
});
const app = createRouteApp(db, actor);
await request(app).get(`/api/companies/${company.id}/tools/stdio-templates`).expect(403);
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: userId,
permissionKey: "tools:admin",
scope: null,
grantedByUserId: "owner",
});
const created = await request(app)
.post(`/api/companies/${company.id}/tools/stdio-templates`)
.send({
templateId: "local.echo-admin",
name: "Local echo admin",
command: "node",
args: ["server.js"],
envKeys: ["ECHO_TOKEN"],
tools: [{ name: "echo", description: "Echo a message.", annotations: { readOnlyHint: true } }],
})
.expect(201);
expect(created.body).toMatchObject({
templateId: "local.echo-admin",
status: "active",
source: "admin",
command: "node",
args: ["server.js"],
envKeys: ["ECHO_TOKEN"],
tools: [expect.objectContaining({ name: "echo" })],
});
const listed = await request(app).get(`/api/companies/${company.id}/tools/stdio-templates`).expect(200);
expect(listed.body.templates).toEqual(
expect.arrayContaining([
expect.objectContaining({ templateId: "paperclip.echo-calculator-time", source: "built_in" }),
expect.objectContaining({ templateId: "local.echo-admin", source: "admin", status: "active" }),
]),
);
const disabled = await request(app)
.post(`/api/companies/${company.id}/tools/stdio-templates/local.echo-admin/disable`)
.send({ reason: "no longer trusted" })
.expect(200);
expect(disabled.body).toMatchObject({ templateId: "local.echo-admin", status: "disabled" });
});
it("launches local stdio slots only through active admin-defined templates", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
await service.createStdioCommandTemplate(company.id, {
templateId: "admin.local-echo",
name: "Admin local echo",
command: "node",
args: ["./echo-mcp.js"],
envKeys: ["ADMIN_ECHO_TOKEN"],
tools: [{ name: "echo", description: "Echo a message.", annotations: { readOnlyHint: true } }],
}, { actorType: "user", actorId: "board" });
const connection = await service.createConnection(company.id, {
name: "Admin local echo",
transport: "local_stdio",
config: { templateId: "admin.local-echo" },
enabled: true,
status: "active",
});
const health = await service.checkHealth(connection.id);
const refresh = await service.refreshCatalog(connection.id);
expect(health.runtimeSlot).toMatchObject({
connectionId: connection.id,
runtimeKind: "local_stdio",
commandTemplateKey: "admin.local-echo",
});
expect(refresh.catalog).toEqual([
expect.objectContaining({ toolName: "echo", status: "active", riskLevel: "read" }),
]);
await expect(service.createConnection(company.id, {
name: "Rejected command config",
transport: "local_stdio",
config: { command: "node", args: ["./unapproved.js"] },
enabled: true,
status: "active",
})).rejects.toThrow("Local stdio MCP connections must use an approved templateId");
await service.disableStdioCommandTemplate(company.id, "admin.local-echo");
await expect(service.createConnection(company.id, {
name: "Disabled admin template",
transport: "local_stdio",
config: { templateId: "admin.local-echo" },
enabled: true,
status: "active",
})).rejects.toThrow("Local stdio MCP connections must use an approved templateId");
});
it("blocks private remote HTTP endpoints in authenticated public deployments", async () => {
const company = await createCompany(db);
const service = toolAccessService(db, { deploymentMode: "authenticated", deploymentExposure: "public" });
await expect(service.createConnection(company.id, {
name: "Metadata endpoint",
transport: "mcp_remote",
config: { url: "http://169.254.169.254/latest/meta-data" },
enabled: true,
status: "active",
})).rejects.toMatchObject({
status: 400,
details: { code: "remote_http_private_endpoint" },
});
});
it("creates profiles with entries, binds them to agents, and resolves effective allowed tools", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `Profile Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: `Profile Fixture ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: `Profile Connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example/mcp" },
transportConfig: { url: "https://fixture.example/mcp" },
healthStatus: "ok",
}).returning();
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "send_email",
toolName: "send_email",
riskLevel: "write",
status: "active",
versionHash: randomUUID(),
schemaHash: randomUUID(),
}).returning();
const profile = await service.createProfile(company.id, {
profileKey: `profile-${randomUUID()}`,
name: "Email tools",
defaultAction: "deny",
entries: [{ selectorType: "tool_name", effect: "include", toolName: "send_email" }],
});
const added = await service.addProfileEntry(profile.id, {
selectorType: "risk_level",
effect: "exclude",
riskLevel: "destructive",
});
await expect(service.updateProfileEntry(added.id, { effect: "include" })).resolves.toMatchObject({
effect: "include",
riskLevel: "destructive",
});
await expect(service.deleteProfileEntry(added.id)).resolves.toMatchObject({ id: added.id });
await service.updateProfile(profile.id, {
entries: [{ selectorType: "connection", effect: "include", connectionId: connection.id }],
});
await service.bindProfile(profile.id, { targetType: "agent", targetId: agent.id, priority: 25 }, { actorType: "user", actorId: "board" });
const listed = await service.listProfiles(company.id);
const effective = await service.getEffectiveProfilesForAgent(company.id, agent.id);
expect(listed).toEqual([
expect.objectContaining({
id: profile.id,
entries: [expect.objectContaining({ selectorType: "connection", connectionId: connection.id })],
bindings: [expect.objectContaining({ targetType: "agent", targetId: agent.id, priority: 25 })],
}),
]);
expect(effective).toMatchObject({
agentId: agent.id,
allowedToolNames: ["send_email"],
allowedTools: [expect.objectContaining({ id: catalogEntry.id, toolName: "send_email" })],
});
await expect(service.unbindProfile(profile.id, { targetType: "agent", targetId: agent.id })).resolves.toEqual({ unbound: 1 });
await expect(service.getEffectiveProfilesForAgent(company.id, agent.id)).resolves.toMatchObject({
profiles: [],
allowedToolNames: [],
});
});
it("lists testable agents with per-connection effective access summaries", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const actor = boardSessionActor(company.id, "operator", userId);
const agent = await createAgent(db, company.id);
await createAgent(db, company.id, "terminated");
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Allow test connection ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: { connectionId: connection.id },
});
const app = createRouteApp(db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }));
const res = await request(app)
.get(`/api/tool-connections/${connection.id}/test-agents`)
.expect(200);
expect(res.body.agents).toHaveLength(1);
expect(res.body.agents[0]).toMatchObject({
id: agent.id,
effectiveAccess: {
connectionId: connection.id,
toolCount: 1,
allowedCount: 1,
askFirstCount: 0,
offCount: 0,
},
});
});
it("surfaces a last-changed audit hint attributed to the agent that authored the governing policy", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const actor = boardSessionActor(company.id, "operator", userId);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Allow with author ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: { connectionId: connection.id },
createdByAgentId: agent.id,
});
const app = createRouteApp(db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }));
const res = await request(app)
.get(`/api/tool-connections/${connection.id}/test-agents`)
.expect(200);
const summary = res.body.agents[0].effectiveAccess;
expect(typeof summary.lastChangedAt).toBe("string");
expect(summary.lastChangedByAgentId).toBe(agent.id);
expect(summary.lastChangedByName).toBe(agent.name);
});
it("executes allowed test calls as a board user while attributing the selected agent", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Allow test call ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: { connectionId: connection.id },
});
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-tool-test",
result: { content: [{ type: "text", text: "sent" }] },
}));
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com", body: "hi" } })
.expect(200);
expect(res.body).toMatchObject({
decision: "allowed",
result: { data: expect.objectContaining({ isError: false, transport: "mcp_http" }) },
});
expect(fetchMock).toHaveBeenCalledOnce();
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id));
expect(invocation).toMatchObject({
actorType: "user",
actorId: userId,
agentId: agent.id,
runId: null,
status: "succeeded",
});
const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, company.id));
expect(audits).toEqual(expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
action: "call_completed",
details: expect.objectContaining({ source: "test", agentId: agent.id, runId: null }),
}),
]));
});
it("turns ask-first test calls into real pending action requests", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Ask first ${randomUUID()}`,
policyType: "require_approval",
priority: 100,
selectors: { connectionId: connection.id },
});
const fetchMock = vi.spyOn(globalThis, "fetch");
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
.expect(200);
expect(res.body).toMatchObject({ decision: "ask_first", actionRequestId: expect.any(String) });
expect(fetchMock).not.toHaveBeenCalled();
const [actionRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, res.body.actionRequestId));
expect(actionRequest).toMatchObject({
companyId: company.id,
issueId: null,
status: "pending",
requestedByUserId: userId,
requestedByAgentId: null,
});
expect(actionRequest!.signedArguments).toBeTruthy();
const events = await db.select().from(toolCallEvents).where(eq(toolCallEvents.companyId, company.id));
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({
eventType: "approval_requested",
actionRequestId: actionRequest!.id,
metadata: expect.objectContaining({ source: "test" }),
}),
]));
});
it("audits ask-first test calls with the real board actor and selected agent", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Ask first ${randomUUID()}`,
policyType: "require_approval",
priority: 100,
selectors: { connectionId: connection.id },
});
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
.expect(200);
const gatewayAudit = await db
.select()
.from(activityLog)
.where(and(eq(activityLog.companyId, company.id), eq(activityLog.action, "tool_gateway.approval_requested")));
expect(gatewayAudit).toEqual(expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
agentId: agent.id,
details: expect.objectContaining({
source: "test",
actionRequestId: res.body.actionRequestId,
invocationId: res.body.invocationId,
}),
}),
]));
const dedicatedAudit = await db
.select()
.from(toolAccessAuditEvents)
.where(eq(toolAccessAuditEvents.companyId, company.id));
expect(dedicatedAudit).toEqual(expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
details: expect.objectContaining({
source: "test",
agentId: agent.id,
actionRequestId: res.body.actionRequestId,
runId: null,
}),
}),
]));
});
it("drives an ask-first test call through its live lifecycle (waiting → approved/done with the real result)", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Ask first ${randomUUID()}`,
policyType: "require_approval",
priority: 100,
selectors: { connectionId: connection.id },
});
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret" });
const app = createRouteApp(db, boardSessionActor(company.id, "operator", userId), gateway);
// 1. Park the call as a pending action request.
const created = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com", body: "hi" } })
.expect(200);
const actionRequestId = created.body.actionRequestId as string;
expect(actionRequestId).toEqual(expect.any(String));
// 2. Status starts as "waiting" and surfaces the redacted "Where" snapshot.
const waiting = await request(app)
.get(`/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`)
.expect(200);
expect(waiting.body).toMatchObject({ actionRequestId, phase: "waiting" });
expect(waiting.body.parameters).toHaveProperty("to");
expect(waiting.body.result).toBeUndefined();
// 3. Approving from the review queue is what runs the parked test call.
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-tool-test",
result: { content: [{ type: "text", text: "sent" }] },
}));
await gateway.approveActionRequest({ companyId: company.id, actionRequestId, actor: { userId } });
expect(fetchMock).toHaveBeenCalled();
// 4. Status mutates into the completed result shape with the real response.
const done = await request(app)
.get(`/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`)
.expect(200);
expect(done.body.phase).toBe("done");
expect(done.body.error).toBeUndefined();
expect(done.body.result).toBeDefined();
expect(typeof done.body.durationMs).toBe("number");
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id));
expect(invocation).toMatchObject({ status: "succeeded", approvalState: "approved" });
});
it("creates a fresh ask-first request when the Test tab reruns the same side-effecting action", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Ask first ${randomUUID()}`,
policyType: "require_approval",
priority: 100,
selectors: { connectionId: connection.id },
});
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret" });
const app = createRouteApp(db, boardSessionActor(company.id, "operator", userId), gateway);
const body = { agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com", body: "hi" } };
const first = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send(body)
.expect(200);
vi.spyOn(globalThis, "fetch").mockResolvedValue(mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-tool-test",
result: { content: [{ type: "text", text: "sent" }] },
}));
await gateway.approveActionRequest({
companyId: company.id,
actionRequestId: first.body.actionRequestId as string,
actor: { userId },
});
const second = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send(body)
.expect(200);
expect(second.body).toMatchObject({ decision: "ask_first", actionRequestId: expect.any(String) });
expect(second.body.actionRequestId).not.toBe(first.body.actionRequestId);
const requests = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.companyId, company.id));
expect(requests).toHaveLength(2);
expect(requests.map((row) => row.status).sort()).toEqual(["approved", "pending"]);
});
it("reports a denied ask-first test call as denied without running the tool", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Ask first ${randomUUID()}`,
policyType: "require_approval",
priority: 100,
selectors: { connectionId: connection.id },
});
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret" });
const app = createRouteApp(db, boardSessionActor(company.id, "operator", userId), gateway);
const created = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
.expect(200);
const actionRequestId = created.body.actionRequestId as string;
const fetchMock = vi.spyOn(globalThis, "fetch");
await gateway.declineActionRequest({ companyId: company.id, actionRequestId, actor: { userId } });
expect(fetchMock).not.toHaveBeenCalled();
const denied = await request(app)
.get(`/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`)
.expect(200);
expect(denied.body.phase).toBe("denied");
expect(denied.body.result).toBeUndefined();
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id));
expect(invocation).toMatchObject({ status: "awaiting_approval", approvalState: "rejected" });
});
it("404s a single-id test-call status fetch for a non-test-origin action request", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const { connection } = await createRemoteToolFixture(db, company.id);
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret" });
const app = createRouteApp(db, boardSessionActor(company.id, "operator", userId), gateway);
await request(app)
.get(`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`)
.expect(404);
});
it("returns off for blocked test calls without executing the remote tool", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Block ${randomUUID()}`,
policyType: "block",
priority: 100,
selectors: { connectionId: connection.id },
});
const fetchMock = vi.spyOn(globalThis, "fetch");
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
.expect(200);
expect(res.body).toMatchObject({
decision: "off",
error: { reasonCode: "deny_policy_block" },
});
expect(fetchMock).not.toHaveBeenCalled();
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id));
expect(invocation).toMatchObject({
status: "denied",
errorCode: "deny_policy_block",
actorType: "user",
actorId: userId,
agentId: agent.id,
runId: null,
});
});
it("audits blocked test calls with the real board actor and selected agent", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Block ${randomUUID()}`,
policyType: "block",
priority: 100,
selectors: { connectionId: connection.id },
});
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
.expect(200);
const gatewayAudit = await db
.select()
.from(activityLog)
.where(and(eq(activityLog.companyId, company.id), eq(activityLog.action, "tool_gateway.call_denied")));
expect(gatewayAudit).toEqual(expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
agentId: agent.id,
details: expect.objectContaining({
source: "test",
invocationId: res.body.invocationId,
reasonCode: "deny_policy_block",
}),
}),
]));
const dedicatedAudit = await db
.select()
.from(toolAccessAuditEvents)
.where(eq(toolAccessAuditEvents.companyId, company.id));
expect(dedicatedAudit).toEqual(expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
action: "call_denied",
reasonCode: "deny_policy_block",
details: expect.objectContaining({
source: "test",
agentId: agent.id,
runId: null,
}),
}),
]));
});
it("denies test calls through agents the board user cannot task", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const unassignableAgent = await createAgent(db, company.id, "terminated");
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Allow denied impersonation fixture ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: { connectionId: connection.id },
});
const fetchMock = vi.spyOn(globalThis, "fetch");
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: unassignableAgent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
.expect(403);
expect(fetchMock).not.toHaveBeenCalled();
await expect(db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id))).resolves.toHaveLength(0);
});
it("does not bypass quarantined catalog entries during test calls", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id, { quarantined: true });
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Allow quarantined fixture ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: { connectionId: connection.id },
});
const fetchMock = vi.spyOn(globalThis, "fetch");
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
.expect(404);
expect(res.body).toMatchObject({ reasonCode: "tool_not_found" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("summarizes profile index counts and restores archived profiles through update", async () => {
const company = await createCompany(db);
const [agentOne, agentTwo] = await db.insert(agents).values([
{
companyId: company.id,
name: `Profile Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
{
companyId: company.id,
name: `Profile Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
},
]).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `summary-app-${randomUUID()}`,
name: "Summary app",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application!.id,
name: "Summary connection",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example/mcp" },
}).returning();
const [readEntry, writeEntry] = await db.insert(toolCatalogEntries).values([
{
companyId: company.id,
applicationId: application!.id,
connectionId: connection!.id,
name: "read_notes",
toolName: "read_notes",
riskLevel: "read",
status: "active",
versionHash: randomUUID(),
schemaHash: randomUUID(),
},
{
companyId: company.id,
applicationId: application!.id,
connectionId: connection!.id,
name: "send_email",
toolName: "send_email",
riskLevel: "write",
status: "active",
versionHash: randomUUID(),
schemaHash: randomUUID(),
},
]).returning();
const service = toolAccessService(db);
const profile = await service.createProfile(company.id, {
profileKey: `profile-${randomUUID()}`,
name: "All except write tools",
defaultAction: "allow",
entries: [{ selectorType: "tool_name", effect: "exclude", toolName: "send_email" }],
});
await service.bindProfile(profile.id, { targetType: "company", targetId: company.id }, { actorType: "user", actorId: "board" });
const [listed] = await service.listProfiles(company.id);
expect(listed).toMatchObject({
id: profile.id,
status: "active",
summary: {
accessMode: "all_except",
allowedToolCount: 1,
allowedApplicationCount: 1,
excludedToolCount: 1,
totalToolCount: 2,
assignmentCount: 1,
appliesToAgentCount: 2,
isCompanyDefault: true,
},
});
await expect(service.getEffectiveProfilesForAgent(company.id, agentOne!.id)).resolves.toMatchObject({
allowedTools: [expect.objectContaining({ id: readEntry!.id, toolName: "read_notes" })],
allowedToolNames: ["read_notes"],
});
const archived = await service.updateProfile(profile.id, { status: "archived" });
expect(archived.status).toBe("archived");
await expect(service.getEffectiveProfilesForAgent(company.id, agentTwo!.id)).resolves.toMatchObject({
profiles: [],
allowedTools: [],
allowedToolNames: [],
});
const restored = await service.updateProfile(profile.id, { status: "active" });
expect(restored.status).toBe("active");
await expect(service.getEffectiveProfilesForAgent(company.id, agentTwo!.id)).resolves.toMatchObject({
allowedTools: [expect.objectContaining({ id: readEntry!.id })],
allowedToolNames: ["read_notes"],
});
expect(writeEntry).toBeDefined();
});
it("shows only the narrowest matching tier in effective agent previews", async () => {
const company = await createCompany(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `Scoped Preview Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `preview-app-${randomUUID()}`,
name: "Preview app",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application!.id,
name: "Preview connection",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example/mcp" },
}).returning();
await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application!.id,
connectionId: connection!.id,
name: "send_email",
toolName: "send_email",
riskLevel: "write",
status: "active",
versionHash: randomUUID(),
schemaHash: randomUUID(),
});
const service = toolAccessService(db);
const [companyProfile, agentProfile] = await Promise.all([
service.createProfile(company.id, {
profileKey: `company-default-${randomUUID()}`,
name: "Company default",
defaultAction: "deny",
entries: [{ selectorType: "tool_name", effect: "include", toolName: "send_email" }],
}),
service.createProfile(company.id, {
profileKey: `agent-override-${randomUUID()}`,
name: "Agent override",
defaultAction: "deny",
}),
]);
await service.bindProfile(companyProfile.id, { targetType: "company", targetId: company.id, priority: 100 }, { actorType: "user", actorId: "board" });
await service.bindProfile(agentProfile.id, { targetType: "agent", targetId: agent!.id, priority: 10 }, { actorType: "user", actorId: "board" });
const effective = await service.getEffectiveProfilesForAgent(company.id, agent!.id);
expect(effective.profiles.map((profile) => profile.id)).toEqual([agentProfile.id]);
expect(effective.bindings.map((binding) => `${binding.targetType}:${binding.targetId}`)).toEqual([`agent:${agent!.id}`]);
expect(effective.allowedTools).toEqual([]);
expect(effective.allowedToolNames).toEqual([]);
});
it("prefers agent-scoped allows over broader company defaults in previews", async () => {
const company = await createCompany(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `Scoped Allow Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `allow-app-${randomUUID()}`,
name: "Allow app",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application!.id,
name: "Allow connection",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example/mcp" },
}).returning();
await db.insert(toolCatalogEntries).values([
{
companyId: company.id,
applicationId: application!.id,
connectionId: connection!.id,
name: "read_notes",
toolName: "read_notes",
riskLevel: "read",
status: "active",
versionHash: randomUUID(),
schemaHash: randomUUID(),
},
{
companyId: company.id,
applicationId: application!.id,
connectionId: connection!.id,
name: "send_email",
toolName: "send_email",
riskLevel: "write",
status: "active",
versionHash: randomUUID(),
schemaHash: randomUUID(),
},
]);
const service = toolAccessService(db);
const [companyProfile, agentProfile] = await Promise.all([
service.createProfile(company.id, {
profileKey: `company-read-${randomUUID()}`,
name: "Company read",
defaultAction: "deny",
entries: [{ selectorType: "tool_name", effect: "include", toolName: "read_notes" }],
}),
service.createProfile(company.id, {
profileKey: `agent-write-${randomUUID()}`,
name: "Agent write",
defaultAction: "deny",
entries: [{ selectorType: "tool_name", effect: "include", toolName: "send_email" }],
}),
]);
await service.bindProfile(companyProfile.id, { targetType: "company", targetId: company.id, priority: 100 }, { actorType: "user", actorId: "board" });
await service.bindProfile(agentProfile.id, { targetType: "agent", targetId: agent!.id, priority: 10 }, { actorType: "user", actorId: "board" });
const effective = await service.getEffectiveProfilesForAgent(company.id, agent!.id);
expect(effective.profiles.map((profile) => profile.id)).toEqual([agentProfile.id]);
expect(effective.allowedToolNames).toEqual(["send_email"]);
});
it("duplicates profiles with entries and optional assignments", async () => {
const company = await createCompany(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `Duplicate Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const service = toolAccessService(db);
const profile = await service.createProfile(company.id, {
profileKey: `profile-${randomUUID()}`,
name: "Email tools source",
defaultAction: "allow",
entries: [{ selectorType: "tool_name", effect: "exclude", toolName: "delete_email" }],
});
await service.bindProfile(profile.id, { targetType: "agent", targetId: agent!.id, priority: 25 }, { actorType: "user", actorId: "board" });
const unassignedCopy = await service.duplicateProfile(profile.id, {
name: "Email tools unassigned copy",
includeAssignments: false,
});
expect(unassignedCopy).toMatchObject({
name: "Email tools unassigned copy",
status: "active",
defaultAction: "allow",
entries: [expect.objectContaining({ selectorType: "tool_name", effect: "exclude", toolName: "delete_email" })],
bindings: [],
summary: expect.objectContaining({ assignmentCount: 0 }),
});
const assignedCopy = await service.duplicateProfile(profile.id, {
name: "Email tools assigned copy",
includeAssignments: true,
});
expect(assignedCopy).toMatchObject({
name: "Email tools assigned copy",
status: "active",
bindings: [expect.objectContaining({ targetType: "agent", targetId: agent!.id, priority: 25 })],
summary: expect.objectContaining({ assignmentCount: 1, appliesToAgentCount: 1 }),
});
});
it("deletes profiles with cascades and guards company defaults", async () => {
const company = await createCompany(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `Delete Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const service = toolAccessService(db);
const profile = await service.createProfile(company.id, {
profileKey: `profile-${randomUUID()}`,
name: "Delete source",
entries: [{ selectorType: "tool_name", effect: "include", toolName: "send_email" }],
});
await service.bindProfile(profile.id, { targetType: "agent", targetId: agent!.id }, { actorType: "user", actorId: "board" });
const deleted = await service.deleteProfile(profile.id, { force: false });
expect(deleted).toMatchObject({
profile: expect.objectContaining({ id: profile.id }),
summary: expect.objectContaining({ assignmentCount: 1, appliesToAgentCount: 1 }),
reassignedToProfileId: null,
});
await expect(service.getProfile(profile.id)).rejects.toMatchObject({ status: 404 });
await expect(db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id))).resolves.toEqual([]);
await expect(db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id))).resolves.toEqual([]);
const defaultProfile = await service.createProfile(company.id, {
profileKey: `default-profile-${randomUUID()}`,
name: "Company default delete guard",
defaultAction: "allow",
});
await service.bindProfile(defaultProfile.id, { targetType: "company", targetId: company.id }, { actorType: "user", actorId: "board" });
await expect(service.deleteProfile(defaultProfile.id, { force: false })).rejects.toMatchObject({
status: 422,
details: {
summary: expect.objectContaining({
isCompanyDefault: true,
assignmentCount: 1,
appliesToAgentCount: 1,
}),
},
});
await expect(service.deleteProfile(defaultProfile.id, { force: true })).resolves.toMatchObject({
profile: expect.objectContaining({ id: defaultProfile.id }),
summary: expect.objectContaining({ isCompanyDefault: true }),
});
});
it("keeps duplicate, delete, and new-tools profile routes board-only and viewer-safe", async () => {
const company = await createCompany(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `Route Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const service = toolAccessService(db);
const profile = await service.createProfile(company.id, {
profileKey: `route-profile-${randomUUID()}`,
name: "Route profile",
defaultAction: "deny",
});
const agentApp = createRouteApp(db, {
type: "agent",
companyId: company.id,
agentId: agent.id,
runId: null,
source: "agent_jwt",
});
const viewerApp = createRouteApp(db, boardSessionActor(company.id, "viewer"));
const viewerRead = await request(viewerApp).get(`/api/tool-profiles/${profile.id}/new-tools`);
expect(viewerRead.status).toBe(200);
expect(viewerRead.body).toMatchObject({
profileId: profile.id,
pendingCount: 0,
tools: [],
});
await request(agentApp).get(`/api/tool-profiles/${profile.id}/new-tools`).expect(403);
await request(agentApp)
.post(`/api/tool-profiles/${profile.id}/duplicate`)
.send({ name: "Agent copy", includeAssignments: true })
.expect(403);
await request(agentApp)
.delete(`/api/tool-profiles/${profile.id}`)
.send({ force: false })
.expect(403);
await request(agentApp)
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
.send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }] })
.expect(403);
await request(viewerApp)
.post(`/api/tool-profiles/${profile.id}/duplicate`)
.send({ name: "Viewer copy", includeAssignments: true })
.expect(403);
await request(viewerApp)
.delete(`/api/tool-profiles/${profile.id}`)
.send({ force: false })
.expect(403);
await request(viewerApp)
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
.send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }] })
.expect(403);
});
it("returns 404 for cross-company profile routes and missing profiles", async () => {
const allowedCompany = await createCompany(db);
const otherCompany = await createCompany(db);
const service = toolAccessService(db);
const profile = await service.createProfile(otherCompany.id, {
profileKey: `other-profile-${randomUUID()}`,
name: "Other company profile",
defaultAction: "deny",
});
const entry = await service.addProfileEntry(profile.id, {
selectorType: "tool_name",
effect: "include",
toolName: "read_notes",
});
const app = createRouteApp(db, {
type: "board",
userId: "member-user",
userName: "Member User",
userEmail: null,
companyIds: [allowedCompany.id],
memberships: [
{
companyId: allowedCompany.id,
membershipRole: "owner",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
});
const crossTenantResponses = [
await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`),
await request(app)
.patch(`/api/tool-profiles/${profile.id}`)
.send({ name: "Cross-tenant edit" }),
await request(app)
.post(`/api/tool-profiles/${profile.id}/duplicate`)
.send({ name: "Copy" }),
await request(app)
.delete(`/api/tool-profiles/${profile.id}`)
.send({}),
await request(app)
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
.send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }] }),
await request(app)
.post(`/api/tool-profiles/${profile.id}/entries`)
.send({ selectorType: "tool_name", effect: "include", toolName: "write_notes" }),
await request(app)
.patch(`/api/tool-profile-entries/${entry.id}`)
.send({ effect: "exclude" }),
await request(app).delete(`/api/tool-profile-entries/${entry.id}`),
];
const missingRes = await request(app).get(`/api/tool-profiles/${randomUUID()}/new-tools`);
expect(crossTenantResponses.map((response) => response.status)).toEqual(
crossTenantResponses.map(() => 404),
);
expect(missingRes.status).toBe(404);
expect(crossTenantResponses[0]!.body).toEqual(missingRes.body);
});
it("returns 404 for cross-company connection routes, including instance admins", async () => {
const allowedCompany = await createCompany(db);
const otherCompany = await createCompany(db);
const service = toolAccessService(db);
const connection = await service.createConnection(otherCompany.id, {
name: "Other company connection",
transport: "mcp_remote",
config: { url: "https://other-company.example/mcp" },
});
const oauthState = randomUUID();
await db.insert(toolOauthStates).values({
state: oauthState,
companyId: otherCompany.id,
connectionId: connection.id,
codeVerifier: "cross-tenant-code-verifier",
createdByActorType: "user",
createdByActorId: "other-user",
createdBySessionId: null,
expiresAt: new Date(Date.now() + 60_000),
});
const toolGateway = {} as ToolGatewayService;
const app = createRouteApp(db, {
type: "board",
userId: "member-user",
userName: "Member User",
userEmail: null,
companyIds: [allowedCompany.id],
memberships: [
{
companyId: allowedCompany.id,
membershipRole: "owner",
status: "active",
},
],
isInstanceAdmin: true,
source: "session",
}, toolGateway);
const foreignOAuthRes = await request(app)
.get("/api/tools/oauth/callback")
.query({ state: oauthState, code: "oauth-code" });
const missingOAuthRes = await request(app)
.get("/api/tools/oauth/callback")
.query({ state: randomUUID(), code: "oauth-code" });
const crossTenantResponses = [
await request(app).post(`/api/tools/oauth/${connection.id}/start`),
await request(app).get(`/api/tool-connections/${connection.id}`),
await request(app).get(`/api/tool-connections/${connection.id}/grants`),
await request(app)
.post(`/api/tool-connections/${connection.id}/grants/installations`)
.send({}),
await request(app).delete(`/api/tool-connections/${connection.id}/grants/${randomUUID()}`),
await request(app).get(`/api/tool-connections/${connection.id}/usage`),
await request(app).get(`/api/tool-connections/${connection.id}/installs`),
await request(app)
.put(`/api/tool-connections/${connection.id}/installs`)
.send({ installs: [] }),
await request(app).get(`/api/tool-connections/${connection.id}/test-agents`),
await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({ agentId: randomUUID(), toolName: "read_notes", parameters: {} }),
await request(app)
.get(`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`),
await request(app)
.patch(`/api/tool-connections/${connection.id}`)
.send({ name: "Cross-tenant edit" }),
await request(app).delete(`/api/tool-connections/${connection.id}`),
await request(app).post(`/api/tool-connections/${connection.id}/health-check`),
await request(app)
.post(`/api/tool-connections/${connection.id}/reconnect`)
.send({ credentialValues: {} }),
await request(app).post(`/api/tool-connections/${connection.id}/catalog/refresh`),
await request(app).get(`/api/tool-connections/${connection.id}/catalog`),
await request(app).get(`/api/tool-connections/${connection.id}/activity?limit=5`),
];
expect(foreignOAuthRes.status).toBe(400);
expect(missingOAuthRes.status).toBe(400);
expect(foreignOAuthRes.body).toEqual(missingOAuthRes.body);
const missingRes = await request(app).get(`/api/tool-connections/${randomUUID()}`);
for (const response of crossTenantResponses) {
expect(response.status).toBe(404);
}
expect(missingRes.status).toBe(404);
expect(crossTenantResponses[1]!.body).toEqual(missingRes.body);
await expect(service.getConnection(connection.id)).resolves.toMatchObject({ id: connection.id });
});
it("installs the safe example fixture idempotently and smokes allow, deny, and audit paths", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const before = await service.listExamples(company.id);
expect(before).toEqual([
expect.objectContaining({
id: "safe-read-only-todo-kv",
install: expect.objectContaining({ installed: false, canInstall: true }),
}),
]);
const install = await service.installExample(company.id, "safe-read-only-todo-kv", {
actorType: "user",
actorId: "board",
});
const secondInstall = await service.installExample(company.id, "safe-read-only-todo-kv", {
actorType: "user",
actorId: "board",
});
expect(install.created).toBe(true);
expect(secondInstall.created).toBe(false);
expect(install.application).toMatchObject({
applicationKey: "paperclip.examples.safe-read-only-todo-kv",
type: "mcp_stdio",
status: "active",
});
expect(install.connection).toMatchObject({
transport: "local_stdio",
status: "active",
enabled: true,
config: expect.objectContaining({ templateId: "paperclip.synthetic-todo-kv" }),
});
expect(install.profile).toMatchObject({
profileKey: "paperclip.examples.safe-read-only-todo-kv.profile",
defaultAction: "deny",
status: "active",
});
expect(install.profileBinding).toMatchObject({
targetType: "company",
targetId: company.id,
});
expect(install.profileEntries.map((entry) => entry.toolName).sort()).toEqual(["get_value", "list_items"]);
const installedCatalogByTool = new Map(install.catalog.map((entry) => [entry.toolName, entry]));
expect(installedCatalogByTool.get("list_items")).toMatchObject({ status: "active", riskLevel: "read" });
expect(installedCatalogByTool.get("set_value")).toMatchObject({ status: "quarantined", riskLevel: "write" });
const smoke = await service.smokeExample(company.id, "safe-read-only-todo-kv", {
actorType: "user",
actorId: "board",
});
expect(smoke.ok).toBe(true);
expect(smoke.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "allow_read_tool", ok: true, decision: "allow", reasonCode: "allow_profile" }),
expect.objectContaining({ name: "deny_write_tool", ok: true, decision: "deny", reasonCode: "deny_default" }),
expect.objectContaining({ name: "audit_written", ok: true }),
]),
);
const auditRows = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, company.id));
expect(auditRows.some((row) => row.action === "tool_access.policy_decision" && row.reasonCode === "allow_profile")).toBe(true);
expect(auditRows.some((row) => row.action === "tool_access.policy_decision" && row.reasonCode === "deny_default")).toBe(true);
});
it("evaluates enabled tool policies by priority with first-match wins", async () => {
const company = await createCompany(db);
const policyService = toolAccessPolicyService(db);
const [allowPolicy, blockPolicy] = await db.insert(toolPolicies).values([
{
companyId: company.id,
name: `Allow first ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: { toolName: "fixture:dangerous_action" },
},
{
companyId: company.id,
name: `Block second ${randomUUID()}`,
policyType: "block",
priority: 200,
selectors: { toolName: "fixture:dangerous_action" },
},
]).returning();
const allowDecision = await policyService.decide({
companyId: company.id,
actor: { actorType: "user", actorId: "board-user" },
request: { toolName: "fixture:dangerous_action", arguments: {} },
});
expect(allowDecision).toMatchObject({
decision: "allow",
reasonCode: "allow_policy",
matchedPolicyIds: [allowPolicy!.id],
});
await policyService.reorderPolicies(company.id, { policyIds: [blockPolicy!.id, allowPolicy!.id] });
const blockDecision = await policyService.decide({
companyId: company.id,
actor: { actorType: "user", actorId: "board-user" },
request: { toolName: "fixture:dangerous_action", arguments: {} },
});
expect(blockDecision).toMatchObject({
decision: "deny",
reasonCode: "deny_policy_block",
matchedPolicyIds: [blockPolicy!.id],
});
});
it("reorders and duplicates policies through board routes", async () => {
const company = await createCompany(db);
const [first, second] = await db.insert(toolPolicies).values([
{
companyId: company.id,
name: `First policy ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: { toolName: "read_notes" },
},
{
companyId: company.id,
name: `Second policy ${randomUUID()}`,
policyType: "block",
priority: 200,
selectors: { toolName: "delete_notes" },
},
]).returning();
const app = createRouteApp(db);
const reorder = await request(app)
.post(`/api/companies/${company.id}/tools/policies/reorder`)
.send({ policyIds: [second!.id, first!.id] });
expect(reorder.status).toBe(200);
expect(reorder.body.policies.map((policy: { id: string; priority: number }) => [policy.id, policy.priority])).toEqual([
[second!.id, 100],
[first!.id, 200],
]);
const duplicate = await request(app)
.post(`/api/companies/${company.id}/tools/policies/${first!.id}/duplicate`)
.send({});
expect(duplicate.status).toBe(201);
expect(duplicate.body).toMatchObject({
name: `${first!.name} copy`,
policyType: first!.policyType,
enabled: false,
selectors: first!.selectors,
});
const otherCompany = await createCompany(db);
const [foreignPolicy] = await db.insert(toolPolicies).values({
companyId: otherCompany.id,
name: `Foreign policy ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: {},
}).returning();
await request(app)
.post(`/api/companies/${company.id}/tools/policies/reorder`)
.send({ policyIds: [second!.id, first!.id, foreignPolicy!.id] })
.expect(422);
const auditRows = await db.select().from(activityLog).where(eq(activityLog.companyId, company.id));
expect(auditRows).toEqual(expect.arrayContaining([
expect.objectContaining({ action: "tool_policy.reordered" }),
expect.objectContaining({ action: "tool_policy.duplicated" }),
]));
});
it("serves the app gallery manifest through the board route", async () => {
const company = await createCompany(db);
const app = createRouteApp(db);
const res = await request(app).get(`/api/companies/${company.id}/tools/gallery`);
expect(res.status).toBe(200);
expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual([
"zapier",
"github",
"slack",
"notion",
"posthog",
"linear",
"google-sheets",
"context7",
]);
expect(res.body.apps.map((app: { slug: string }) => app.slug)).not.toContain("google-drive");
expect(res.body.apps).toEqual(
expect.arrayContaining([
expect.objectContaining({
slug: "posthog",
methods: expect.arrayContaining([
expect.objectContaining({ key: "mcp-oauth", auth: "oauth" }),
expect.objectContaining({ key: "mcp-api-key", auth: "api_key" }),
]),
}),
expect.objectContaining({
slug: "slack",
methods: expect.arrayContaining([
expect.objectContaining({
auth: "oauth",
defaults: expect.objectContaining({ authorizationEndpoint: "https://slack.com/oauth/v2/authorize" }),
}),
]),
ownershipAvailability: expect.objectContaining({
platform_shared: false,
platform_provisioned: false,
customer: true,
dcr: true,
}),
}),
expect.objectContaining({
slug: "zapier",
methods: expect.arrayContaining([
expect.objectContaining({
credentialFields: [expect.objectContaining({ key: "authorization" })],
keyPlacement: expect.objectContaining({ location: "header", name: "Authorization" }),
}),
]),
}),
expect.objectContaining({
slug: "google-sheets",
availability: expect.objectContaining({ available: false }),
}),
]),
);
});
it("previews remote mcp.json headers as secret replacement fields without echoing values", async () => {
const company = await createCompany(db);
const app = createRouteApp(db);
const res = await request(app)
.post(`/api/companies/${company.id}/tools/mcp/import-json`)
.send({
mcpJson: {
mcpServers: {
secure: {
url: "https://secure.example/mcp",
headers: {
Authorization: "Bearer raw-token",
"X-API-Key": "raw-key",
},
},
},
},
});
expect(res.status).toBe(200);
expect(JSON.stringify(res.body)).not.toContain("raw-token");
expect(JSON.stringify(res.body)).not.toContain("raw-key");
expect(res.body.drafts).toEqual([
expect.objectContaining({
name: "secure",
transport: "mcp_remote",
status: "draft",
config: { url: "https://secure.example/mcp" },
credentialFields: [
expect.objectContaining({ configPath: "headers.Authorization", key: "Authorization", placement: "header" }),
expect.objectContaining({ configPath: "headers.X-API-Key", key: "X-API-Key", placement: "header" }),
],
}),
]);
});
it("creates link-based MCP connections with imported header secrets before catalog review", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
const headers = init?.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer imported-token");
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: {
tools: [
{
name: "kv_get",
description: "Read a value.",
inputSchema: { type: "object", properties: { key: { type: "string" } } },
annotations: { readOnlyHint: true },
},
],
},
});
});
const result = await service.connectGalleryApp(company.id, {
link: "https://secure.example/mcp",
name: "Secure import",
credentialValues: { "headers.Authorization": "Bearer imported-token" },
}, { actorType: "user", actorId: "board" });
expect(fetchMock).toHaveBeenCalled();
expect(result.connection.status).toBe("draft");
expect(result.connection.credentialRefs).toEqual([
expect.objectContaining({
name: "headers.Authorization",
placement: "header",
key: "Authorization",
prefix: null,
}),
]);
expect(result.connection.config).toMatchObject({ url: "https://secure.example/mcp" });
expect(JSON.stringify(result.connection.config)).not.toContain("imported-token");
expect(result.actions.readOnly).toEqual([
expect.objectContaining({ toolName: "kv_get", riskLevel: "read" }),
]);
});
it("requires an explicit PostHog method and projects validated project filters", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
await expect(service.connectGalleryApp(company.id, {
galleryKey: "posthog",
configValues: { projectId: "12345", features: "insights" },
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 400 });
const fetchMock = mockToolsList([
{ name: "query_insight", annotations: { readOnlyHint: true } },
{ name: "delete_feature_flag" },
{ name: "brand_new_tool" },
]);
const result = await service.connectGalleryApp(company.id, {
galleryKey: "posthog",
connectionMethodKey: "mcp-api-key",
credentialValues: { "credentials.authorization": "phx_test-secret" },
configValues: {
projectId: "12345",
readOnly: true,
features: "insights, error_tracking\ninsights",
tools: "query_insight",
mode: "tools",
},
}, { actorType: "user", actorId: "board" });
expect(fetchMock).toHaveBeenCalledWith(
"https://mcp.posthog.com/mcp?project_id=12345&readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools",
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer phx_test-secret" }),
}),
);
expect(result.connection).toMatchObject({
authKind: "api_key",
config: {
sourceTemplateKey: "posthog",
connectionMethodKey: "mcp-api-key",
methodConfig: {
projectId: "12345",
readOnly: true,
features: "insights,error_tracking",
tools: "query_insight",
mode: "tools",
},
safeDefault: true,
},
});
expect(JSON.stringify(result.connection.config)).not.toContain("phx_test-secret");
expect(result.catalog).toEqual(expect.arrayContaining([
expect.objectContaining({ toolName: "query_insight", riskLevel: "read", status: "active" }),
expect.objectContaining({ toolName: "delete_feature_flag", riskLevel: "destructive", status: "quarantined" }),
expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "quarantined" }),
]));
});
it("stores approved class-3 credential refs on thin tool connections", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const [secret] = await db.insert(companySecrets).values({
companyId: company.id,
key: `discord.bot_token.${randomUUID()}`,
name: `Discord bot token ${randomUUID()}`,
provider: "local_encrypted",
}).returning();
const connection = await service.createConnection(company.id, {
applicationName: "Discord",
name: "Discord bot token",
transport: "mcp_remote",
config: { url: "https://discord.example.test/mcp" },
enabled: false,
status: "draft",
credentialSecretRefs: [{
secretId: secret!.id,
versionSelector: "latest",
configPath: "credentials.bot_token",
label: "Discord bot token",
projectionClass: "class_3_static_lease",
projectionAllowlistKey: "discord.bot_token",
}],
});
expect(connection.credentialSecretRefs).toEqual([
expect.objectContaining({
secretId: secret!.id,
configPath: "credentials.bot_token",
projectionClass: "class_3_static_lease",
projectionAllowlistKey: "discord.bot_token",
}),
]);
const bindings = await db
.select()
.from(companySecretBindings)
.where(and(eq(companySecretBindings.companyId, company.id), eq(companySecretBindings.targetId, connection.id)));
expect(bindings).toEqual([
expect.objectContaining({
secretId: secret!.id,
targetType: "tool_connection",
configPath: "credentials.bot_token",
projectionClass: "class_3_static_lease",
projectionAllowlistKey: "discord.bot_token",
}),
]);
});
it("rejects class-3 tool connection refs outside the enumerated allowlist", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `blocked-${randomUUID()}`,
name: `Blocked App ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [secret] = await db.insert(companySecrets).values({
companyId: company.id,
key: `github.token.${randomUUID()}`,
name: `GitHub token ${randomUUID()}`,
provider: "local_encrypted",
}).returning();
await expect(service.createConnection(company.id, {
applicationId: application!.id,
name: "Blocked class-3 token",
transport: "mcp_remote",
config: { url: "https://blocked.example.test/mcp" },
enabled: false,
status: "draft",
credentialSecretRefs: [{
secretId: secret!.id,
versionSelector: "latest",
configPath: "credentials.bot_token",
label: "GitHub token",
projectionClass: "class_3_static_lease",
projectionAllowlistKey: "github.token",
}],
})).rejects.toMatchObject({
status: 422,
details: { code: "class_3_static_lease_not_allowed" },
});
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(0);
});
it("rejects Google Sheets gallery connects that claim a spreadsheet bound to another company", async () => {
const companyA = await createCompany(db);
const companyB = await createCompany(db);
const service = toolAccessService(db);
vi.stubEnv("GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", JSON.stringify({
client_email: "robot@example.iam.gserviceaccount.com",
}));
await service.connectGalleryApp(companyB.id, {
galleryKey: "google-sheets",
name: "Company B sheets",
configValues: { allowedSpreadsheetIds: ["shared-sheet"] },
}, { actorType: "user", actorId: "board-b" });
await expect(service.connectGalleryApp(companyA.id, {
galleryKey: "google-sheets",
name: "Company A sheets",
configValues: { allowedSpreadsheetIds: ["shared-sheet"] },
}, { actorType: "user", actorId: "board-a" })).rejects.toMatchObject({
status: 409,
details: {
code: "google_sheets_spreadsheet_already_bound",
spreadsheetIds: ["shared-sheet"],
},
});
await expect(db.select().from(toolConnections)).resolves.toHaveLength(1);
});
it("stores Google Sheets catalog input schemas from the approved stdio template", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
vi.stubEnv("GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", JSON.stringify({
client_email: "robot@example.iam.gserviceaccount.com",
}));
const connect = await service.connectGalleryApp(company.id, {
galleryKey: "google-sheets",
name: "Company sheets",
configValues: { allowedSpreadsheetIds: ["sheet-with-inputs"] },
}, { actorType: "user", actorId: "board" });
const descriptions = Object.fromEntries(connect.catalog.map((entry) => [entry.toolName, entry.description]));
expect(descriptions).toMatchObject({
list_spreadsheets: "List the Google Sheets spreadsheets configured in this connection allowlist.",
get_spreadsheet_info: "Get spreadsheet metadata and sheet tab information for an allowlisted spreadsheet.",
read_values: "Read cell values from an allowlisted spreadsheet range.",
search_rows: "Search rows in an allowlisted spreadsheet range.",
append_rows: "Append rows to an allowlisted spreadsheet range.",
update_values: "Update values in an allowlisted spreadsheet range.",
add_sheet_tab: "Add a sheet tab to an allowlisted spreadsheet.",
clear_values: "Clear values in an allowlisted spreadsheet range.",
delete_rows: "Delete rows from an allowlisted spreadsheet tab.",
});
expect(connect.catalog.find((entry) => entry.toolName === "read_values")?.inputSchema).toMatchObject({
type: "object",
properties: {
spreadsheetId: expect.objectContaining({ type: "string" }),
range: expect.objectContaining({ type: "string" }),
},
required: ["spreadsheetId", "range"],
});
expect(connect.catalog.find((entry) => entry.toolName === "append_rows")?.inputSchema).toMatchObject({
properties: {
spreadsheetId: expect.objectContaining({ type: "string" }),
range: expect.objectContaining({ type: "string" }),
values: expect.objectContaining({ type: "array" }),
valueInputOption: expect.objectContaining({ enum: ["RAW", "USER_ENTERED"] }),
},
required: ["spreadsheetId", "range", "values"],
});
expect(connect.catalog.find((entry) => entry.toolName === "delete_rows")?.inputSchema).toMatchObject({
properties: {
spreadsheetId: expect.objectContaining({ type: "string" }),
sheetId: expect.objectContaining({ type: "integer" }),
startIndex: expect.objectContaining({ type: "integer" }),
endIndex: expect.objectContaining({ type: "integer" }),
},
required: ["spreadsheetId", "sheetId", "startIndex", "endIndex"],
});
await db
.update(toolCatalogEntries)
.set({ inputSchema: { type: "object", properties: {} } })
.where(eq(toolCatalogEntries.id, connect.catalog.find((entry) => entry.toolName === "read_values")!.id));
expect((await service.listCatalog(connect.connectionId)).find((entry) => entry.toolName === "read_values")?.inputSchema).toMatchObject({
properties: {
spreadsheetId: expect.objectContaining({ type: "string" }),
range: expect.objectContaining({ type: "string" }),
},
required: ["spreadsheetId", "range"],
});
});
it("rejects raw Google Sheets connection patches that claim another company's spreadsheet", async () => {
const companyA = await createCompany(db);
const companyB = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
vi.stubEnv("GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", JSON.stringify({
client_email: "robot@example.iam.gserviceaccount.com",
}));
await service.connectGalleryApp(companyB.id, {
galleryKey: "google-sheets",
name: "Company B sheets",
configValues: { allowedSpreadsheetIds: ["company-b-sheet"] },
}, { actorType: "user", actorId: "board-b" });
const companyAConnection = await service.connectGalleryApp(companyA.id, {
galleryKey: "google-sheets",
name: "Company A sheets",
configValues: { allowedSpreadsheetIds: ["company-a-sheet"] },
}, { actorType: "user", actorId: "board-a" });
const res = await request(app)
.patch(`/api/tool-connections/${companyAConnection.connectionId}`)
.send({
config: {
templateId: "paperclip.google-sheets",
sourceTemplateKey: "google-sheets",
allowedSpreadsheetIds: ["company-b-sheet"],
env: { GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "company-b-sheet" },
},
});
expect(res.status).toBe(409);
expect(res.body).toMatchObject({
error: "Google Sheets spreadsheet is already connected to another company.",
details: {
code: "google_sheets_spreadsheet_already_bound",
spreadsheetIds: ["company-b-sheet"],
},
});
const [stillCompanyA] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, companyAConnection.connectionId));
expect(stillCompanyA.config.allowedSpreadsheetIds).toEqual(["company-a-sheet"]);
expect(stillCompanyA.config.env).toMatchObject({
GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "company-a-sheet",
});
});
it("tags a pause PATCH with a lifecycle activity row the Activity tab can surface", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "Google Sheets",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Sheets",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://sheets.example/mcp" },
transportConfig: { url: "https://sheets.example/mcp" },
}).returning();
const res = await request(app)
.patch(`/api/tool-connections/${connection.id}`)
.send({ enabled: false });
expect(res.status).toBe(200);
const rows = await db
.select()
.from(activityLog)
.where(and(eq(activityLog.companyId, company.id), eq(activityLog.entityId, connection.id)));
expect(rows).toHaveLength(1);
expect(rows[0]?.details).toMatchObject({ lifecycle: "paused", enabled: false });
const activity = await service.listConnectionActivity(connection.id, company.id, 20);
expect(activity.lifecycleEvents.map((event) => event.type)).toEqual(["app_paused"]);
});
it("allows same-company Google Sheets updates and derives the env mirror from the allowlist", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
vi.stubEnv("GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", JSON.stringify({
client_email: "robot@example.iam.gserviceaccount.com",
}));
const first = await service.connectGalleryApp(company.id, {
galleryKey: "google-sheets",
name: "First sheets",
configValues: { allowedSpreadsheetIds: ["same-company-sheet"] },
}, { actorType: "user", actorId: "board" });
const second = await service.connectGalleryApp(company.id, {
galleryKey: "google-sheets",
name: "Second sheets",
configValues: { allowedSpreadsheetIds: ["same-company-sheet"] },
}, { actorType: "user", actorId: "board" });
const updated = await service.updateConnection(second.connectionId, {
config: {
templateId: "paperclip.google-sheets",
sourceTemplateKey: "google-sheets",
allowedSpreadsheetIds: ["same-company-sheet", "new-company-sheet", "same-company-sheet"],
env: {
GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "attacker-controlled-sheet",
EXTRA_ENV: "preserved",
},
},
});
expect(first.connection.config.allowedSpreadsheetIds).toEqual(["same-company-sheet"]);
expect(updated.config.allowedSpreadsheetIds).toEqual(["same-company-sheet", "new-company-sheet"]);
expect(updated.config.env).toEqual({
EXTRA_ENV: "preserved",
GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "same-company-sheet,new-company-sheet",
});
expect(updated.transportConfig).toEqual(updated.config);
});
it("creates and resolves an agent-initiated user authorization grant card", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
const service = toolAccessService(db);
const connected = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack user auth" });
const workspaceStarted = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "workspace-owner" },
});
const workspaceState = new URL(workspaceStarted.authorizationUrl).searchParams.get("state")!;
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
const body = init?.body as URLSearchParams;
const userAuthorization = body.get("code") === "user-authorization-code";
return {
ok: true,
status: 200,
json: async () => ({
access_token: userAuthorization ? "user-access-token" : "workspace-access-token",
refresh_token: userAuthorization ? "user-refresh-token" : "workspace-refresh-token",
expires_in: 3600,
}),
} as Response;
}
if (href === "https://mcp.slack.com/mcp") {
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] } });
}
throw new Error(`unexpected fetch ${href}`);
});
await service.completeOAuthCallback({
state: workspaceState,
code: "workspace-authorization-code",
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "workspace-owner" },
});
const [workspaceConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
const workspaceSecretIds = workspaceConnection.credentialSecretRefs.map((ref) => ref.secretId).sort();
const started = await service.startAuthorizationForAgent({
companyId: company.id,
connectionId: connected.connectionId,
agentId: agent.id,
runId: run.id,
subjectUserId: "user-for-run",
scopes: ["users:read"],
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
});
const authorizationUrl = new URL(started.authorizationUrl);
expect(authorizationUrl.searchParams.get("scope")).toBe("users:read");
const [state] = await db.select().from(toolOauthStates);
expect(state).toMatchObject({ subjectUserId: "user-for-run", issueId: issue.id, requestedScopes: ["users:read"] });
const [interaction] = await db.select().from(issueThreadInteractions);
expect(interaction).toMatchObject({
issueId: issue.id,
kind: "request_confirmation",
status: "pending",
title: "Connect your account",
});
expect(interaction.payload).toMatchObject({ target: { href: started.authorizationUrl } });
await service.completeOAuthCallback({
state: state.state,
code: "user-authorization-code",
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "user-for-run" },
});
const [grant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.connectionId, connected.connectionId),
eq(connectionGrants.subjectUserId, "user-for-run"),
));
expect(grant).toMatchObject({ kind: "user", status: "active" });
expect(grant.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual(["oauth.access_token", "oauth.refresh_token"]);
expect(grant.credentialSecretRefs.map((ref) => ref.secretId).sort()).not.toEqual(workspaceSecretIds);
const [unchangedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
expect(unchangedConnection.credentialSecretRefs.map((ref) => ref.secretId).sort()).toEqual(workspaceSecretIds);
const [resolved] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction.id));
expect(resolved).toMatchObject({ status: "accepted", result: { version: 1, outcome: "accepted" } });
});
it("starts and completes OAuth app sign-in with PKCE state and secret-backed tokens", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip-public.example");
const company = await createCompany(db);
const app = createRouteApp(db);
const connectRes = await request(app)
.post(`/api/companies/${company.id}/tools/apps/connect`)
.send({ galleryKey: "slack", name: "Slack workspace" });
expect(connectRes.status).toBe(201);
expect(connectRes.body.connection).toMatchObject({
status: "draft",
enabled: false,
credentialSecretRefs: [],
config: expect.objectContaining({ sourceTemplateKey: "slack" }),
});
const startUrl = new URL(connectRes.body.auth.startUrl);
expect(`${startUrl.origin}${startUrl.pathname}`).toBe("https://slack.com/oauth/v2/authorize");
expect(startUrl.searchParams.get("client_id")).toBe("slack-client-id");
expect(startUrl.searchParams.get("code_challenge_method")).toBe("S256");
expect(startUrl.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(startUrl.searchParams.get("redirect_uri")).toBe("https://paperclip-public.example/api/tools/oauth/callback");
const state = startUrl.searchParams.get("state");
expect(state).toBeTruthy();
await expect(db.select().from(toolOauthStates)).resolves.toEqual([
expect.objectContaining({
state,
connectionId: connectRes.body.connectionId,
companyId: company.id,
createdByActorType: "user",
createdByActorId: "board-user",
createdBySessionId: null,
}),
]);
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
const body = init?.body as URLSearchParams;
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("oauth-code");
expect(body.get("client_secret")).toBe("slack-client-secret");
expect(body.get("code_verifier")).toBeTruthy();
expect(body.get("redirect_uri")).toBe("https://paperclip-public.example/api/tools/oauth/callback");
return {
ok: true,
json: async () => ({
ok: true,
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
token_type: "Bearer",
scope: "channels:read chat:write search:read",
}),
} as Response;
}
if (href === "https://mcp.slack.com/mcp") {
expect(init?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer access-token" }));
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: {
tools: [
{ name: "search_messages", description: "Search messages.", annotations: { readOnlyHint: true } },
{ name: "send_message", description: "Send a message.", annotations: { readOnlyHint: false } },
],
},
});
}
throw new Error(`unexpected fetch ${href}`);
});
const callbackRes = await request(app)
.get("/api/tools/oauth/callback")
.query({ state, code: "oauth-code" });
expect(callbackRes.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(callbackRes.body.connection).toMatchObject({
id: connectRes.body.connectionId,
status: "active",
enabled: true,
credentialSecretRefs: [
expect.objectContaining({ configPath: "oauth.access_token", label: "OAuth access token" }),
expect.objectContaining({ configPath: "oauth.refresh_token", label: "OAuth refresh token" }),
],
});
expect(callbackRes.body.actions.readOnly).toEqual([
expect.objectContaining({ toolName: "search_messages", riskLevel: "read" }),
]);
expect(callbackRes.body.actions.canMakeChanges).toEqual([
expect.objectContaining({ toolName: "send_message", riskLevel: "write" }),
]);
const redirectConnectRes = await request(app)
.post(`/api/companies/${company.id}/tools/apps/connect`)
.send({ galleryKey: "slack", name: "Slack redirect" })
.expect(201);
const redirectState = new URL(redirectConnectRes.body.auth.startUrl).searchParams.get("state");
expect(redirectState).toBeTruthy();
const redirectCallbackRes = await request(app)
.get("/api/tools/oauth/callback")
.set("Accept", "text/html")
.query({ state: redirectState, code: "oauth-code" });
expect(redirectCallbackRes.status).toBe(303);
expect(redirectCallbackRes.headers.location).toBe(
`/${company.issuePrefix}/apps/${redirectConnectRes.body.connectionId}/setup?oauth=connected`,
);
expect(fetchMock).toHaveBeenCalledTimes(6);
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(6);
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectRes.body.connectionId));
expect(JSON.stringify(connection.config)).not.toContain("access-token");
expect(JSON.stringify(connection.config)).not.toContain("refresh-token");
});
it("requires non-viewer board access to start OAuth for active app connections", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
const company = await createCompany(db);
const service = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack reauth" });
await db
.update(toolConnections)
.set({ status: "active", updatedAt: new Date() })
.where(eq(toolConnections.id, connect.connectionId));
const viewerApp = createRouteApp(db, boardSessionActor(company.id, "viewer", "viewer-user"));
await request(viewerApp)
.post(`/api/tools/oauth/${connect.connectionId}/start`)
.send({})
.expect(403);
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/apps/connect`)
.send({ galleryKey: "slack", name: "Viewer Slack" })
.expect(403);
const operatorActor = boardSessionActor(company.id, "operator", "operator-user");
const operatorApp = createRouteApp(db, operatorActor);
const startRes = await request(operatorApp)
.post(`/api/tools/oauth/${connect.connectionId}/start`)
.send({})
.expect(200);
const state = new URL(startRes.body.authorizationUrl).searchParams.get("state");
expect(state).toBeTruthy();
await expect(db.select().from(toolOauthStates)).resolves.toEqual([
expect.objectContaining({
state,
connectionId: connect.connectionId,
companyId: company.id,
createdByActorType: "user",
createdByActorId: "operator-user",
createdBySessionId: operatorActor.sessionId,
}),
]);
});
it("requires non-viewer board access to finish app activation and bind profiles", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
mockToolsList([
{
name: "kv_get",
description: "Read a value.",
inputSchema: { type: "object", properties: { key: { type: "string" } } },
annotations: { readOnlyHint: true },
},
]);
const connect = await service.connectGalleryApp(company.id, {
link: "https://secure.example/mcp",
name: "Viewer finish blocked",
credentialValues: { "headers.Authorization": "Bearer imported-token" },
}, { actorType: "user", actorId: "board" });
const viewerApp = createRouteApp(db, boardSessionActor(company.id, "viewer", "viewer-user"));
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/apps/${connect.connectionId}/finish`)
.send({
enabledCatalogEntryIds: connect.catalog.map((entry) => entry.id),
askFirstCatalogEntryIds: [],
access: "all_agents",
})
.expect(403);
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connect.connectionId));
expect(connection.status).toBe("draft");
expect(connection.enabled).toBe(false);
await expect(db.select().from(toolProfileBindings)).resolves.toHaveLength(0);
});
it("binds OAuth callback completion to the initiating board session", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
const company = await createCompany(db);
const service = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack bound" });
const initiatingActor = boardSessionActor(company.id, "operator", "oauth-operator");
const initiatingApp = createRouteApp(db, initiatingActor);
const startRes = await request(initiatingApp)
.post(`/api/tools/oauth/${connect.connectionId}/start`)
.send({})
.expect(200);
const state = new URL(startRes.body.authorizationUrl).searchParams.get("state")!;
const anonymousApp = createRouteApp(db, { type: "none", source: "none" });
await request(anonymousApp)
.get("/api/tools/oauth/callback")
.query({ state, code: "oauth-code" })
.expect(403);
const otherApp = createRouteApp(db, boardSessionActor(company.id, "operator", "other-operator"));
await request(otherApp)
.get("/api/tools/oauth/callback")
.query({ state, code: "oauth-code" })
.expect(403);
const otherSessionSameUserApp = createRouteApp(
db,
boardSessionActor(company.id, "operator", "oauth-operator", "other-session"),
);
await request(otherSessionSameUserApp)
.get("/api/tools/oauth/callback")
.query({ state, code: "oauth-code" })
.expect(403);
const downgradedActor = {
...initiatingActor,
companyIds: [company.id],
memberships: [{ companyId: company.id, membershipRole: "viewer" as const, status: "active" }],
};
const downgradedApp = createRouteApp(db, downgradedActor);
await request(downgradedApp)
.get("/api/tools/oauth/callback")
.query({ state, code: "oauth-code" })
.expect(403);
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1);
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
const body = init?.body as URLSearchParams;
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("oauth-code");
return {
ok: true,
json: async () => ({
ok: true,
access_token: "bound-access-token",
refresh_token: "bound-refresh-token",
expires_in: 3600,
token_type: "Bearer",
}),
} as Response;
}
if (href === "https://mcp.slack.com/mcp") {
expect(init?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer bound-access-token" }));
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "search_messages", annotations: { readOnlyHint: true } }] },
});
}
throw new Error(`unexpected fetch ${href}`);
});
await request(initiatingApp)
.get("/api/tools/oauth/callback")
.query({ state, code: "oauth-code" })
.expect(200);
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
});
it("discovers Notion MCP OAuth metadata, registers one public client, and reuses it", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
const company = await createCompany(db);
const service = toolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "notion",
name: "Notion DCR",
});
const redirectUri = "https://paperclip-dev.tail29c1aa.ts.net/api/tools/oauth/callback";
const registrationBodies: Array<Record<string, unknown>> = [];
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp") {
return mcpHttpResponse({
authorization_servers: ["https://mcp.notion.com"],
scopes_supported: ["default"],
});
}
if (href === "https://mcp.notion.com/.well-known/oauth-authorization-server") {
return mcpHttpResponse({
issuer: "https://mcp.notion.com",
authorization_endpoint: "https://mcp.notion.com/authorize",
token_endpoint: "https://mcp.notion.com/token",
registration_endpoint: "https://mcp.notion.com/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
});
}
if (href === "https://mcp.notion.com/register") {
expect(init?.method).toBe("POST");
registrationBodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
return mcpHttpResponse({
client_id: "notion-dcr-client",
client_secret: "notion-dcr-secret",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
});
}
throw new Error(`unexpected fetch ${href}`);
});
const [first, concurrent] = await Promise.all([
service.startOAuth(company.id, connected.connectionId, {
redirectUri,
actor: { actorType: "user", actorId: "board" },
}),
service.startOAuth(company.id, connected.connectionId, {
redirectUri,
actor: { actorType: "user", actorId: "board" },
}),
]);
expect(new URL(first.authorizationUrl).origin).toBe("https://mcp.notion.com");
expect(new URL(concurrent.authorizationUrl).searchParams.get("client_id")).toBe("notion-dcr-client");
expect(registrationBodies).toEqual([{
client_name: "Paperclip (paperclip-dev.tail29c1aa.ts.net)",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}]);
fetchMock.mockClear();
const reused = await service.startOAuth(company.id, connected.connectionId, {
redirectUri,
actor: { actorType: "user", actorId: "board" },
});
expect(new URL(reused.authorizationUrl).searchParams.get("client_id")).toBe("notion-dcr-client");
expect(fetchMock).not.toHaveBeenCalled();
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
expect(connection).toMatchObject({ ownership: "dcr" });
expect(connection.config).toMatchObject({
oauth: {
provider: "notion",
clientId: "notion-dcr-client",
clientRegistrationSource: "dcr",
clientTokenEndpointAuthMethod: "none",
clientRedirectUri: redirectUri,
registrationUrl: "https://mcp.notion.com/register",
},
});
expect(connection.credentialSecretRefs).toEqual([
expect.objectContaining({ configPath: "oauth.client_secret", required: false }),
]);
expect(JSON.stringify(connection.config)).not.toContain("notion-dcr-secret");
});
it.each([
[
"a confidential token endpoint auth method",
{ client_id: "notion-dcr-client", token_endpoint_auth_method: "client_secret_basic" },
"token_endpoint_auth_method",
],
[
"a different redirect URI",
{ client_id: "notion-dcr-client", redirect_uris: ["https://attacker.example/callback"] },
"redirect_uris",
],
[
"a reduced grant set",
{ client_id: "notion-dcr-client", grant_types: ["authorization_code"] },
"grant_types",
],
[
"missing response types",
{ client_id: "notion-dcr-client", response_types: undefined },
"response_types",
],
[
"an oversized client id",
{ client_id: "x".repeat(4_097) },
"client_id",
],
])("rejects DCR responses that return %s", async (_label, registrationResponse, field) => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
const company = await createCompany(db);
const service = toolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "notion",
name: `Notion invalid DCR ${field}`,
});
const redirectUri = "https://paperclip-dev.tail29c1aa.ts.net/api/tools/oauth/callback";
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const href = String(url);
if (href === "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp") {
return mcpHttpResponse({ authorization_servers: ["https://mcp.notion.com"] });
}
if (href === "https://mcp.notion.com/.well-known/oauth-authorization-server") {
return mcpHttpResponse({
authorization_endpoint: "https://mcp.notion.com/authorize",
token_endpoint: "https://mcp.notion.com/token",
registration_endpoint: "https://mcp.notion.com/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
});
}
if (href === "https://mcp.notion.com/register") {
return mcpHttpResponse({
client_id: "notion-dcr-client",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
...registrationResponse,
});
}
throw new Error(`unexpected fetch ${href}`);
});
await expect(service.startOAuth(company.id, connected.connectionId, {
redirectUri,
actor: { actorType: "user", actorId: "board" },
})).rejects.toMatchObject({
status: 502,
details: {
code: "oauth_dcr_response_invalid",
field,
},
});
const [connection] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connected.connectionId));
expect(connection.ownership).not.toBe("dcr");
expect((connection.config.oauth as Record<string, unknown> | undefined)?.clientId).toBeUndefined();
});
it("fails fast when Notion DCR is attempted from a non-loopback HTTP origin", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
const company = await createCompany(db);
const service = toolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "notion",
name: "Notion invalid origin",
});
const fetchMock = vi.spyOn(globalThis, "fetch");
await expect(service.startOAuth(company.id, connected.connectionId, {
redirectUri: "http://paperclip-dev:3100/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
})).rejects.toMatchObject({
status: 422,
message: "This provider requires an HTTPS or loopback origin. Configure TLS before connecting.",
details: expect.objectContaining({
code: "oauth_redirect_origin_unsupported",
docsPath: "docs/deploy",
}),
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("leases rotating OAuth refresh tokens across service instances before concurrent remote app calls", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const concurrentService = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack refresh" });
const start = await service.startOAuth(company.id, connect.connectionId, {
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
let refreshCallCount = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
const body = init?.body as URLSearchParams;
if (body.get("grant_type") === "authorization_code") {
return {
ok: true,
json: async () => ({
ok: true,
access_token: "old-access-token",
refresh_token: "refresh-token",
expires_in: 3600,
token_type: "Bearer",
}),
} as Response;
}
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("refresh-token");
refreshCallCount += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return {
ok: true,
json: async () => ({
ok: true,
access_token: "new-access-token",
refresh_token: "new-refresh-token",
expires_in: 3600,
token_type: "Bearer",
}),
} as Response;
}
if (href === "https://mcp.slack.com/mcp") {
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "search_messages", annotations: { readOnlyHint: true } }] },
});
}
throw new Error(`unexpected fetch ${href}`);
});
await service.completeOAuthCallback({
state,
code: "oauth-code",
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const [connected] = await db.select().from(toolConnections).where(eq(toolConnections.id, connect.connectionId));
await db
.update(toolConnections)
.set({
config: {
...connected.config,
oauth: {
...(connected.config.oauth as Record<string, unknown>),
expiresAt: "2000-01-01T00:00:00.000Z",
},
},
})
.where(eq(toolConnections.id, connect.connectionId));
const [health, concurrentHealth] = await Promise.all([
service.checkHealth(connect.connectionId),
concurrentService.checkHealth(connect.connectionId),
]);
expect(health.connection.healthStatus).toBe("ok");
expect(concurrentHealth.connection.healthStatus).toBe("ok");
expect(refreshCallCount).toBe(1);
const fetchCalls = vi.mocked(globalThis.fetch).mock.calls;
const mcpCalls = fetchCalls.filter(([url]) => String(url) === "https://mcp.slack.com/mcp");
expect(mcpCalls.at(-1)?.[1]?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer new-access-token" }));
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connect.connectionId));
expect(Date.parse(String((connection.config.oauth as { expiresAt: string }).expiresAt))).toBeGreaterThan(Date.now());
const refreshRef = connection.credentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token")!;
const refreshVersions = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, refreshRef.secretId));
expect(refreshVersions).toHaveLength(2);
expect(refreshVersions.map((version) => version.status).sort()).toEqual(["current", "previous"]);
const credentialAccessEvents = await db
.select()
.from(secretAccessEvents)
.where(and(eq(secretAccessEvents.companyId, company.id), eq(secretAccessEvents.consumerId, connect.connectionId)));
expect(credentialAccessEvents).toEqual(expect.arrayContaining([
expect.objectContaining({ configPath: "oauth.refresh_token", outcome: "success" }),
expect.objectContaining({ configPath: "credentials.oauth.access_token", outcome: "success" }),
]));
});
it("treats invalid_grant as terminal without replaying a rotated refresh token", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack invalid grant" });
const start = await service.startOAuth(company.id, connect.connectionId, {
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
return mcpHttpResponse({
ok: true,
access_token: "expired-access-token",
refresh_token: "single-use-refresh-token",
expires_in: 3600,
token_type: "Bearer",
});
}
if (href === "https://mcp.slack.com/mcp") {
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] } });
}
throw new Error(`unexpected fetch ${href}`);
});
await service.completeOAuthCallback({
state,
code: "oauth-code",
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const [connected] = await db.select().from(toolConnections).where(eq(toolConnections.id, connect.connectionId));
await db.update(toolConnections).set({
config: {
...connected.config,
oauth: {
...(connected.config.oauth as Record<string, unknown>),
expiresAt: "2000-01-01T00:00:00.000Z",
},
},
}).where(eq(toolConnections.id, connect.connectionId));
let refreshCallCount = 0;
fetchMock.mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
const body = init?.body as URLSearchParams;
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("single-use-refresh-token");
refreshCallCount += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return {
ok: false,
status: 400,
headers: { get: () => null },
json: async () => ({ error: "invalid_grant", error_description: "Refresh token was already used" }),
} as unknown as Response;
}
throw new Error(`unexpected fetch ${href}`);
});
const results = await Promise.allSettled([
service.checkHealth(connect.connectionId),
service.checkHealth(connect.connectionId),
]);
expect(results).toEqual([
expect.objectContaining({ status: "rejected" }),
expect.objectContaining({ status: "rejected" }),
]);
for (const result of results) {
expect(result.status === "rejected" ? result.reason : null).toMatchObject({
details: expect.objectContaining({ code: "oauth_reauthorization_required" }),
});
}
expect(refreshCallCount).toBe(1);
const [reauthorizationRequired] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connect.connectionId));
expect(reauthorizationRequired).toMatchObject({
status: "draft",
enabled: false,
healthStatus: "error",
});
expect(reauthorizationRequired.credentialSecretRefs.map((ref) => ref.configPath)).not.toContain("oauth.access_token");
expect(reauthorizationRequired.credentialSecretRefs.map((ref) => ref.configPath)).not.toContain("oauth.refresh_token");
expect(reauthorizationRequired.credentialRefs.map((ref) => ref.name)).not.toContain("oauth.access_token");
});
it("does not disable a connection when invalid_grant used a superseded refresh-token version", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, {
galleryKey: "slack",
name: "Slack stale invalid grant",
});
const start = await service.startOAuth(company.id, connect.connectionId, {
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
return mcpHttpResponse({
ok: true,
access_token: "expired-access-token",
refresh_token: "submitted-refresh-token",
expires_in: 3600,
token_type: "Bearer",
});
}
if (href === "https://mcp.slack.com/mcp") {
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] } });
}
throw new Error(`unexpected fetch ${href}`);
});
await service.completeOAuthCallback({
state,
code: "oauth-code",
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const [connected] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connect.connectionId));
const refreshRef = connected.credentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token")!;
await db.update(toolConnections).set({
config: {
...connected.config,
oauth: {
...(connected.config.oauth as Record<string, unknown>),
expiresAt: "2000-01-01T00:00:00.000Z",
},
},
}).where(eq(toolConnections.id, connect.connectionId));
fetchMock.mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
const body = init?.body as URLSearchParams;
expect(body.get("refresh_token")).toBe("submitted-refresh-token");
await secretService(db).rotate(refreshRef.secretId, { value: "newer-refresh-token" });
return {
ok: false,
status: 400,
headers: { get: () => null },
json: async () => ({ error: "invalid_grant" }),
} as unknown as Response;
}
throw new Error(`unexpected fetch ${href}`);
});
await expect(service.checkHealth(connect.connectionId)).rejects.toMatchObject({
status: 502,
details: expect.objectContaining({ code: "oauth_refresh_superseded" }),
});
const [preserved] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connect.connectionId));
expect(preserved).toMatchObject({ status: "active", enabled: true });
expect(preserved.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(expect.arrayContaining([
"oauth.access_token",
"oauth.refresh_token",
]));
expect((preserved.config.oauth as Record<string, unknown>).refreshLease).toBeUndefined();
});
it("fails closed instead of replaying a refresh token after an abandoned lease", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const fixture = await createOAuthConnection(db, company.id);
const refreshSecret = await secretService(db).create(company.id, {
provider: "local_encrypted",
name: `OAuth refresh ${randomUUID()}`,
key: `oauth.refresh.${randomUUID()}`,
value: "possibly-consumed-refresh-token",
});
await db.insert(companySecretBindings).values({
companyId: company.id,
secretId: refreshSecret.id,
targetType: "tool_connection",
targetId: fixture.connection.id,
configPath: "oauth.refresh_token",
});
await db.update(toolConnections).set({
config: {
...fixture.connection.config,
oauth: {
...(fixture.connection.config.oauth as Record<string, unknown>),
expiresAt: "2000-01-01T00:00:00.000Z",
refreshLease: {
id: "abandoned-refresh",
expiresAt: "2000-01-01T00:00:00.000Z",
},
},
},
credentialSecretRefs: [
...fixture.connection.credentialSecretRefs,
{
secretId: refreshSecret.id,
versionSelector: "latest",
configPath: "oauth.refresh_token",
required: false,
label: "OAuth refresh token",
},
],
}).where(eq(toolConnections.id, fixture.connection.id));
const fetchMock = vi.spyOn(globalThis, "fetch");
await expect(service.checkHealth(fixture.connection.id)).rejects.toMatchObject({
status: 502,
details: expect.objectContaining({ code: "oauth_refresh_outcome_unknown" }),
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses OAuth client credentials for shared machine-to-machine MCP connections", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_M2M_CLIENT_ID", "m2m-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_M2M_CLIENT_SECRET", "m2m-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const connection = await service.createConnection(company.id, {
name: "Machine OAuth",
transport: "mcp_remote",
config: {
url: "https://m2m.example.test/mcp",
oauth: {
provider: "m2m",
tokenUrl: "https://m2m.example.test/oauth/token",
grantType: "client_credentials",
scopes: ["tools.read"],
},
},
enabled: true,
status: "active",
});
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://m2m.example.test/oauth/token") {
const body = init?.body as URLSearchParams;
expect(body.get("grant_type")).toBe("client_credentials");
expect(body.get("client_id")).toBe("m2m-client-id");
expect(body.get("client_secret")).toBe("m2m-client-secret");
expect(body.get("scope")).toBe("tools.read");
return {
ok: true,
json: async () => ({
access_token: "m2m-access-token",
expires_in: 3600,
token_type: "Bearer",
}),
} as Response;
}
if (href === "https://m2m.example.test/mcp") {
expect(init?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer m2m-access-token" }));
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "machine_read", annotations: { readOnlyHint: true } }] },
});
}
throw new Error(`unexpected fetch ${href}`);
});
const health = await service.checkHealth(connection.id, { actorType: "system", actorId: "health-check" });
expect(health.connection.healthStatus).toBe("ok");
expect(fetchMock).toHaveBeenCalledTimes(2);
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
expect(updated.credentialSecretRefs).toEqual([
expect.objectContaining({ configPath: "oauth.access_token", label: "OAuth access token" }),
]);
expect(updated.credentialRefs).toEqual([
expect.objectContaining({ name: "oauth.access_token", key: "Authorization", prefix: "Bearer " }),
]);
expect(JSON.stringify(updated.config)).not.toContain("m2m-access-token");
});
it("fails expired OAuth credentials without a refresh token and returns reconnect links", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack no refresh" });
const start = await service.startOAuth(company.id, connect.connectionId, {
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
return {
ok: true,
json: async () => ({
ok: true,
access_token: "access-without-refresh",
expires_in: 3600,
token_type: "Bearer",
}),
} as Response;
}
if (href === "https://mcp.slack.com/mcp") {
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "search_messages", annotations: { readOnlyHint: true } }] },
});
}
throw new Error(`unexpected fetch ${href}`);
});
await service.completeOAuthCallback({
state,
code: "oauth-code",
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const [connected] = await db.select().from(toolConnections).where(eq(toolConnections.id, connect.connectionId));
await db
.update(toolConnections)
.set({
config: {
...connected.config,
oauth: {
...(connected.config.oauth as Record<string, unknown>),
expiresAt: "2000-01-01T00:00:00.000Z",
},
},
credentialSecretRefs: connected.credentialSecretRefs.filter((ref) => ref.configPath !== "oauth.refresh_token"),
})
.where(eq(toolConnections.id, connect.connectionId));
fetchMock.mockClear();
await expect(service.checkHealth(connect.connectionId, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
status: 502,
details: expect.objectContaining({
code: "oauth_refresh_missing",
setupUrl: `/apps/${connect.connectionId}/setup`,
reconnectUrl: `/apps/${connect.connectionId}/advanced`,
connection: expect.objectContaining({ healthStatus: "failed" }),
}),
});
expect(fetchMock).not.toHaveBeenCalled();
const auditRows = await db
.select()
.from(toolAccessAuditEvents)
.where(eq(toolAccessAuditEvents.action, "tool_connection.credential_resolution"));
const audit = auditRows.find((row) => row.outcome === "failure");
expect(audit).toMatchObject({
outcome: "failure",
reasonCode: "oauth_refresh_missing",
});
expect(JSON.stringify(audit)).not.toContain("access-without-refresh");
});
it("returns a callback error when the provider rejects sign-in", async () => {
const company = await createCompany(db);
const app = createRouteApp(db, boardSessionActor(company.id, "operator", "operator-user"));
const res = await request(app)
.get("/api/tools/oauth/callback")
.query({ error: "access_denied", error_description: "User declined" });
expect(res.status).toBe(400);
});
it("aggregates app connections needing attention through the board route", async () => {
const company = await createCompany(db);
const app = createRouteApp(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: `Attention app ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: `Attention connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example/mcp" },
transportConfig: { url: "https://fixture.example/mcp" },
healthStatus: "error",
healthMessage: "Token revoked.",
}).returning();
const [ignoredConnection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: `Healthy connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://healthy.example/mcp" },
transportConfig: { url: "https://healthy.example/mcp" },
healthStatus: "ok",
}).returning();
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "send_email",
toolName: "send_email",
riskLevel: "write",
isWrite: true,
status: "quarantined",
versionHash: "v1",
schemaHash: "s1",
quarantineReason: "pending_review",
quarantinedAt: new Date(),
}).returning();
await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: ignoredConnection.id,
name: "search",
toolName: "search",
riskLevel: "read",
isReadOnly: true,
status: "active",
versionHash: "v1",
schemaHash: "s1",
});
const [invocation] = await db.insert(toolInvocations).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
toolName: "send_email",
status: "awaiting_approval",
approvalState: "pending",
}).returning();
await db.insert(toolActionRequests).values({
companyId: company.id,
invocationId: invocation.id,
status: "pending",
canonicalArgumentsHash: "args-hash",
canonicalArgumentsSummary: { summary: "redacted", redactedFields: [] },
});
const res = await request(app).get(`/api/companies/${company.id}/tools/apps/attention`);
expect(res.status).toBe(200);
expect(res.body.totals).toMatchObject({
connections: 1,
health: 1,
quarantinedCatalogEntries: 1,
pendingActionRequests: 1,
});
expect(res.body.apps).toEqual([
expect.objectContaining({
connection: expect.objectContaining({ id: connection.id, healthStatus: "error" }),
healthNeedsAttention: true,
quarantinedCatalogEntryCount: 1,
pendingActionRequestCount: 1,
reasons: ["health", "quarantined_catalog_entries", "pending_action_requests"],
}),
]);
});
it("cancels invalid-signature pending action requests but keeps unsigned in-flight ones out of the review queue without cancelling them", async () => {
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "current-secret");
const company = await createCompany(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: `Action review app ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: `Action review connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example/mcp" },
}).returning();
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "kv_set",
toolName: "kv_set",
title: "KV Set",
riskLevel: "write",
isWrite: true,
status: "active",
versionHash: "v1",
schemaHash: "s1",
}).returning();
const canonicalArguments = canonicalToolArguments({ key: "alpha", value: "one" });
const invocationValues = [1, 2, 3].map(() => ({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
toolName: "kv_set",
argumentsHash: "args-hash",
argumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length },
policyDecision: "require_approval" as const,
approvalState: "pending" as const,
status: "awaiting_approval" as const,
}));
const [validInvocation, missingSignatureInvocation, oldSecretInvocation] =
await db.insert(toolInvocations).values(invocationValues).returning();
const validSignedArguments = signToolArguments({
invocationId: validInvocation.id,
toolName: validInvocation.toolName,
canonicalArguments,
signingSecret: "current-secret",
});
const oldSecretSignedArguments = signToolArguments({
invocationId: oldSecretInvocation.id,
toolName: oldSecretInvocation.toolName,
canonicalArguments,
signingSecret: "old-secret",
});
const [validRequest, missingSignatureRequest, oldSecretRequest] = await db.insert(toolActionRequests).values([
{
companyId: company.id,
invocationId: validInvocation.id,
status: "pending",
canonicalArgumentsHash: "args-hash",
canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length },
signedArguments: validSignedArguments,
},
{
companyId: company.id,
invocationId: missingSignatureInvocation.id,
status: "pending",
canonicalArgumentsHash: "args-hash",
canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length },
signedArguments: null,
},
{
companyId: company.id,
invocationId: oldSecretInvocation.id,
status: "pending",
canonicalArgumentsHash: "args-hash",
canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length },
signedArguments: oldSecretSignedArguments,
},
]).returning();
const list = await toolAccessService(db).listActionRequests(company.id, "pending");
const rows = await db.select().from(toolActionRequests);
const statusById = new Map(rows.map((row) => [row.id, row.status]));
expect(list.map((item) => item.request.id)).toEqual([validRequest.id]);
expect(statusById.get(validRequest.id)).toBe("pending");
// An unsigned request is still being created; the read hides it but keeps it
// pending, so the creator can finish signing and the later approve succeeds.
expect(statusById.get(missingSignatureRequest.id)).toBe("pending");
// A request signed with a rotated/old secret is unverifiable and is cancelled.
expect(statusById.get(oldSecretRequest.id)).toBe("cancelled");
});
it("tracks new profile tools, reviews mixed allow/block decisions, and clears pending counts", async () => {
const company = await createCompany(db);
const app = createRouteApp(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: `Review app ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: `Review connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://review.example/mcp" },
transportConfig: { url: "https://review.example/mcp" },
healthStatus: "ok",
}).returning();
const oldSeenAt = new Date("2026-01-01T00:00:00.000Z");
const profileCreatedAt = new Date("2026-01-02T00:00:00.000Z");
const newSeenAt = new Date("2026-01-03T00:00:00.000Z");
const [oldEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "read_email",
toolName: "read_email",
title: "Read email",
description: "Read mailbox messages.",
riskLevel: "read",
isReadOnly: true,
status: "active",
versionHash: "old-v1",
schemaHash: "old-s1",
firstSeenAt: oldSeenAt,
lastSeenAt: oldSeenAt,
}).returning();
const [sendEntry, deleteEntry] = await db.insert(toolCatalogEntries).values([
{
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "send_email",
toolName: "send_email",
title: "Send email",
description: "Send outbound messages.",
riskLevel: "write" as const,
isReadOnly: false,
isWrite: true,
status: "active" as const,
versionHash: "send-v1",
schemaHash: "send-s1",
firstSeenAt: newSeenAt,
lastSeenAt: newSeenAt,
},
{
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "delete_email",
toolName: "delete_email",
title: "Delete email",
description: "Delete mailbox messages.",
riskLevel: "destructive" as const,
isReadOnly: false,
isDestructive: true,
status: "active" as const,
versionHash: "delete-v1",
schemaHash: "delete-s1",
firstSeenAt: newSeenAt,
lastSeenAt: newSeenAt,
},
]).returning();
const [profile] = await db.insert(toolProfiles).values({
companyId: company.id,
profileKey: `review-${randomUUID()}`,
name: "Read-only starter",
status: "active",
defaultAction: "deny",
createdAt: profileCreatedAt,
updatedAt: profileCreatedAt,
}).returning();
await db.insert(toolProfileEntries).values({
companyId: company.id,
profileId: profile.id,
selectorType: "catalog_entry",
effect: "include",
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: oldEntry.id,
});
const listRes = await request(app).get(`/api/companies/${company.id}/tools/profiles`);
expect(listRes.status).toBe(200);
expect(listRes.body.profiles).toContainEqual(expect.objectContaining({
id: profile.id,
newToolsPendingCount: 2,
}));
const detailRes = await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`);
expect(detailRes.status).toBe(200);
expect(detailRes.body).toMatchObject({
profileId: profile.id,
pendingCount: 2,
tools: expect.arrayContaining([
expect.objectContaining({
catalogEntryId: sendEntry.id,
toolName: "send_email",
applicationName: application.name,
connectionName: connection.name,
capability: "write",
addedAt: newSeenAt.toISOString(),
}),
expect.objectContaining({
catalogEntryId: deleteEntry.id,
capability: "destructive",
}),
]),
});
const reviewRes = await request(app)
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
.send({
decisions: [
{ catalogEntryId: sendEntry.id, decision: "allow" },
{ catalogEntryId: deleteEntry.id, decision: "keep_blocked" },
],
});
expect(reviewRes.status).toBe(200);
expect(reviewRes.body).toMatchObject({
allowedCount: 1,
keptBlockedCount: 1,
profile: expect.objectContaining({ id: profile.id, newToolsPendingCount: 0 }),
entriesCreated: [expect.objectContaining({ catalogEntryId: sendEntry.id, effect: "include" })],
reviewedCatalogEntryIds: expect.arrayContaining([sendEntry.id, deleteEntry.id]),
});
const profileEntries = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id));
expect(profileEntries.some((entry) => entry.catalogEntryId === sendEntry.id && entry.effect === "include")).toBe(true);
expect(profileEntries.some((entry) => entry.catalogEntryId === deleteEntry.id)).toBe(false);
const [reviewedProfile] = await db.select().from(toolProfiles).where(eq(toolProfiles.id, profile.id));
expect(reviewedProfile.newToolsReviewedAt).toBeInstanceOf(Date);
const afterReviewRes = await request(app).get(`/api/companies/${company.id}/tools/profiles`);
expect(afterReviewRes.body.profiles).toContainEqual(expect.objectContaining({
id: profile.id,
newToolsPendingCount: 0,
}));
});
it("returns addedAt for auto-allowed effective profile tools without pending review state", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: "Tool User",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "Auto app",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Auto connection",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://auto.example/mcp" },
transportConfig: { url: "https://auto.example/mcp" },
healthStatus: "ok",
}).returning();
const addedAt = new Date("2026-02-03T00:00:00.000Z");
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "auto_allowed",
toolName: "auto_allowed",
riskLevel: "write",
isWrite: true,
status: "active",
versionHash: "auto-v1",
schemaHash: "auto-s1",
firstSeenAt: addedAt,
lastSeenAt: addedAt,
}).returning();
const [profile] = await db.insert(toolProfiles).values({
companyId: company.id,
profileKey: `auto-${randomUUID()}`,
name: "Auto allow",
status: "active",
defaultAction: "allow",
}).returning();
await db.insert(toolProfileBindings).values({
companyId: company.id,
profileId: profile.id,
targetType: "company",
targetId: company.id,
});
const effective = await service.getEffectiveProfilesForAgent(company.id, agent.id);
expect(effective.allowedTools).toContainEqual(expect.objectContaining({
id: catalogEntry.id,
addedAt,
firstSeenAt: addedAt,
}));
const profiles = await service.listProfiles(company.id);
expect(profiles.find((item) => item.id === profile.id)?.newToolsPendingCount).toBe(0);
});
it("surfaces and clears profile new-tools attention feed items", async () => {
const company = await createCompany(db);
const app = createRouteApp(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "Attention review app",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Attention review connection",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://attention-review.example/mcp" },
transportConfig: { url: "https://attention-review.example/mcp" },
healthStatus: "ok",
}).returning();
const [oldEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "read_records",
toolName: "read_records",
riskLevel: "read",
isReadOnly: true,
status: "active",
versionHash: "read-v1",
schemaHash: "read-s1",
firstSeenAt: new Date("2026-03-01T00:00:00.000Z"),
lastSeenAt: new Date("2026-03-01T00:00:00.000Z"),
}).returning();
const [newEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "write_records",
toolName: "write_records",
riskLevel: "write",
isWrite: true,
status: "active",
versionHash: "write-v1",
schemaHash: "write-s1",
firstSeenAt: new Date("2026-03-03T00:00:00.000Z"),
lastSeenAt: new Date("2026-03-03T00:00:00.000Z"),
}).returning();
const [profile] = await db.insert(toolProfiles).values({
companyId: company.id,
profileKey: `attention-review-${randomUUID()}`,
name: "Read-only starter",
status: "active",
defaultAction: "deny",
createdAt: new Date("2026-03-02T00:00:00.000Z"),
updatedAt: new Date("2026-03-02T00:00:00.000Z"),
}).returning();
await db.insert(toolProfileEntries).values({
companyId: company.id,
profileId: profile.id,
selectorType: "catalog_entry",
effect: "include",
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: oldEntry.id,
});
const attentionRes = await request(app).get(`/api/companies/${company.id}/tools/apps/attention`);
expect(attentionRes.status).toBe(200);
expect(attentionRes.body.totals).toMatchObject({
connections: 1,
newToolsPendingReview: 1,
newToolsPendingProfiles: 1,
});
expect(attentionRes.body.apps).toEqual([
expect.objectContaining({
connection: expect.objectContaining({ id: connection.id }),
newToolsPendingReviewCount: 1,
newToolsPendingProfiles: [expect.objectContaining({
profileId: profile.id,
profileName: "Read-only starter",
pendingCount: 1,
})],
reasons: ["profile_new_tools"],
}),
]);
const reviewRes = await request(app)
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
.send({ decisions: [{ catalogEntryId: newEntry.id, decision: "keep_blocked" }] });
expect(reviewRes.status).toBe(200);
const clearedRes = await request(app).get(`/api/companies/${company.id}/tools/apps/attention`);
expect(clearedRes.body.totals).toMatchObject({
connections: 0,
newToolsPendingReview: 0,
newToolsPendingProfiles: 0,
});
expect(clearedRes.body.apps).toEqual([]);
});
it("rolls back app connect drafts when health check fails", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"));
await expect(service.connectGalleryApp(company.id, {
link: "https://broken.example/mcp",
name: "Broken app",
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 502 });
await expect(db.select().from(toolApplications)).resolves.toHaveLength(0);
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
await expect(db.select().from(toolCatalogEntries)).resolves.toHaveLength(0);
});
it("reuses and revives an existing application when connecting with applicationId", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
mockToolsList([
{
name: "read_items",
description: "Read items.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
},
]);
const first = await service.connectGalleryApp(company.id, {
link: "https://reuse.example.test/actions",
name: "Reusable app",
}, { actorType: "user", actorId: "board" });
const applicationId = first.application.id;
// Simulate "Remove app": archive the connection and its application.
await db.update(toolConnections)
.set({ status: "archived" })
.where(eq(toolConnections.id, first.connectionId));
await db.update(toolApplications)
.set({ status: "archived", archivedAt: new Date() })
.where(eq(toolApplications.id, applicationId));
const second = await service.connectGalleryApp(company.id, {
link: "https://reuse.example.test/actions",
name: "Reusable app",
applicationId,
}, { actorType: "user", actorId: "board" });
expect(second.application.id).toBe(applicationId);
// The archived connection is revived in place, not duplicated.
expect(second.connectionId).toBe(first.connectionId);
await expect(db.select().from(toolApplications)).resolves.toHaveLength(1);
await expect(db.select().from(toolConnections)).resolves.toHaveLength(1);
const [revived] = await db.select().from(toolApplications).where(eq(toolApplications.id, applicationId));
expect(revived.status).toBe("draft");
expect(revived.archivedAt).toBeNull();
await expect(service.connectGalleryApp(company.id, {
link: "https://reuse.example.test/actions",
applicationId: randomUUID(),
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 404 });
});
it("allows multiple same-named connections on one application", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
mockToolsList([
{
name: "read_items",
description: "Read items.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
},
]);
const first = await service.connectGalleryApp(company.id, {
link: "https://first.example.test/actions",
name: "Notion",
}, { actorType: "user", actorId: "board" });
const second = await service.connectGalleryApp(company.id, {
link: "https://second.example.test/actions",
name: "Notion",
applicationId: first.application.id,
}, { actorType: "user", actorId: "board" });
expect(second.application.id).toBe(first.application.id);
expect(second.connectionId).not.toBe(first.connectionId);
const rows = await db.select().from(toolConnections).where(eq(toolConnections.applicationId, first.application.id));
expect(rows).toHaveLength(2);
expect(rows.map((row) => row.name)).toEqual(["Notion", "Notion"]);
expect(new Set(rows.map((row) => row.uid))).toHaveProperty("size", 2);
});
it("does not delete a reused application when the connect rolls back", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
mockToolsList([
{
name: "read_items",
description: "Read items.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
},
]);
const first = await service.connectGalleryApp(company.id, {
link: "https://rollback.example.test/actions",
name: "Rollback app",
}, { actorType: "user", actorId: "board" });
await db.update(toolConnections)
.set({ status: "archived" })
.where(eq(toolConnections.id, first.connectionId));
await db.update(toolApplications)
.set({ status: "archived", archivedAt: new Date() })
.where(eq(toolApplications.id, first.application.id));
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"));
await expect(service.connectGalleryApp(company.id, {
link: "https://rollback.example.test/actions",
applicationId: first.application.id,
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 502 });
const [stillThere] = await db.select().from(toolApplications).where(eq(toolApplications.id, first.application.id));
expect(stillThere).toBeTruthy();
expect(stillThere.status).toBe("archived");
const [connectionBack] = await db.select().from(toolConnections).where(eq(toolConnections.id, first.connectionId));
expect(connectionBack.status).toBe("archived");
});
it("connects pasted links with an optional secret-backed app key", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const fetchMock = mockToolsList([
{
name: "read_items",
description: "Read items.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
},
]);
const connect = await service.connectGalleryApp(company.id, {
link: "https://links.example.test/actions",
name: "Linked app",
credentialValues: { "credentials.authorization": "link-secret" },
}, { actorType: "user", actorId: "board" });
expect(fetchMock).toHaveBeenCalledWith(
"https://links.example.test/actions",
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer link-secret" }),
}),
);
expect(connect.connection).toMatchObject({
status: "draft",
enabled: false,
config: { url: "https://links.example.test/actions", quarantineNewEntries: true },
credentialSecretRefs: [
expect.objectContaining({
configPath: "credentials.authorization",
label: "App key",
}),
],
});
expect(JSON.stringify(connect.connection.config)).not.toContain("link-secret");
await expect(db.select().from(companySecrets)).resolves.toHaveLength(1);
await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(2);
});
it("returns a sign-in-required code when a pasted link answers with an OAuth challenge", async () => {
const company = await createCompany(db);
const app = createRouteApp(db);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 401,
headers: { get: (name: string) => name.toLowerCase() === "www-authenticate" ? "Bearer realm=\"app\"" : null },
text: async () => JSON.stringify({ error: "unauthorized" }),
json: async () => ({}),
} as Response);
const res = await request(app)
.post(`/api/companies/${company.id}/tools/apps/connect`)
.send({ link: "https://signin.example.test/actions", name: "Sign-in app" });
expect(res.status).toBe(502);
expect(res.body).toMatchObject({
error: "This app needs you to sign in.",
details: expect.objectContaining({ code: "oauth_challenge" }),
});
await expect(db.select().from(toolApplications)).resolves.toHaveLength(0);
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
});
it("rejects OAuth metadata redirects to private endpoints", async () => {
const company = await createCompany(db);
const app = createRouteApp(db, undefined, undefined, {
deploymentMode: "authenticated",
deploymentExposure: "public",
});
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://8.8.8.8/mcp") {
return {
ok: false,
status: 401,
headers: { get: (name: string) => name.toLowerCase() === "www-authenticate"
? 'Bearer resource_metadata="https://8.8.8.8/.well-known/oauth-protected-resource"'
: null },
text: async () => "",
json: async () => ({}),
} as Response;
}
if (href === "https://8.8.8.8/.well-known/oauth-protected-resource") {
expect(init?.redirect).toBe("manual");
return {
ok: false,
status: 302,
headers: { get: (name: string) => name.toLowerCase() === "location" ? "http://169.254.169.254/oauth" : null },
json: async () => ({}),
} as Response;
}
throw new Error(`unexpected fetch ${href}`);
});
const res = await request(app)
.post(`/api/companies/${company.id}/tools/apps/connect`)
.send({ link: "https://8.8.8.8/mcp", name: "Redirect OAuth MCP" });
expect(res.status).toBe(502);
expect(fetchMock).toHaveBeenCalledWith(
"https://8.8.8.8/.well-known/oauth-protected-resource",
expect.objectContaining({ redirect: "manual" }),
);
expect(fetchMock.mock.calls.some(([url]) => String(url).startsWith("http://169.254.169.254"))).toBe(false);
});
it("discovers OAuth for pasted MCP links and completes sign-in without a gallery entry", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_ID", "generic-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_SECRET", "generic-client-secret");
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
const company = await createCompany(db);
const app = createRouteApp(db);
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://generic.example.test/mcp") {
return {
ok: false,
status: 401,
headers: {
get: (name: string) => name.toLowerCase() === "www-authenticate"
? "Bearer resource_metadata=\"https://generic.example.test/.well-known/oauth-protected-resource\""
: null,
},
text: async () => "",
json: async () => ({}),
} as Response;
}
if (href === "https://generic.example.test/.well-known/oauth-protected-resource") {
return {
ok: true,
json: async () => ({
authorization_endpoint: "https://generic.example.test/oauth/authorize",
token_endpoint: "https://generic.example.test/oauth/token",
scopes_supported: ["tools.read", "tools.write"],
}),
} as Response;
}
if (href === "https://generic.example.test/oauth/token") {
const body = init?.body as URLSearchParams;
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("client_id")).toBe("generic-client-id");
expect(body.get("client_secret")).toBe("generic-client-secret");
return {
ok: true,
json: async () => ({
access_token: "generic-access-token",
refresh_token: "generic-refresh-token",
expires_in: 3600,
token_type: "Bearer",
scope: "tools.read tools.write",
}),
} as Response;
}
throw new Error(`unexpected fetch ${href}`);
});
const connectRes = await request(app)
.post(`/api/companies/${company.id}/tools/apps/connect`)
.send({ link: "https://generic.example.test/mcp", name: "Generic OAuth MCP" });
expect(connectRes.status).toBe(201);
expect(connectRes.body.auth).toMatchObject({ kind: "oauth" });
const startUrl = new URL(connectRes.body.auth.startUrl);
expect(`${startUrl.origin}${startUrl.pathname}`).toBe("https://generic.example.test/oauth/authorize");
expect(startUrl.searchParams.get("client_id")).toBe("generic-client-id");
expect(startUrl.searchParams.get("scope")).toBe("tools.read tools.write");
const state = startUrl.searchParams.get("state");
expect(state).toBeTruthy();
expect(connectRes.body.connection.config.oauth).toMatchObject({
provider: "generic_example_test",
tokenUrl: "https://generic.example.test/oauth/token",
grantType: "authorization_code",
});
fetchMock.mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://generic.example.test/oauth/token") {
const body = init?.body as URLSearchParams;
expect(body.get("code")).toBe("generic-code");
return {
ok: true,
json: async () => ({
access_token: "generic-access-token",
refresh_token: "generic-refresh-token",
expires_in: 3600,
token_type: "Bearer",
}),
} as Response;
}
if (href === "https://generic.example.test/mcp") {
expect(init?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer generic-access-token" }));
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "read_generic", annotations: { readOnlyHint: true } }] },
});
}
throw new Error(`unexpected fetch ${href}`);
});
const callbackRes = await request(app)
.get("/api/tools/oauth/callback")
.query({ state, code: "generic-code" });
expect(callbackRes.status).toBe(200);
expect(callbackRes.body.catalog).toEqual([
expect.objectContaining({ toolName: "read_generic", riskLevel: "read" }),
]);
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectRes.body.connectionId));
expect(connection.config).toMatchObject({
oauth: expect.objectContaining({
provider: "generic_example_test",
credentialScope: expect.objectContaining({ type: "user" }),
}),
});
expect(JSON.stringify(connection.config)).not.toContain("generic-access-token");
});
it("blocks Smoke Lab OAuth issuer URLs from the normal tool OAuth secret pipeline", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const smokeAuthorizeUrl = `http://127.0.0.1:3100/api/companies/${company.id}/smoke-lab/oauth/authorize`;
const smokeTokenUrl = `http://127.0.0.1:3100/api/companies/${company.id}/smoke-lab/oauth/token`;
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `smoke-oauth-masquerade-${randomUUID()}`,
name: "Smoke OAuth masquerade",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application!.id,
name: "Smoke OAuth masquerade connection",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: false,
healthStatus: "unchecked",
config: {
url: "http://127.0.0.1:3100/mcp",
oauth: {
provider: "smoke_lab",
authorizationUrl: smokeAuthorizeUrl,
tokenUrl: smokeTokenUrl,
scopes: ["repo", "user:email", "offline_access"],
},
},
transportConfig: { url: "http://127.0.0.1:3100/mcp" },
credentialSecretRefs: [],
credentialRefs: [],
}).returning();
await expect(service.startOAuth(company.id, connection!.id, {
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
})).rejects.toMatchObject({
status: 422,
message: "Smoke Lab OAuth provider cannot be used for tool app sign-in",
});
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
await db.insert(toolOauthStates).values({
state: "legacy-smoke-state",
companyId: company.id,
connectionId: connection!.id,
codeVerifier: "legacy-smoke-code-verifier",
createdByActorType: "user",
createdByActorId: "board",
createdBySessionId: null,
expiresAt: new Date(Date.now() + 60_000),
});
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("smoke OAuth token endpoint must not be called"));
await expect(service.completeOAuthCallback({
state: "legacy-smoke-state",
code: "smoke-code",
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
})).rejects.toMatchObject({
status: 422,
message: "Smoke Lab OAuth provider cannot be used for tool app sign-in",
});
expect(fetchMock).not.toHaveBeenCalled();
const [updatedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id));
expect(updatedConnection!.credentialSecretRefs).toEqual([]);
await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(0);
await expect(db.select().from(companySecrets)).resolves.toHaveLength(0);
});
it("starts OAuth only for the marked Smoke Lab HTTP fixture", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: "paperclip.smoke-lab.http-fixture",
name: "Smoke Lab HTTP MCP fixture",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application!.id,
name: "Smoke Lab HTTP MCP fixture",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
healthStatus: "ok",
config: {
smokeLabFixture: "oauth-http",
url: "http://smoke-fixture.test/mcp",
oauth: {
provider: "smoke_lab",
smokeLabFixture: true,
scopes: ["smoke:openid", "smoke:profile", "smoke:email"],
},
},
transportConfig: {},
credentialSecretRefs: [],
credentialRefs: [],
}).returning();
const result = await service.startOAuth(company.id, connection!.id, {
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const authorizationUrl = new URL(result.authorizationUrl);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe(
`http://paperclip.test/api/companies/${company.id}/smoke-lab/oauth/authorize`,
);
expect(authorizationUrl.searchParams.get("client_id")).toBe("paperclip-smoke-lab");
expect(authorizationUrl.searchParams.get("scope")).toBe("smoke:openid smoke:profile smoke:email");
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1);
const state = authorizationUrl.searchParams.get("state");
expect(state).toBeTruthy();
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
if (String(url).endsWith("/smoke-lab/oauth/token")) {
return {
ok: true,
json: async () => ({
access_token: "smoke-access-token",
refresh_token: "smoke-refresh-token",
token_type: "Bearer",
scope: "smoke:openid smoke:profile smoke:email",
}),
} as Response;
}
if (String(url) === "http://smoke-fixture.test/mcp") {
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "todo.list", annotations: { readOnlyHint: true } }] },
});
}
throw new Error(`unexpected fetch ${String(url)}`);
});
await service.completeOAuthCallback({
state: state!,
code: "smoke-code",
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain(
`http://paperclip.test/api/companies/${company.id}/smoke-lab/oauth/token`,
);
expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain("http://smoke-fixture.test/mcp");
const [updatedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id));
expect(updatedConnection).toMatchObject({ enabled: true });
expect(updatedConnection!.config).toMatchObject({
oauth: expect.objectContaining({ connectedAt: expect.any(String) }),
});
});
it("connects gallery apps and finishes access profiles, bindings, and ask-first policies", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const fetchMock = mockToolsList([
{
name: "list_zaps",
description: "List Zapier actions.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
},
{
name: "update_zap",
description: "Update a Zapier action.",
inputSchema: { type: "object", properties: { id: { type: "string" } } },
annotations: { readOnlyHint: false },
},
]);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `App Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const connect = await service.connectGalleryApp(company.id, {
galleryKey: "zapier",
name: "Zapier workspace",
credentialValues: { "credentials.authorization": "zap-secret" },
}, { actorType: "user", actorId: "board" });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledWith(
"https://mcp.zapier.com/api/mcp",
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer zap-secret" }),
}),
);
expect(connect.connection).toMatchObject({
status: "draft",
enabled: false,
config: expect.objectContaining({ sourceTemplateKey: "zapier", quarantineNewEntries: true }),
credentialSecretRefs: [
expect.objectContaining({
configPath: "credentials.authorization",
label: "Zapier MCP token",
}),
],
});
expect(connect.actions.readOnly).toEqual([
expect.objectContaining({ toolName: "list_zaps", riskLevel: "read" }),
]);
expect(connect.actions.canMakeChanges).toEqual([
expect.objectContaining({ toolName: "update_zap", riskLevel: "write" }),
]);
const listEntry = connect.catalog.find((entry) => entry.toolName === "list_zaps")!;
const updateEntry = connect.catalog.find((entry) => entry.toolName === "update_zap")!;
expect(connect.catalog).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: listEntry.id, status: "active", quarantineReason: null }),
expect.objectContaining({ id: updateEntry.id, status: "active", quarantineReason: null }),
]),
);
const finish = await service.finishGalleryAppConnection(company.id, connect.connectionId, {
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
askFirstCatalogEntryIds: [updateEntry.id],
access: { agentIds: [agent.id] },
}, { actorType: "user", actorId: "board" });
expect(finish.connection).toMatchObject({ id: connect.connectionId, status: "active", enabled: true });
expect(finish.profile).toMatchObject({
profileKey: `app:${connect.connectionId}`,
defaultAction: "deny",
status: "active",
});
expect(finish.profileEntries).toEqual(
expect.arrayContaining([
expect.objectContaining({ selectorType: "catalog_entry", catalogEntryId: listEntry.id, effect: "include" }),
expect.objectContaining({ selectorType: "catalog_entry", catalogEntryId: updateEntry.id, effect: "include" }),
]),
);
expect(finish.profileBindings).toEqual([
expect.objectContaining({ targetType: "agent", targetId: agent.id }),
]);
expect(finish.policies).toEqual([
expect.objectContaining({
policyType: "require_approval",
enabled: true,
selectors: { catalogEntryId: updateEntry.id },
}),
]);
const repeatFinish = await service.finishGalleryAppConnection(company.id, connect.connectionId, {
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
askFirstCatalogEntryIds: [updateEntry.id],
access: { agentIds: [agent.id, agent.id] },
}, { actorType: "user", actorId: "board" });
expect(repeatFinish.profile.id).toBe(finish.profile.id);
expect(repeatFinish.profileEntries).toHaveLength(2);
expect(repeatFinish.profileBindings).toEqual([
expect.objectContaining({ targetType: "agent", targetId: agent.id }),
]);
expect(repeatFinish.policies).toEqual([
expect.objectContaining({
policyType: "require_approval",
enabled: true,
selectors: { catalogEntryId: updateEntry.id },
}),
]);
await expect(db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, finish.profile.id))).resolves.toHaveLength(1);
await expect(db.select().from(toolPolicies).where(eq(toolPolicies.companyId, company.id))).resolves.toHaveLength(1);
const finishedCatalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connect.connectionId));
expect(finishedCatalog).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: listEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null }),
expect.objectContaining({ id: updateEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null }),
]),
);
fetchMock.mockResolvedValueOnce(mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: {
tools: [
{
name: "list_zaps",
description: "List Zapier actions.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
},
{
name: "update_zap",
description: "Update a Zapier action with new args.",
inputSchema: { type: "object", properties: { id: { type: "string" }, label: { type: "string" } } },
annotations: { readOnlyHint: false },
},
{
name: "create_zap",
description: "Create a Zapier action.",
inputSchema: { type: "object", properties: { label: { type: "string" } } },
annotations: { readOnlyHint: false },
},
],
},
}));
const rereview = await service.refreshCatalog(connect.connectionId, { actorType: "user", actorId: "board" });
expect(rereview.quarantinedCount).toBe(2);
expect(rereview.catalog).toEqual(
expect.arrayContaining([
expect.objectContaining({ toolName: "list_zaps", status: "active" }),
expect.objectContaining({ toolName: "update_zap", status: "quarantined", quarantineReason: "pending_review" }),
expect.objectContaining({ toolName: "create_zap", status: "quarantined", quarantineReason: "pending_review" }),
]),
);
const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.companyId, company.id));
expect(policy).toMatchObject({
policyType: "require_approval",
selectors: { catalogEntryId: updateEntry.id },
config: expect.objectContaining({
source: "app_gallery_finish",
connectionId: connect.connectionId,
catalogEntryId: updateEntry.id,
}),
});
const createEntry = rereview.catalog.find((entry) => entry.toolName === "create_zap")!;
await service.finishGalleryAppConnection(company.id, connect.connectionId, {
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
askFirstCatalogEntryIds: [updateEntry.id],
access: { agentIds: [agent.id] },
}, { actorType: "user", actorId: "board" });
const stillQuarantined = await db
.select()
.from(toolCatalogEntries)
.where(eq(toolCatalogEntries.connectionId, connect.connectionId));
expect(stillQuarantined).toEqual(expect.arrayContaining([
expect.objectContaining({ id: updateEntry.id, status: "quarantined" }),
expect.objectContaining({ id: createEntry.id, status: "quarantined" }),
]));
await expect(service.finishGalleryAppConnection(company.id, connect.connectionId, {
enabledCatalogEntryIds: [listEntry.id, createEntry.id],
askFirstCatalogEntryIds: [createEntry.id],
reviewedCatalogEntryIds: [createEntry.id],
access: { agentIds: [agent.id] },
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
status: 400,
message: "Action review decisions must cover every currently quarantined action exactly once",
});
const reviewed = await service.finishGalleryAppConnection(company.id, connect.connectionId, {
enabledCatalogEntryIds: [listEntry.id, createEntry.id],
askFirstCatalogEntryIds: [createEntry.id],
reviewedCatalogEntryIds: [updateEntry.id, createEntry.id],
access: { agentIds: [agent.id] },
}, { actorType: "user", actorId: "board" });
expect(reviewed.profileEntries).toEqual(expect.arrayContaining([
expect.objectContaining({ catalogEntryId: listEntry.id }),
expect.objectContaining({ catalogEntryId: createEntry.id }),
]));
expect(reviewed.profileEntries).not.toEqual(expect.arrayContaining([
expect.objectContaining({ catalogEntryId: updateEntry.id }),
]));
const reviewedCatalog = await db
.select()
.from(toolCatalogEntries)
.where(eq(toolCatalogEntries.connectionId, connect.connectionId));
expect(reviewedCatalog).toEqual(expect.arrayContaining([
expect.objectContaining({ id: updateEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null }),
expect.objectContaining({ id: createEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null }),
]));
const attentionAfterReview = await service.listAppsNeedingAttention(company.id);
expect(attentionAfterReview.apps).toEqual([]);
});
it("resolves Notion reads as allowed, mutations as ask-first, and denies cross-company use", async () => {
const company = await createCompany(db);
const otherCompany = await createCompany(db);
const agent = await createAgent(db, company.id);
const service = toolAccessService(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `app-gallery:notion:${randomUUID()}`,
name: "Notion workspace",
type: "mcp_http",
status: "draft",
metadata: { sourceTemplateKey: "notion", galleryKey: "notion" },
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Notion workspace",
uid: `notion/${randomUUID()}`,
transport: "mcp_remote",
authKind: "oauth",
status: "draft",
enabled: false,
config: {
url: "https://mcp.notion.com/mcp",
sourceTemplateKey: "notion",
quarantineNewEntries: true,
},
transportConfig: { url: "https://mcp.notion.com/mcp" },
healthStatus: "ok",
}).returning();
mockToolsList([
{ name: "notion-fetch", annotations: { readOnlyHint: true } },
// These two mutations do not contain a generic create/update verb.
{ name: "notion-move-pages" },
{ name: "notion-duplicate-page", annotations: { readOnlyHint: true } },
]);
const refresh = await service.refreshCatalog(connection.id);
const fetchEntry = refresh.catalog.find((entry) => entry.toolName === "notion-fetch")!;
const moveEntry = refresh.catalog.find((entry) => entry.toolName === "notion-move-pages")!;
const duplicateEntry = refresh.catalog.find((entry) => entry.toolName === "notion-duplicate-page")!;
expect(refresh.catalog).toEqual(expect.arrayContaining([
expect.objectContaining({ id: fetchEntry.id, riskLevel: "read", isReadOnly: true }),
expect.objectContaining({ id: moveEntry.id, riskLevel: "write", isWrite: true }),
expect.objectContaining({ id: duplicateEntry.id, riskLevel: "write", isWrite: true }),
]));
await expect(service.finishGalleryAppConnection(otherCompany.id, connection.id, {
enabledCatalogEntryIds: [fetchEntry.id],
askFirstCatalogEntryIds: [],
access: "all_agents",
})).rejects.toMatchObject({ status: 404 });
await service.finishGalleryAppConnection(company.id, connection.id, {
enabledCatalogEntryIds: [fetchEntry.id, moveEntry.id, duplicateEntry.id],
askFirstCatalogEntryIds: [moveEntry.id, duplicateEntry.id],
access: "all_agents",
});
const policyService = toolAccessPolicyService(db);
const decide = (catalogEntryId: string, toolName: string) => policyService.decide({
companyId: company.id,
actor: { actorType: "agent", actorId: agent.id },
request: { connectionId: connection.id, catalogEntryId, toolName, arguments: {} },
});
await expect(decide(fetchEntry.id, fetchEntry.toolName)).resolves.toMatchObject({
decision: "allow",
reasonCode: "allow_profile",
});
await expect(decide(moveEntry.id, moveEntry.toolName)).resolves.toMatchObject({
decision: "require_approval",
reasonCode: "requires_approval_policy",
});
await expect(decide(duplicateEntry.id, duplicateEntry.toolName)).resolves.toMatchObject({
decision: "require_approval",
reasonCode: "requires_approval_policy",
});
await expect(policyService.decide({
companyId: otherCompany.id,
actor: { actorType: "user", actorId: "other-board" },
request: {
connectionId: connection.id,
catalogEntryId: fetchEntry.id,
toolName: fetchEntry.toolName,
arguments: {},
},
})).resolves.toMatchObject({
decision: "deny",
reasonCode: "deny_missing_tool",
});
});
it("rolls back gallery app finish when a later write fails after clearing profile state", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
mockToolsList([
{
name: "list_zaps",
description: "List Zapier actions.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
},
{
name: "update_zap",
description: "Update a Zapier action.",
inputSchema: { type: "object", properties: { id: { type: "string" } } },
annotations: { readOnlyHint: false },
},
]);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `Rollback Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const connect = await service.connectGalleryApp(company.id, {
galleryKey: "zapier",
name: "Zapier rollback",
credentialValues: { "credentials.authorization": "zap-secret" },
}, { actorType: "user", actorId: "board" });
const listEntry = connect.catalog.find((entry) => entry.toolName === "list_zaps")!;
const updateEntry = connect.catalog.find((entry) => entry.toolName === "update_zap")!;
const firstFinish = await service.finishGalleryAppConnection(company.id, connect.connectionId, {
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
askFirstCatalogEntryIds: [updateEntry.id],
access: { agentIds: [agent.id] },
}, { actorType: "user", actorId: "board" });
const entriesBefore = await db
.select()
.from(toolProfileEntries)
.where(eq(toolProfileEntries.profileId, firstFinish.profile.id));
const bindingsBefore = await db
.select()
.from(toolProfileBindings)
.where(eq(toolProfileBindings.profileId, firstFinish.profile.id));
const policiesBefore = await db
.select()
.from(toolPolicies)
.where(and(eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true)));
await db.insert(toolProfiles).values({
companyId: company.id,
profileKey: `conflict-${randomUUID()}`,
name: "Conflicting app profile",
status: "active",
defaultAction: "deny",
});
await db
.update(toolConnections)
.set({ name: "Conflicting app profile", updatedAt: new Date() })
.where(eq(toolConnections.id, connect.connectionId));
await expect(service.finishGalleryAppConnection(company.id, connect.connectionId, {
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
askFirstCatalogEntryIds: [updateEntry.id],
access: { agentIds: [agent.id] },
}, { actorType: "user", actorId: "board" })).rejects.toThrow();
const entriesAfter = await db
.select()
.from(toolProfileEntries)
.where(eq(toolProfileEntries.profileId, firstFinish.profile.id));
const bindingsAfter = await db
.select()
.from(toolProfileBindings)
.where(eq(toolProfileBindings.profileId, firstFinish.profile.id));
const policiesAfter = await db
.select()
.from(toolPolicies)
.where(and(eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true)));
expect(entriesAfter.map((entry) => entry.catalogEntryId).sort()).toEqual(
entriesBefore.map((entry) => entry.catalogEntryId).sort(),
);
expect(bindingsAfter.map((binding) => `${binding.targetType}:${binding.targetId}`).sort()).toEqual(
bindingsBefore.map((binding) => `${binding.targetType}:${binding.targetId}`).sort(),
);
expect(policiesAfter.map((policy) => policy.id).sort()).toEqual(policiesBefore.map((policy) => policy.id).sort());
});
it("reconnects a gallery app by rotating the existing credential in place (PAP-10859)", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
mockToolsList([
{ name: "list_zaps", description: "List", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true } },
]);
const connect = await service.connectGalleryApp(company.id, {
galleryKey: "zapier",
name: "Zapier reconnect",
credentialValues: { "credentials.authorization": "old-secret" },
}, { actorType: "user", actorId: "board" });
const before = await service.getConnection(connect.connectionId, company.id);
const beforeRef = before.credentialSecretRefs.find((r) => r.configPath === "credentials.authorization")!;
expect(beforeRef).toBeDefined();
await expect(
service.reconnectGalleryApp(connect.connectionId, company.id, { credentialValues: {} }, { actorType: "user", actorId: "board" }),
).rejects.toMatchObject({ message: expect.stringContaining("Paste a new key") });
const result = await service.reconnectGalleryApp(
connect.connectionId,
company.id,
{ credentialValues: { "credentials.authorization": "new-secret" } },
{ actorType: "user", actorId: "board" },
);
expect(result.connection.id).toBe(connect.connectionId);
const after = await service.getConnection(connect.connectionId, company.id);
const afterRef = after.credentialSecretRefs.find((r) => r.configPath === "credentials.authorization")!;
// Rotated in place: same secret, no duplicate ref created.
expect(after.credentialSecretRefs).toHaveLength(before.credentialSecretRefs.length);
expect(afterRef.secretId).toBe(beforeRef.secretId);
});
it("stops and restarts local stdio runtime slots through the board service", async () => {
const company = await createCompany(db);
const service = toolAccessService(db, { now: () => new Date("2026-06-06T01:00:00.000Z") });
const connection = await service.createConnection(company.id, {
name: "Restartable local fixture",
transport: "local_stdio",
config: { templateId: "paperclip.echo-calculator-time" },
enabled: true,
status: "active",
});
const health = await service.checkHealth(connection.id);
expect(health.runtimeSlot).toMatchObject({
connectionId: connection.id,
status: "stopped",
runtimeKind: "local_stdio",
});
const restarted = await service.restartRuntimeSlot(company.id, health.runtimeSlot!.id, {
actorType: "user",
actorId: "board-user",
});
expect(restarted).toMatchObject({
id: health.runtimeSlot!.id,
status: "running",
runtimeKind: "local_stdio",
healthStatus: "ok",
});
expect(restarted.providerRef).toMatch(/^local-stdio:/);
const stopped = await service.stopRuntimeSlot(company.id, health.runtimeSlot!.id, {
actorType: "user",
actorId: "board-user",
});
expect(stopped).toMatchObject({
id: health.runtimeSlot!.id,
status: "stopped",
healthMessage: "Runtime slot stopped.",
});
const activities = await db.select().from(activityLog);
expect(activities).toEqual(
expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: "board-user",
action: "tool_runtime_slot.operator_restarted",
entityId: health.runtimeSlot!.id,
}),
expect.objectContaining({
actorType: "user",
actorId: "board-user",
action: "tool_runtime_slot.operator_stopped",
entityId: health.runtimeSlot!.id,
}),
]),
);
});
it("exposes board runtime slot stop and restart endpoints", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const connection = await service.createConnection(company.id, {
name: "Route local fixture",
transport: "local_stdio",
config: { templateId: "paperclip.echo-calculator-time" },
enabled: true,
status: "active",
});
const health = await service.checkHealth(connection.id);
const slotId = health.runtimeSlot!.id;
const restart = await request(app)
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`)
.send({});
expect(restart.status).toBe(200);
expect(restart.body).toMatchObject({
id: slotId,
companyId: company.id,
runtimeKind: "local_stdio",
status: "running",
});
const stop = await request(app)
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`)
.send({});
expect(stop.status).toBe(200);
expect(stop.body).toMatchObject({
id: slotId,
companyId: company.id,
runtimeKind: "local_stdio",
status: "stopped",
});
});
it("requires tools:manage_runtime for company-scoped runtime slot routes", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const userId = `runtime-operator-${randomUUID()}`;
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "operator",
});
const actor = boardSessionActor(company.id, "operator", userId);
const app = createRouteApp(db, actor);
const connection = await service.createConnection(company.id, {
name: "Permissioned local fixture",
transport: "local_stdio",
config: { templateId: "paperclip.echo-calculator-time" },
enabled: true,
status: "active",
});
const health = await service.checkHealth(connection.id);
const slotId = health.runtimeSlot!.id;
await request(app).get(`/api/companies/${company.id}/tools/runtime-slots`).expect(403);
await request(app)
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`)
.send({})
.expect(403);
await request(app)
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`)
.send({})
.expect(403);
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: userId,
permissionKey: "tools:manage_runtime",
scope: null,
grantedByUserId: "owner",
});
const list = await request(app).get(`/api/companies/${company.id}/tools/runtime-slots`).expect(200);
expect(list.body.runtimeSlots).toEqual(
expect.arrayContaining([expect.objectContaining({ id: slotId, runtimeKind: "local_stdio" })]),
);
const restart = await request(app)
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`)
.send({})
.expect(200);
expect(restart.body).toMatchObject({
id: slotId,
companyId: company.id,
runtimeKind: "local_stdio",
status: "running",
});
const stop = await request(app)
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`)
.send({})
.expect(200);
expect(stop.body).toMatchObject({
id: slotId,
companyId: company.id,
runtimeKind: "local_stdio",
status: "stopped",
});
});
it("updates tool applications through the board route and records activity", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const application = await service.createApplication(company.id, {
name: "Editable app",
description: "Before",
type: "mcp_http",
});
const res = await request(app)
.patch(`/api/tool-applications/${application.id}`)
.send({ name: "Edited app", description: "After" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: application.id,
companyId: company.id,
name: "Edited app",
description: "After",
type: "mcp_http",
});
const activities = await db.select().from(activityLog).where(eq(activityLog.entityId, application.id));
expect(activities).toEqual([
expect.objectContaining({
action: "tool_application.updated",
companyId: company.id,
details: expect.objectContaining({ name: "Edited app" }),
}),
]);
});
it("returns 409 instead of 500 when an application update collides with a duplicate name", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
await service.createApplication(company.id, { name: "Existing app", type: "mcp_http" });
const application = await service.createApplication(company.id, { name: "Editable app", type: "mcp_http" });
const res = await request(app)
.patch(`/api/tool-applications/${application.id}`)
.send({ name: "Existing app" });
expect(res.status).toBe(409);
expect(res.body).toMatchObject({
error: "A tool access record with that name already exists",
});
});
it("returns 404 for cross-company application updates and missing applications", async () => {
const allowedCompany = await createCompany(db);
const otherCompany = await createCompany(db);
const application = await toolAccessService(db).createApplication(otherCompany.id, {
name: "Other company app",
type: "mcp_http",
});
const app = createRouteApp(db, {
type: "board",
userId: "member-user",
userName: "Member User",
userEmail: null,
companyIds: [allowedCompany.id],
memberships: [
{
companyId: allowedCompany.id,
membershipRole: "owner",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
});
const forbiddenRes = await request(app)
.patch(`/api/tool-applications/${application.id}`)
.send({ name: "Forbidden edit" });
const missingRes = await request(createRouteApp(db))
.patch(`/api/tool-applications/${randomUUID()}`)
.send({ name: "Missing edit" });
expect(forbiddenRes.status).toBe(404);
expect(missingRes.status).toBe(404);
});
it("keeps direct application and connection mutation routes viewer-safe", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const application = await service.createApplication(company.id, {
name: "Viewer guarded app",
type: "mcp_http",
});
const connection = await service.createConnection(company.id, {
applicationId: application.id,
name: "Viewer guarded connection",
transport: "mcp_remote",
config: { url: "https://viewer-guard.example/mcp" },
status: "active",
enabled: true,
});
const viewerApp = createRouteApp(db, boardSessionActor(company.id, "viewer", "viewer-user"));
const responses = [
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/applications`)
.send({ name: "Viewer create app", type: "mcp_http" }),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/connections`)
.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" }),
await request(viewerApp)
.delete(`/api/tool-applications/${application.id}`),
await request(viewerApp)
.patch(`/api/tool-connections/${connection.id}`)
.send({ name: "Viewer edited connection" }),
await request(viewerApp)
.delete(`/api/tool-connections/${connection.id}`),
await request(viewerApp)
.post(`/api/tool-connections/${connection.id}/health-check`)
.send({}),
await request(viewerApp)
.post(`/api/tool-connections/${connection.id}/catalog/refresh`)
.send({}),
];
for (const res of responses) {
expect(res.status).toBe(403);
expect(res.body.error).toContain("Viewer access is read-only");
}
});
it("keeps direct profile and policy mutation routes viewer-safe", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const service = toolAccessService(db);
const profile = await service.createProfile(company.id, {
profileKey: `viewer-guarded-profile-${randomUUID()}`,
name: "Viewer guarded profile",
defaultAction: "deny",
});
const entry = await service.addProfileEntry(profile.id, {
selectorType: "tool_name",
effect: "include",
toolName: "read_notes",
});
await service.bindProfile(profile.id, { targetType: "agent", targetId: agent.id }, { actorType: "user", actorId: "board" });
const [firstPolicy, secondPolicy] = await db.insert(toolPolicies).values([
{
companyId: company.id,
name: `Viewer guarded allow ${randomUUID()}`,
policyType: "allow",
priority: 100,
selectors: { toolName: "read_notes" },
},
{
companyId: company.id,
name: `Viewer guarded block ${randomUUID()}`,
policyType: "block",
priority: 200,
selectors: { toolName: "delete_notes" },
},
]).returning();
const viewerApp = createRouteApp(db, boardSessionActor(company.id, "viewer", "viewer-user"));
await request(viewerApp).get(`/api/companies/${company.id}/tools/profiles`).expect(200);
await request(viewerApp).get(`/api/companies/${company.id}/tools/policies`).expect(200);
const responses = [
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/profiles`)
.send({ profileKey: `viewer-created-profile-${randomUUID()}`, name: "Viewer created profile", defaultAction: "deny" }),
await request(viewerApp)
.patch(`/api/tool-profiles/${profile.id}`)
.send({ name: "Viewer edited profile" }),
await request(viewerApp)
.post(`/api/tool-profiles/${profile.id}/entries`)
.send({ selectorType: "tool_name", effect: "include", toolName: "viewer_tool" }),
await request(viewerApp)
.patch(`/api/tool-profile-entries/${entry.id}`)
.send({ effect: "exclude" }),
await request(viewerApp)
.delete(`/api/tool-profile-entries/${entry.id}`),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/profiles/${profile.id}/bind`)
.send({ targetType: "agent", targetId: agent.id, priority: 10 }),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/profiles/${profile.id}/unbind`)
.send({ targetType: "agent", targetId: agent.id }),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/policies/reorder`)
.send({ policyIds: [secondPolicy!.id, firstPolicy!.id] }),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/policies`)
.send({ name: "Viewer policy", policyType: "allow", selectors: { toolName: "viewer_tool" } }),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/policies/${firstPolicy!.id}/duplicate`)
.send({ name: "Viewer policy copy" }),
await request(viewerApp)
.patch(`/api/companies/${company.id}/tools/policies/${firstPolicy!.id}`)
.send({ enabled: false }),
await request(viewerApp)
.delete(`/api/companies/${company.id}/tools/policies/${firstPolicy!.id}`),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/action-requests/${randomUUID()}/trust-rule`)
.send({ name: "Viewer trust rule" }),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/trust-rules/${firstPolicy!.id}/revoke`)
.send({ reason: "viewer revoke" }),
await request(viewerApp)
.post(`/api/companies/${company.id}/tools/examples/safe-read-only-todo-kv/install`),
];
for (const res of responses) {
expect(res.status).toBe(403);
expect(res.body.error).toContain("Viewer access is read-only");
}
});
it("deletes an application with zero connections and records activity", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const application = await service.createApplication(company.id, {
name: "Deletable app",
type: "mcp_http",
});
const res = await request(app).delete(`/api/tool-applications/${application.id}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: application.id, name: "Deletable app" });
const remaining = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, application.id));
expect(remaining).toHaveLength(0);
const activities = await db.select().from(activityLog).where(eq(activityLog.entityId, application.id));
expect(activities).toEqual([
expect.objectContaining({
action: "tool_application.deleted",
companyId: company.id,
details: expect.objectContaining({ name: "Deletable app", type: "mcp_http" }),
}),
]);
});
it("returns 409 and keeps the application when it still has connections", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const connection = await service.createConnection(company.id, {
name: "Guarded connection",
transport: "mcp_remote",
config: { url: "https://fixture.example/mcp" },
});
const res = await request(app).delete(`/api/tool-applications/${connection.applicationId}`);
expect(res.status).toBe(409);
expect(String(res.body.error)).toMatch(/connection/i);
const remaining = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, connection.applicationId));
expect(remaining).toHaveLength(1);
});
it("archives the application when its last connection is removed", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const connection = await service.createConnection(company.id, {
name: "Single connection",
transport: "mcp_remote",
config: { url: "https://fixture.example/mcp" },
status: "active",
enabled: true,
});
const res = await request(app).delete(`/api/tool-connections/${connection.id}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: connection.id, status: "archived", enabled: false });
const [application] = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, connection.applicationId));
expect(application).toMatchObject({ status: "archived" });
expect(application?.archivedAt).toBeInstanceOf(Date);
const activities = await db.select().from(activityLog).where(eq(activityLog.companyId, company.id));
expect(activities).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "tool_connection.archived",
entityId: connection.id,
}),
expect.objectContaining({
action: "tool_application.archived",
entityId: connection.applicationId,
details: expect.objectContaining({ reason: "last_connection_removed" }),
}),
]),
);
});
it("keeps the application active when another connection remains", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const app = createRouteApp(db);
const application = await service.createApplication(company.id, {
name: "Shared app",
type: "mcp_http",
});
const first = await service.createConnection(company.id, {
applicationId: application.id,
name: "First connection",
transport: "mcp_remote",
config: { url: "https://one.example/mcp" },
status: "active",
enabled: true,
});
await service.createConnection(company.id, {
applicationId: application.id,
name: "Second connection",
transport: "mcp_remote",
config: { url: "https://two.example/mcp" },
status: "active",
enabled: true,
});
const res = await request(app).delete(`/api/tool-connections/${first.id}`);
expect(res.status).toBe(200);
const [remainingApplication] = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, application.id));
expect(remainingApplication).toMatchObject({ status: "active", archivedAt: null });
const activities = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, application.id));
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: "mcp_remote",
config: { url: "https://fixture.example/mcp" },
});
// Simulate the delete-vs-create race: skip the endpoint's "any connections?" pre-check and
// issue the raw DELETE it would run afterwards, standing in for a connection created in the
// gap. Under the old ON DELETE CASCADE schema this silently removed the linked connection;
// the hardened ON DELETE NO ACTION FK must reject it so the delete can never become an
// implicit cascade.
await expect(
db.delete(toolApplications).where(eq(toolApplications.id, connection.applicationId)),
).rejects.toThrow();
const remainingApp = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, connection.applicationId));
const remainingConnection = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connection.id));
expect(remainingApp).toHaveLength(1);
expect(remainingConnection).toHaveLength(1);
});
it("still cascades application + connection deletes when the owning company is removed", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const connection = await service.createConnection(company.id, {
name: "Company-scoped connection",
transport: "mcp_remote",
config: { url: "https://fixture.example/mcp" },
});
// NO ACTION (not RESTRICT) must keep the company teardown cascade intact: deleting the
// company cascades to both tool_applications and tool_connections in one statement, and the
// end-of-statement FK check passes because the connection is already gone. RESTRICT would
// abort this delete mid-cascade.
await db.delete(companies).where(eq(companies.id, company.id));
const remainingApp = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, connection.applicationId));
const remainingConnection = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connection.id));
expect(remainingApp).toHaveLength(0);
expect(remainingConnection).toHaveLength(0);
});
it("returns 404 for cross-company application deletes and missing applications", async () => {
const allowedCompany = await createCompany(db);
const otherCompany = await createCompany(db);
const application = await toolAccessService(db).createApplication(otherCompany.id, {
name: "Other company app",
type: "mcp_http",
});
const app = createRouteApp(db, {
type: "board",
userId: "member-user",
userName: "Member User",
userEmail: null,
companyIds: [allowedCompany.id],
memberships: [
{
companyId: allowedCompany.id,
membershipRole: "owner",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
});
const forbiddenRes = await request(app).delete(`/api/tool-applications/${application.id}`);
const missingRes = await request(createRouteApp(db)).delete(`/api/tool-applications/${randomUUID()}`);
expect(forbiddenRes.status).toBe(404);
expect(missingRes.status).toBe(404);
const stillThere = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, application.id));
expect(stillThere).toHaveLength(1);
});
it("links run tool decisions to invocations, audit events, and pending action requests", async () => {
const company = await createCompany(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: `Tool runner ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const [issue] = await db.insert(issues).values({
companyId: company.id,
title: `Tool approval ${randomUUID()}`,
status: "in_progress",
}).returning();
const [run] = await db.insert(heartbeatRuns).values({
companyId: company.id,
agentId: agent.id,
invocationSource: "assignment",
status: "running",
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "Governed tools",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Remote MCP",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://example.invalid/mcp" },
}).returning();
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "send_email",
toolName: "send_email",
riskLevel: "write",
versionHash: randomUUID(),
schemaHash: randomUUID(),
}).returning();
const [invocation] = await db.insert(toolInvocations).values({
companyId: company.id,
actorType: "agent",
actorId: agent.id,
agentId: agent.id,
issueId: issue.id,
runId: run.id,
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
toolName: "send_email",
argumentsHash: "abc123",
argumentsSummary: { summary: "{\"to\":\"redacted\"}", sha256: "abc123", sizeBytes: 18 },
policyDecision: "require_approval",
approvalState: "pending",
status: "awaiting_approval",
}).returning();
const [interaction] = await db.insert(issueThreadInteractions).values({
companyId: company.id,
issueId: issue.id,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee_on_accept",
title: "Approve tool action",
summary: "send_email requires approval.",
createdByAgentId: agent.id,
payload: {
version: 1,
prompt: "Approve send_email?",
acceptLabel: "Approve action",
rejectLabel: "Reject action",
target: { type: "custom", key: "tool-action:test", revisionId: "abc123", label: "send_email" },
},
}).returning();
const [actionRequest] = await db.insert(toolActionRequests).values({
companyId: company.id,
invocationId: invocation.id,
issueId: issue.id,
interactionId: interaction.id,
status: "pending",
canonicalArgumentsHash: "abc123",
canonicalArgumentsSummary: { summary: "{\"to\":\"redacted\"}", sha256: "abc123", sizeBytes: 18 },
previewMarkdown: "Tool: `send_email`",
requestedByAgentId: agent.id,
}).returning();
const [auditEvent] = await db.insert(toolCallEvents).values({
companyId: company.id,
eventType: "approval_requested",
actorType: "agent",
actorId: agent.id,
agentId: agent.id,
runId: run.id,
issueId: issue.id,
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
invocationId: invocation.id,
actionRequestId: actionRequest.id,
toolName: "send_email",
decision: "require_approval",
outcome: "pending",
reasonCode: "requires_approval_policy",
requestHash: "abc123",
requestSummary: { summary: "{\"to\":\"redacted\"}", sha256: "abc123", sizeBytes: 18 },
metadata: { interactionId: interaction.id },
}).returning();
const lookup = await toolAccessService(db).getRunDecisionLookup(company.id, run.id);
expect(lookup).toMatchObject({
runId: run.id,
decisions: [
{
invocation: expect.objectContaining({ id: invocation.id, runId: run.id, toolName: "send_email" }),
actionRequest: expect.objectContaining({ id: actionRequest.id, status: "pending" }),
latestAuditEvent: expect.objectContaining({ id: auditEvent.id, actionRequestId: actionRequest.id }),
decision: "require_approval",
reasonCode: "requires_approval_policy",
pendingAction: expect.objectContaining({
actionRequestId: actionRequest.id,
interactionId: interaction.id,
previewMarkdown: "Tool: `send_email`",
}),
},
],
});
});
it("enriches connection activity with issue and approval resolver context", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: "CodexCoder",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "GitHub",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "GitHub",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://github.example/mcp" },
transportConfig: { url: "https://github.example/mcp" },
}).returning();
const [issue] = await db.insert(issues).values({
companyId: company.id,
title: "Fix app connection copy",
status: "in_progress",
identifier: "PAP-10912",
assigneeAgentId: agent.id,
}).returning();
const [run] = await db.insert(heartbeatRuns).values({
companyId: company.id,
agentId: agent.id,
invocationSource: "assignment",
status: "running",
startedAt: new Date("2026-06-12T10:00:00Z"),
}).returning();
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "mark_done",
toolName: "mark_done",
title: "Mark done",
riskLevel: "write",
isWrite: true,
status: "active",
versionHash: "v1",
schemaHash: "s1",
}).returning();
const [invocation] = await db.insert(toolInvocations).values({
companyId: company.id,
actorType: "agent",
actorId: agent.id,
agentId: agent.id,
issueId: issue.id,
runId: run.id,
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
toolName: "Mark done",
policyDecision: "require_approval",
approvalState: "approved",
status: "completed",
}).returning();
await db.insert(authUsers).values({
id: "board-user",
name: "Dotta",
email: "dotta@example.com",
emailVerified: true,
createdAt: new Date("2026-06-12T09:00:00Z"),
updatedAt: new Date("2026-06-12T09:00:00Z"),
});
const [actionRequest] = await db.insert(toolActionRequests).values({
companyId: company.id,
invocationId: invocation.id,
issueId: issue.id,
status: "approved",
canonicalArgumentsHash: "abc123",
canonicalArgumentsSummary: { summary: "{}", sha256: "abc123", sizeBytes: 2 },
requestedByAgentId: agent.id,
resolvedByUserId: "board-user",
resolvedAt: new Date("2026-06-12T10:05:00Z"),
}).returning();
await db.insert(toolCallEvents).values([
{
companyId: company.id,
eventType: "call_completed",
actorType: "agent",
actorId: agent.id,
agentId: agent.id,
runId: run.id,
issueId: issue.id,
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
invocationId: invocation.id,
toolName: "Get value",
decision: "allow",
outcome: "success",
createdAt: new Date("2026-06-12T10:04:00Z"),
},
{
companyId: company.id,
eventType: "approval_resolved",
actorType: "agent",
actorId: agent.id,
agentId: agent.id,
runId: run.id,
issueId: issue.id,
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
invocationId: invocation.id,
actionRequestId: actionRequest.id,
toolName: "Mark done",
decision: "require_approval",
outcome: "success",
createdAt: new Date("2026-06-12T10:06:00Z"),
},
]);
const activity = await service.listConnectionActivity(connection.id, company.id, 10);
expect(activity.events.map((event) => event.eventType)).toEqual(["approval_resolved", "call_completed"]);
expect(activity.issues[issue.id]).toEqual({
identifier: "PAP-10912",
title: "Fix app connection copy",
});
expect(activity.actionRequests[actionRequest.id]).toEqual({
status: "approved",
resolverDisplayName: "Dotta",
resolvedByAgentId: null,
resolvedByUserId: "board-user",
});
});
it("surfaces connection lifecycle events on the activity timeline", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: "CodexCoder",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "Google Sheets",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Google Sheets (stdio smoke)",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://sheets.example/mcp" },
transportConfig: { url: "https://sheets.example/mcp" },
}).returning();
await db.insert(authUsers).values({
id: "lifecycle-user",
name: "Dotta",
email: "dotta@example.com",
emailVerified: true,
createdAt: new Date("2026-06-12T09:00:00Z"),
updatedAt: new Date("2026-06-12T09:00:00Z"),
});
await db.insert(activityLog).values([
{
companyId: company.id,
actorType: "user",
actorId: "lifecycle-user",
action: "tool_app.connected",
entityType: "tool_connection",
entityId: connection.id,
details: { galleryKey: "google-sheets" },
createdAt: new Date("2026-06-12T10:00:00Z"),
},
{
companyId: company.id,
actorType: "user",
actorId: "lifecycle-user",
action: "tool_connection.updated",
entityType: "tool_connection",
entityId: connection.id,
details: { lifecycle: "paused", enabled: false },
createdAt: new Date("2026-06-12T10:01:00Z"),
},
{
companyId: company.id,
actorType: "user",
actorId: "lifecycle-user",
action: "tool_connection.updated",
entityType: "tool_connection",
entityId: connection.id,
details: { lifecycle: "allowlist_changed", added: 2, removed: 0, total: 2 },
createdAt: new Date("2026-06-12T10:02:00Z"),
},
{
// A plain settings update (no lifecycle tag) must stay out of the feed.
companyId: company.id,
actorType: "user",
actorId: "lifecycle-user",
action: "tool_connection.updated",
entityType: "tool_connection",
entityId: connection.id,
details: { status: "active", enabled: true },
createdAt: new Date("2026-06-12T10:03:00Z"),
},
{
companyId: company.id,
actorType: "user",
actorId: "board",
action: "tool_connection.archived",
entityType: "tool_connection",
entityId: connection.id,
details: { transport: "mcp_remote" },
createdAt: new Date("2026-06-12T10:04:00Z"),
},
]);
await db.insert(toolAccessAuditEvents).values([
{
companyId: company.id,
connectionId: connection.id,
actorType: "system",
action: "tool_connection.catalog_refresh",
outcome: "success",
details: { discoveredCount: 5, quarantinedCount: 3 },
createdAt: new Date("2026-06-12T10:05:00Z"),
},
{
// A refresh that quarantined nothing should not appear.
companyId: company.id,
connectionId: connection.id,
actorType: "system",
action: "tool_connection.catalog_refresh",
outcome: "success",
details: { discoveredCount: 5, quarantinedCount: 0 },
createdAt: new Date("2026-06-12T09:59:00Z"),
},
]);
const activity = await service.listConnectionActivity(connection.id, company.id, 20);
expect(activity.lifecycleEvents.map((event) => event.type)).toEqual([
"actions_quarantined",
"disconnected",
"allowlist_changed",
"app_paused",
"app_connected",
]);
const byType = Object.fromEntries(activity.lifecycleEvents.map((event) => [event.type, event]));
expect(byType.app_connected?.actorDisplayName).toBe("Dotta");
expect(byType.app_paused?.actorDisplayName).toBe("Dotta");
expect(byType.allowlist_changed?.details).toMatchObject({ added: 2, removed: 0 });
expect(byType.disconnected?.actorDisplayName).toBe("The board");
expect(byType.actions_quarantined?.details).toMatchObject({ count: 3 });
});
it("rejects runtime controls for non-local runtime kinds", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "Remote app",
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Remote runtime",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example/mcp" },
transportConfig: { url: "https://fixture.example/mcp" },
}).returning();
const [slot] = await db.insert(toolRuntimeSlots).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
slotKey: `${connection.id}:remote`,
ownerScopeType: "connection",
ownerScopeId: connection.id,
runtimeKind: "mcp_remote",
status: "running",
reuseKey: connection.id,
provider: "paperclip",
providerRef: "remote:https://fixture.example/mcp",
healthStatus: "ok",
}).returning();
await expect(service.stopRuntimeSlot(company.id, slot.id, { actorType: "user", actorId: "board-user" }))
.rejects.toMatchObject({
status: 422,
details: expect.objectContaining({
code: "runtime_control_unsupported",
runtimeKind: "mcp_remote",
}),
});
});
it("summarizes runtime health and flags stale slots plus degraded connections", async () => {
const company = await createCompany(db);
const generatedAt = new Date("2026-06-06T00:00:00.000Z");
const service = toolAccessService(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
trustedLocalStdioRuntimeHost: null,
now: () => generatedAt,
});
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "Local stdio fixture",
type: "mcp_stdio",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Degraded local stdio",
uid: `test/${randomUUID()}`,
transport: "local_stdio",
status: "active",
enabled: true,
config: { templateId: "paperclip.echo-calculator-time" },
transportConfig: { templateId: "paperclip.echo-calculator-time" },
healthStatus: "missing_secret",
healthMessage: "A configured credential secret could not be resolved.",
}).returning();
const staleAt = new Date(generatedAt.getTime() - 10 * 60 * 1000);
await db.insert(toolRuntimeSlots).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
slotKey: `${connection.id}:paperclip.echo-calculator-time`,
ownerScopeType: "connection",
ownerScopeId: connection.id,
runtimeKind: "local_stdio",
status: "running",
reuseKey: connection.id,
provider: "paperclip",
providerRef: "local-stdio:test-host:slot",
commandTemplateKey: "paperclip.echo-calculator-time",
healthStatus: "ok",
startedAt: staleAt,
lastUsedAt: staleAt,
updatedAt: staleAt,
});
await db.insert(toolAccessAuditEvents).values([
{
companyId: company.id,
action: "runtime_deferred",
outcome: "failure",
reasonCode: "runtime_host_capacity_exhausted",
details: { durationMs: 250 },
createdAt: generatedAt,
},
{
companyId: company.id,
action: "runtime_restart_suppressed",
outcome: "failure",
reasonCode: "runtime_restart_suppressed",
details: {},
createdAt: generatedAt,
},
]);
await db.insert(toolCallEvents).values([
{
companyId: company.id,
eventType: "call_failed",
outcome: "timeout",
toolName: "mcp-stdio-fixture:increment_counter",
createdAt: generatedAt,
},
{
companyId: company.id,
eventType: "call_completed",
outcome: "success",
toolName: "mcp-stdio-fixture:runtime_status",
createdAt: generatedAt,
},
]);
const health = await service.getRuntimeHealth(company.id);
expect(health.status).toBe("critical");
expect(health.supportMatrix.localStdio.supported).toBe(false);
expect(health.metrics).toMatchObject({
activeSlots: 1,
runningSlots: 1,
stuckRunningSlots: 1,
capacityDeferralsLastHour: 1,
restartSuppressionsLastHour: 1,
toolCallsLastHour: 2,
toolTimeoutsLastHour: 1,
timeoutRateLastHour: 50,
degradedConnections: 1,
localStdioConnections: 1,
auditWriteFailuresLastHour: 0,
});
expect(health.alerts.map((alert) => alert.name)).toEqual(
expect.arrayContaining([
"mcp_runtime_stuck_running_slot",
"mcp_runtime_restart_storm",
"mcp_runtime_connection_health_degraded",
]),
);
expect(health.recommendations.find((alert) => alert.name === "mcp_runtime_audit_write_failures"))
.toMatchObject({ status: "ok", observed: "0 audit write failure(s) in 1 hour." });
});
it("fires runtime health from the durable audit-write failure counter", async () => {
const company = await createCompany(db);
const generatedAt = new Date("2026-06-06T00:00:00.000Z");
const service = toolAccessService(db, { now: () => generatedAt });
await db.insert(toolRuntimeMetricCounters).values({
companyId: company.id,
metric: "audit_write_failed",
bucketStartAt: new Date(generatedAt.getTime() - 5 * 60 * 1000),
count: 2,
createdAt: generatedAt,
updatedAt: generatedAt,
});
const health = await service.getRuntimeHealth(company.id);
expect(health.metrics.auditWriteFailuresLastHour).toBe(2);
expect(health.alerts.find((alert) => alert.name === "mcp_runtime_audit_write_failures"))
.toMatchObject({
severity: "critical",
status: "firing",
observed: "2 audit write failure(s) in 1 hour.",
});
});
it("does not degrade runtime health for draft or not-enabled setup connections", async () => {
const company = await createCompany(db);
const service = toolAccessService(db, { now: () => new Date("2026-06-06T00:00:00.000Z") });
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: "Setup apps",
type: "mcp_http",
status: "active",
}).returning();
await db.insert(toolConnections).values([
{
companyId: company.id,
applicationId: application.id,
name: "Imported draft",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "draft",
enabled: false,
config: { url: "https://draft.example/mcp" },
transportConfig: { url: "https://draft.example/mcp" },
healthStatus: "missing_secret",
healthMessage: "Needs setup before first use.",
},
{
companyId: company.id,
applicationId: application.id,
name: "OAuth connected, not enabled",
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: false,
config: { url: "https://not-enabled.example/mcp" },
transportConfig: { url: "https://not-enabled.example/mcp" },
healthStatus: "missing_secret",
healthMessage: "Catalog access has not been enabled.",
},
]);
const health = await service.getRuntimeHealth(company.id);
expect(health.status).toBe("ok");
expect(health.metrics).toMatchObject({
activeConnections: 0,
disabledConnections: 0,
degradedConnections: 0,
});
expect(health.alerts.map((alert) => alert.name)).not.toContain("mcp_runtime_connection_health_degraded");
expect(health.recommendations.find((alert) => alert.name === "mcp_runtime_connection_health_degraded"))
.toMatchObject({
status: "ok",
observed: "0 degraded connection(s), 0 disabled connection(s).",
});
});
it("rejects enabled local stdio connections in public hosted mode without a trusted runtime host", async () => {
const company = await createCompany(db);
const hostedService = toolAccessService(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
trustedLocalStdioRuntimeHost: null,
});
await expect(hostedService.createConnection(company.id, {
name: "Hosted local stdio",
transport: "local_stdio",
config: { templateId: "paperclip.echo-calculator-time" },
enabled: true,
status: "active",
})).rejects.toMatchObject({
status: 422,
message: expect.stringContaining("cannot be enabled"),
});
const trustedService = toolAccessService(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
trustedLocalStdioRuntimeHost: "trusted-worker-1",
});
await expect(trustedService.createConnection(company.id, {
name: "Trusted hosted local stdio",
transport: "local_stdio",
config: { templateId: "paperclip.echo-calculator-time" },
enabled: true,
status: "active",
})).resolves.toMatchObject({
transport: "local_stdio",
enabled: true,
});
});
it("previews mcp.json imports as draft managed connection records without carrying raw header values", async () => {
const company = await createCompany(db);
const preview = await toolAccessService(db).previewMcpJsonImport({
mcpJson: {
mcpServers: {
github: {
url: "https://mcp.example/github",
headers: { Authorization: "Bearer should-not-be-stored" },
},
local: {
command: "npx",
args: ["-y", "@example/local-mcp"],
},
},
},
});
expect(company.id).toBeTruthy();
expect(JSON.stringify(preview)).not.toContain("should-not-be-stored");
expect(preview.drafts).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "github",
transport: "mcp_remote",
status: "draft",
config: { url: "https://mcp.example/github" },
warnings: [expect.stringContaining("Paperclip secret")],
}),
expect.objectContaining({
name: "local",
transport: "local_stdio",
status: "draft",
config: { importedCommand: "npx", importedArgs: ["-y", "@example/local-mcp"] },
warnings: [expect.stringContaining("approved Paperclip template")],
}),
]),
);
});
it("fails closed when credential secrets cannot be resolved and writes value-free audit", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const connection = await service.createConnection(company.id, {
name: "Secret-backed remote",
transport: "mcp_remote",
config: { url: "https://fixture.example/mcp" },
enabled: true,
status: "active",
});
await db
.update(toolConnections)
.set({
credentialRefs: [
{
name: "authorization",
secretId: randomUUID(),
version: "latest",
placement: "header",
key: "Authorization",
prefix: "Bearer ",
},
],
})
.where(eq(toolConnections.id, connection.id));
await expect(service.checkHealth(connection.id, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
status: 422,
details: expect.objectContaining({ code: "secret_missing" }),
});
const [updatedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
const [audit] = await db
.select()
.from(toolAccessAuditEvents)
.where(eq(toolAccessAuditEvents.action, "tool_connection.health_check"));
expect(updatedConnection).toMatchObject({
healthStatus: "missing_secret",
healthMessage: "A configured credential secret could not be resolved.",
});
expect(audit).toMatchObject({
action: "tool_connection.health_check",
outcome: "failure",
reasonCode: "secret_missing",
details: { status: "missing_secret", transport: "mcp_remote" },
});
expect(JSON.stringify(audit)).not.toContain("Bearer ");
expect(JSON.stringify(audit)).not.toContain("Authorization");
});
it("sweeps enabled active connection health and records failing connections", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("revoked token"));
const connection = await service.createConnection(company.id, {
name: "Swept remote",
transport: "mcp_remote",
config: { url: "https://fixture.example/mcp" },
enabled: true,
status: "active",
});
const sweep = await service.sweepConnectionHealth({ staleAfterMs: 0 });
const [updatedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
expect(sweep).toMatchObject({
checked: 1,
healthy: 0,
failed: 1,
failedConnectionIds: [connection.id],
});
expect(updatedConnection).toMatchObject({
healthStatus: "error",
healthMessage: "revoked token",
lastError: "revoked token",
});
});
it("enriches listConnections with lastUsedAt from the most recent tool-call event", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
const used = await service.createConnection(company.id, {
name: "Used remote",
transport: "mcp_remote",
config: { url: "https://used.example/mcp" },
enabled: true,
status: "active",
});
const unused = await service.createConnection(company.id, {
name: "Unused remote",
transport: "mcp_remote",
config: { url: "https://unused.example/mcp" },
enabled: true,
status: "active",
});
const older = new Date("2026-06-01T00:00:00.000Z");
const newest = new Date("2026-06-09T12:30:00.000Z");
await db.insert(toolCallEvents).values([
{
companyId: company.id,
eventType: "call_completed",
connectionId: used.id,
toolName: "search_notes",
outcome: "success",
createdAt: older,
},
{
companyId: company.id,
eventType: "call_completed",
connectionId: used.id,
toolName: "search_notes",
outcome: "success",
createdAt: newest,
},
]);
const connections = await service.listConnections(company.id);
const usedRow = connections.find((connection) => connection.id === used.id);
const unusedRow = connections.find((connection) => connection.id === unused.id);
expect(new Date(usedRow!.lastUsedAt!).toISOString()).toBe(newest.toISOString());
expect(unusedRow!.lastUsedAt).toBeNull();
});
it("syncs installs, auto-extends agent access, and exposes install state", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
const app = createRouteApp(db);
const put = await request(app)
.put(`/api/tool-connections/${connection.id}/installs`)
.send({ installs: [{ targetType: "agent", targetId: agent.id }] });
expect(put.status).toBe(200);
expect(put.body).toMatchObject({
connectionId: connection.id,
installs: [{ targetType: "agent", targetId: agent.id }],
});
const [install] = await db.select().from(toolConnectionInstalls);
expect(install).toMatchObject({ companyId: company.id, connectionId: connection.id, targetId: agent.id });
const profile = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connection.id}`));
expect(profile).toHaveLength(1);
const binding = await db.select().from(toolProfileBindings).where(and(
eq(toolProfileBindings.profileId, profile[0]!.id),
eq(toolProfileBindings.targetType, "agent"),
eq(toolProfileBindings.targetId, agent.id),
));
expect(binding).toHaveLength(1);
const events = await db.select().from(activityLog).where(eq(activityLog.action, "tool_connection.install_access_extended"));
expect(events).toHaveLength(1);
const effective = await toolAccessService(db).getEffectiveProfilesForAgent(company.id, agent.id);
expect(effective.installedConnections.map((item) => item.id)).toEqual([connection.id]);
expect(effective.allowedTools.some((tool) => tool.connectionId === connection.id)).toBe(true);
const get = await request(app).get(`/api/tool-connections/${connection.id}`);
expect(get.status).toBe(200);
expect(get.body.installs).toEqual(expect.arrayContaining([
expect.objectContaining({ targetType: "agent", targetId: agent.id }),
]));
});
});
describe("classifyRisk", () => {
const risk = (name: string, annotations?: Record<string, unknown>) =>
classifyRisk({ name, annotations });
it("classifies unprefixed write verbs as write", () => {
expect(risk("create_widget")).toBe("write");
expect(risk("update_zap")).toBe("write");
expect(risk("send_message")).toBe("write");
expect(risk("set_value")).toBe("write");
});
it("classifies namespaced write verbs as write (PAP-10902)", () => {
// Real MCP servers return colon-namespaced names that the old leading-anchor
// regex fell through to "read", pre-enabling writes in the Connect wizard.
expect(risk("qa10864:create_widget")).toBe("write");
expect(risk("github:create_issue")).toBe("write");
expect(risk("notion:update_page")).toBe("write");
expect(risk("linear:create_issue")).toBe("write");
});
it("classifies camelCase write verbs as write", () => {
expect(risk("slack:postMessage")).toBe("write");
expect(risk("createIssue")).toBe("write");
});
it("classifies namespaced destructive verbs as destructive", () => {
expect(risk("delete_widget")).toBe("destructive");
expect(risk("github:delete_repo")).toBe("destructive");
expect(risk("notion:remove_page")).toBe("destructive");
expect(risk("cms:unpublish_post")).toBe("destructive");
});
it("classifies read verbs and noise as read", () => {
expect(risk("search_notes")).toBe("read");
expect(risk("github:list_issues")).toBe("read");
expect(risk("getUser")).toBe("read");
expect(risk("echo")).toBe("read");
// Verbs embedded mid-word must not trigger (no segment boundary).
expect(risk("settings")).toBe("read");
expect(risk("dataset_export")).toBe("read");
});
it("honours explicit annotation hints over name heuristics", () => {
expect(risk("list_items", { destructiveHint: true })).toBe("destructive");
expect(risk("list_items", { writeHint: true })).toBe("write");
expect(risk("list_items", { readOnlyHint: false })).toBe("write");
});
it("classifies the reviewed Notion MCP catalog with provider-scoped defaults", () => {
const notionRisk = (name: string, annotations?: Record<string, unknown>) =>
classifyRisk({ name, annotations }, "notion");
const readTools = [
"notion-search",
"notion-fetch",
"notion-query-data-sources",
"notion-query-database-view",
"notion-query-meeting-notes",
"notion-get-comments",
"notion-get-teams",
"notion-get-users",
"notion-get-async-task",
];
const writeTools = [
"notion-create-pages",
"notion-update-page",
"notion-convert-page-to-skill",
"notion-move-pages",
"notion-duplicate-page",
"notion-create-database",
"notion-create-folder",
"notion-update-data-source",
"notion-create-view",
"notion-update-view",
"notion-create-comment",
];
for (const toolName of readTools) expect(notionRisk(toolName)).toBe("read");
for (const toolName of writeTools) {
expect(notionRisk(toolName, { readOnlyHint: true })).toBe("write");
}
expect(notionRisk("notion-delete-page")).toBe("destructive");
expect(classifyRisk({ name: "move_pages" })).toBe("read");
});
it("uses conservative PostHog defaults for unknown and nested-execution tools", () => {
expect(classifyRisk({ name: "query_insight", annotations: { readOnlyHint: true } }, "posthog")).toBe("read");
expect(classifyRisk({ name: "brand_new_tool" }, "posthog")).toBe("write");
expect(classifyRisk({ name: "exec" }, "posthog")).toBe("destructive");
});
});
describe("normalizeConnectionMethodConfig", () => {
const posthog = getConnectableAppDefinition("posthog")!;
const apiKeyMethod = posthog.methods.find((method) => method.key === "mcp-api-key")!;
it("normalizes and projects PostHog scope without accepting arbitrary config", () => {
expect(normalizeConnectionMethodConfig(apiKeyMethod, {
projectId: "12345",
readOnly: true,
features: "insights, error_tracking\ninsights",
tools: "query_insight",
mode: "tools",
})).toEqual({
values: {
projectId: "12345",
readOnly: true,
features: "insights,error_tracking",
tools: "query_insight",
mode: "tools",
},
url: "https://mcp.posthog.com/mcp?project_id=12345&readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools",
});
expect(() => normalizeConnectionMethodConfig(apiKeyMethod, {
projectId: "not-a-project",
features: "insights",
})).toThrow("Project ID has an invalid value");
expect(() => normalizeConnectionMethodConfig(apiKeyMethod, {
projectId: "12345",
features: "insights",
apiKey: "must-not-be-config",
})).toThrow("Unknown connection setting: apiKey");
});
});