mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip lets people manage agent work and approve app actions. > - An approval must bind the action that the person reviewed. > - OAuth access tokens can expire while that person is away. > - The old target snapshot treated each token refresh as a new authorization. It also imposed a one-hour review deadline. > - This pull request separates token renewal from authorization identity and keeps the human decision durable. > - Current access, the reviewed arguments, meaningful target changes, task closure, and execution receipts still control dispatch. > - People can approve later without repeating a decision after routine connection maintenance. ## Linked Issues or Issue Description **What happened?** A pending app action returned `approved_tool_target_changed` after its OAuth tokens refreshed. The tool and connection settings were unchanged. The same card also expired after one hour. A sign-in problem could discard an accepted decision before provider dispatch. **Expected behavior** The original card remains reviewable after routine OAuth refresh and a server restart. A later approval runs the exact reviewed action once. A connection repair preserves the decision while Paperclip verifies the same authorization. Meaningful changes require a new review. **Steps to reproduce** 1. Connect an OAuth MCP app and configure an action to require approval. 2. Request the action, then refresh its access and refresh tokens before approving. 3. Approve the original card. Before this change, it returns HTTP 409 even when the authorization is unchanged. 4. Wait more than one hour. Before this change, the card expires without a task-specific deadline. **Paperclip version or commit** Reproduced from master at `76254ead2229`. The final branch is rebased onto current master. ## What Changed - Add versioned approval target comparison. New snapshots bind credential identity epochs, consent, permissions, ownership, connection configuration, tool contracts, and credential references. Authenticated OAuth refresh preserves identity. Manual credential replacement does not. - Remove the implicit one-hour deadline from ordinary app action reviews. Keep explicit deadlines and terminal-task expiry. Synchronize the nullable interaction deadline in shared contracts and UI. - Keep accepted, undispatched actions when sign-in is required. Show a saved-approval repair state. Resume on committed connection changes after target and access revalidation, without an idle retry loop. - Preserve the existing execution claim, result receipt, and uncertain-outcome no-replay rules. - Explain meaningful target changes without inviting the person to retry the stale card. - Add a production task-thread Storybook example and document compatibility and recovery behavior. ## Verification - Red: production-refresher invalid-grant handling and formal-approval withdrawal during a sign-in wait also reproduced as failing regressions before their fixes. - Red: before implementation, the local OAuth HTTP fixture failed for both public and confidential clients with `approved_tool_target_changed` after token refresh. - Green: the extended fixture passes for public and confidential clients with personal and shared grants. It covers actual local HTTP token exchanges, multiple refreshes, a rebuilt gateway, an ended originating run, approval after 25 hours, duplicate approval, preserved sharing arguments, sign-in waiting, and event-driven repair. The gateways use the production OAuth refresher; an invalid refresh preserves the accepted decision before provider dispatch. Omitted-scope responses cover equivalent scope-reporting changes. The provider is an isolated local fixture, not a deployed Notebook service. - Negative controls refuse changed consent, scopes, tool definition, destination, and manual credential replacement before provider execution. - Existing gateway service, connected-tool acceptance, secret owner, secret service, unsigned-review queue, and connection-health suites passed. Explicit-deadline fixtures still prove expiry behavior, including deadlines on accepted sign-in waits. A withdrawn formal approval cancels a waiting action before connection recovery. - Approval card, task-chat, error-copy and target-comparison tests passed. Server and UI typechecks passed. Token gates and Storybook catalog checks passed. - Inspected the new saved-approval repair card in local Storybook using the production task thread. It shows a reconnect link and no second approval button or false running state. - `pnpm verify --explain` returned exit 2 and requested broad CI coverage. [Full CI](https://github.com/paperclipai/paperclip/actions/runs/42677855507) passed on `eb4f1c7caa9ee6e802c314ffc5eadec475a5de0c`, including build, typecheck/release registry, general and serialized tests, and all eight browser shards. - Greptile reviewed the same final head at 5/5. Both findings from the first review are fixed and their threads resolved; no open follow-ups remain. ## Risks - No database migration is needed. The new comparison reuses the vault identity epoch. Only authenticated OAuth refresh code can preserve that epoch; HTTP credential-write schemas cannot select that behavior. - Earlier signed requests keep their original exact-version checks and saved deadlines. Already-expired cards are not revived. A historical card whose credentials already changed needs a fresh review. - A new consent, account, permission set, destination, or manually replaced credential must not inherit the old authorization. Reconnection continues automatically only when revalidation proves the same saved authorization. - The interaction enrichment now permits a null deadline. Existing string deadlines remain valid. The web client and shared validator change together. - This PR does not deploy to staging or perform a live Notebook publication. ## Model Used OpenAI Codex, GPT-6 family, with repository tools, code execution, and browser inspection. The runtime does not expose the exact model ID, context-window limit, or reasoning-mode label; those values are not asserted. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
6436 lines
271 KiB
TypeScript
6436 lines
271 KiB
TypeScript
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { createServer, type IncomingMessage } from "node:http";
|
|
import express from "express";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import request from "supertest";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
agentWakeupRequests,
|
|
companySecretBindings,
|
|
companySecrets,
|
|
companySecretVersions,
|
|
companyMemberships,
|
|
companies,
|
|
connectionGrantMembers,
|
|
connectionGrantDelegations,
|
|
connectionGrants,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueThreadInteractions,
|
|
issues,
|
|
principalPermissionGrants,
|
|
projects,
|
|
toolAccessAuditEvents,
|
|
toolActionRequests,
|
|
toolApplications,
|
|
toolCatalogEntries,
|
|
toolCallEvents,
|
|
toolConnectionInstalls,
|
|
toolConnections,
|
|
toolGatewayRateLimitCounters,
|
|
toolGatewaySessions,
|
|
toolInvocations,
|
|
toolMcpGateways,
|
|
toolMcpGatewayTokens,
|
|
toolPolicies,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
toolStdioCommandTemplates,
|
|
toolRuntimeSlots,
|
|
secretAccessEvents,
|
|
userSecretDeclarations,
|
|
userSecretDefinitions,
|
|
} from "@paperclipai/db";
|
|
import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js";
|
|
import { resolveNativeRuntimeMcpSnapshot } from "../services/native-runtime/runtime-context.js";
|
|
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
|
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "../routes/tool-gateway.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import {
|
|
canonicalToolArguments,
|
|
readSignedToolArgumentsPayload,
|
|
signToolArguments,
|
|
summarizeToolValue,
|
|
} from "../services/tool-content-guards.js";
|
|
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
|
|
import { resolveConnectionGrantSecret } from "../services/connection-credentials.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import * as cogneeBridge from "../services/cognee-connection.js";
|
|
import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
const externalTestDatabase = resolveExternalTestDatabaseUrl("PAPERCLIP_TOOL_GATEWAY_TEST_DATABASE_URL");
|
|
const embeddedPostgresSupport = externalTestDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
const testToolActionSigningSecret = "test-tool-action-signing-secret";
|
|
|
|
type Db = ReturnType<typeof createDb>;
|
|
type ToolGatewayServiceOptions = NonNullable<Parameters<typeof createToolGatewayService>[1]>;
|
|
|
|
async function createCompany(db: Db) {
|
|
return db
|
|
.insert(companies)
|
|
.values({
|
|
name: `Gateway ${randomUUID()}`,
|
|
issuePrefix: `TG${randomUUID().slice(0, 6).toUpperCase()}`,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createAgent(db: Db, companyId: string, permissions: Record<string, unknown> = {}) {
|
|
return db
|
|
.insert(agents)
|
|
.values({
|
|
companyId,
|
|
name: `Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createIssueAndRun(db: Db, companyId: string, agentId: string) {
|
|
const project = await db
|
|
.insert(projects)
|
|
.values({ companyId, name: `Project ${randomUUID()}` })
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const issue = await db
|
|
.insert(issues)
|
|
.values({
|
|
companyId,
|
|
projectId: project.id,
|
|
title: `Gateway issue ${randomUUID()}`,
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const run = await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
contextSnapshot: { issueId: issue.id, projectId: project.id },
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
return { project, issue, run };
|
|
}
|
|
|
|
async function createActiveMember(db: Db, companyId: string, userId: string) {
|
|
await db.insert(companyMemberships).values({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
});
|
|
}
|
|
|
|
async function allowToolsForAgent(db: Db, companyId: string, agentId: string, toolNames: string[]) {
|
|
const profile = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey: `gateway-${randomUUID()}`,
|
|
name: `Gateway profile ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId,
|
|
profileId: profile.id,
|
|
targetType: "agent",
|
|
targetId: agentId,
|
|
});
|
|
if (toolNames.length > 0) {
|
|
await db.insert(toolProfileEntries).values(toolNames.map((toolName) => ({
|
|
companyId,
|
|
profileId: profile.id,
|
|
selectorType: "tool_name" as const,
|
|
effect: "include" as const,
|
|
toolName,
|
|
})));
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
async function allowAllToolsForAgent(db: Db, companyId: string, agentId: string) {
|
|
const profile = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey: `gateway-all-${randomUUID()}`,
|
|
name: `Gateway all profile ${randomUUID()}`,
|
|
defaultAction: "allow",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId,
|
|
profileId: profile.id,
|
|
targetType: "agent",
|
|
targetId: agentId,
|
|
});
|
|
return profile;
|
|
}
|
|
|
|
async function createRemoteMcpTool(
|
|
db: Db,
|
|
companyId: string,
|
|
input: {
|
|
applicationKey?: string | null;
|
|
connectionName?: string;
|
|
url?: string;
|
|
toolName?: string;
|
|
title?: string | null;
|
|
connectionEnabled?: boolean;
|
|
connectionStatus?: "draft" | "active" | "disabled" | "archived";
|
|
healthStatus?: "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret";
|
|
catalogStatus?: "active" | "disabled" | "quarantined" | "removed";
|
|
quarantinedAt?: Date | null;
|
|
credentialRefs?: typeof toolConnections.$inferInsert["credentialRefs"];
|
|
credentialSecretRefs?: typeof toolConnections.$inferInsert["credentialSecretRefs"];
|
|
riskLevel?: "read" | "write" | "destructive";
|
|
stdioScript?: string;
|
|
envKeys?: string[];
|
|
connectionConfig?: Record<string, unknown>;
|
|
} = {},
|
|
) {
|
|
const applicationKey = input.applicationKey ?? `app-${randomUUID().slice(0, 8)}`;
|
|
let application = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(and(eq(toolApplications.companyId, companyId), eq(toolApplications.applicationKey, applicationKey)))
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
if (!application) {
|
|
[application] = await db.insert(toolApplications).values({
|
|
companyId,
|
|
applicationKey,
|
|
name: `Remote app ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
}).returning();
|
|
}
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId,
|
|
applicationId: application.id,
|
|
name: input.connectionName ?? `Remote connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: input.connectionStatus ?? "active",
|
|
enabled: input.connectionEnabled ?? true,
|
|
healthStatus: input.healthStatus ?? "ok",
|
|
config: { url: input.url ?? "https://mcp.example.test/mcp", ...(input.connectionConfig ?? {}) },
|
|
transportConfig: { url: input.url ?? "https://mcp.example.test/mcp", ...(input.connectionConfig ?? {}) },
|
|
credentialRefs: input.credentialRefs ?? [],
|
|
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
|
}).returning();
|
|
await db.insert(connectionGrants).values({
|
|
companyId,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
credentialSecretRefs: connection.credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: true,
|
|
});
|
|
if (input.credentialRefs?.length || input.credentialSecretRefs?.length) {
|
|
await db.insert(companySecretBindings).values([
|
|
...(input.credentialRefs ?? []).map((ref) => ({
|
|
companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection" as const,
|
|
targetId: connection!.id,
|
|
configPath: `credentials.${ref.name}`,
|
|
})),
|
|
...(input.credentialSecretRefs ?? []).map((ref) => ({
|
|
companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection" as const,
|
|
targetId: connection!.id,
|
|
configPath: ref.configPath,
|
|
versionSelector: String(ref.versionSelector ?? "latest"),
|
|
required: ref.required ?? true,
|
|
label: ref.label ?? null,
|
|
})),
|
|
]).onConflictDoNothing();
|
|
}
|
|
const toolName = input.toolName ?? "kv_set";
|
|
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
|
|
companyId,
|
|
applicationId: application.id,
|
|
connectionId: connection!.id,
|
|
entryKind: "tool",
|
|
name: `${toolName}-${randomUUID()}`,
|
|
toolName,
|
|
title: input.title ?? "KV Set",
|
|
description: `Call ${toolName}`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { key: { type: "string" }, value: { type: "string" } },
|
|
required: ["key", "value"],
|
|
additionalProperties: false,
|
|
},
|
|
annotations: { readOnlyHint: false },
|
|
riskLevel: input.riskLevel ?? "write",
|
|
isReadOnly: (input.riskLevel ?? "write") === "read",
|
|
isWrite: (input.riskLevel ?? "write") === "write",
|
|
isDestructive: (input.riskLevel ?? "write") === "destructive",
|
|
status: input.catalogStatus ?? "active",
|
|
versionHash: randomUUID(),
|
|
quarantinedAt: input.quarantinedAt ?? null,
|
|
}).returning();
|
|
return { application, connection: connection!, catalogEntry: catalogEntry! };
|
|
}
|
|
|
|
async function createLocalStdioMcpTool(
|
|
db: Db,
|
|
companyId: string,
|
|
input: {
|
|
applicationKey?: string | null;
|
|
connectionName?: string;
|
|
toolName?: string;
|
|
title?: string | null;
|
|
connectionEnabled?: boolean;
|
|
connectionStatus?: "draft" | "active" | "disabled" | "archived";
|
|
healthStatus?: "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret";
|
|
catalogStatus?: "active" | "disabled" | "quarantined" | "removed";
|
|
riskLevel?: "read" | "write" | "destructive";
|
|
credentialPolicy?: "shared" | "per_user" | "per_user_with_fallback";
|
|
credentialSecretRefs?: typeof toolConnections.$inferInsert["credentialSecretRefs"];
|
|
stdioScript?: string;
|
|
envKeys?: string[];
|
|
connectionConfig?: Record<string, unknown>;
|
|
} = {},
|
|
) {
|
|
const applicationKey = input.applicationKey ?? `local-app-${randomUUID().slice(0, 8)}`;
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId,
|
|
applicationKey,
|
|
name: `Local stdio app ${randomUUID()}`,
|
|
type: "mcp_stdio",
|
|
status: "active",
|
|
}).returning();
|
|
const toolName = input.toolName ?? "echo";
|
|
const templateKey = `test.local-stdio.${randomUUID()}`;
|
|
const stdioScript = input.stdioScript ?? `
|
|
const readline = require("node:readline");
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on("line", (line) => {
|
|
const message = JSON.parse(line);
|
|
if (message.method === "initialize") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "test-stdio", version: "0.0.0" } } }) + "\\n");
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: "local:" + String(message.params?.arguments?.message ?? "") }], structuredContent: { echoed: message.params?.arguments?.message ?? null } } }) + "\\n");
|
|
}
|
|
});
|
|
`;
|
|
await db.insert(toolStdioCommandTemplates).values({
|
|
companyId,
|
|
templateKey,
|
|
name: `Local stdio template ${randomUUID()}`,
|
|
command: process.execPath,
|
|
args: ["-e", stdioScript],
|
|
envKeys: input.envKeys ?? [],
|
|
tools: [
|
|
{
|
|
name: toolName,
|
|
title: input.title ?? "Local Echo",
|
|
description: `Call ${toolName}`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
additionalProperties: false,
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
],
|
|
});
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
name: input.connectionName ?? `Local stdio connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "local_stdio",
|
|
status: input.connectionStatus ?? "active",
|
|
enabled: input.connectionEnabled ?? true,
|
|
healthStatus: input.healthStatus ?? "ok",
|
|
credentialPolicy: input.credentialPolicy ?? "shared",
|
|
config: { templateId: templateKey, ...(input.connectionConfig ?? {}) },
|
|
transportConfig: { templateId: templateKey, ...(input.connectionConfig ?? {}) },
|
|
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
|
}).returning();
|
|
await db.insert(connectionGrants).values({
|
|
companyId,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
credentialSecretRefs: connection.credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: true,
|
|
});
|
|
if (input.credentialSecretRefs?.length) {
|
|
await db.insert(companySecretBindings).values(input.credentialSecretRefs.map((ref) => ({
|
|
companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection" as const,
|
|
targetId: connection.id,
|
|
configPath: ref.configPath,
|
|
versionSelector: String(ref.versionSelector ?? "latest"),
|
|
required: ref.required ?? true,
|
|
label: ref.label ?? null,
|
|
}))).onConflictDoNothing();
|
|
}
|
|
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
entryKind: "tool",
|
|
name: `${toolName}-${randomUUID()}`,
|
|
toolName,
|
|
title: input.title ?? "Local Echo",
|
|
description: `Call ${toolName}`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
additionalProperties: false,
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
riskLevel: input.riskLevel ?? "read",
|
|
isReadOnly: (input.riskLevel ?? "read") === "read",
|
|
isWrite: (input.riskLevel ?? "read") === "write",
|
|
isDestructive: (input.riskLevel ?? "read") === "destructive",
|
|
status: input.catalogStatus ?? "active",
|
|
versionHash: randomUUID(),
|
|
}).returning();
|
|
return { application: application!, connection: connection!, catalogEntry: catalogEntry!, templateKey };
|
|
}
|
|
|
|
function expectedConnectedToolName(input: { applicationKey: string | null; connectionId: string; toolName: string }) {
|
|
const applicationSegment = (input.applicationKey ?? "mcp")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 64) || "mcp";
|
|
const toolSegment = input.toolName
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 64) || "tool";
|
|
return `mcp.${applicationSegment}-${input.connectionId.replace(/-/g, "").slice(0, 8)}:${toolSegment}`;
|
|
}
|
|
|
|
function expectGatewayError(error: unknown, status: number, reasonCode: string) {
|
|
expect(error).toBeInstanceOf(ToolGatewayHttpError);
|
|
const gatewayError = error as ToolGatewayHttpError;
|
|
expect(gatewayError.status).toBe(status);
|
|
expect(gatewayError.reasonCode).toBe(reasonCode);
|
|
}
|
|
|
|
function tamperToken(token: string) {
|
|
const replacement = token.endsWith("A") ? "B" : "A";
|
|
return `${token.slice(0, -1)}${replacement}`;
|
|
}
|
|
|
|
function createTestToolGatewayService(db: Db, options: ToolGatewayServiceOptions = {}) {
|
|
return createToolGatewayService(db, {
|
|
...options,
|
|
toolActionSigningSecret: options.toolActionSigningSecret ?? testToolActionSigningSecret,
|
|
});
|
|
}
|
|
|
|
function createGatewayRouteApp(
|
|
db: Db,
|
|
gateway = createTestToolGatewayService(db),
|
|
actor?: Express.Request["actor"],
|
|
) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
if (actor) {
|
|
app.use((req, _res, next) => {
|
|
req.actor = actor;
|
|
next();
|
|
});
|
|
}
|
|
app.use(mcpGatewayProtocolRoutes(gateway));
|
|
app.use("/api", toolGatewayRoutes(db, gateway));
|
|
return app;
|
|
}
|
|
|
|
type FakeMcpRequest = {
|
|
headers: IncomingMessage["headers"];
|
|
body: Record<string, unknown> | null;
|
|
};
|
|
|
|
async function startFakeRemoteMcpServer(handler: (request: FakeMcpRequest) => Promise<{
|
|
status?: number;
|
|
headers?: Record<string, string>;
|
|
body?: unknown;
|
|
rawBody?: string;
|
|
delayMs?: number;
|
|
bodyDelayMs?: number;
|
|
}> | {
|
|
status?: number;
|
|
headers?: Record<string, string>;
|
|
body?: unknown;
|
|
rawBody?: string;
|
|
delayMs?: number;
|
|
bodyDelayMs?: number;
|
|
}) {
|
|
const requests: FakeMcpRequest[] = [];
|
|
const server = createServer(async (req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
req.on("end", async () => {
|
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
let body: Record<string, unknown> | null = null;
|
|
try {
|
|
body = raw ? JSON.parse(raw) as Record<string, unknown> : null;
|
|
} catch {
|
|
body = null;
|
|
}
|
|
const requestRecord = { headers: req.headers, body };
|
|
requests.push(requestRecord);
|
|
const response = await handler(requestRecord);
|
|
if (response.delayMs) {
|
|
await new Promise((resolve) => setTimeout(resolve, response.delayMs));
|
|
}
|
|
res.statusCode = response.status ?? 200;
|
|
for (const [key, value] of Object.entries(response.headers ?? {})) {
|
|
res.setHeader(key, value);
|
|
}
|
|
if (response.bodyDelayMs) {
|
|
res.setHeader("content-type", "application/json");
|
|
res.flushHeaders();
|
|
await new Promise((resolve) => setTimeout(resolve, response.bodyDelayMs));
|
|
}
|
|
if (response.rawBody !== undefined) {
|
|
res.end(response.rawBody);
|
|
} else {
|
|
if (!res.headersSent) res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify(response.body ?? {
|
|
jsonrpc: "2.0",
|
|
id: body?.id ?? "test",
|
|
result: { content: [{ type: "text", text: "ok" }] },
|
|
}));
|
|
}
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Expected TCP fake MCP server address");
|
|
return {
|
|
url: `http://127.0.0.1:${address.port}/mcp`,
|
|
requests,
|
|
close: () => new Promise<void>((resolve, reject) => {
|
|
server.close((error) => error ? reject(error) : resolve());
|
|
}),
|
|
};
|
|
}
|
|
|
|
describeEmbeddedPostgres("tool gateway acceptance", () => {
|
|
let db!: Db;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
if (externalTestDatabase) { db = createDb(externalTestDatabase); return; }
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 90_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(toolCallEvents);
|
|
await db.delete(toolRuntimeSlots);
|
|
await db.delete(toolGatewaySessions);
|
|
await db.delete(toolGatewayRateLimitCounters);
|
|
await db.delete(toolActionRequests);
|
|
await db.delete(toolInvocations);
|
|
await db.delete(toolAccessAuditEvents);
|
|
await db.delete(toolPolicies);
|
|
await db.delete(toolMcpGatewayTokens);
|
|
await db.delete(toolMcpGateways);
|
|
await db.delete(toolProfileEntries);
|
|
await db.delete(toolProfileBindings);
|
|
await db.delete(toolProfiles);
|
|
await db.delete(toolConnections);
|
|
await db.delete(toolApplications);
|
|
await db.delete(secretAccessEvents);
|
|
await db.delete(userSecretDeclarations);
|
|
await db.delete(companySecretBindings);
|
|
await db.delete(companySecretVersions);
|
|
await db.delete(companySecrets);
|
|
await db.delete(userSecretDefinitions);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(agentWakeupRequests);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(issues);
|
|
await db.delete(projects);
|
|
await db.delete(principalPermissionGrants);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("preserves provider tool errors in MCP responses and failed invocation audits", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(({ body }) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: body?.id,
|
|
result: { content: [{ type: "text", text: "Search denied: outside consented tag" }], isError: true },
|
|
},
|
|
}));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url, toolName: "search_memory", riskLevel: "read",
|
|
});
|
|
await db.update(toolCatalogEntries).set({
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
}).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
const toolName = expectedConnectedToolName({
|
|
applicationKey: application.applicationKey,
|
|
connectionId: connection.id,
|
|
toolName: catalogEntry.toolName,
|
|
});
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id, profileKey: `error-${randomUUID()}`,
|
|
name: "Provider error test", defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id, profileId: profile.id,
|
|
selectorType: "tool_name", effect: "include", toolName,
|
|
});
|
|
const gateway = createTestToolGatewayService(db);
|
|
const named = await gateway.createNamedGateway({
|
|
companyId: company.id, body: { name: "Provider error test", profileId: profile.id },
|
|
});
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id, gatewayId: named.id, body: { name: "Test", clientLabel: "Test", ownerNote: "" },
|
|
});
|
|
const response = await request(createGatewayRouteApp(db, gateway))
|
|
.post(named.endpointPath)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: {} } })
|
|
.expect(200);
|
|
expect(response.body.result).toEqual({
|
|
content: [{ type: "text", text: "Search denied: outside consented tag" }], isError: true,
|
|
});
|
|
expect(await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id)))
|
|
.toEqual([expect.objectContaining({ status: "failed", errorCode: "tool_error" })]);
|
|
const events = await db.select().from(toolCallEvents).where(eq(toolCallEvents.companyId, company.id));
|
|
expect(events).toContainEqual(expect.objectContaining({ eventType: "call_failed", outcome: "failure", reasonCode: "tool_error" }));
|
|
expect(events.some((event) => event.eventType === "call_completed")).toBe(false);
|
|
const agent = await createAgent(db, company.id);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user",
|
|
principalId: "test-user", status: "active", membershipRole: "member" });
|
|
const testCall = await gateway.executeTestCall({ companyId: company.id, connectionId: connection.id,
|
|
agentId: agent.id, userId: "test-user", toolName, parameters: {} });
|
|
expect(testCall).toMatchObject({ decision: "allowed", error: { reasonCode: "tool_error" } });
|
|
expect(await db.select().from(toolInvocations).where(eq(toolInvocations.id, testCall.invocationId)))
|
|
.toEqual([expect.objectContaining({ status: "failed", errorCode: "tool_error" })]);
|
|
const testEvents = await db.select().from(toolCallEvents).where(eq(toolCallEvents.invocationId, testCall.invocationId));
|
|
expect(testEvents.some(event => event.eventType === "call_failed")).toBe(true);
|
|
expect(testEvents.some(event => event.eventType === "call_completed")).toBe(false);
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it.each(["managed", "mcp-key"])("returns %s GitHub recovery guidance directly to MCP callers without a pending approval", async method => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const denied = "failed to create repository: POST https://api.github.com/user/repos: 403 Resource not accessible by integration []";
|
|
const remote = await startFakeRemoteMcpServer(({ body }) => ({ body: {
|
|
jsonrpc: "2.0", id: body?.id, result: {
|
|
content: [{ type: "text", text: denied }], isError: true,
|
|
},
|
|
} }));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url, applicationKey: "github", toolName: "create_repository", riskLevel: "write",
|
|
connectionConfig: { sourceTemplateKey: "github", connectionMethodKey: method },
|
|
});
|
|
await db.update(toolCatalogEntries).set({ inputSchema: {
|
|
type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false,
|
|
} }).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
const toolName = expectedConnectedToolName({ applicationKey: application.applicationKey,
|
|
connectionId: connection.id, toolName: catalogEntry.toolName });
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const named = await gateway.createNamedGateway({ companyId: company.id,
|
|
body: { name: "GitHub recovery", profileId: profile.id } });
|
|
const token = await gateway.createNamedGatewayToken({ companyId: company.id, gatewayId: named.id,
|
|
body: { name: "Test", clientLabel: "Test", ownerNote: "" } });
|
|
const assertGuidance = (message: string) => {
|
|
expect(message).toContain(denied);
|
|
expect(message).toMatch(/Do not retry/);
|
|
if (method === "managed") {
|
|
expect(message).toMatch(/personal account/i);
|
|
expect(message).toMatch(/organization argument/i);
|
|
expect(message).toContain("Refresh access");
|
|
expect(message).not.toMatch(/replace.*token|Administration|classic token/i);
|
|
} else {
|
|
expect(message).toMatch(/token.*permissions/i);
|
|
expect(message).toMatch(/replace.*token/i);
|
|
expect(message).not.toMatch(/GitHub App|Refresh access/);
|
|
}
|
|
};
|
|
const unauthorized = await request(createGatewayRouteApp(db, gateway)).post(named.endpointPath)
|
|
.set("authorization", "Bearer invalid-token")
|
|
.send({ jsonrpc: "2.0", id: 0, method: "tools/call", params: { name: toolName, arguments: { name: "unauthorized" } } })
|
|
.expect(401);
|
|
expect(unauthorized.body.error).toBeDefined();
|
|
expect(unauthorized.body.result).toBeUndefined();
|
|
expect(remote.requests).toHaveLength(0);
|
|
const response = await request(createGatewayRouteApp(db, gateway)).post(named.endpointPath)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: { name: "mcp-example" } } })
|
|
.expect(200);
|
|
expect(response.body.result.isError).toBe(true);
|
|
assertGuidance(response.body.result.content[0].text);
|
|
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const failure = await gateway.executeTool({ sessionToken: session.token, tool: toolName,
|
|
parameters: { name: "agent-example" } }).catch(error => error);
|
|
expect(failure).toMatchObject({ reasonCode: "github_access_denied" });
|
|
assertGuidance(failure.message);
|
|
|
|
await createActiveMember(db, company.id, "test-user");
|
|
const testCall = await gateway.executeTestCall({ companyId: company.id, connectionId: connection.id,
|
|
agentId: agent.id, userId: "test-user", toolName, parameters: { name: "test-example" } });
|
|
expect(testCall).toMatchObject({ decision: "allowed", error: { reasonCode: "github_access_denied" } });
|
|
assertGuidance((testCall as { error: { message: string } }).error.message);
|
|
expect(remote.requests).toHaveLength(3);
|
|
expect(await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, company.id))).toEqual([]);
|
|
expect(await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id)))
|
|
.toEqual(expect.arrayContaining([expect.objectContaining({ status: "failed", errorCode: "github_access_denied" })]));
|
|
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))[0]?.healthStatus).toBe("ok");
|
|
} finally { await remote.close(); }
|
|
});
|
|
|
|
it("defaults GitHub repository creation to the only installed organization for agents and connection tests", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const remote = await startFakeRemoteMcpServer(({ body }) => ({ body: {
|
|
jsonrpc: "2.0", id: body?.id, result: { content: [{ type: "text", text: "Repository created" }] },
|
|
} }));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url, applicationKey: "github", toolName: "create_repository", riskLevel: "write",
|
|
connectionConfig: { sourceTemplateKey: "github", connectionMethodKey: "managed" },
|
|
});
|
|
await db.update(toolCatalogEntries).set({ inputSchema: {
|
|
type: "object", required: ["name"], additionalProperties: false, properties: {
|
|
name: { type: "string" },
|
|
organization: { type: "string", description: "Organization to create the repository in (omit to create in your personal account)" },
|
|
},
|
|
} }).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
const github = { userId: "1", login: "octocat", installationCount: 1, repositoryCount: 1,
|
|
repositorySelection: "all" as const, installationIds: ["9"], installationOwnerLogins: ["acme"],
|
|
repositories: [{ id: "10", fullName: "acme/site", installationId: "9", ownerType: "organization" as const }] };
|
|
await db.update(connectionGrants).set({ providerTenant: { github } })
|
|
.where(eq(connectionGrants.connectionId, connection.id));
|
|
const toolName = expectedConnectedToolName({ applicationKey: application.applicationKey,
|
|
connectionId: connection.id, toolName: catalogEntry.toolName });
|
|
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const listed = (await gateway.listToolsForSession(session.token)).find((tool) => tool.name === toolName);
|
|
expect((listed?.parametersSchema as { properties?: Record<string, { description?: string }> } | undefined)
|
|
?.properties?.organization?.description).toMatch(/installed only on one organization; then Paperclip uses that organization/);
|
|
|
|
await gateway.executeTool({ sessionToken: session.token, tool: toolName, parameters: { name: "agent-site" } });
|
|
await gateway.executeTool({ sessionToken: session.token, tool: toolName,
|
|
parameters: { name: "chosen-site", organization: "globex" } });
|
|
await createActiveMember(db, company.id, "test-user");
|
|
const testCall = await gateway.executeTestCall({ companyId: company.id, connectionId: connection.id,
|
|
agentId: agent.id, userId: "test-user", toolName, parameters: { name: "test-site" } });
|
|
expect(testCall).toMatchObject({ decision: "allowed" });
|
|
// With the App also on the personal account, GitHub's personal default applies.
|
|
await db.update(connectionGrants).set({ providerTenant: { github: { ...github, installationOwnerLogins: ["octocat", "acme"] } } })
|
|
.where(eq(connectionGrants.connectionId, connection.id));
|
|
await gateway.executeTool({ sessionToken: session.token, tool: toolName, parameters: { name: "personal-site" } });
|
|
|
|
expect(remote.requests.filter((entry) => entry.body?.method === "tools/call")
|
|
.map((entry) => (entry.body?.params as { arguments?: unknown } | undefined)?.arguments)).toEqual([
|
|
{ name: "agent-site", organization: "acme" },
|
|
{ name: "chosen-site", organization: "globex" },
|
|
{ name: "test-site", organization: "acme" },
|
|
{ name: "personal-site" },
|
|
]);
|
|
} finally { await remote.close(); }
|
|
});
|
|
|
|
it.each(["config", "transport", "application", "legacy_application"])("records an actionable GitHub access error from %s metadata after one approved call and keeps the connection usable", async source => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const denied = "failed to create repository: POST https://api.github.com/user/repos: 403 Resource not accessible by integration []";
|
|
let fail = true;
|
|
const remote = await startFakeRemoteMcpServer(({ body }) => ({ body: {
|
|
jsonrpc: "2.0", id: body?.id, result: {
|
|
content: [{ type: "text", text: fail ? denied : "Repository read succeeded" }], isError: fail,
|
|
},
|
|
} }));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url, applicationKey: "github", toolName: "create_repository", riskLevel: "write",
|
|
connectionConfig: source === "config" ? { sourceTemplateKey: "github" } : {},
|
|
});
|
|
if (source === "transport") await db.update(toolConnections).set({ transportConfig: { sourceTemplateKey: "github" } })
|
|
.where(eq(toolConnections.id, connection.id));
|
|
if (source === "application" || source === "legacy_application") await db.update(toolApplications)
|
|
.set({ metadata: source === "application" ? { sourceTemplateKey: "github" } : { source: "github" } })
|
|
.where(eq(toolApplications.id, application.id));
|
|
await db.update(toolCatalogEntries).set({ inputSchema: {
|
|
type: "object", properties: { name: { type: "string" }, private: { type: "boolean" } },
|
|
required: ["name"], additionalProperties: false,
|
|
} }).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
const toolName = expectedConnectedToolName({ applicationKey: application.applicationKey,
|
|
connectionId: connection.id, toolName: catalogEntry.toolName });
|
|
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
|
await db.insert(toolPolicies).values({ companyId: company.id, name: "Review GitHub writes",
|
|
policyType: "require_approval", selectors: { connectionId: connection.id }, priority: 10 });
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const parameters = { name: "example-prototype", private: true };
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: toolName, parameters }))
|
|
.rejects.toMatchObject({ reasonCode: "approval_required" });
|
|
expect(remote.requests).toHaveLength(0);
|
|
const [action] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, company.id));
|
|
await db.update(issueThreadInteractions).set({ status: "accepted", resolvedAt: new Date() })
|
|
.where(eq(issueThreadInteractions.id, action!.interactionId!));
|
|
await db.update(toolActionRequests).set({ status: "approved", resolvedAt: new Date() })
|
|
.where(eq(toolActionRequests.id, action!.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: toolName, parameters,
|
|
approvedActionRequestId: action!.id })).rejects.toMatchObject({ reasonCode: "github_access_denied" });
|
|
expect(remote.requests).toHaveLength(1);
|
|
expect(remote.requests[0]?.body).toMatchObject({ method: "tools/call",
|
|
params: { name: "create_repository", arguments: parameters } });
|
|
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, action!.invocationId));
|
|
expect(invocation).toMatchObject({ status: "failed", errorCode: "github_access_denied",
|
|
errorMessage: expect.stringContaining(denied) });
|
|
const [card] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, action!.interactionId!));
|
|
expect(card!.result).toMatchObject({ toolAction: { status: "failed", errorCode: "github_access_denied" } });
|
|
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))[0]?.healthStatus).toBe("ok");
|
|
fail = false;
|
|
await db.delete(toolPolicies).where(eq(toolPolicies.companyId, company.id));
|
|
const result = await gateway.executeTool({ sessionToken: session.token, tool: toolName,
|
|
parameters: { name: "permitted-prototype", private: true } });
|
|
expect(result.status).toBe("completed");
|
|
expect(remote.requests).toHaveLength(2);
|
|
} finally { await remote.close(); }
|
|
});
|
|
|
|
it("returns a JSON-RPC reconciliation error instead of repeating an unverifiable legacy write", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(({ body }) => ({
|
|
body: { jsonrpc: "2.0", id: body?.id, result: { content: [{ type: "text", text: "saved" }] } },
|
|
}));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, { url: remote.url });
|
|
const toolName = expectedConnectedToolName({ applicationKey: application.applicationKey,
|
|
connectionId: connection.id, toolName: catalogEntry.toolName });
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId: company.id,
|
|
profileKey: `legacy-${randomUUID()}`, name: "Legacy write test", defaultAction: "deny" }).returning();
|
|
await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id,
|
|
selectorType: "tool_name", effect: "include", toolName });
|
|
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow fixture writes",
|
|
policyType: "allow", selectors: { toolName } });
|
|
const gateway = createTestToolGatewayService(db);
|
|
const named = await gateway.createNamedGateway({ companyId: company.id,
|
|
body: { name: "Legacy write test", profileId: profile.id } });
|
|
const token = await gateway.createNamedGatewayToken({ companyId: company.id,
|
|
gatewayId: named.id, body: { name: "Test", clientLabel: "Test", ownerNote: "" } });
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const parameters = { key: "large-note", value: `${"x".repeat(500)}original` };
|
|
await request(app).post(named.endpointPath).set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: "initial-write", method: "tools/call", params: { name: toolName, arguments: parameters } }).expect(200);
|
|
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id));
|
|
expect(invocation.status).toBe("succeeded");
|
|
const oldHash = invocation.argumentsSummary!.sha256!;
|
|
const scope = { companyId: invocation.companyId, runId: invocation.runId, issueId: invocation.issueId,
|
|
applicationId: invocation.applicationId, connectionId: invocation.connectionId,
|
|
catalogEntryId: invocation.catalogEntryId, toolName: invocation.toolName, argumentsHash: oldHash };
|
|
const oldKey = `side_effect:${createHash("sha256").update(JSON.stringify(Object.fromEntries(
|
|
Object.entries(scope).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0),
|
|
))).digest("hex")}`;
|
|
await db.update(toolInvocations).set({ argumentsHash: oldHash, idempotencyKey: oldKey })
|
|
.where(eq(toolInvocations.id, invocation.id));
|
|
const requestCount = remote.requests.length;
|
|
const replay = await request(app).post(named.endpointPath).set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: "legacy-write", method: "tools/call", params: { name: toolName, arguments: parameters } }).expect(409);
|
|
expect(replay.body).toMatchObject({ jsonrpc: "2.0", id: "legacy-write", error: {
|
|
code: -32000, data: { reasonCode: "legacy_tool_idempotency_unverifiable", invocationId: invocation.id },
|
|
} });
|
|
expect(replay.body.error.message).toContain("Reconcile");
|
|
expect(remote.requests).toHaveLength(requestCount);
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("exposes a named gateway with scoped bearer-token auth and revocation", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(async ({ body }) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: body?.id,
|
|
result: { content: [{ type: "text", text: "read ok" }], structuredContent: { ok: true } },
|
|
},
|
|
}));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url,
|
|
applicationKey: "named-gateway-app",
|
|
toolName: "read_note",
|
|
title: "Read note",
|
|
riskLevel: "read",
|
|
});
|
|
const gatewayToolName = expectedConnectedToolName({
|
|
applicationKey: application.applicationKey,
|
|
connectionId: connection.id,
|
|
toolName: catalogEntry.toolName,
|
|
});
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `named-gateway-${randomUUID()}`,
|
|
name: `Named gateway ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: gatewayToolName,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "External reader", profileId: profile.id },
|
|
});
|
|
expect(created.gatewayPublicId).toMatch(/^gw_[a-f0-9]{32}$/);
|
|
expect(created.endpointPath).toBe(`/mcp/gateways/${created.gatewayPublicId}`);
|
|
expect(created.clientSnippets.length).toBeGreaterThan(0);
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Cursor", clientLabel: "Cursor desktop", ownerNote: "QA fixture token" },
|
|
});
|
|
expect(token.subjectType).toBe("gateway_client");
|
|
expect(token.clientLabel).toBe("Cursor desktop");
|
|
expect(token.ownerNote).toBe("QA fixture token");
|
|
expect(token.tokenPrefix).toMatch(/^pcgw_[a-f0-9]{8}$/);
|
|
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const publicEndpoint = created.endpointPath;
|
|
const queryOnly = await request(app)
|
|
.post(`${publicEndpoint}?paperclip_capability=${encodeURIComponent(token.token)}`)
|
|
.send({ jsonrpc: "2.0", id: "query-only", method: "tools/list" })
|
|
.expect(401);
|
|
expect(queryOnly.body.error).toBe("Bearer token is required");
|
|
|
|
const listed = await request(app)
|
|
.post(publicEndpoint)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(200);
|
|
const visibleToolNames = listed.body.result.tools.map((tool: { name: string }) => tool.name);
|
|
expect(visibleToolNames).toContain(gatewayToolName);
|
|
expect(visibleToolNames).not.toContain("mcp-remote-fixture:update_note");
|
|
|
|
const toolOnlyResources = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: "resources", method: "resources/list" })
|
|
.expect(200);
|
|
expect(toolOnlyResources.body.result.resources).toEqual([]);
|
|
|
|
const called = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: { name: gatewayToolName, arguments: { key: "a", value: "b" } },
|
|
})
|
|
.expect(200);
|
|
expect(called.body.result.content).toEqual([{ type: "text", text: "read ok" }]);
|
|
const upstreamRequestCountAfterAllowedCall = remote.requests.length;
|
|
|
|
const denied = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 3,
|
|
method: "tools/call",
|
|
params: { name: "mcp-remote-fixture:update_note", arguments: { noteId: "n1", body: "blocked" } },
|
|
})
|
|
.expect(403);
|
|
expect(denied.body.error.data.reasonCode).toBe("deny_default");
|
|
expect(remote.requests.length).toBe(upstreamRequestCountAfterAllowedCall);
|
|
const deniedAuditRows = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(and(eq(activityLog.companyId, company.id), eq(activityLog.action, "tool_gateway.call_completed")));
|
|
expect(JSON.stringify(deniedAuditRows)).not.toContain("blocked");
|
|
|
|
const listOnlyToken = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: {
|
|
name: "Discovery only",
|
|
clientLabel: "Discovery client",
|
|
ownerNote: "List-only regression token",
|
|
allowedActions: ["tools/list"],
|
|
},
|
|
});
|
|
await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${listOnlyToken.token}`)
|
|
.send({ jsonrpc: "2.0", id: 4, method: "tools/list" })
|
|
.expect(200);
|
|
const scopedDenied = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${listOnlyToken.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 5,
|
|
method: "tools/call",
|
|
params: { name: gatewayToolName, arguments: { key: "a", value: "b" } },
|
|
})
|
|
.expect(403);
|
|
expect(scopedDenied.body.error.data.reasonCode).toBe("gateway_token_action_denied");
|
|
|
|
await gateway.revokeNamedGatewayToken({ companyId: company.id, tokenId: token.id });
|
|
const revoked = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 6, method: "tools/list" })
|
|
.expect(401);
|
|
expect(revoked.body.error.data.reasonCode).toBe("gateway_token_revoked");
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("keeps additive app-gallery assignments out of gateway-only runtimes", async () => {
|
|
const company = await createCompany(db);
|
|
const assigned = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "gateway-assigned-app",
|
|
connectionName: "Dedicated GitHub identity",
|
|
toolName: "get_me",
|
|
riskLevel: "read",
|
|
});
|
|
const unassigned = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "gateway-unassigned-app",
|
|
connectionName: "Personal GitHub identity",
|
|
toolName: "get_me",
|
|
riskLevel: "read",
|
|
});
|
|
const assignedToolName = expectedConnectedToolName({
|
|
applicationKey: assigned.application.applicationKey,
|
|
connectionId: assigned.connection.id,
|
|
toolName: assigned.catalogEntry.toolName,
|
|
});
|
|
const unassignedToolName = expectedConnectedToolName({
|
|
applicationKey: unassigned.application.applicationKey,
|
|
connectionId: unassigned.connection.id,
|
|
toolName: unassigned.catalogEntry.toolName,
|
|
});
|
|
const [gatewayProfile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `runtime-gateway-${randomUUID()}`,
|
|
name: "Resolved runtime identity",
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: gatewayProfile.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: assigned.connection.id,
|
|
});
|
|
const [appProfile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `app:${unassigned.connection.id}`,
|
|
name: "Personal GitHub",
|
|
defaultAction: "deny",
|
|
metadata: { source: "app_gallery_finish", connectionId: unassigned.connection.id },
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: appProfile.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: unassigned.connection.id,
|
|
});
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId: company.id,
|
|
profileId: appProfile.id,
|
|
targetType: "company",
|
|
targetId: company.id,
|
|
priority: 100,
|
|
metadata: { source: "app_gallery_finish" },
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: {
|
|
name: "Resolved runtime GitHub",
|
|
profileId: gatewayProfile.id,
|
|
defaultProfileMode: "gateway_only",
|
|
},
|
|
});
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Runtime token", clientLabel: "Runtime token", ownerNote: "" },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
|
|
const listed = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(200);
|
|
const visibleToolNames = listed.body.result.tools.map((tool: { name: string }) => tool.name);
|
|
expect(visibleToolNames).toContain(assignedToolName);
|
|
expect(visibleToolNames).not.toContain(unassignedToolName);
|
|
|
|
const denied = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: { name: unassignedToolName, arguments: {} },
|
|
})
|
|
.expect(403);
|
|
expect(denied.body.error.data.reasonCode).toBe("deny_default");
|
|
});
|
|
|
|
it("proxies namespaced resources and prompts only for fully assigned MCP connections", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(async ({ body }) => {
|
|
const method = body?.method;
|
|
if (method === "resources/list") {
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: { resources: [{ uri: "notes://one", name: "Note one", mimeType: "text/plain" }] } } };
|
|
}
|
|
if (method === "resources/read") {
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: { contents: [{ uri: String((body?.params as Record<string, unknown>)?.uri), mimeType: "text/plain", text: "resource body" }] } } };
|
|
}
|
|
if (method === "prompts/list") {
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: { prompts: [{ name: "summarize", title: "Summarize note" }] } } };
|
|
}
|
|
if (method === "prompts/get") {
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: { description: "Summary prompt", messages: [{ role: "user", content: { type: "text", text: "Summarize it" } }] } } };
|
|
}
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: {} } };
|
|
});
|
|
try {
|
|
const assigned = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url,
|
|
applicationKey: "context-app",
|
|
connectionName: "Assigned context",
|
|
toolName: "search_notes",
|
|
riskLevel: "read",
|
|
});
|
|
await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url,
|
|
applicationKey: "unassigned-context-app",
|
|
connectionName: "Unassigned context",
|
|
toolName: "private_search",
|
|
riskLevel: "read",
|
|
});
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `context-${randomUUID()}`,
|
|
name: `Context ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: assigned.connection.id,
|
|
});
|
|
const gateway = createTestToolGatewayService(db);
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Context gateway", profileId: profile.id },
|
|
});
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Native runner", clientLabel: "Native runner", ownerNote: "" },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
|
|
const resources = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "resources/list" })
|
|
.expect(200);
|
|
expect(resources.body.result.resources).toHaveLength(1);
|
|
expect(resources.body.result.resources[0]).toMatchObject({
|
|
uri: expect.stringMatching(new RegExp(`^paperclip-resource://${assigned.connection.id}/`)),
|
|
name: "Assigned context: Note one",
|
|
});
|
|
const resourceUri = resources.body.result.resources[0].uri as string;
|
|
|
|
const read = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 2, method: "resources/read", params: { uri: resourceUri } })
|
|
.expect(200);
|
|
expect(read.body.result.contents[0]).toMatchObject({ uri: resourceUri, text: "resource body" });
|
|
|
|
const prompts = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 3, method: "prompts/list" })
|
|
.expect(200);
|
|
expect(prompts.body.result.prompts).toHaveLength(1);
|
|
expect(prompts.body.result.prompts[0].title).toBe("Assigned context: Summarize note");
|
|
const promptName = prompts.body.result.prompts[0].name as string;
|
|
|
|
const wrapper = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "paperclip_get_prompt", arguments: { name: promptName } } })
|
|
.expect(200);
|
|
expect(wrapper.body.result.structuredContent).toMatchObject({ description: "Summary prompt" });
|
|
expect(remote.requests.filter((entry) => entry.body?.method === "resources/read")[0]?.body?.params).toEqual({ uri: "notes://one" });
|
|
expect(remote.requests.filter((entry) => entry.body?.method === "prompts/get")[0]?.body?.params).toEqual({ name: "summarize", arguments: {} });
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("omits archived gateways from listNamedGateways", async () => {
|
|
const company = await createCompany(db);
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `archived-list-${randomUUID()}`,
|
|
name: `Archived list ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
const gateway = createTestToolGatewayService(db);
|
|
|
|
const kept = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Kept gateway", profileId: profile.id },
|
|
});
|
|
const retired = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Retired gateway", profileId: profile.id },
|
|
});
|
|
|
|
// Both are visible while active.
|
|
let listed = await gateway.listNamedGateways(company.id);
|
|
expect(listed.map((g) => g.id).sort()).toEqual([kept.id, retired.id].sort());
|
|
|
|
// Archiving one drops it from the list (but not the active one).
|
|
await gateway.updateNamedGateway({
|
|
companyId: company.id,
|
|
gatewayId: retired.id,
|
|
body: { status: "archived" },
|
|
});
|
|
listed = await gateway.listNamedGateways(company.id);
|
|
expect(listed.map((g) => g.id)).toEqual([kept.id]);
|
|
});
|
|
|
|
it("throttles named gateway bearer auth failures without leaking bearer material", async () => {
|
|
const company = await createCompany(db);
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `auth-throttle-${randomUUID()}`,
|
|
name: `Auth throttle ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
const gateway = createTestToolGatewayService(db, {
|
|
mcpGatewayProtocolLimits: {
|
|
authFailures: { max: 1, windowMs: 60_000 },
|
|
},
|
|
});
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Public auth throttle", profileId: profile.id },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const badToken = `pcgw_${randomUUID()}.not-a-real-secret`;
|
|
|
|
const first = await request(app)
|
|
.post(`/mcp/gateways/${created.gatewayPublicId}`)
|
|
.set("authorization", `Bearer ${badToken}`)
|
|
.set("x-paperclip-client-name", "Noisy client")
|
|
.set("x-request-id", "auth-throttle-test")
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(401);
|
|
expect(first.body.error.data.reasonCode).toBe("gateway_token_invalid");
|
|
|
|
const throttled = await request(app)
|
|
.post(`/mcp/gateways/${created.gatewayPublicId}`)
|
|
.set("authorization", `Bearer ${badToken}`)
|
|
.set("x-paperclip-client-name", "Noisy client")
|
|
.set("x-request-id", "auth-throttle-test")
|
|
.send({ jsonrpc: "2.0", id: 2, method: "tools/list" })
|
|
.expect(429);
|
|
expect(throttled.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_auth_throttled",
|
|
reasonText: "The MCP gateway authentication attempt was throttled after repeated failures.",
|
|
});
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.reasonCode, "gateway_auth_throttled"));
|
|
expect(audits).toHaveLength(1);
|
|
expect(audits[0]).toMatchObject({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
gatewayPublicId: created.gatewayPublicId,
|
|
clientName: "Noisy client",
|
|
correlationId: "auth-throttle-test",
|
|
});
|
|
expect(audits[0]!.details).toMatchObject({
|
|
limiterKeyClass: "gateway_auth",
|
|
tokenPrefix: `pcgw_${badToken.slice(5, 13)}`,
|
|
});
|
|
expect(JSON.stringify(audits)).not.toContain(badToken);
|
|
expect(JSON.stringify(audits)).not.toContain("authorization");
|
|
});
|
|
|
|
it("prunes expired persisted public gateway auth limiter counters", async () => {
|
|
let now = Date.now();
|
|
const company = await createCompany(db);
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `auth-limiter-prune-${randomUUID()}`,
|
|
name: `Auth limiter prune ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
const gateway = createTestToolGatewayService(db, {
|
|
now: () => now,
|
|
mcpGatewayProtocolLimits: {
|
|
authFailures: { max: 100, windowMs: 100 },
|
|
},
|
|
});
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Public auth limiter prune", profileId: profile.id },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const endpoint = `/mcp/gateways/${created.gatewayPublicId}`;
|
|
|
|
await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer pcgw_${randomUUID()}.bad-secret`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(401);
|
|
const initialCounters = await db.select().from(toolGatewayRateLimitCounters);
|
|
expect(initialCounters.length).toBeGreaterThan(0);
|
|
|
|
now += 60_001;
|
|
await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer pcgw_${randomUUID()}.bad-secret`)
|
|
.send({ jsonrpc: "2.0", id: 2, method: "tools/list" })
|
|
.expect(401);
|
|
|
|
const remainingCounters = await db.select().from(toolGatewayRateLimitCounters);
|
|
expect(remainingCounters.every((counter) => counter.resetAt.getTime() > now)).toBe(true);
|
|
});
|
|
|
|
it("shares public gateway auth limiter counters across service instances", async () => {
|
|
const company = await createCompany(db);
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `auth-limiter-shared-${randomUUID()}`,
|
|
name: `Auth limiter shared ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
const serviceA = createTestToolGatewayService(db, {
|
|
mcpGatewayProtocolLimits: {
|
|
authFailures: { max: 1, windowMs: 60_000 },
|
|
},
|
|
});
|
|
const created = await serviceA.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Public auth limiter shared", profileId: profile.id },
|
|
});
|
|
const serviceB = createTestToolGatewayService(db, {
|
|
mcpGatewayProtocolLimits: {
|
|
authFailures: { max: 1, windowMs: 60_000 },
|
|
},
|
|
});
|
|
const badToken = `pcgw_${randomUUID()}.not-a-real-secret`;
|
|
|
|
await request(createGatewayRouteApp(db, serviceA))
|
|
.post(`/mcp/gateways/${created.gatewayPublicId}`)
|
|
.set("authorization", `Bearer ${badToken}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(401);
|
|
|
|
const throttled = await request(createGatewayRouteApp(db, serviceB))
|
|
.post(`/mcp/gateways/${created.gatewayPublicId}`)
|
|
.set("authorization", `Bearer ${badToken}`)
|
|
.set("x-paperclip-client-name", "Shared counter client")
|
|
.set("x-request-id", "auth-limiter-shared-test")
|
|
.send({ jsonrpc: "2.0", id: 2, method: "tools/list" })
|
|
.expect(429);
|
|
expect(throttled.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_auth_throttled",
|
|
reasonText: "The MCP gateway authentication attempt was throttled after repeated failures.",
|
|
});
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.reasonCode, "gateway_auth_throttled"));
|
|
expect(audits).toHaveLength(1);
|
|
expect(audits[0]).toMatchObject({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
gatewayPublicId: created.gatewayPublicId,
|
|
clientName: "Shared counter client",
|
|
correlationId: "auth-limiter-shared-test",
|
|
});
|
|
expect(audits[0]!.details).toMatchObject({
|
|
limiterKeyClass: "gateway_auth",
|
|
tokenPrefix: `pcgw_${badToken.slice(5, 13)}`,
|
|
});
|
|
expect(JSON.stringify(audits)).not.toContain(badToken);
|
|
expect(JSON.stringify(audits)).not.toContain("authorization");
|
|
});
|
|
|
|
it("rate limits public named gateway session setup, discovery, and calls with redacted audits", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(async ({ body }) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: body?.id,
|
|
result: { content: [{ type: "text", text: "read ok" }], structuredContent: { ok: true } },
|
|
},
|
|
}));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url,
|
|
applicationKey: "limited-named-gateway-app",
|
|
toolName: "read_note",
|
|
title: "Read note",
|
|
riskLevel: "read",
|
|
});
|
|
const gatewayToolName = expectedConnectedToolName({
|
|
applicationKey: application.applicationKey,
|
|
connectionId: connection.id,
|
|
toolName: catalogEntry.toolName,
|
|
});
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `protocol-limit-${randomUUID()}`,
|
|
name: `Protocol limit ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: gatewayToolName,
|
|
});
|
|
// Pin the clock so every request in this test shares one rate-limit
|
|
// window. The window boundary aligns to wall-clock time, so a real clock
|
|
// can advance past the boundary between two paired requests and reset the
|
|
// counter. That reset makes the second request return 200 instead of 429.
|
|
const fixedNow = Date.now();
|
|
const gateway = createTestToolGatewayService(db, {
|
|
now: () => fixedNow,
|
|
mcpGatewayProtocolLimits: {
|
|
gatewayRequests: { max: 1, windowMs: 60_000 },
|
|
tokenRequests: { max: 1, windowMs: 60_000 },
|
|
sessionSetup: { max: 1, windowMs: 60_000 },
|
|
},
|
|
});
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Public protocol limits", profileId: profile.id },
|
|
});
|
|
const tokenA = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Client A", clientLabel: "Client A", ownerNote: "" },
|
|
});
|
|
const tokenB = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Client B", clientLabel: "Client B", ownerNote: "" },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const endpoint = `/mcp/gateways/${created.gatewayPublicId}`;
|
|
|
|
const initialized = await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenA.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "initialize" })
|
|
.expect(200);
|
|
expect(initialized.body.result).toMatchObject({
|
|
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
_meta: { "paperclip/mcp-app-ui": "unsupported" },
|
|
});
|
|
const setupLimited = await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenA.token}`)
|
|
.send({ jsonrpc: "2.0", id: 2, method: "initialize" })
|
|
.expect(429);
|
|
expect(setupLimited.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_rate_limited",
|
|
limiterKeyClass: "token",
|
|
protocolMethod: "initialize",
|
|
});
|
|
|
|
await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenA.token}`)
|
|
.send({ jsonrpc: "2.0", id: 3, method: "tools/list" })
|
|
.expect(200);
|
|
const discoveryLimited = await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenB.token}`)
|
|
.send({ jsonrpc: "2.0", id: 4, method: "tools/list" })
|
|
.expect(429);
|
|
expect(discoveryLimited.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_rate_limited",
|
|
limiterKeyClass: "gateway",
|
|
protocolMethod: "tools/list",
|
|
});
|
|
|
|
await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenB.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 5,
|
|
method: "tools/call",
|
|
params: { name: gatewayToolName, arguments: { key: "a", value: "b" } },
|
|
})
|
|
.expect(200);
|
|
const callLimited = await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenB.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 6,
|
|
method: "tools/call",
|
|
params: { name: gatewayToolName, arguments: { key: "a", value: "b" } },
|
|
})
|
|
.expect(429);
|
|
expect(callLimited.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_rate_limited",
|
|
limiterKeyClass: "token",
|
|
protocolMethod: "tools/call",
|
|
});
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.reasonCode, "gateway_rate_limited"));
|
|
expect(audits).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ gatewayId: created.id, gatewayPublicId: created.gatewayPublicId }),
|
|
]));
|
|
expect(audits.map((audit) => audit.details)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ protocolMethod: "initialize", limiterKeyClass: "token" }),
|
|
expect.objectContaining({ protocolMethod: "tools/list", limiterKeyClass: "gateway" }),
|
|
expect.objectContaining({ protocolMethod: "tools/call", limiterKeyClass: "token" }),
|
|
]));
|
|
const serializedAudits = JSON.stringify(audits);
|
|
expect(serializedAudits).not.toContain(tokenA.token);
|
|
expect(serializedAudits).not.toContain(tokenB.token);
|
|
expect(serializedAudits).not.toContain("authorization");
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("hides and denies every external tool when an agent has no gateway profile", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 25 } });
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
await expect(gateway.listToolsForSession(session.token)).resolves.toEqual([]);
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:echo",
|
|
parameters: { message: "not allowed" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected unauthorized tool call to fail");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "deny_default"),
|
|
);
|
|
|
|
const [deniedAudit] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_denied"));
|
|
expect(deniedAudit).toMatchObject({
|
|
companyId: company.id,
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
});
|
|
|
|
it("fences an uncertain upstream mutation after fresh Retry while allowing independent reads", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const remote = await createRemoteMcpTool(db, company.id);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token))
|
|
.find(item => item.connectionId === remote.connection.id)!;
|
|
const [source] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
|
|
issueId: issue.id, status: "failed", createdAt: new Date(Date.now() - 60_000) }).returning();
|
|
const intentId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({ id: intentId, companyId: company.id, agentId: agent.id,
|
|
runId: run.id, source: "on_demand", reason: "task_retry", status: "coalesced",
|
|
requestedByActorType: "user", requestedByActorId: "board",
|
|
payload: { issueId: issue.id, taskRetry: { schema: "paperclip.task-retry.v1",
|
|
generationId: intentId, sourceRunId: source.id, actorId: "board", strategy: "fresh_isolated_workspace" } } });
|
|
const [uncertain] = await db.insert(toolInvocations).values({ companyId: company.id,
|
|
issueId: issue.id, runId: source.id, agentId: agent.id, connectionId: remote.connection.id,
|
|
toolName: tool.name, riskLevel: "write", status: "timed_out", startedAt: new Date() }).returning();
|
|
const fetcher = vi.spyOn(globalThis, "fetch");
|
|
try {
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
|
|
idempotencyKey: randomUUID(), parameters: { key: "new-key", value: "do not repeat" } }))
|
|
.rejects.toMatchObject({ status: 409, reasonCode: "recovery_effect_outcome_unresolved" });
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
expect(await gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:add",
|
|
parameters: { a: 2, b: 3 } })).toMatchObject({ status: "completed", result: { content: "5" } });
|
|
const [retained] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, uncertain.id));
|
|
expect(retained.status).toBe("timed_out");
|
|
} finally { fetcher.mockRestore(); }
|
|
});
|
|
|
|
it("filters discovery, executes a remote HTTP fixture, and audits run and issue links", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
"mcp-remote-fixture:add",
|
|
"mcp-stdio-fixture:increment_counter",
|
|
"mcp-stdio-fixture:runtime_status",
|
|
]);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 25 } });
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const toolNames = (await gateway.listToolsForSession(session.token)).map((tool) => tool.name);
|
|
expect(toolNames).toContain("mcp-remote-fixture:add");
|
|
expect(toolNames).toContain("mcp-stdio-fixture:increment_counter");
|
|
expect(toolNames).not.toContain("mcp-remote-fixture:echo");
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:add",
|
|
parameters: { a: 4, b: 7 },
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
tool: "mcp-remote-fixture:add",
|
|
result: {
|
|
content: "11",
|
|
data: {
|
|
result: 11,
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const [invocation] = await db.select().from(toolInvocations);
|
|
expect(invocation).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
toolName: "mcp-remote-fixture:add",
|
|
status: "succeeded",
|
|
});
|
|
const [callEvent] = await db.select().from(toolCallEvents);
|
|
expect(callEvent).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
toolName: "mcp-remote-fixture:add",
|
|
outcome: "success",
|
|
});
|
|
const [dedicatedAudit] = await db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(eq(toolCallEvents.eventType, "call_completed"));
|
|
expect(dedicatedAudit).toMatchObject({
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
toolName: "mcp-remote-fixture:add",
|
|
});
|
|
});
|
|
|
|
it("lists connected remote MCP catalog tools only for the scoped company and agent policy", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const unprofiledAgent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { run: unprofiledRun } = await createIssueAndRun(db, company.id, unprofiledAgent.id);
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
});
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const { run: otherRun } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
|
const otherRemoteTool = await createRemoteMcpTool(db, otherCompany.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
});
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
catalogEntryId: remoteTool.catalogEntry.id,
|
|
});
|
|
const otherProfile = await allowToolsForAgent(db, otherCompany.id, otherAgent.id, []);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: otherCompany.id,
|
|
profileId: otherProfile.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: otherRemoteTool.connection.id,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const unprofiledSession = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: unprofiledAgent.id,
|
|
runId: unprofiledRun.id,
|
|
});
|
|
const otherSession = await gateway.createSession({
|
|
companyId: otherCompany.id,
|
|
agentId: otherAgent.id,
|
|
runId: otherRun.id,
|
|
});
|
|
|
|
const tools = await gateway.listToolsForSession(session.token);
|
|
const connectedTool = tools.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toMatchObject({
|
|
name: expect.stringMatching(/^mcp\.kv-demo-[0-9a-f]{8}:kv-set$/),
|
|
displayName: "Set KV value",
|
|
providerType: "mcp_remote_http",
|
|
risk: "write",
|
|
applicationId: remoteTool.application.id,
|
|
applicationKey: "kv-demo",
|
|
connectionId: remoteTool.connection.id,
|
|
catalogEntryId: remoteTool.catalogEntry.id,
|
|
upstreamToolName: "kv_set",
|
|
parametersSchema: expect.objectContaining({ type: "object" }),
|
|
providerMetadata: expect.objectContaining({
|
|
applicationKey: "kv-demo",
|
|
connectionId: remoteTool.connection.id,
|
|
catalogEntryId: remoteTool.catalogEntry.id,
|
|
transport: "mcp_remote",
|
|
upstreamToolName: "kv_set",
|
|
annotations: { readOnlyHint: false },
|
|
risk: expect.objectContaining({ level: "write", isWrite: true }),
|
|
}),
|
|
});
|
|
|
|
await expect(gateway.listToolsForSession(unprofiledSession.token)).resolves.toEqual([]);
|
|
const otherTools = await gateway.listToolsForSession(otherSession.token);
|
|
expect(otherTools).toEqual([
|
|
expect.objectContaining({
|
|
providerType: "mcp_remote_http",
|
|
connectionId: otherRemoteTool.connection.id,
|
|
catalogEntryId: otherRemoteTool.catalogEntry.id,
|
|
}),
|
|
]);
|
|
expect(otherTools.map((tool) => tool.catalogEntryId)).not.toContain(remoteTool.catalogEntry.id);
|
|
});
|
|
|
|
|
|
it("hides retired Composio child tools and denies a previously discovered tool without upstream traffic", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const remote = await createRemoteMcpTool(db, company.id, { applicationKey: "composio", connectionName: "Old toolkit", toolName: "read", title: "Read" });
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
|
|
await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id, selectorType: "catalog_entry", effect: "include", catalogEntryId: remote.catalogEntry.id });
|
|
const remoteHttpRequest = vi.fn();
|
|
const gateway = createTestToolGatewayService(db, { remoteHttpRequest });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const listed = await gateway.listToolsForSession(session.token);
|
|
const tool = listed.find((entry) => entry.connectionId === remote.connection.id)!;
|
|
expect(tool).toBeDefined();
|
|
await db.update(toolConnections).set({ config: { provider: "composio", parentConnectionId: randomUUID(), toolkitSlug: "github", url: "https://legacy.example/mcp" } }).where(eq(toolConnections.id, remote.connection.id));
|
|
expect((await gateway.listToolsForSession(session.token)).some((entry) => entry.connectionId === remote.connection.id)).toBe(false);
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).rejects.toThrow();
|
|
expect(remoteHttpRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lists and executes connected local stdio MCP catalog tools through the gateway", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const unprofiledAgent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { run: unprofiledRun } = await createIssueAndRun(db, company.id, unprofiledAgent.id);
|
|
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "local-demo",
|
|
connectionName: "Local Demo",
|
|
toolName: "echo",
|
|
title: "Local echo",
|
|
});
|
|
const expectedName = expectedConnectedToolName({
|
|
applicationKey: "local-demo",
|
|
connectionId: localTool.connection.id,
|
|
toolName: "echo",
|
|
});
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
catalogEntryId: localTool.catalogEntry.id,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const unprofiledSession = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: unprofiledAgent.id,
|
|
runId: unprofiledRun.id,
|
|
});
|
|
|
|
const tools = await gateway.listToolsForSession(session.token);
|
|
expect(tools).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: expectedName,
|
|
displayName: "Local echo",
|
|
providerType: "mcp_local_stdio",
|
|
risk: "read",
|
|
applicationId: localTool.application.id,
|
|
applicationKey: "local-demo",
|
|
connectionId: localTool.connection.id,
|
|
catalogEntryId: localTool.catalogEntry.id,
|
|
upstreamToolName: "echo",
|
|
providerMetadata: expect.objectContaining({
|
|
transport: "local_stdio",
|
|
connectionId: localTool.connection.id,
|
|
catalogEntryId: localTool.catalogEntry.id,
|
|
upstreamToolName: "echo",
|
|
}),
|
|
}),
|
|
]));
|
|
await expect(gateway.listToolsForSession(unprofiledSession.token)).resolves.toEqual([]);
|
|
|
|
await expect(gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: expectedName,
|
|
parameters: { message: "hello" },
|
|
})).resolves.toMatchObject({
|
|
status: "completed",
|
|
result: {
|
|
content: "local:hello",
|
|
data: {
|
|
structuredContent: { echoed: "hello" },
|
|
transport: "local_stdio",
|
|
spawnedLocalProcess: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
|
|
expect(slot).toMatchObject({
|
|
status: "idle",
|
|
commandTemplateKey: localTool.templateKey,
|
|
healthStatus: "ok",
|
|
});
|
|
});
|
|
|
|
it("passes only approved env values to local stdio MCP processes", async () => {
|
|
const previousDatabaseUrl = process.env.DATABASE_URL;
|
|
process.env.DATABASE_URL = "postgres://server-secret.example/paperclip";
|
|
try {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const allowedToken = await secretService(db).create(company.id, {
|
|
name: `Local stdio token ${randomUUID()}`,
|
|
key: `local_stdio_token_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: "allowed-token",
|
|
});
|
|
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "local-env-demo",
|
|
connectionName: "Local Env Demo",
|
|
toolName: "inspect_env",
|
|
title: "Inspect env",
|
|
envKeys: ["ALLOWED_TOKEN"],
|
|
credentialSecretRefs: [{
|
|
secretId: allowedToken.id,
|
|
versionSelector: "latest",
|
|
configPath: "env.ALLOWED_TOKEN",
|
|
required: true,
|
|
label: "Allowed token",
|
|
}],
|
|
connectionConfig: { env: { ALLOWED_TOKEN: "connection-level-token", EXTRA_CONFIG: "extra-value", NODE_OPTIONS: "--trace-warnings" } },
|
|
stdioScript: `
|
|
const readline = require("node:readline");
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on("line", (line) => {
|
|
const message = JSON.parse(line);
|
|
if (message.method === "initialize") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "env-stdio", version: "0.0.0" } } }) + "\\n");
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") {
|
|
process.stdout.write(JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
content: [{ type: "text", text: "env" }],
|
|
structuredContent: {
|
|
databaseUrl: process.env.DATABASE_URL ?? null,
|
|
allowedToken: process.env.ALLOWED_TOKEN ?? null,
|
|
extraConfig: process.env.EXTRA_CONFIG ?? null,
|
|
nodeOptions: process.env.NODE_OPTIONS ?? null,
|
|
hasPath: Boolean(process.env.PATH || process.env.Path),
|
|
},
|
|
},
|
|
}) + "\\n");
|
|
}
|
|
});
|
|
`,
|
|
});
|
|
const expectedName = expectedConnectedToolName({
|
|
applicationKey: "local-env-demo",
|
|
connectionId: localTool.connection.id,
|
|
toolName: "inspect_env",
|
|
});
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
catalogEntryId: localTool.catalogEntry.id,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
|
|
await expect(gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: expectedName,
|
|
parameters: { message: "hello" },
|
|
})).resolves.toMatchObject({
|
|
status: "completed",
|
|
result: {
|
|
data: {
|
|
structuredContent: {
|
|
databaseUrl: null,
|
|
allowedToken: "***REDACTED***",
|
|
extraConfig: null,
|
|
nodeOptions: null,
|
|
hasPath: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
if (previousDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
else process.env.DATABASE_URL = previousDatabaseUrl;
|
|
}
|
|
});
|
|
|
|
it.each(["credentials.authorization", "headers.X-Api-Key", "oauth.access_token"])(
|
|
"resolves a personal remote credential using its declared %s path",
|
|
async (configPath) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
|
const secrets = secretService(db);
|
|
const definition = await secrets.createUserSecretDefinition(company.id, {
|
|
name: "Personal remote token",
|
|
key: `personal_remote_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
});
|
|
const secret = await secrets.createCurrentUserSecretValue(company.id, "alice", {
|
|
definitionId: definition.id, value: "personal-remote-test-token",
|
|
});
|
|
const remote = await createRemoteMcpTool(db, company.id);
|
|
await db.update(toolConnections).set({
|
|
credentialPolicy: "per_user",
|
|
credentialRefs: [{ name: configPath, secretId: secret.id, placement: "header", key: "Authorization", prefix: "Bearer " }],
|
|
}).where(eq(toolConnections.id, remote.connection.id));
|
|
await secrets.syncUserSecretDeclarationsForTarget(company.id,
|
|
{ targetType: "tool_connection", targetId: remote.connection.id },
|
|
[{ definitionKey: definition.key, configPath, envKey: "Authorization", versionSelector: "latest", required: true }],
|
|
{ replaceAll: true });
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id, connectionId: remote.connection.id, kind: "user", subjectUserId: "alice",
|
|
credentialSecretRefs: [{ secretId: secret.id, configPath, versionSelector: "latest" }],
|
|
status: "active", isDefault: false,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const headers: string[] = [];
|
|
const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => {
|
|
headers.push(new Headers(init.headers).get("authorization") ?? "");
|
|
const body = JSON.parse(String(init.body)) as { id: string };
|
|
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id,
|
|
result: { content: [{ type: "text", text: "retrieved synthetic memory" }] } }),
|
|
{ headers: { "content-type": "application/json" } });
|
|
} });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === remote.connection.id)!;
|
|
expect((await gateway.executeTool({ sessionToken: session.token, tool: tool.name,
|
|
parameters: { key: "test", value: "synthetic" } })).status).toBe("completed");
|
|
expect(headers).toEqual(["Bearer personal-remote-test-token"]);
|
|
},
|
|
);
|
|
|
|
it("passes only the selected grant identity to local stdio MCP processes", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await createActiveMember(db, company.id, "bob");
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
|
const values = {
|
|
organization: `organization-${randomUUID()}`,
|
|
alice: `alice-${randomUUID()}`,
|
|
bob: `bob-${randomUUID()}`,
|
|
};
|
|
const organizationSecret = await secretService(db).create(company.id, {
|
|
name: `Organization stdio token ${randomUUID()}`,
|
|
key: `organization_stdio_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: values.organization,
|
|
});
|
|
const secrets = secretService(db);
|
|
const identityDefinition = await secrets.createUserSecretDefinition(company.id, {
|
|
name: `Personal stdio token ${randomUUID()}`,
|
|
key: `personal_stdio_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
});
|
|
const aliceSecret = await secrets.createCurrentUserSecretValue(company.id, "alice", {
|
|
definitionId: identityDefinition.id,
|
|
value: values.alice,
|
|
});
|
|
const bobSecret = await secrets.createCurrentUserSecretValue(company.id, "bob", {
|
|
definitionId: identityDefinition.id,
|
|
value: values.bob,
|
|
});
|
|
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "local-grant-identity",
|
|
toolName: "identity",
|
|
title: "Grant identity",
|
|
envKeys: ["IDENTITY_TOKEN"],
|
|
credentialSecretRefs: [{
|
|
secretId: organizationSecret.id,
|
|
versionSelector: "latest",
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
required: true,
|
|
label: "Organization identity",
|
|
}],
|
|
connectionConfig: { env: { IDENTITY_TOKEN: "legacy-connection-identity" } },
|
|
stdioScript: `
|
|
const readline = require("node:readline");
|
|
const identities = ${JSON.stringify(values)};
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on("line", (line) => {
|
|
const message = JSON.parse(line);
|
|
if (message.method === "initialize") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "identity-stdio", version: "0.0.0" } } }) + "\\n");
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") {
|
|
const identity = Object.entries(identities).find(([, value]) => value === process.env.IDENTITY_TOKEN)?.[0] ?? "unknown";
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: identity }], structuredContent: { identity } } }) + "\\n");
|
|
}
|
|
});
|
|
`,
|
|
});
|
|
await secrets.syncUserSecretDeclarationsForTarget(
|
|
company.id,
|
|
{ targetType: "tool_connection", targetId: localTool.connection.id },
|
|
[{
|
|
definitionKey: identityDefinition.key,
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
envKey: "IDENTITY_TOKEN",
|
|
versionSelector: "latest",
|
|
required: true,
|
|
label: "Personal identity",
|
|
}],
|
|
{ replaceAll: true },
|
|
);
|
|
const grantRef = (secretId: string, label: string) => ({
|
|
secretId,
|
|
versionSelector: "latest" as const,
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
required: true,
|
|
label,
|
|
});
|
|
const [aliceGrant] = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: localTool.connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
credentialSecretRefs: [grantRef(aliceSecret.id, "Alice identity")],
|
|
status: "active",
|
|
isDefault: false,
|
|
}).returning();
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: localTool.connection.id,
|
|
kind: "user",
|
|
subjectUserId: "bob",
|
|
credentialSecretRefs: [grantRef(bobSecret.id, "Bob identity")],
|
|
status: "active",
|
|
isDefault: false,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.connectionId === localTool.connection.id)!;
|
|
const executeIdentity = async () => {
|
|
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
|
|
return (result.result as { data?: { structuredContent?: { identity?: string } } }).data?.structuredContent?.identity;
|
|
};
|
|
|
|
expect(await executeIdentity()).toBe("organization");
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
expect(await executeIdentity()).toBe("alice");
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
expect(await executeIdentity()).toBe("alice");
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, aliceGrant.id));
|
|
expect(await executeIdentity()).toBe("organization");
|
|
|
|
await db.delete(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
|
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
|
|
|
const [organizationGrant] = await db.select().from(connectionGrants).where(and(
|
|
eq(connectionGrants.connectionId, localTool.connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
));
|
|
await db.insert(connectionGrantMembers).values({
|
|
companyId: company.id,
|
|
grantId: organizationGrant.id,
|
|
subjectType: "user",
|
|
subjectId: "sales-user",
|
|
});
|
|
await db.update(toolConnections).set({ credentialPolicy: "shared" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.rejects.toMatchObject({ status: 403, reasonCode: "grant_audience_denied" });
|
|
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
|
|
|
await db.insert(connectionGrantMembers).values({
|
|
companyId: company.id,
|
|
grantId: organizationGrant.id,
|
|
subjectType: "user",
|
|
subjectId: "alice",
|
|
});
|
|
expect(await executeIdentity()).toBe("organization");
|
|
|
|
await db.delete(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, organizationGrant.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.rejects.toMatchObject({ status: 409, reasonCode: "organization_authorization_required" });
|
|
|
|
await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, organizationGrant.id));
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, run.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
|
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
|
});
|
|
|
|
it("uses the signed-in tester's personal grant for Test-tab calls", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const userId = `test-user-${randomUUID()}`;
|
|
await createActiveMember(db, company.id, userId);
|
|
const personalValue = `personal-${randomUUID()}`;
|
|
const definitionKey = `personal_test_${randomUUID().replace(/-/g, "")}`;
|
|
const [definition] = await db.insert(userSecretDefinitions).values({
|
|
companyId: company.id,
|
|
key: definitionKey,
|
|
name: `Personal Test token ${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
managedMode: "paperclip_managed",
|
|
}).returning();
|
|
const personalSecret = await secretService(db).createCurrentUserSecretValue(company.id, userId, {
|
|
definitionId: definition.id,
|
|
value: personalValue,
|
|
});
|
|
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "personal-test-tab",
|
|
toolName: "identity",
|
|
title: "Personal test identity",
|
|
envKeys: ["IDENTITY_TOKEN"],
|
|
stdioScript: `
|
|
const readline = require("node:readline");
|
|
const expected = ${JSON.stringify(personalValue)};
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on("line", (line) => {
|
|
const message = JSON.parse(line);
|
|
if (message.method === "initialize") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "test-identity", version: "0.0.0" } } }) + "\\n");
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") {
|
|
const identity = process.env.IDENTITY_TOKEN === expected ? "personal" : "wrong";
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: identity }], structuredContent: { identity } } }) + "\\n");
|
|
}
|
|
});
|
|
`,
|
|
});
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(
|
|
toolConnections.id,
|
|
localTool.connection.id,
|
|
));
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: localTool.connection.id,
|
|
kind: "user",
|
|
subjectUserId: userId,
|
|
credentialSecretRefs: [{
|
|
secretId: personalSecret.id,
|
|
versionSelector: "latest",
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
required: true,
|
|
label: "Personal identity",
|
|
}],
|
|
status: "active",
|
|
isDefault: false,
|
|
});
|
|
await secretService(db).syncUserSecretDeclarationsForTarget(
|
|
company.id,
|
|
{ targetType: "tool_connection", targetId: localTool.connection.id },
|
|
[{
|
|
definitionKey,
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
envKey: "IDENTITY_TOKEN",
|
|
versionSelector: "latest",
|
|
required: true,
|
|
label: "Personal identity",
|
|
}],
|
|
{ replaceAll: true },
|
|
);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
|
|
|
await expect(gateway.executeTestCall({
|
|
companyId: company.id,
|
|
connectionId: localTool.connection.id,
|
|
agentId: agent.id,
|
|
userId,
|
|
toolName: "identity",
|
|
parameters: {},
|
|
})).resolves.toMatchObject({
|
|
decision: "allowed",
|
|
result: { data: { structuredContent: { identity: "personal" } } },
|
|
});
|
|
});
|
|
|
|
it("keeps connected remote MCP gateway names collision-safe and excludes inactive catalog sources", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const first = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo Primary",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
});
|
|
const second = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo Secondary",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
});
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "disabled-demo",
|
|
connectionName: "Disabled Demo",
|
|
toolName: "kv_set",
|
|
connectionEnabled: false,
|
|
});
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "unhealthy-demo",
|
|
connectionName: "Unhealthy Demo",
|
|
toolName: "kv_set",
|
|
healthStatus: "error",
|
|
});
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "quarantined-demo",
|
|
connectionName: "Quarantined Demo",
|
|
toolName: "kv_set",
|
|
catalogStatus: "quarantined",
|
|
quarantinedAt: new Date(),
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
|
|
const connectedTools = (await gateway.listToolsForSession(session.token))
|
|
.filter((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTools).toHaveLength(2);
|
|
expect(connectedTools.map((tool) => tool.catalogEntryId).sort()).toEqual([
|
|
first.catalogEntry.id,
|
|
second.catalogEntry.id,
|
|
].sort());
|
|
expect(new Set(connectedTools.map((tool) => tool.name)).size).toBe(2);
|
|
expect(connectedTools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
|
|
expect.stringMatching(new RegExp(`^mcp\\.kv-demo-${first.connection.id.replace(/-/g, "").slice(0, 8)}:kv-set$`)),
|
|
expect.stringMatching(new RegExp(`^mcp\\.kv-demo-${second.connection.id.replace(/-/g, "").slice(0, 8)}:kv-set$`)),
|
|
]));
|
|
});
|
|
|
|
it("dispatches a bounded alias to the original long upstream tool name", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const upstreamName = "file_download_batch_exports_count_rows_create";
|
|
const fake = await startFakeRemoteMcpServer((request) => {
|
|
expect(request.body).toMatchObject({ method: "tools/call", params: { name: upstreamName, arguments: {} } });
|
|
return { body: { jsonrpc: "2.0", id: request.body?.id, result: { content: [{ type: "text", text: "ok" }] } } };
|
|
});
|
|
try {
|
|
const { catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: `app-gallery:posthog:${randomUUID()}`, toolName: upstreamName, url: fake.url,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.catalogEntryId === catalogEntry.id)!;
|
|
expect(`mcp__paperclip-assigned__${tool.name}`.length).toBeLessThanOrEqual(128);
|
|
expect((await gateway.listToolsForSession(session.token)).find((entry) => entry.catalogEntryId === catalogEntry.id)?.name).toBe(tool.name);
|
|
await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["local_trusted", { deploymentMode: "local_trusted" as const, deploymentExposure: "private" as const }],
|
|
["authenticated/private", { deploymentMode: "authenticated" as const, deploymentExposure: "private" as const }],
|
|
["authenticated/public", { deploymentMode: "authenticated" as const, deploymentExposure: "public" as const }],
|
|
])("always blocks link-local gateway dispatch in %s before fetch", async (_label, deployment) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "private-endpoint",
|
|
toolName: "kv_set",
|
|
url: "http://169.254.169.254/mcp",
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch should not be called"));
|
|
try {
|
|
const gateway = createTestToolGatewayService(db, deployment);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected private endpoint to be blocked");
|
|
},
|
|
(error) => expectGatewayError(error, 422, "remote_http_private_endpoint"),
|
|
);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
fetchSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ connectionMethodKey: "managed", transportConfigOnly: false },
|
|
{ connectionMethodKey: "mcp-key", transportConfigOnly: false },
|
|
{ connectionMethodKey: "managed", transportConfigOnly: true },
|
|
{ connectionMethodKey: "mcp-key", transportConfigOnly: true },
|
|
])("sends the GitHub Actions toolset for $connectionMethodKey (legacy config: $transportConfigOnly)", async ({ connectionMethodKey, transportConfigOnly }) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const parameters = { method: "run_workflow", owner: "example", repo: "release", workflow_id: "nightly.yml", ref: "main" };
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers["x-mcp-toolsets"]).toBe("default,actions");
|
|
expect(fakeRequest.body).toMatchObject({
|
|
method: "tools/call",
|
|
params: { name: "actions_run_trigger", arguments: parameters },
|
|
});
|
|
return { body: { jsonrpc: "2.0", id: fakeRequest.body?.id, result: { content: [{ type: "text", text: "Workflow dispatched" }] } } };
|
|
});
|
|
try {
|
|
const { connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "github",
|
|
url: fake.url,
|
|
toolName: "actions_run_trigger",
|
|
riskLevel: "destructive",
|
|
connectionConfig: { sourceTemplateKey: "github", connectionMethodKey },
|
|
});
|
|
if (transportConfigOnly) {
|
|
await db.update(toolConnections).set({ config: { url: fake.url } }).where(eq(toolConnections.id, connection.id));
|
|
}
|
|
await db.update(toolCatalogEntries).set({
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: Object.fromEntries(Object.keys(parameters).map((key) => [key, { type: "string" }])),
|
|
required: Object.keys(parameters),
|
|
additionalProperties: false,
|
|
},
|
|
}).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.catalogEntryId === catalogEntry.id);
|
|
expect(tool).toBeTruthy();
|
|
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters });
|
|
expect(result.status).toBe("completed");
|
|
expect(fake.requests).toHaveLength(1);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("executes a connected remote HTTP MCP tool with stored credentials and redacted audit state", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const credentialValue = `remote-secret-${randomUUID()}`;
|
|
const secret = await secretService(db).create(company.id, {
|
|
name: `Remote MCP token ${randomUUID()}`,
|
|
key: `remote_mcp_token_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: credentialValue,
|
|
});
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers.authorization).toBe(`Bearer ${credentialValue}`);
|
|
expect(fakeRequest.headers["x-posthog-project-id"]).toBe("12345");
|
|
const params = fakeRequest.body?.params as Record<string, unknown>;
|
|
const args = params.arguments as Record<string, unknown>;
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: `stored ${String(args.key)}=${String(args.value)}` }],
|
|
structuredContent: { saved: true, key: args.key },
|
|
},
|
|
},
|
|
};
|
|
});
|
|
try {
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
url: fake.url,
|
|
credentialRefs: [{
|
|
name: "credentials.authorization",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
}],
|
|
credentialSecretRefs: [{
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.authorization",
|
|
required: true,
|
|
label: "Remote MCP token",
|
|
}],
|
|
connectionConfig: {
|
|
sourceTemplateKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
methodConfig: {
|
|
projectId: "12345",
|
|
readOnly: true,
|
|
features: "insights",
|
|
mode: "tools",
|
|
},
|
|
},
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
tool: connectedTool!.name,
|
|
result: {
|
|
content: "stored alpha=one",
|
|
data: {
|
|
structuredContent: { saved: true, key: "alpha" },
|
|
isError: false,
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
},
|
|
});
|
|
expect(fake.requests).toHaveLength(1);
|
|
// Streamable HTTP requires advertising both JSON and SSE on the call (PAP-11096).
|
|
expect(fake.requests[0]!.headers.accept).toBe("application/json, text/event-stream");
|
|
expect(fake.requests[0]!.body).toMatchObject({
|
|
method: "tools/call",
|
|
params: {
|
|
name: "kv_set",
|
|
arguments: { key: "alpha", value: "one" },
|
|
},
|
|
});
|
|
|
|
const [invocation] = await db.select().from(toolInvocations);
|
|
expect(invocation).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
toolName: connectedTool!.name,
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "kv-demo",
|
|
upstreamToolName: "kv_set",
|
|
riskLevel: "write",
|
|
status: "succeeded",
|
|
});
|
|
expect(invocation.applicationId).toBe(connectedTool!.applicationId);
|
|
expect(invocation.connectionId).toBe(connectedTool!.connectionId);
|
|
expect(invocation.catalogEntryId).toBe(connectedTool!.catalogEntryId);
|
|
expect(invocation.argumentsSummary).toMatchObject({
|
|
summary: expect.stringContaining("\"key\":\"alpha\""),
|
|
});
|
|
expect(invocation.resultSummary).toMatchObject({
|
|
summary: expect.stringContaining("\"saved\":true"),
|
|
});
|
|
|
|
const callEvents = await db.select().from(toolCallEvents);
|
|
expect(callEvents).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
eventType: "policy_decision",
|
|
applicationId: connectedTool!.applicationId,
|
|
connectionId: connectedTool!.connectionId,
|
|
catalogEntryId: connectedTool!.catalogEntryId,
|
|
toolName: connectedTool!.name,
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
}),
|
|
expect.objectContaining({
|
|
eventType: "call_completed",
|
|
applicationId: connectedTool!.applicationId,
|
|
connectionId: connectedTool!.connectionId,
|
|
catalogEntryId: connectedTool!.catalogEntryId,
|
|
toolName: connectedTool!.name,
|
|
metadata: expect.objectContaining({
|
|
applicationKey: "kv-demo",
|
|
providerType: "mcp_remote_http",
|
|
upstreamToolName: "kv_set",
|
|
risk: "write",
|
|
}),
|
|
}),
|
|
]));
|
|
|
|
const gatewayAudits = await db.select().from(toolAccessAuditEvents);
|
|
expect(gatewayAudits).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
action: "tool_access.policy_decision",
|
|
connectionId: connectedTool!.connectionId,
|
|
catalogEntryId: connectedTool!.catalogEntryId,
|
|
reasonCode: "allow_profile",
|
|
details: expect.objectContaining({
|
|
applicationKey: "kv-demo",
|
|
providerType: "mcp_remote_http",
|
|
upstreamToolName: "kv_set",
|
|
riskLevel: "write",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
action: "call_completed",
|
|
connectionId: connectedTool!.connectionId,
|
|
catalogEntryId: connectedTool!.catalogEntryId,
|
|
reasonCode: "tool_completed",
|
|
details: expect.objectContaining({
|
|
applicationKey: "kv-demo",
|
|
providerType: "mcp_remote_http",
|
|
upstreamToolName: "kv_set",
|
|
risk: "write",
|
|
resultSummary: expect.objectContaining({ summary: expect.stringContaining("\"saved\":true") }),
|
|
}),
|
|
}),
|
|
]));
|
|
|
|
const persisted = JSON.stringify({
|
|
invocations: await db.select().from(toolInvocations),
|
|
callEvents: await db.select().from(toolCallEvents),
|
|
audits: await db.select().from(toolAccessAuditEvents),
|
|
activity: await db.select().from(activityLog),
|
|
});
|
|
expect(persisted).not.toContain(credentialValue);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("starts with another user's personal app and requests authorization only when used", async () => {
|
|
const originalApiUrl = process.env.PAPERCLIP_API_URL;
|
|
process.env.PAPERCLIP_API_URL = "http://paperclip.example.test";
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "carol");
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "carol" }).where(eq(heartbeatRuns.id, run.id));
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "connected" }] },
|
|
},
|
|
}));
|
|
try {
|
|
const { connection, application, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: fake.url,
|
|
toolName: "whoami",
|
|
riskLevel: "read",
|
|
});
|
|
// Personal credentials live on grants, so a company-level health probe
|
|
// may be unable to authenticate even though this user's grant is valid.
|
|
// The cached catalog must remain visible so grant resolution can happen.
|
|
await db.update(toolConnections).set({
|
|
credentialPolicy: "per_user",
|
|
healthStatus: "error",
|
|
healthMessage: "This app needs you to sign in.",
|
|
}).where(eq(toolConnections.id, connection.id));
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await db.delete(connectionGrants).where(eq(connectionGrants.connectionId, connection.id));
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id, connectionId: connection.id, kind: "user",
|
|
subjectUserId: "alice", credentialSecretRefs: [], status: "active", isDefault: false,
|
|
});
|
|
await db.insert(toolConnectionInstalls).values({
|
|
companyId: company.id, connectionId: connection.id, targetType: "agent", targetId: agent.id,
|
|
});
|
|
const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent, runId: run.id });
|
|
expect(snapshot.bindingId).toBe(`native-mcp:${run.id}`);
|
|
const runtime = await buildPaperclipRuntimeMcpServers({ db, agent, runId: run.id, expectedAssignmentDigest: snapshot.digest });
|
|
expect(runtime).toHaveLength(1);
|
|
// Startup and tool discovery never contact the provider or ask Carol to sign in.
|
|
expect(fake.requests).toHaveLength(0);
|
|
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id))).toEqual([]);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const endpoint = new URL(runtime[0]!.url!).pathname;
|
|
const listed = await request(app).post(endpoint).set("authorization", `Bearer ${runtime[0]!.token}`)
|
|
.send({ jsonrpc: "2.0", id: "list", method: "tools/list" }).expect(200);
|
|
const tool = listed.body.result.tools.find((entry: { name: string }) => entry.name === expectedConnectedToolName({
|
|
applicationKey: application.applicationKey, connectionId: connection.id, toolName: catalogEntry.toolName,
|
|
}));
|
|
expect(tool).toBeDefined();
|
|
const call = () => request(app).post(endpoint).set("authorization", `Bearer ${runtime[0]!.token}`)
|
|
.send({ jsonrpc: "2.0", id: "call", method: "tools/call", params: { name: tool.name, arguments: {} } });
|
|
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "none",
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
idempotencyKey: `connection-authorization:${connection.id}:carol`,
|
|
title: "Connect your account",
|
|
summary: `Connect ${connection.name} to continue`,
|
|
payload: {
|
|
version: 1,
|
|
prompt: `Connect your account to ${connection.name}`,
|
|
acceptLabel: "Open authorization",
|
|
rejectLabel: "Not now",
|
|
},
|
|
});
|
|
|
|
const missingGrant = await call().expect(409);
|
|
expect(missingGrant.body.error.data.reasonCode).toBe("user_authorization_required");
|
|
expect(fake.requests).toHaveLength(0); // Alice's grant must never authorize Carol's call.
|
|
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id));
|
|
expect(interaction).toMatchObject({
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
addresseeUserId: "carol",
|
|
});
|
|
expect(interaction!.payload).toMatchObject({
|
|
prompt: `Connect your ${connection.name} account to continue`,
|
|
target: { key: `connection:${connection.uid}:user:carol` },
|
|
});
|
|
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "carol",
|
|
credentialSecretRefs: [],
|
|
status: "active",
|
|
isDefault: false,
|
|
});
|
|
const result = await call().expect(200);
|
|
expect(result.body.result.content).toEqual([{ type: "text", text: "connected" }]);
|
|
expect(fake.requests).toHaveLength(1);
|
|
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
|
|
eq(toolConnections.id, connection.id),
|
|
)).resolves.toEqual([{ healthStatus: "ok" }]);
|
|
} finally {
|
|
if (originalApiUrl === undefined) delete process.env.PAPERCLIP_API_URL;
|
|
else process.env.PAPERCLIP_API_URL = originalApiUrl;
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it.each(([
|
|
"zapier", "url", "bearer", "header", "public",
|
|
] as const).flatMap((kind) => (kind === "public" ? ["setup"] : ["setup", "inline"])
|
|
.map((reconnectMode) => ({ kind, reconnectMode }))))(
|
|
"executes $kind setup and $reconnectMode reconnect through a run-scoped gateway with canonical vault declarations",
|
|
async ({ kind, reconnectMode }) => {
|
|
for (const grantKind of ["user", "organization"] as const) {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
|
const secretUrl = kind === "zapier"
|
|
? "https://mcp.zapier.com/api/v1/connect?token=fixture-canary"
|
|
: "https://8.8.8.8/mcp?token=fixture-canary";
|
|
const calls: { url: string; headers: Headers; method: unknown }[] = [];
|
|
const remoteHttpRequest: NonNullable<ToolGatewayServiceOptions["remoteHttpRequest"]> = async (url, init) => {
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
calls.push({ url: String(url), headers: new Headers(init.headers), method: body.method });
|
|
if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
|
|
return Response.json({ jsonrpc: "2.0", id: body.id, result:
|
|
body.method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
|
|
: body.method === "tools/list" ? { tools: [
|
|
{ name: "read_fixture", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } },
|
|
{ name: "write_fixture", inputSchema: { type: "object" }, annotations: { readOnlyHint: false, destructiveHint: false } },
|
|
] } : { content: [{ type: "text", text: "performed" }] },
|
|
});
|
|
};
|
|
const options = { remoteHttpRequest, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] };
|
|
const service = toolAccessService(db, options);
|
|
const setupInput: Parameters<typeof service.connectGalleryApp>[1] = {
|
|
name: `Fixture ${kind}`, grantKind,
|
|
...(kind === "zapier" ? { galleryKey: "zapier", connectionMethodKey: "generated-url", link: secretUrl }
|
|
: kind === "url" ? { link: secretUrl }
|
|
: kind === "bearer" ? { link: "https://8.8.8.8/mcp", authMode: "bearer" as const, credentialValues: { "credentials.authorization": "fixture-canary" } }
|
|
: kind === "header" ? { link: "https://8.8.8.8/mcp", authMode: "custom_headers" as const, credentialValues: { "headers.X-Api-Key": "fixture-canary" } }
|
|
: { link: "https://8.8.8.8/mcp", authMode: "none" as const }),
|
|
};
|
|
const connected = await service.connectGalleryApp(company.id, setupInput, { actorType: "user", actorId: "alice" });
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId));
|
|
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id));
|
|
let activeSecretId = secret?.id;
|
|
if (kind !== "public") {
|
|
expect(secret).toMatchObject({ scope: grantKind === "user" ? "user" : "company", ownerUserId: grantKind === "user" ? "alice" : null });
|
|
const expectedPath = kind === "header" ? "headers.X-Api-Key" : kind === "bearer" ? "credentials.authorization" : "remote.url";
|
|
expect(grant!.credentialSecretRefs[0]!.configPath).toBe(expectedPath);
|
|
const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection!.id));
|
|
const bindings = await db.select().from(companySecretBindings).where(eq(companySecretBindings.targetId, connection!.id));
|
|
expect(grantKind === "user" ? declarations : bindings).toEqual([expect.objectContaining({ configPath: expectedPath })]);
|
|
expect(grantKind === "user" ? bindings : declarations).toEqual([]);
|
|
} else expect(secret).toBeUndefined();
|
|
await service.finishGalleryAppConnection(company.id, connected.connectionId, {
|
|
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] },
|
|
}, { actorType: "user", actorId: "alice" });
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, options);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tools = (await gateway.listToolsForSession(session.token)).filter((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(tools).toHaveLength(2);
|
|
for (const tool of tools) {
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.resolves.toMatchObject({ status: "completed", result: { content: "performed" } });
|
|
}
|
|
for (const call of calls.filter((call) => call.method === "tools/call")) {
|
|
if (kind === "zapier" || kind === "url") expect(call.url).toBe(secretUrl);
|
|
if (kind === "bearer") expect(call.headers.get("authorization")).toBe("Bearer fixture-canary");
|
|
if (kind === "header") expect(call.headers.get("x-api-key")).toBe("fixture-canary");
|
|
}
|
|
expect(JSON.stringify(await db.select().from(toolAccessAuditEvents))).not.toContain("fixture-canary");
|
|
if (grantKind === "user" && secret) {
|
|
await expect(resolveConnectionGrantSecret(db, connection!, { ...grant!, subjectUserId: "bob" }, grant!.credentialSecretRefs[0]!, { consumerType: "tool_connection", consumerId: connection!.id }))
|
|
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
|
// Health must reject the exact legacy ownership mismatch that invocation rejects.
|
|
await db.update(companySecrets).set({ scope: "company", ownerUserId: null, userSecretDefinitionId: null }).where(eq(companySecrets.id, secret.id));
|
|
await expect(service.checkHealth(connection!.id, { actorType: "user", actorId: "alice" }))
|
|
.rejects.toMatchObject({ details: { code: "grant_credential_invalid", connection: { healthStatus: "missing_secret" } } });
|
|
await expect(service.refreshCatalog(connection!.id, { actorType: "user", actorId: "alice" }))
|
|
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
|
const otherCompany = await createCompany(db);
|
|
// The owner reconnects explicitly; no startup process adopts the old row.
|
|
await db.update(connectionGrants).set({ updatedAt: sql`'2026-09-30T12:00:00.123456Z'::timestamptz` })
|
|
.where(eq(connectionGrants.id, grant!.id));
|
|
const freshValue = "reconnected-fixture";
|
|
if (reconnectMode === "inline") {
|
|
if (kind === "url" || kind === "zapier") {
|
|
await expect(service.reconnectGalleryApp(connection!.id, company.id, {
|
|
credentialValues: { "remote.url": "https://8.8.8.8/different-endpoint?token=rejected-fixture" },
|
|
}, { actorType: "user", actorId: "alice" }))
|
|
.rejects.toMatchObject({ details: { code: "mcp_remote_url_credential_mismatch" } });
|
|
}
|
|
const configPath = kind === "url" || kind === "zapier" ? "remote.url" : kind === "header" ? "headers.X-Api-Key" : "credentials.authorization";
|
|
const reconnected = await service.reconnectGalleryApp(connection!.id, company.id, {
|
|
credentialValues: { [configPath]: kind === "url" || kind === "zapier" ? secretUrl.replace("fixture-canary", freshValue) : freshValue },
|
|
}, { actorType: "user", actorId: "alice" });
|
|
expect(reconnected.connection.id).toBe(connection!.id);
|
|
expect(reconnected.connection.healthStatus).toBe("ok");
|
|
} else {
|
|
const reconnected = await service.connectGalleryApp(company.id, {
|
|
...setupInput, reconnectConnectionId: connection!.id,
|
|
...(kind === "zapier" || kind === "url" ? { link: secretUrl.replace("fixture-canary", freshValue) }
|
|
: { credentialValues: { [kind === "header" ? "headers.X-Api-Key" : "credentials.authorization"]: freshValue } }),
|
|
}, { actorType: "user", actorId: "alice" });
|
|
expect(reconnected.connectionId).toBe(connection!.id);
|
|
await service.finishGalleryAppConnection(company.id, reconnected.connectionId, {
|
|
enabledCatalogEntryIds: reconnected.catalog.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] }, preserveExistingAccess: true,
|
|
}, { actorType: "user", actorId: "alice" });
|
|
}
|
|
const [restoredGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id));
|
|
expect(restoredGrant).toMatchObject({ id: grant!.id, kind: "user", subjectUserId: "alice", status: "active" });
|
|
activeSecretId = restoredGrant!.credentialSecretRefs[0]!.secretId;
|
|
expect(activeSecretId).not.toBe(secret.id);
|
|
const [freshSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, activeSecretId));
|
|
expect(freshSecret).toMatchObject({ scope: "user", ownerUserId: "alice" });
|
|
await expect(resolveConnectionGrantSecret(db, { ...connection!, companyId: otherCompany.id }, restoredGrant!, restoredGrant!.credentialSecretRefs[0]!, { consumerType: "tool_connection", consumerId: connection!.id }))
|
|
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
|
expect(await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection!.id)))
|
|
.toEqual([expect.objectContaining({ userSecretDefinitionId: freshSecret!.userSecretDefinitionId,
|
|
configPath: restoredGrant!.credentialSecretRefs[0]!.configPath })]);
|
|
expect(await db.select().from(companySecretBindings).where(eq(companySecretBindings.targetId, connection!.id))).toEqual([]);
|
|
expect((await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))[0])
|
|
.toMatchObject({ scope: "company", ownerUserId: null, userSecretDefinitionId: null });
|
|
calls.length = 0;
|
|
const restoredSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const restoredTools = (await gateway.listToolsForSession(restoredSession.token)).filter((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(restoredTools).toHaveLength(2);
|
|
for (const tool of restoredTools) {
|
|
await expect(gateway.executeTool({ sessionToken: restoredSession.token, tool: tool.name, parameters: { verification: "after-reconnect" } }))
|
|
.resolves.toMatchObject({ status: "completed", result: { content: "performed" } });
|
|
}
|
|
const restoredCalls = calls.filter((call) => call.method === "tools/call");
|
|
expect(restoredCalls).toHaveLength(2);
|
|
for (const call of restoredCalls) {
|
|
if (kind === "zapier" || kind === "url") expect(call.url).toBe(secretUrl.replace("fixture-canary", freshValue));
|
|
if (kind === "bearer") expect(call.headers.get("authorization")).toBe(`Bearer ${freshValue}`);
|
|
if (kind === "header") expect(call.headers.get("x-api-key")).toBe(freshValue);
|
|
}
|
|
}
|
|
if (secret) {
|
|
const removed = await service.archiveConnection(connection!.id, company.id, { actorType: "user", actorId: "alice" });
|
|
expect(removed.removal.secretsRevoked).toBe(1);
|
|
const [deleted] = await db.select().from(companySecrets).where(eq(companySecrets.id, activeSecretId!));
|
|
expect(deleted?.status ?? "deleted").toBe("deleted");
|
|
}
|
|
}
|
|
}, 30_000,
|
|
);
|
|
|
|
it.each(["header", "json"] as const)("returns an actionable insufficient-scope error from %s without exposing provider text", async (format) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createRemoteMcpTool(db, company.id, { url: "https://8.8.8.8/mcp", riskLevel: "write" });
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, {
|
|
remoteHttpRequest: async () => Response.json({ error: "insufficient_scope", message: "provider-secret-canary" }, {
|
|
status: 403, headers: format === "header" ? { "www-authenticate": 'Bearer error="insufficient_scope"' } : {},
|
|
}),
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.providerType === "mcp_remote_http")!;
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { key: "key", value: "value" } }))
|
|
.rejects.toMatchObject({ status: 403, reasonCode: "oauth_insufficient_scope", message: expect.stringContaining("Reconnect") });
|
|
expect(JSON.stringify(await db.select().from(toolAccessAuditEvents))).not.toContain("provider-secret-canary");
|
|
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
|
|
expect(updated!.healthStatus).toBe("degraded");
|
|
});
|
|
|
|
it("uses the responsible user's identity directly and requires delegation only without one", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await db.update(heartbeatRuns).set({
|
|
responsibleUserId: "alice",
|
|
invocationSource: "automation",
|
|
}).where(eq(heartbeatRuns.id, run.id));
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "delegated" }] },
|
|
},
|
|
}));
|
|
try {
|
|
const { connection } = await createRemoteMcpTool(db, company.id, {
|
|
url: fake.url,
|
|
toolName: "whoami",
|
|
riskLevel: "read",
|
|
});
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
|
|
const grant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
credentialSecretRefs: [],
|
|
status: "active",
|
|
isDefault: false,
|
|
}).returning().then((rows) => rows[0]!);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.providerType === "mcp_remote_http")!;
|
|
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.resolves.toMatchObject({ status: "completed", result: { content: "delegated" } });
|
|
expect(fake.requests).toHaveLength(1);
|
|
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id)))
|
|
.toEqual([]);
|
|
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, run.id));
|
|
const unattendedSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const unattendedTool = (await gateway.listToolsForSession(unattendedSession.token))
|
|
.find((item) => item.providerType === "mcp_remote_http")!;
|
|
await expect(gateway.executeTool({
|
|
sessionToken: unattendedSession.token,
|
|
tool: unattendedTool.name,
|
|
parameters: {},
|
|
})).rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
|
expect(fake.requests).toHaveLength(1);
|
|
|
|
await db.insert(connectionGrantDelegations).values({
|
|
companyId: company.id,
|
|
grantId: grant.id,
|
|
agentId: agent.id,
|
|
createdByUserId: "alice",
|
|
});
|
|
await expect(gateway.executeTool({
|
|
sessionToken: unattendedSession.token,
|
|
tool: unattendedTool.name,
|
|
parameters: {},
|
|
}))
|
|
.resolves.toMatchObject({ status: "completed", result: { content: "delegated" } });
|
|
expect(fake.requests).toHaveLength(2);
|
|
|
|
await db.update(companyMemberships).set({ status: "suspended" }).where(and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "alice"),
|
|
));
|
|
await expect(gateway.executeTool({
|
|
sessionToken: unattendedSession.token,
|
|
tool: unattendedTool.name,
|
|
parameters: {},
|
|
}))
|
|
.rejects.toMatchObject({ status: 403, reasonCode: "grant_owner_membership_inactive" });
|
|
expect(fake.requests).toHaveLength(2);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("keeps managed credentials authoritative even when legacy override flags are set", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const credentialValue = `managed-credential-${randomUUID()}`;
|
|
const secret = await secretService(db).create(company.id, {
|
|
name: `Header policy token ${randomUUID()}`,
|
|
key: `header_policy_token_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: credentialValue,
|
|
});
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers.authorization).toBe(`Bearer ${credentialValue}`);
|
|
expect(fakeRequest.headers["x-client-request-id"]).toBe("caller-123");
|
|
expect(fakeRequest.headers["x-static-mode"]).toBe("canary");
|
|
expect(fakeRequest.headers["x-paperclip-agent-id"]).toBe(agent.id);
|
|
expect(fakeRequest.headers["x-paperclip-issue-id"]).toBe(issue.id);
|
|
expect(fakeRequest.headers["x-paperclip-tool-gateway-token"]).toBeUndefined();
|
|
expect(fakeRequest.headers["x-unlisted-header"]).toBeUndefined();
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "headers ok" }] },
|
|
},
|
|
};
|
|
});
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "header-policy",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
credentialRefs: [{
|
|
name: "authorization",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
}],
|
|
credentialSecretRefs: [{
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.authorization",
|
|
required: true,
|
|
label: "Remote MCP token",
|
|
}],
|
|
});
|
|
await db.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
url: fake.url,
|
|
headerPolicy: {
|
|
allowManagedCredentialOverride: true,
|
|
passthrough: {
|
|
allowedHeaders: ["x-client-request-id", "authorization", "x-paperclip-tool-gateway-token"],
|
|
allowManagedCredentialOverride: true,
|
|
},
|
|
staticHeaders: [{ name: "x-static-mode", value: "canary" }],
|
|
metadata: { forward: ["agent_id", "issue_id"] },
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, remoteTool.connection.id));
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === remoteTool.connection.id);
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
callerHeaders: {
|
|
authorization: "Bearer caller-must-not-win",
|
|
"x-client-request-id": "caller-123",
|
|
"x-paperclip-tool-gateway-token": "caller-session-token",
|
|
"x-unlisted-header": "drop-me",
|
|
},
|
|
});
|
|
|
|
const [activity] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_completed"));
|
|
expect(activity.details).toMatchObject({
|
|
headerSummary: {
|
|
credentialHeaderNames: ["authorization"],
|
|
passthroughHeaderNames: ["x-client-request-id"],
|
|
droppedPassthroughHeaderNames: expect.arrayContaining([
|
|
"authorization",
|
|
"x-paperclip-tool-gateway-token",
|
|
"x-unlisted-header",
|
|
]),
|
|
staticHeaderNames: ["x-static-mode"],
|
|
metadataHeaderNames: ["x-paperclip-agent-id", "x-paperclip-issue-id"],
|
|
collisionRules: expect.arrayContaining([
|
|
{ header: "authorization", source: "caller", action: "kept_managed_credential" },
|
|
{ header: "x-paperclip-tool-gateway-token", source: "caller", action: "dropped_sensitive_header" },
|
|
]),
|
|
},
|
|
});
|
|
const persisted = JSON.stringify({
|
|
activity: await db.select().from(activityLog),
|
|
events: await db.select().from(toolCallEvents),
|
|
invocations: await db.select().from(toolInvocations),
|
|
});
|
|
expect(persisted).not.toContain(credentialValue);
|
|
expect(persisted).not.toContain("caller-must-not-win");
|
|
expect(persisted).not.toContain("caller-123");
|
|
expect(persisted).not.toContain("caller-session-token");
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("drops auth-bearing and Paperclip session headers from passthrough allowlists", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers.authorization).toBeUndefined();
|
|
expect(fakeRequest.headers["x-auth-token"]).toBeUndefined();
|
|
expect(fakeRequest.headers["x-paperclip-tool-gateway-token"]).toBeUndefined();
|
|
expect(fakeRequest.headers["x-client-request-id"]).toBe("caller-456");
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "headers ok" }] },
|
|
},
|
|
};
|
|
});
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "header-policy-sensitive",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await db.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
url: fake.url,
|
|
headerPolicy: {
|
|
passthrough: {
|
|
allowedHeaders: [
|
|
"authorization",
|
|
"x-auth-token",
|
|
"x-client-request-id",
|
|
"x-paperclip-tool-gateway-token",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, remoteTool.connection.id));
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === remoteTool.connection.id);
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "beta", value: "two" },
|
|
callerHeaders: {
|
|
authorization: "Bearer caller-should-drop",
|
|
"x-auth-token": "drop-auth-token",
|
|
"x-client-request-id": "caller-456",
|
|
"x-paperclip-tool-gateway-token": "drop-gateway-token",
|
|
},
|
|
});
|
|
|
|
const [activity] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_completed"));
|
|
expect(activity.details).toMatchObject({
|
|
headerSummary: {
|
|
credentialHeaderNames: [],
|
|
passthroughHeaderNames: ["x-client-request-id"],
|
|
droppedPassthroughHeaderNames: expect.arrayContaining([
|
|
"authorization",
|
|
"x-auth-token",
|
|
"x-paperclip-tool-gateway-token",
|
|
]),
|
|
collisionRules: expect.arrayContaining([
|
|
{ header: "authorization", source: "caller", action: "dropped_sensitive_header" },
|
|
{ header: "x-auth-token", source: "caller", action: "dropped_sensitive_header" },
|
|
{ header: "x-paperclip-tool-gateway-token", source: "caller", action: "dropped_sensitive_header" },
|
|
]),
|
|
},
|
|
});
|
|
const persisted = JSON.stringify({
|
|
activity: await db.select().from(activityLog),
|
|
events: await db.select().from(toolCallEvents),
|
|
invocations: await db.select().from(toolInvocations),
|
|
});
|
|
expect(persisted).not.toContain("caller-should-drop");
|
|
expect(persisted).not.toContain("drop-auth-token");
|
|
expect(persisted).not.toContain("drop-gateway-token");
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("uses virtual on-demand run_tool while applying target tool policy and audit metadata", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: "virtual ok" }],
|
|
structuredContent: { receivedArguments: (fakeRequest.body?.params as Record<string, unknown>).arguments },
|
|
},
|
|
},
|
|
}));
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "virtual-demo",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await db.update(toolConnections)
|
|
.set({ config: { url: fake.url, onDemandTools: { enabled: true } } })
|
|
.where(eq(toolConnections.id, remoteTool.connection.id));
|
|
const targetToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [targetToolName]);
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const visibleTools = await gateway.listToolsForSession(session.token);
|
|
expect(visibleTools.map((tool) => tool.name)).toEqual(expect.arrayContaining(["search_tools", "run_tool"]));
|
|
expect(visibleTools.map((tool) => tool.name)).not.toContain(targetToolName);
|
|
|
|
const search = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "search_tools",
|
|
parameters: { query: "kv", limit: 5 },
|
|
});
|
|
expect(JSON.stringify(search.result)).toContain(targetToolName);
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "run_tool",
|
|
parameters: {
|
|
tool: targetToolName,
|
|
arguments: { key: "virtual-key", value: "virtual-value" },
|
|
},
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
tool: "run_tool",
|
|
targetTool: targetToolName,
|
|
});
|
|
expect(fake.requests.at(-1)!.body).toMatchObject({
|
|
params: {
|
|
name: "kv_set",
|
|
arguments: { key: "virtual-key", value: "virtual-value" },
|
|
},
|
|
});
|
|
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.toolName, targetToolName));
|
|
expect(invocation).toMatchObject({
|
|
providerType: "mcp_remote_http",
|
|
connectionId: remoteTool.connection.id,
|
|
catalogEntryId: remoteTool.catalogEntry.id,
|
|
status: "succeeded",
|
|
});
|
|
const completedEvents = await db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(eq(toolCallEvents.eventType, "call_completed"));
|
|
expect(completedEvents).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: targetToolName,
|
|
metadata: expect.objectContaining({
|
|
virtualToolName: "run_tool",
|
|
targetToolName,
|
|
}),
|
|
}),
|
|
]));
|
|
const completedActivity = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_completed"));
|
|
expect(completedActivity).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
details: expect.objectContaining({
|
|
virtualToolName: "run_tool",
|
|
targetToolName,
|
|
connectionId: remoteTool.connection.id,
|
|
}),
|
|
}),
|
|
]));
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("decodes an SSE-framed tools/call response from a spec-compliant Streamable HTTP server", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
// Spec-compliant server: 406 unless the request advertises both content
|
|
// types, and replies with an SSE-framed body (PAP-11096).
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
const accept = String(fakeRequest.headers.accept ?? "");
|
|
if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
|
|
return { status: 406, rawBody: "Not Acceptable" };
|
|
}
|
|
const message = {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "sse ok" }], structuredContent: { via: "sse" } },
|
|
};
|
|
return {
|
|
headers: { "content-type": "text/event-stream" },
|
|
rawBody: `event: message\ndata: ${JSON.stringify(message)}\n\n`,
|
|
};
|
|
});
|
|
try {
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo SSE",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
url: fake.url,
|
|
credentialRefs: [],
|
|
credentialSecretRefs: [],
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
result: { content: "sse ok", data: { structuredContent: { via: "sse" }, transport: "mcp_http" } },
|
|
});
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("discovers and calls the SDK-backed KV demo MCP server over Streamable HTTP", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const kvDemo: KvDemoHttpServer = createKvDemoHttpServer();
|
|
const port = await kvDemo.listen(0, "127.0.0.1");
|
|
try {
|
|
const access = toolAccessService(db);
|
|
const connection = await access.createConnection(company.id, {
|
|
name: "KV demo SDK fixture",
|
|
connectionPurpose: "tool",
|
|
transport: "mcp_remote",
|
|
authKind: "none",
|
|
ownership: "customer",
|
|
connectionKind: "managed",
|
|
config: { url: `http://127.0.0.1:${port}/mcp` },
|
|
transportConfig: { url: `http://127.0.0.1:${port}/mcp` },
|
|
credentialSecretRefs: [],
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const refresh = await access.refreshCatalog(connection.id, { actorType: "user", actorId: "board" });
|
|
expect(refresh.catalog.map((entry) => entry.toolName).sort()).toEqual([
|
|
"kv_delete",
|
|
"kv_get",
|
|
"kv_list",
|
|
"kv_set",
|
|
]);
|
|
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http" && tool.upstreamToolName === "kv_set");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "streamable-key", value: "streamable-value" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
tool: connectedTool!.name,
|
|
result: {
|
|
data: {
|
|
isError: false,
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
},
|
|
});
|
|
expect(kvDemo.store.snapshot().entries).toEqual([
|
|
expect.objectContaining({ key: "streamable-key", value: "streamable-value" }),
|
|
]);
|
|
} finally {
|
|
await kvDemo.close();
|
|
}
|
|
});
|
|
|
|
it("expires legacy managed-connector approvals before provider dispatch", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "should not run" }] },
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "shopify",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
connectionConfig: {
|
|
sourceTemplateKey: "shopify",
|
|
connectionMethodKey: "ucp-commerce",
|
|
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
|
},
|
|
});
|
|
const toolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review managed Shopify writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: toolName,
|
|
parameters: { key: "legacy", value: "reviewed" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected managed Shopify call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
const signedPayload = readSignedToolArgumentsPayload({
|
|
signedArguments: actionRequest.signedArguments,
|
|
invocationId: actionRequest.invocationId,
|
|
toolName,
|
|
signingSecret: testToolActionSigningSecret,
|
|
});
|
|
expect(signedPayload?.executionOnApprove).toBe(true);
|
|
|
|
// Model an approval signed before Shopify's UCP agent profile became a
|
|
// required managed argument. Its signature and hash are valid for that
|
|
// historical payload, but it is no longer compatible with dispatch.
|
|
const legacyParameters = { key: "legacy", value: "reviewed" };
|
|
const legacyCanonical = canonicalToolArguments(legacyParameters);
|
|
const legacySummary = summarizeToolValue(legacyParameters);
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
signedArguments: signToolArguments({
|
|
invocationId: actionRequest.invocationId,
|
|
toolName,
|
|
canonicalArguments: legacyCanonical,
|
|
approvalSnapshot: signedPayload?.approvalSnapshot,
|
|
executionOnApprove: true,
|
|
signingSecret: testToolActionSigningSecret,
|
|
}),
|
|
canonicalArgumentsHash: legacySummary.sha256,
|
|
canonicalArgumentsSummary: legacySummary,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
|
|
await expect(gateway.approveActionRequest({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
interactionId: actionRequest.interactionId!,
|
|
actionRequestId: actionRequest.id,
|
|
actor: { userId: "board-user" },
|
|
})).resolves.toMatchObject({ status: "expired" });
|
|
|
|
expect(fake.requests).toHaveLength(0);
|
|
const [expiredRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
expect(expiredRequest.status).toBe("expired");
|
|
const [failedInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
expect(failedInvocation).toMatchObject({
|
|
status: "failed",
|
|
approvalState: "expired",
|
|
errorCode: "approved_tool_managed_arguments_changed",
|
|
});
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("does not expire a managed-connector provider execution already in flight", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "concurrent execution won" }] },
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "shopify",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
connectionConfig: {
|
|
sourceTemplateKey: "shopify",
|
|
connectionMethodKey: "ucp-commerce",
|
|
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
|
},
|
|
});
|
|
const toolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review raced managed Shopify writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
let driftExpiryReached!: () => void;
|
|
const driftExpiryStarted = new Promise<void>((resolve) => {
|
|
driftExpiryReached = resolve;
|
|
});
|
|
let resumeDriftExpiry!: () => void;
|
|
const driftExpiryResume = new Promise<void>((resolve) => {
|
|
resumeDriftExpiry = resolve;
|
|
});
|
|
const gateway = createTestToolGatewayService(db, {
|
|
beforeManagedArgumentDriftExpiry: async () => {
|
|
driftExpiryReached();
|
|
await driftExpiryResume;
|
|
},
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: toolName,
|
|
parameters: { key: "raced", value: "reviewed" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected managed Shopify call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
const signedPayload = readSignedToolArgumentsPayload({
|
|
signedArguments: actionRequest.signedArguments,
|
|
invocationId: actionRequest.invocationId,
|
|
toolName,
|
|
signingSecret: testToolActionSigningSecret,
|
|
});
|
|
const legacyParameters = { key: "raced", value: "reviewed" };
|
|
const legacyCanonical = canonicalToolArguments(legacyParameters);
|
|
const legacySummary = summarizeToolValue(legacyParameters);
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
signedArguments: signToolArguments({
|
|
invocationId: actionRequest.invocationId,
|
|
toolName,
|
|
canonicalArguments: legacyCanonical,
|
|
approvalSnapshot: signedPayload?.approvalSnapshot,
|
|
executionOnApprove: true,
|
|
signingSecret: testToolActionSigningSecret,
|
|
}),
|
|
canonicalArgumentsHash: legacySummary.sha256,
|
|
canonicalArgumentsSummary: legacySummary,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
|
|
const approvedAt = new Date();
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({ status: "accepted", resolvedByAgentId: agent.id, resolvedAt: approvedAt, updatedAt: approvedAt })
|
|
.where(eq(issueThreadInteractions.id, actionRequest.interactionId!));
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "approved", resolvedByAgentId: agent.id, resolvedAt: approvedAt, updatedAt: approvedAt })
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({ approvalState: "approved", updatedAt: approvedAt })
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
|
|
const retrying = gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: toolName,
|
|
parameters: legacyParameters,
|
|
approvedActionRequestId: actionRequest.id,
|
|
});
|
|
await driftExpiryStarted;
|
|
|
|
const executingAt = new Date();
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "executing", updatedAt: executingAt })
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "executing",
|
|
approvalState: "approved",
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
startedAt: executingAt,
|
|
completedAt: null,
|
|
updatedAt: executingAt,
|
|
})
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
resumeDriftExpiry();
|
|
|
|
await retrying.then(
|
|
() => {
|
|
throw new Error("Expected the stale approved retry to request a new approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approved_tool_managed_arguments_changed"),
|
|
);
|
|
const [inFlightRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
const [inFlightInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
expect(inFlightRequest.status).toBe("executing");
|
|
expect(inFlightInvocation).toMatchObject({
|
|
status: "executing",
|
|
approvalState: "approved",
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
});
|
|
|
|
const completedAt = new Date();
|
|
const winnerSummary = summarizeToolValue({ winner: "concurrent execution" });
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "executed", resolvedAt: completedAt, updatedAt: completedAt })
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "succeeded",
|
|
resultSummary: winnerSummary,
|
|
completedAt,
|
|
updatedAt: completedAt,
|
|
})
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
const [winnerInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
expect(winnerInvocation).toMatchObject({
|
|
status: "succeeded",
|
|
approvalState: "approved",
|
|
resultSummary: winnerSummary,
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
});
|
|
expect(fake.requests).toHaveLength(0);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("enforces policy, approvals, retries, rate limits, and company boundaries for connected remote MCP calls", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const { run: otherRun } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
|
let pauseApprovedExecution = false;
|
|
let approvedExecutionReached!: () => void;
|
|
const approvedExecutionStarted = new Promise<void>((resolve) => {
|
|
approvedExecutionReached = resolve;
|
|
});
|
|
let releaseApprovedExecution = () => {};
|
|
const approvedExecutionRelease = new Promise<void>((resolve) => {
|
|
releaseApprovedExecution = resolve;
|
|
});
|
|
const fake = await startFakeRemoteMcpServer(async (fakeRequest) => {
|
|
const requestArguments = (fakeRequest.body?.params as Record<string, unknown> | undefined)?.arguments;
|
|
if (
|
|
pauseApprovedExecution
|
|
&& requestArguments
|
|
&& typeof requestArguments === "object"
|
|
&& (requestArguments as Record<string, unknown>).key === "approved"
|
|
) {
|
|
pauseApprovedExecution = false;
|
|
approvedExecutionReached();
|
|
await approvedExecutionRelease;
|
|
}
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: "connected ok" }],
|
|
structuredContent: {
|
|
receivedArguments: requestArguments,
|
|
leakedToken: "sk-connected-mcp-secret-123456",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
const denyTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "deny-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
const denyToolName = expectedConnectedToolName({
|
|
applicationKey: denyTool.application.applicationKey,
|
|
connectionId: denyTool.connection.id,
|
|
toolName: denyTool.catalogEntry.toolName,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: denyToolName,
|
|
parameters: { key: "blocked", value: "secret=sk-denied-secret-123456" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to be denied by default");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "deny_default"),
|
|
);
|
|
|
|
const [deniedInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.toolName, denyToolName));
|
|
expect(deniedInvocation).toMatchObject({
|
|
companyId: company.id,
|
|
status: "denied",
|
|
errorCode: "deny_default",
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "deny-app",
|
|
upstreamToolName: "kv_set",
|
|
riskLevel: "write",
|
|
});
|
|
expect(JSON.stringify(deniedInvocation)).not.toContain("sk-denied-secret-123456");
|
|
|
|
const approvalTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "shopify",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
connectionConfig: {
|
|
sourceTemplateKey: "shopify",
|
|
connectionMethodKey: "ucp-commerce",
|
|
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
|
},
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
expectedConnectedToolName({
|
|
applicationKey: approvalTool.application.applicationKey,
|
|
connectionId: approvalTool.connection.id,
|
|
toolName: approvalTool.catalogEntry.toolName,
|
|
}),
|
|
]);
|
|
const approvalToolName = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === approvalTool.connection.id)!.name;
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: approvalTool.connection.id },
|
|
description: "Connected MCP writes need review.",
|
|
priority: 10,
|
|
});
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: approvalToolName,
|
|
parameters: { key: "approved", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
const [approvalRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
expect(approvalRequest).toMatchObject({
|
|
issueId: issue.id,
|
|
status: "pending",
|
|
canonicalArgumentsHash: expect.any(String),
|
|
canonicalArgumentsSummary: {
|
|
summary: expect.stringContaining("valid-with-capabilities.json"),
|
|
},
|
|
});
|
|
const [approvalInteraction] = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, approvalRequest.interactionId!));
|
|
expect(approvalInteraction).toMatchObject({
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve KV Set?",
|
|
detailsMarkdown: expect.stringContaining('"value":"original"'),
|
|
target: {
|
|
type: "custom",
|
|
key: `tool-action:${approvalRequest.id}`,
|
|
},
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: approvalRequest.id,
|
|
invocationId: approvalRequest.invocationId,
|
|
toolName: approvalToolName,
|
|
toolDisplayName: expect.any(String),
|
|
connectionId: approvalTool.connection.id,
|
|
applicationId: approvalTool.application.id,
|
|
appDisplayName: approvalTool.application.name,
|
|
risk: "write",
|
|
previewMarkdown: approvalRequest.previewMarkdown,
|
|
argumentsSummaryJson: expect.stringContaining('"value":"original"'),
|
|
argumentsHash: approvalRequest.canonicalArgumentsHash,
|
|
expiresAt: null,
|
|
},
|
|
},
|
|
});
|
|
const [approvalInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, approvalRequest.invocationId));
|
|
expect(approvalInvocation).toMatchObject({
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
status: "awaiting_approval",
|
|
policyDecision: "require_approval",
|
|
connectionId: approvalTool.connection.id,
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "shopify",
|
|
upstreamToolName: "kv_set",
|
|
});
|
|
|
|
// Matching persisted company/connection rows must still be hidden from
|
|
// the Test tab when a real agent run owns the approval.
|
|
await expect(gateway.getTestCallStatus({
|
|
companyId: company.id,
|
|
connectionId: approvalTool.connection.id,
|
|
actionRequestId: approvalRequest.id,
|
|
})).rejects.toMatchObject({ status: 404, reasonCode: "action_request_not_found" });
|
|
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
resolvedByAgentId: agent.id,
|
|
resolvedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, approvalRequest.interactionId!));
|
|
|
|
pauseApprovedExecution = true;
|
|
const approvedExecution = gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: approvalToolName,
|
|
parameters: { key: "approved", value: "tampered" },
|
|
approvedActionRequestId: approvalRequest.id,
|
|
});
|
|
await approvedExecutionStarted;
|
|
const [executingApproval] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, approvalRequest.id));
|
|
expect(executingApproval.status).toBe("executing");
|
|
|
|
const concurrentRetry = gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: approvalToolName,
|
|
parameters: { key: "approved", value: "original" },
|
|
});
|
|
releaseApprovedExecution();
|
|
|
|
await expect(approvedExecution).resolves.toMatchObject({
|
|
status: "completed",
|
|
tool: approvalToolName,
|
|
result: {
|
|
data: {
|
|
structuredContent: {
|
|
leakedToken: "***REDACTED***",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await expect(concurrentRetry).resolves.toMatchObject({
|
|
status: "replayed",
|
|
result: expect.anything(),
|
|
});
|
|
expect(fake.requests.at(-1)!.body).toMatchObject({
|
|
params: {
|
|
name: "kv_set",
|
|
arguments: {
|
|
key: "approved",
|
|
value: "original",
|
|
meta: {
|
|
"ucp-agent": {
|
|
profile: "https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const [executedApproval] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, approvalRequest.id));
|
|
expect(executedApproval.status).toBe("executed");
|
|
const [completedInteraction] = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, approvalRequest.interactionId!));
|
|
expect(completedInteraction.result).toMatchObject({
|
|
version: 1,
|
|
outcome: "accepted",
|
|
toolAction: {
|
|
version: 1,
|
|
status: "executed",
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
updatedAt: expect.any(String),
|
|
},
|
|
});
|
|
const approvedCompletion = (await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_completed")))
|
|
.find((event) => event.details?.invocationId === approvalRequest.invocationId);
|
|
expect(approvedCompletion?.details).toMatchObject({
|
|
argumentsSummary: {
|
|
summary: expect.stringContaining('"value":"original"'),
|
|
},
|
|
execution: {
|
|
transport: "mcp_remote",
|
|
request: {
|
|
protocol: "MCP JSON-RPC 2.0",
|
|
httpMethod: "POST",
|
|
endpoint: fake.url,
|
|
mcpMethod: "tools/call",
|
|
requestId: expect.stringMatching(/^paperclip-tool-/),
|
|
upstreamToolName: "kv_set",
|
|
dispatched: true,
|
|
},
|
|
response: {
|
|
httpStatus: 200,
|
|
contentType: "application/json",
|
|
bodySizeBytes: expect.any(Number),
|
|
},
|
|
},
|
|
});
|
|
|
|
const rejectedTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "rejected-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
expectedConnectedToolName({
|
|
applicationKey: rejectedTool.application.applicationKey,
|
|
connectionId: rejectedTool.connection.id,
|
|
toolName: rejectedTool.catalogEntry.toolName,
|
|
}),
|
|
]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Reject connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: rejectedTool.connection.id },
|
|
description: "This approval will be rejected.",
|
|
priority: 5,
|
|
});
|
|
const rejectedToolName = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === rejectedTool.connection.id)!.name;
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rejectedToolName,
|
|
parameters: { key: "rejected", value: "never-run" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval before rejection");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
const rejectedRequest = (await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id)))
|
|
.find((requestRow) => requestRow.id !== approvalRequest.id)!;
|
|
await gateway.declineActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: rejectedRequest.id,
|
|
actor: { userId: "board-user" },
|
|
});
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rejectedToolName,
|
|
parameters: { key: "rejected", value: "retry" },
|
|
approvedActionRequestId: rejectedRequest.id,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected rejected approval to block retry");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "action_not_approved"),
|
|
);
|
|
|
|
const rateTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "rate-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
expectedConnectedToolName({
|
|
applicationKey: rateTool.application.applicationKey,
|
|
connectionId: rateTool.connection.id,
|
|
toolName: rateTool.catalogEntry.toolName,
|
|
}),
|
|
]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "One connected call",
|
|
policyType: "rate_limit",
|
|
selectors: { connectionId: rateTool.connection.id },
|
|
config: { limit: 1, windowSeconds: 60 },
|
|
priority: 1,
|
|
});
|
|
const rateToolName = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === rateTool.connection.id)!.name;
|
|
const rateClock = Date.now();
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
vi.setSystemTime(rateClock);
|
|
try {
|
|
await expect(gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rateToolName,
|
|
parameters: { key: "rate", value: "first" },
|
|
})).resolves.toMatchObject({ status: "completed" });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rateToolName,
|
|
parameters: { key: "rate", value: "second" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to be rate limited");
|
|
},
|
|
(error) => expectGatewayError(error, 429, "rate_limited"),
|
|
);
|
|
const [rateLimitedInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.toolName, rateToolName))
|
|
.then((rows) => rows.filter((row) => row.status === "rate_limited"));
|
|
expect(rateLimitedInvocation).toMatchObject({
|
|
connectionId: rateTool.connection.id,
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "rate-app",
|
|
errorCode: "rate_limited",
|
|
});
|
|
|
|
vi.setSystemTime(rateClock + 60_000);
|
|
await expect(gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rateToolName,
|
|
parameters: { key: "rate", value: "next-window" },
|
|
})).resolves.toMatchObject({ status: "completed" });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
const otherTool = await createRemoteMcpTool(db, otherCompany.id, {
|
|
applicationKey: "other-company-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await allowAllToolsForAgent(db, otherCompany.id, otherAgent.id);
|
|
const otherGateway = createTestToolGatewayService(db);
|
|
const otherSession = await otherGateway.createSession({ companyId: otherCompany.id, agentId: otherAgent.id, runId: otherRun.id });
|
|
const otherToolName = (await otherGateway.listToolsForSession(otherSession.token))
|
|
.find((tool) => tool.connectionId === otherTool.connection.id)!.name;
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: otherToolName,
|
|
parameters: { key: "cross", value: "company" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected cross-company connected MCP tool name to be hidden");
|
|
},
|
|
(error) => expectGatewayError(error, 404, "tool_not_found"),
|
|
);
|
|
|
|
const persisted = JSON.stringify({
|
|
invocations: await db.select().from(toolInvocations),
|
|
callEvents: await db.select().from(toolCallEvents),
|
|
audits: await db.select().from(toolAccessAuditEvents),
|
|
activity: await db.select().from(activityLog),
|
|
});
|
|
expect(persisted).not.toContain("sk-connected-mcp-secret-123456");
|
|
expect(persisted).not.toContain("sk-denied-secret-123456");
|
|
expect(persisted).toContain("mcp_remote_http");
|
|
expect(persisted).toContain("shopify");
|
|
expect(persisted).toContain("kv_set");
|
|
} finally {
|
|
releaseApprovedExecution();
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("requires re-review when an approved connected MCP replay target changed", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: "should not run after target drift" }],
|
|
},
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "approval-drift-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
const remoteToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [remoteToolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review driftable connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "approved", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
resolvedByAgentId: agent.id,
|
|
resolvedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, actionRequest.interactionId!));
|
|
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: { url: "https://changed.example.invalid/mcp" },
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, remoteTool.connection.id));
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "approved", value: "tampered" },
|
|
approvedActionRequestId: actionRequest.id,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected approved connected MCP retry to fail after target drift");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approved_tool_target_changed"),
|
|
);
|
|
|
|
expect(fake.requests).toHaveLength(0);
|
|
const [afterReplayAttempt] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
expect(afterReplayAttempt).toMatchObject({
|
|
issueId: issue.id,
|
|
status: "approved",
|
|
});
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("expires an abandoned unsigned ask-first request so a later retry can proceed", async () => {
|
|
// The gateway builds an ask-first request in two steps inside one call: it
|
|
// inserts the row with a null signature and a null expiry, then signs the
|
|
// row. If the gateway stops between the two steps, the row stays pending
|
|
// and unsigned forever, and the review queue hides it. A later retry of the
|
|
// same tool call must not replay that dead row. It must expire the row and
|
|
// create a fresh, signable request.
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "should not run while pending approval" }] },
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "abandoned-unsigned-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
const remoteToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [remoteToolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review abandoned connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "abandoned", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [firstRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
|
|
// Rewind the row to the abandoned state: created long ago, pending, never
|
|
// signed. The old createdAt proves the create stopped, not that a parallel
|
|
// create still runs, so a later retry can expire the row.
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
signedArguments: null,
|
|
expiresAt: null,
|
|
interactionId: null,
|
|
createdAt: new Date(Date.now() - 10 * 60 * 1000),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolActionRequests.id, firstRequest.id));
|
|
|
|
// Retry the same tool call. The gateway must not replay the dead row.
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "abandoned", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected retry to require a fresh approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
// The abandoned row is expired, not replayed as a live approval.
|
|
const [afterRetry] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, firstRequest.id));
|
|
expect(afterRetry.status).toBe("expired");
|
|
|
|
// The retry created a fresh, signed request that the queue can show.
|
|
const allRequests = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
const freshRequest = allRequests.find((request) => request.id !== firstRequest.id);
|
|
expect(freshRequest).toBeTruthy();
|
|
expect(freshRequest!.status).toBe("pending");
|
|
expect(freshRequest!.signedArguments).not.toBeNull();
|
|
expect(freshRequest!.expiresAt).toBeNull();
|
|
|
|
// The tool never executed while the approval stayed pending.
|
|
expect(fake.requests).toHaveLength(0);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("does not expire a recent unsigned ask-first request that a parallel create still owns", async () => {
|
|
// The create signs the row in two steps. A concurrent matching call can see
|
|
// the row after the insert but before the sign. A recent createdAt means a
|
|
// parallel create still runs, so the concurrent call must replay the live
|
|
// request. It must not expire the row and must not create a duplicate.
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "should not run while pending approval" }] },
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "inflight-unsigned-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
const remoteToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [remoteToolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review inflight connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "inflight", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [firstRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
|
|
// Rewind to the in-flight window: unsigned, but created just now. This is
|
|
// the state a concurrent matching call sees while the create still runs.
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ signedArguments: null, expiresAt: null, interactionId: null, updatedAt: new Date() })
|
|
.where(eq(toolActionRequests.id, firstRequest.id));
|
|
|
|
// The concurrent matching call replays the live request; it does not expire.
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "inflight", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected the concurrent call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
// The row stays pending and is not expired.
|
|
const [afterRetry] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, firstRequest.id));
|
|
expect(afterRetry.status).toBe("pending");
|
|
|
|
// No duplicate request was created for the same tool call.
|
|
const allRequests = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
expect(allRequests).toHaveLength(1);
|
|
|
|
// The tool never executed while the approval stayed pending.
|
|
expect(fake.requests).toHaveLength(0);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("requires re-review when an approved connected MCP replay credential latest version changed", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const originalCredential = `approved-replay-token-${randomUUID()}`;
|
|
const rotatedCredential = `rotated-replay-token-${randomUUID()}`;
|
|
const secret = await secretService(db).create(company.id, {
|
|
name: `Approved replay MCP token ${randomUUID()}`,
|
|
key: `approved_replay_mcp_token_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: originalCredential,
|
|
});
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers.authorization).toBe(`Bearer ${originalCredential}`);
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: "should not run after credential drift" }],
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "approval-credential-drift-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
credentialRefs: [{
|
|
name: "authorization",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
}],
|
|
credentialSecretRefs: [{
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.authorization",
|
|
required: true,
|
|
label: "Remote MCP token",
|
|
}],
|
|
});
|
|
const remoteToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [remoteToolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review credential driftable connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "approved", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
resolvedByAgentId: agent.id,
|
|
resolvedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, actionRequest.interactionId!));
|
|
|
|
await secretService(db).rotate(secret.id, { value: rotatedCredential });
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "approved", value: "tampered" },
|
|
approvedActionRequestId: actionRequest.id,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected approved connected MCP retry to fail after credential drift");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approved_tool_target_changed"),
|
|
);
|
|
|
|
expect(fake.requests).toHaveLength(0);
|
|
const [afterReplayAttempt] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
expect(afterReplayAttempt).toMatchObject({
|
|
issueId: issue.id,
|
|
status: "approved",
|
|
});
|
|
const persisted = JSON.stringify({
|
|
actionRequests: await db.select().from(toolActionRequests),
|
|
callEvents: await db.select().from(toolCallEvents),
|
|
});
|
|
expect(persisted).not.toContain(originalCredential);
|
|
expect(persisted).not.toContain(rotatedCredential);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
for (const scenario of ["slow success", "default deadline", "explicit deadline", "peer reset", "cyclic peer reset", "primitive peer reset"] as const) {
|
|
it(`records remote SSE ${scenario} without confusing a call deadline with a broken transport`, 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 createRemoteMcpTool(db, company.id);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const reading = Promise.withResolvers<void>();
|
|
let bodyController!: ReadableStreamDefaultController<Uint8Array>;
|
|
let requestId!: string;
|
|
let signal!: AbortSignal;
|
|
const progress = 'data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":1}}\n\n';
|
|
const cause = Object.assign(new Error("peer failure Authorization: Bearer fixture-private-value"), { code: "ECONNRESET" });
|
|
const failure = Object.assign(new Error("aborted", { cause }), { code: "ECONNRESET" });
|
|
if (scenario === "cyclic peer reset") Object.assign(cause, { cause: failure });
|
|
const gateway = createTestToolGatewayService(db, {
|
|
remoteHttpRequest: async (_url, init) => {
|
|
requestId = JSON.parse(String(init.body)).id;
|
|
signal = init.signal!;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
bodyController = controller;
|
|
controller.enqueue(new TextEncoder().encode(progress));
|
|
init.signal!.addEventListener("abort", () => controller.error(failure), { once: true });
|
|
},
|
|
pull() { reading.resolve(); },
|
|
}, { highWaterMark: 0 });
|
|
return new Response(body, { headers: { "content-type": "text/event-stream", "x-request-id": "sse-request" } });
|
|
},
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.providerType === "mcp_remote_http")!;
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
const call = gateway.executeTool({
|
|
sessionToken: session.token, tool: tool.name, parameters: { key: "a", value: "b" },
|
|
...(scenario === "explicit deadline" ? { timeoutMs: 1500 } : {}),
|
|
}).then((value) => ({ value, error: null }), (error: unknown) => ({ value: null, error }));
|
|
try {
|
|
await reading.promise;
|
|
if (scenario.includes("peer reset")) bodyController.error(scenario === "primitive peer reset" ? "peer reset" : failure);
|
|
else await vi.advanceTimersByTimeAsync(scenario === "slow success" ? 12_000 : scenario === "explicit deadline" ? 1500 : 30_000);
|
|
if (scenario === "slow success") {
|
|
expect(signal.aborted).toBe(false);
|
|
bodyController.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ jsonrpc: "2.0", id: requestId, result: { content: [{ type: "text", text: "completed after twelve seconds" }] } })}\n\n`));
|
|
}
|
|
const result = await call;
|
|
const [event] = await db.select().from(toolCallEvents).where(eq(toolCallEvents.eventType, scenario === "slow success" ? "call_completed" : "call_failed"));
|
|
const [stored] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
|
|
if (scenario === "slow success") {
|
|
expect(result.error).toBeNull();
|
|
expect(JSON.stringify(result.value)).toContain("completed after twelve seconds");
|
|
expect(stored.healthStatus).toBe("ok");
|
|
expect(event.metadata).toMatchObject({ execution: { stream: { eventsReceived: 2, matchedResponse: true, ended: false } } });
|
|
} else {
|
|
const timeout = scenario === "default deadline" || scenario === "explicit deadline";
|
|
expectGatewayError(result.error, timeout ? 504 : 502, timeout ? "tool_timeout" : "mcp_remote_fetch_failed");
|
|
expect(stored.healthStatus).toBe("ok");
|
|
expect(event.metadata).toMatchObject({ execution: {
|
|
failureKind: timeout ? "invocation_timeout" : "transport_failure",
|
|
deadline: { timeoutMs: scenario === "explicit deadline" ? 1500 : 30_000, aborted: timeout },
|
|
error: scenario === "primitive peer reset"
|
|
? { name: null, message: "peer reset", code: null, cause: null }
|
|
: { name: "Error", message: "aborted", code: "ECONNRESET", cause: { name: "Error", code: "ECONNRESET" } },
|
|
response: { httpStatus: 200, contentType: "text/event-stream", upstreamRequestId: "sse-request" },
|
|
stream: { bytesReceived: Buffer.byteLength(progress), chunksReceived: 1, eventsReceived: 1, matchedResponse: false, ended: false },
|
|
} });
|
|
expect(JSON.stringify(event.metadata)).not.toContain("fixture-private-value");
|
|
if (scenario !== "primitive peer reset") expect(JSON.stringify(event.metadata)).toContain("peer failure");
|
|
if (scenario === "cyclic peer reset") expect(JSON.stringify(event.metadata)).toContain("[Circular cause]");
|
|
}
|
|
} finally {
|
|
vi.useRealTimers();
|
|
bodyController.error(new Error("fixture cleanup"));
|
|
await call;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function startRemoteCallWithControlledBody(respond: (body: ReadableStream<Uint8Array>) => Response, waitForRead = true) {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createRemoteMcpTool(db, company.id);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const reading = Promise.withResolvers<void>();
|
|
let bodyController!: ReadableStreamDefaultController<Uint8Array>;
|
|
let requestId!: string;
|
|
const gateway = createTestToolGatewayService(db, {
|
|
remoteHttpRequest: async (_url, init) => {
|
|
requestId = JSON.parse(String(init.body)).id;
|
|
return respond(new ReadableStream<Uint8Array>({
|
|
start(controller) { bodyController = controller; },
|
|
pull() { reading.resolve(); },
|
|
}, { highWaterMark: 0 }));
|
|
},
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.providerType === "mcp_remote_http")!;
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
const call = gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { key: "a", value: "b" }, timeoutMs: 1500 })
|
|
.then((value) => ({ value, error: null }), (error: unknown) => ({ value: null, error }));
|
|
if (waitForRead) await reading.promise;
|
|
const failedEvent = async () => (await db.select().from(toolCallEvents).where(eq(toolCallEvents.eventType, "call_failed")))[0];
|
|
return { call, failedEvent, body: () => bodyController, requestId: () => requestId };
|
|
}
|
|
|
|
it("keeps a tool question when the call deadline passes while the question is recorded", async () => {
|
|
const remote = await startRemoteCallWithControlledBody((body) => new Response(body, { headers: { "content-type": "text/event-stream" } }));
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
const question = { jsonrpc: "2.0", id: "question-1", method: "elicitation/create", params: { message: "Which folder?", requestedSchema: { type: "object" } } };
|
|
remote.body().enqueue(new TextEncoder().encode(`data: ${JSON.stringify(question)}\n\n`));
|
|
expectGatewayError((await remote.call).error, 409, "elicitation_required");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
remote.body().error(new Error("fixture cleanup"));
|
|
await remote.call;
|
|
}
|
|
});
|
|
|
|
it("records a transport failure kind when the remote hostname does not resolve", async () => {
|
|
const remote = await startRemoteCallWithControlledBody(() => {
|
|
throw new ToolGatewayHttpError(502, "Remote MCP connection hostname did not resolve", "remote_http_dns_failed");
|
|
}, false);
|
|
try {
|
|
expectGatewayError((await remote.call).error, 502, "remote_http_dns_failed");
|
|
expect((await remote.failedEvent()).metadata).toMatchObject({ execution: { failureKind: "transport_failure" } });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("records the bytes of an error response body that fails partway", async () => {
|
|
const remote = await startRemoteCallWithControlledBody((body) => new Response(body, { status: 503, headers: { "content-type": "text/plain" } }));
|
|
try {
|
|
const partial = "upstream overloaded, partial";
|
|
remote.body().enqueue(new TextEncoder().encode(partial));
|
|
remote.body().error(Object.assign(new Error("aborted"), { code: "ECONNRESET" }));
|
|
expectGatewayError((await remote.call).error, 502, "mcp_remote_fetch_failed");
|
|
expect((await remote.failedEvent()).metadata).toMatchObject({ execution: {
|
|
failureKind: "transport_failure",
|
|
response: { httpStatus: 503, bodySizeBytes: Buffer.byteLength(partial) },
|
|
} });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
const remoteFailureCases = [
|
|
{
|
|
name: "HTTP status",
|
|
reasonCode: "mcp_remote_status",
|
|
status: 502,
|
|
response: () => ({ status: 503, body: { error: "unavailable" } }),
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
reasonCode: "mcp_remote_invalid_json",
|
|
status: 502,
|
|
response: () => ({ rawBody: "not json" }),
|
|
},
|
|
{
|
|
name: "malformed MCP response",
|
|
reasonCode: "remote_mcp_malformed_response",
|
|
status: 502,
|
|
response: () => ({ body: { jsonrpc: "2.0", id: "bad", result: { content: { type: "text", text: "bad" } } } }),
|
|
},
|
|
{
|
|
name: "response size",
|
|
reasonCode: "mcp_remote_response_too_large",
|
|
status: 502,
|
|
response: () => {
|
|
const rawBody = "x".repeat(1_000_001);
|
|
return { rawBody, headers: { "content-length": String(Buffer.byteLength(rawBody)) } };
|
|
},
|
|
},
|
|
{
|
|
name: "timeout abort",
|
|
reasonCode: "tool_timeout",
|
|
status: 504,
|
|
timeoutMs: 10,
|
|
response: () => ({ delayMs: 75, body: { jsonrpc: "2.0", id: "slow", result: { content: [{ type: "text", text: "late" }] } } }),
|
|
},
|
|
];
|
|
|
|
for (const scenario of remoteFailureCases) {
|
|
it(`returns a controlled gateway error for remote MCP ${scenario.name}`, async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer(() => scenario.response());
|
|
try {
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: `failure-${scenario.reasonCode}`,
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
timeoutMs: scenario.timeoutMs,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected remote MCP call to fail");
|
|
},
|
|
(error) => expectGatewayError(error, scenario.status, scenario.reasonCode),
|
|
);
|
|
|
|
const [invocation] = await db.select().from(toolInvocations);
|
|
expect(invocation).toMatchObject({
|
|
status: scenario.status === 504 ? "timed_out" : "failed",
|
|
errorCode: scenario.reasonCode,
|
|
});
|
|
const [failureAudit] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, scenario.status === 504 ? "tool_gateway.call_deferred" : "tool_gateway.call_failed"));
|
|
expect(failureAudit.details).toMatchObject({
|
|
argumentsSummary: {
|
|
summary: expect.stringContaining('"key":"alpha"'),
|
|
},
|
|
execution: {
|
|
transport: "mcp_remote",
|
|
request: {
|
|
endpoint: fake.url,
|
|
mcpMethod: "tools/call",
|
|
dispatched: true,
|
|
},
|
|
},
|
|
});
|
|
if (scenario.reasonCode === "mcp_remote_status") {
|
|
expect(failureAudit.details).toMatchObject({
|
|
execution: { response: { httpStatus: 503 } },
|
|
});
|
|
}
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
function initializeFakeMcpSession(request: FakeMcpRequest, sessionId: string) {
|
|
return {
|
|
headers: { "mcp-session-id": sessionId },
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: request.body?.id,
|
|
result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } },
|
|
},
|
|
};
|
|
}
|
|
|
|
const perCallResponseFailures = [
|
|
{
|
|
name: "a tool call timeout",
|
|
reasonCode: "tool_timeout",
|
|
status: 504,
|
|
timeoutMs: 300,
|
|
response: () => ({ delayMs: 1_000, body: {} }),
|
|
},
|
|
{
|
|
name: "a response body timeout",
|
|
reasonCode: "tool_timeout",
|
|
status: 504,
|
|
timeoutMs: 300,
|
|
response: () => ({ bodyDelayMs: 1_000, body: {} }),
|
|
},
|
|
{
|
|
name: "an oversized response",
|
|
reasonCode: "mcp_remote_response_too_large",
|
|
response: () => ({ headers: { "content-type": "application/json" }, rawBody: "x".repeat(1_200_000) }),
|
|
check: (error: ToolGatewayHttpError) => {
|
|
expect(error.message).toContain("smaller page size");
|
|
expect(error.details).toMatchObject({ maxBytes: 1_000_000 });
|
|
},
|
|
},
|
|
{
|
|
name: "an invalid JSON response",
|
|
reasonCode: "mcp_remote_invalid_json",
|
|
response: () => ({ headers: { "content-type": "application/json" }, rawBody: "not json" }),
|
|
},
|
|
{
|
|
name: "a response without the requested message ID",
|
|
reasonCode: "remote_mcp_malformed_response",
|
|
response: () => ({ body: { jsonrpc: "2.0", id: "someone-else", result: { content: [{ type: "text", text: "wrong id" }] } } }),
|
|
},
|
|
];
|
|
|
|
for (const scenario of perCallResponseFailures) {
|
|
it(`keeps a remote MCP connection healthy and starts a fresh MCP session after ${scenario.name}`, async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
let initializeCount = 0;
|
|
let toolCallCount = 0;
|
|
const fake = await startFakeRemoteMcpServer((request) => {
|
|
if (request.body?.method === "initialize") {
|
|
initializeCount += 1;
|
|
return initializeFakeMcpSession(request, `session-${initializeCount}`);
|
|
}
|
|
if (request.body?.method === "notifications/initialized") return { status: 202, rawBody: "" };
|
|
toolCallCount += 1;
|
|
if (toolCallCount === 1) return scenario.response();
|
|
return { body: { jsonrpc: "2.0", id: request.body?.id, result: { content: [{ type: "text", text: "small page" }] } } };
|
|
});
|
|
try {
|
|
const { connection } = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: `per-call-${scenario.reasonCode}`,
|
|
toolName: "kv_get",
|
|
url: fake.url,
|
|
connectionConfig: { mcpSessionRequired: true },
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "everything" },
|
|
timeoutMs: scenario.timeoutMs,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected the remote MCP call to fail");
|
|
},
|
|
(error) => {
|
|
expectGatewayError(error, scenario.status ?? 502, scenario.reasonCode);
|
|
scenario.check?.(error as ToolGatewayHttpError);
|
|
},
|
|
);
|
|
|
|
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
|
|
eq(toolConnections.id, connection.id),
|
|
)).resolves.toEqual([{ healthStatus: "ok" }]);
|
|
|
|
expect(toolCallCount).toBe(1);
|
|
const [failedInvocation] = await db.select().from(toolInvocations);
|
|
expect(failedInvocation).toMatchObject({
|
|
status: scenario.status === 504 ? "timed_out" : "failed",
|
|
errorCode: scenario.reasonCode,
|
|
});
|
|
|
|
const nextSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await expect(gateway.listToolsForSession(nextSession.token)).resolves.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ name: connectedTool!.name })]),
|
|
);
|
|
await expect(gateway.executeTool({
|
|
sessionToken: nextSession.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "page-1" },
|
|
})).resolves.toMatchObject({ status: "completed" });
|
|
|
|
const toolCallSessionIds = fake.requests
|
|
.filter((request) => request.body?.method === "tools/call")
|
|
.map((request) => request.headers["mcp-session-id"]);
|
|
expect(toolCallSessionIds).toEqual(["session-1", "session-2"]);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
it("resets only the failing agent's MCP session when another agent is initializing on the same connection", async () => {
|
|
const company = await createCompany(db);
|
|
const agentA = await createAgent(db, company.id);
|
|
const agentB = await createAgent(db, company.id);
|
|
const { run: runA } = await createIssueAndRun(db, company.id, agentA.id);
|
|
const { run: runB } = await createIssueAndRun(db, company.id, agentB.id);
|
|
let initializeCount = 0;
|
|
let markOversizedCallStarted!: () => void;
|
|
const oversizedCallStarted = new Promise<void>((resolve) => { markOversizedCallStarted = resolve; });
|
|
let markSecondInitializeStarted!: () => void;
|
|
const secondInitializeStarted = new Promise<void>((resolve) => { markSecondInitializeStarted = resolve; });
|
|
let releaseSecondInitialize!: () => void;
|
|
const secondInitializeReleased = new Promise<void>((resolve) => { releaseSecondInitialize = resolve; });
|
|
const fake = await startFakeRemoteMcpServer(async (request) => {
|
|
if (request.body?.method === "initialize") {
|
|
initializeCount += 1;
|
|
const sessionId = `session-${initializeCount}`;
|
|
if (initializeCount === 2) {
|
|
markSecondInitializeStarted();
|
|
await secondInitializeReleased;
|
|
}
|
|
return initializeFakeMcpSession(request, sessionId);
|
|
}
|
|
if (request.body?.method === "notifications/initialized") return { status: 202, rawBody: "" };
|
|
if (request.headers["mcp-session-id"] === "session-1") {
|
|
markOversizedCallStarted();
|
|
// Fail agent A's call only while agent B's initialization is in flight.
|
|
await secondInitializeStarted;
|
|
return { headers: { "content-type": "application/json" }, rawBody: "x".repeat(1_200_000) };
|
|
}
|
|
return { body: { jsonrpc: "2.0", id: request.body?.id, result: { content: [{ type: "text", text: "ok" }] } } };
|
|
});
|
|
let callA: Promise<unknown> | undefined;
|
|
let callB: Promise<unknown> | undefined;
|
|
try {
|
|
const { connection } = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "overlapping-sessions",
|
|
toolName: "kv_get",
|
|
url: fake.url,
|
|
connectionConfig: { mcpSessionRequired: true },
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agentA.id);
|
|
await allowAllToolsForAgent(db, company.id, agentB.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const sessionA = await gateway.createSession({ companyId: company.id, agentId: agentA.id, runId: runA.id });
|
|
const sessionB = await gateway.createSession({ companyId: company.id, agentId: agentB.id, runId: runB.id });
|
|
const connectedTool = (await gateway.listToolsForSession(sessionA.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
callA = gateway.executeTool({
|
|
sessionToken: sessionA.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "everything" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected agent A's oversized remote MCP response to fail");
|
|
},
|
|
(error) => expectGatewayError(error, 502, "mcp_remote_response_too_large"),
|
|
);
|
|
await oversizedCallStarted;
|
|
callB = gateway.executeTool({
|
|
sessionToken: sessionB.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "page-1" },
|
|
});
|
|
await callA;
|
|
releaseSecondInitialize();
|
|
await expect(callB).resolves.toMatchObject({ status: "completed" });
|
|
|
|
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
|
|
eq(toolConnections.id, connection.id),
|
|
)).resolves.toEqual([{ healthStatus: "ok" }]);
|
|
const toolCallSessionIds = fake.requests
|
|
.filter((request) => request.body?.method === "tools/call")
|
|
.map((request) => request.headers["mcp-session-id"]);
|
|
expect(toolCallSessionIds).toEqual(["session-1", "session-2"]);
|
|
} finally {
|
|
// Open both gates and settle both calls so a failed assertion can't leave a fake-server request hanging.
|
|
markSecondInitializeStarted();
|
|
releaseSecondInitialize();
|
|
await Promise.allSettled([callA, callB]);
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("persists hashed sessions and accepts them across gateway service instances", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-remote-fixture:add"]);
|
|
|
|
const gatewayA = createTestToolGatewayService(db);
|
|
const session = await gatewayA.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const [storedSession] = await db
|
|
.select()
|
|
.from(toolGatewaySessions)
|
|
.where(eq(toolGatewaySessions.id, session.id));
|
|
expect(storedSession).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
tokenHash: createHash("sha256").update(session.token).digest("hex"),
|
|
});
|
|
expect(JSON.stringify(storedSession)).not.toContain(session.token);
|
|
|
|
const gatewayB = createTestToolGatewayService(db);
|
|
await expect(gatewayB.listToolsForSession(session.token)).resolves.toEqual([
|
|
expect.objectContaining({ name: "mcp-remote-fixture:add" }),
|
|
]);
|
|
await expect(gatewayB.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:add",
|
|
parameters: { a: 2, b: 5 },
|
|
})).resolves.toMatchObject({
|
|
status: "completed",
|
|
result: { content: "7" },
|
|
});
|
|
|
|
const [usedSession] = await db
|
|
.select()
|
|
.from(toolGatewaySessions)
|
|
.where(eq(toolGatewaySessions.id, session.id));
|
|
expect(usedSession.lastUsedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("rejects gateway session tokens passed through query strings", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
|
|
const listWithQueryToken = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.query({ sessionToken: session.token });
|
|
expect(listWithQueryToken.status).toBe(401);
|
|
expect(listWithQueryToken.body).toEqual({ error: "Tool gateway session token is required" });
|
|
|
|
const callWithQueryToken = await request(app)
|
|
.post("/api/tool-gateway/tools/call")
|
|
.query({ sessionToken: session.token })
|
|
.send({ tool: "mcp-remote-fixture:add", parameters: { a: 1, b: 2 } });
|
|
expect(callWithQueryToken.status).toBe(401);
|
|
expect(callWithQueryToken.body).toEqual({ error: "Tool gateway session token is required" });
|
|
|
|
const listWithHeaderToken = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(listWithHeaderToken.status).toBe(200);
|
|
});
|
|
|
|
it("revokes a gateway session through the authenticated route and audits without token values", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const beforeRevoke = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(beforeRevoke.status).toBe(200);
|
|
|
|
const revoked = await request(app)
|
|
.post(`/api/tool-gateway/sessions/${session.id}/revoke`)
|
|
.send({ companyId: company.id });
|
|
expect(revoked.status).toBe(200);
|
|
expect(revoked.body).toEqual({
|
|
sessionId: session.id,
|
|
revokedAt: expect.any(String),
|
|
});
|
|
expect(JSON.stringify(revoked.body)).not.toContain(session.token);
|
|
|
|
const afterRevoke = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(afterRevoke.status).toBe(401);
|
|
expect(afterRevoke.body.reasonCode).toBe("session_revoked");
|
|
|
|
const [activity] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.session_revoked"));
|
|
expect(activity).toMatchObject({
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
expect(activity.details).toMatchObject({
|
|
gatewaySessionId: session.id,
|
|
reasonCode: "session_revoked",
|
|
previousRevokedAt: null,
|
|
});
|
|
|
|
const [accessAudit] = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.action, "session_revoked"));
|
|
expect(accessAudit).toMatchObject({
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
action: "session_revoked",
|
|
outcome: "success",
|
|
reasonCode: "session_revoked",
|
|
});
|
|
const serializedAudits = JSON.stringify({ activity, accessAudit });
|
|
expect(serializedAudits).not.toContain(session.token);
|
|
});
|
|
|
|
it("denies wrong-company gateway session revocation without revoking the session", async () => {
|
|
const company = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "board",
|
|
userId: "other-board-user",
|
|
source: "session",
|
|
companyIds: [otherCompany.id],
|
|
memberships: [{ companyId: otherCompany.id, membershipRole: "operator", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const revoked = await request(app)
|
|
.post(`/api/tool-gateway/sessions/${session.id}/revoke`)
|
|
.send({ companyId: otherCompany.id });
|
|
expect(revoked.status).toBe(404);
|
|
expect(revoked.body.reasonCode).toBe("session_not_found");
|
|
|
|
const stillActive = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(stillActive.status).toBe(200);
|
|
|
|
const revokedRows = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.session_revoked"));
|
|
expect(revokedRows).toHaveLength(0);
|
|
});
|
|
|
|
it("scopes agent gateway session revocation to the authenticated run", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { run: otherRun } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const otherRunSession = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: otherRun.id,
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "agent",
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
source: "agent_jwt",
|
|
});
|
|
|
|
const wrongRun = await request(app)
|
|
.post(`/api/tool-gateway/sessions/${otherRunSession.id}/revoke`)
|
|
.send();
|
|
expect(wrongRun.status).toBe(403);
|
|
expect(wrongRun.body.reasonCode).toBe("session_scope_mismatch");
|
|
|
|
const otherRunStillActive = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", otherRunSession.token);
|
|
expect(otherRunStillActive.status).toBe(200);
|
|
|
|
const ownRun = await request(app)
|
|
.post(`/api/tool-gateway/sessions/${session.id}/revoke`)
|
|
.send();
|
|
expect(ownRun.status).toBe(200);
|
|
|
|
const ownRunDenied = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(ownRunDenied.status).toBe(401);
|
|
expect(ownRunDenied.body.reasonCode).toBe("session_revoked");
|
|
});
|
|
|
|
it("keeps action request approval routes viewer-safe", async () => {
|
|
const company = await createCompany(db);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "board",
|
|
userId: "viewer-user",
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [{ companyId: company.id, membershipRole: "viewer", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const approve = await request(app)
|
|
.post(`/api/tool-gateway/action-requests/${randomUUID()}/approve`)
|
|
.send({ companyId: company.id });
|
|
const decline = await request(app)
|
|
.post(`/api/tool-gateway/action-requests/${randomUUID()}/decline`)
|
|
.send({ companyId: company.id });
|
|
|
|
for (const res of [approve, decline]) {
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Viewer access is read-only");
|
|
}
|
|
});
|
|
|
|
it("denies agents without responsible-user authority from runtime control and gateway audit routes", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "agent",
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
source: "agent_jwt",
|
|
});
|
|
|
|
const list = await request(app)
|
|
.get("/api/tool-gateway/runtime-slots")
|
|
.query({ companyId: company.id });
|
|
expect(list.status).toBe(403);
|
|
|
|
|
|
const stop = await request(app)
|
|
.post("/api/tool-gateway/runtime-slots/slot-1/stop")
|
|
.send({ companyId: company.id });
|
|
expect(stop.status).toBe(403);
|
|
|
|
|
|
const restart = await request(app)
|
|
.post("/api/tool-gateway/runtime-slots/slot-1/restart")
|
|
.send({ companyId: company.id });
|
|
expect(restart.status).toBe(403);
|
|
|
|
|
|
const audit = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id });
|
|
expect(audit.status).toBe(403);
|
|
|
|
});
|
|
|
|
it("allows board runtime control and audit reads through explicit board permissions", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const userId = `board-${randomUUID()}`;
|
|
await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
});
|
|
await db.insert(principalPermissionGrants).values([
|
|
{
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey: "tools:manage_runtime",
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey: "tools:view_audit",
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
},
|
|
]);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const gateway = createTestToolGatewayService(db, {
|
|
runtimeSupervisor: { restartBackoffMs: 0, idleTtlMs: 10_000 },
|
|
});
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const slotId = (first.result as { data: { slotId: string } }).data.slotId;
|
|
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "board",
|
|
userId,
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const list = await request(app)
|
|
.get("/api/tool-gateway/runtime-slots")
|
|
.query({ companyId: company.id });
|
|
expect(list.status).toBe(200);
|
|
expect(list.body).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: slotId, status: "idle" }),
|
|
]));
|
|
|
|
const stop = await request(app)
|
|
.post(`/api/tool-gateway/runtime-slots/${slotId}/stop`)
|
|
.send({ companyId: company.id });
|
|
expect(stop.status).toBe(200);
|
|
expect(stop.body).toMatchObject({ id: slotId, status: "stopped" });
|
|
|
|
const restart = await request(app)
|
|
.post(`/api/tool-gateway/runtime-slots/${slotId}/restart`)
|
|
.send({ companyId: company.id });
|
|
expect(restart.status).toBe(200);
|
|
expect(restart.body).toMatchObject({ id: slotId, status: "running" });
|
|
|
|
const audit = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, limit: 20 });
|
|
expect(audit.status).toBe(200);
|
|
expect(audit.body.events).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
action: expect.stringMatching(/^tool_gateway\./),
|
|
}),
|
|
]));
|
|
expect(audit.body).toHaveProperty("nextCursor");
|
|
});
|
|
|
|
it("aggregates connection activity with server-side filters, pagination, and enrichment", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const otherAgent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
name: "Plugin: acme.plugin-mail",
|
|
type: "mcp_stdio",
|
|
}).returning();
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Plugin: acme.plugin-mail",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "local_stdio",
|
|
status: "active",
|
|
enabled: true,
|
|
}).returning();
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `audit-${randomUUID()}`,
|
|
name: `Audit ${randomUUID()}`,
|
|
}).returning();
|
|
const [gateway, otherGateway] = await db.insert(toolMcpGateways).values([
|
|
{
|
|
companyId: company.id,
|
|
name: `Audit gateway ${randomUUID()}`,
|
|
slug: `audit-${randomUUID()}`,
|
|
profileId: profile!.id,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
name: `Other gateway ${randomUUID()}`,
|
|
slug: `other-${randomUUID()}`,
|
|
profileId: profile!.id,
|
|
},
|
|
]).returning();
|
|
const [newerInvocation, olderInvocation, otherInvocation] = await db.insert(toolInvocations).values([
|
|
{
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
gatewayId: gateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
toolName: "mail:send_email",
|
|
argumentsSummary: { summary: JSON.stringify({ to: "person@example.test", token: "***REDACTED***" }) },
|
|
resultSummary: { summary: JSON.stringify({ delivered: true }) },
|
|
policyDecision: "allow",
|
|
status: "succeeded",
|
|
startedAt: new Date(Date.now() - 1_500),
|
|
completedAt: new Date(Date.now() - 1_000),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
gatewayId: gateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
toolName: "mail:read_email",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
actorId: otherAgent.id,
|
|
agentId: otherAgent.id,
|
|
runId: run.id,
|
|
gatewayId: otherGateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
toolName: "other:delete_everything",
|
|
},
|
|
]).returning();
|
|
const now = Date.now();
|
|
const callEvents = await db.insert(toolCallEvents).values([
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_completed",
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
gatewayId: gateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
invocationId: newerInvocation!.id,
|
|
toolName: "mail:send_email",
|
|
decision: "allow",
|
|
reasonCode: "tool_completed",
|
|
outcome: "success",
|
|
metadata: { upstreamToolName: "fixture.todo.list" },
|
|
createdAt: new Date(now - 1_000),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_completed",
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
gatewayId: gateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
invocationId: olderInvocation!.id,
|
|
toolName: "mail:read_email",
|
|
decision: "allow",
|
|
reasonCode: "profile_allows_tool",
|
|
outcome: "success",
|
|
createdAt: new Date(now - 2_000),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_denied",
|
|
actorType: "agent",
|
|
actorId: otherAgent.id,
|
|
agentId: otherAgent.id,
|
|
runId: run.id,
|
|
gatewayId: otherGateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
invocationId: otherInvocation!.id,
|
|
toolName: "other:delete_everything",
|
|
decision: "deny",
|
|
reasonCode: "deny_policy_block",
|
|
outcome: "denied",
|
|
createdAt: new Date(now - 500),
|
|
},
|
|
]).returning();
|
|
const [connectedEvent] = await db.insert(activityLog).values({
|
|
companyId: company.id,
|
|
actorType: "system",
|
|
actorId: "system",
|
|
action: "tool_app.connected",
|
|
entityType: "tool_connection",
|
|
entityId: connection!.id,
|
|
details: { galleryKey: "mail" },
|
|
createdAt: new Date(now - 45 * 24 * 60 * 60 * 1000),
|
|
}).returning();
|
|
|
|
const app = createGatewayRouteApp(db, createTestToolGatewayService(db), {
|
|
type: "board",
|
|
userId: "instance-admin",
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [{ companyId: company.id, membershipRole: "owner", status: "active" }],
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const allActivity = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id });
|
|
expect(allActivity.status).toBe(200);
|
|
expect(allActivity.body.events.map((event: { id: string }) => event.id)).toEqual([
|
|
callEvents[2]!.id,
|
|
callEvents[0]!.id,
|
|
callEvents[1]!.id,
|
|
connectedEvent!.id,
|
|
]);
|
|
expect(allActivity.body.events.find((event: { id: string }) => event.id === connectedEvent!.id)).toMatchObject({
|
|
action: "tool_connection.app_connected",
|
|
connectionId: connection!.id,
|
|
applicationId: application!.id,
|
|
appDisplayName: "Mail",
|
|
lifecycleType: "app_connected",
|
|
});
|
|
|
|
const firstPage = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, gateway: gateway!.id, app: connection!.id, agent: agent.id, outcome: "allowed", window: "24h", limit: 1 });
|
|
expect(firstPage.status).toBe(200);
|
|
expect(firstPage.body.events).toEqual([
|
|
expect.objectContaining({
|
|
action: "tool_gateway.call_completed",
|
|
agentId: agent.id,
|
|
agentDisplayName: agent.name,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
appDisplayName: "Mail",
|
|
toolDisplayName: "Send Email",
|
|
normalizedOutcome: "allowed",
|
|
invocation: expect.objectContaining({
|
|
id: newerInvocation!.id,
|
|
toolName: "mail:send_email",
|
|
status: "succeeded",
|
|
policyDecision: "allow",
|
|
argumentsSummary: expect.objectContaining({ summary: expect.stringContaining("***REDACTED***") }),
|
|
resultSummary: expect.objectContaining({ summary: expect.stringContaining("delivered") }),
|
|
}),
|
|
}),
|
|
]);
|
|
expect(typeof firstPage.body.nextCursor).toBe("string");
|
|
|
|
const secondPage = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({
|
|
companyId: company.id,
|
|
gateway: gateway!.id,
|
|
app: connection!.id,
|
|
agent: agent.id,
|
|
outcome: "allowed",
|
|
window: "24h",
|
|
limit: 1,
|
|
cursor: firstPage.body.nextCursor,
|
|
});
|
|
expect(secondPage.status).toBe(200);
|
|
expect(secondPage.body.events).toEqual([
|
|
expect.objectContaining({
|
|
action: "tool_gateway.call_completed",
|
|
toolDisplayName: "Read Email",
|
|
}),
|
|
]);
|
|
expect(secondPage.body.nextCursor).toBeNull();
|
|
|
|
// Free-text search resolves against the raw tool name server-side.
|
|
const byToolName = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, window: "24h", search: "delete_everything" });
|
|
expect(byToolName.status).toBe(200);
|
|
expect(byToolName.body.events).toEqual([
|
|
expect.objectContaining({ action: "tool_gateway.call_denied", toolDisplayName: "Delete Everything" }),
|
|
]);
|
|
|
|
const byUpstreamToolName = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, window: "24h", search: "fixture.todo.list" });
|
|
expect(byUpstreamToolName.status).toBe(200);
|
|
expect(byUpstreamToolName.body.events).toEqual([
|
|
expect.objectContaining({ action: "tool_gateway.call_completed", toolDisplayName: "Send Email" }),
|
|
]);
|
|
|
|
// ...and against the humanized agent name (resolved to the agent's events).
|
|
const byAgentName = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, window: "24h", search: otherAgent.name });
|
|
expect(byAgentName.status).toBe(200);
|
|
expect(byAgentName.body.events).toEqual([
|
|
expect.objectContaining({ agentId: otherAgent.id }),
|
|
]);
|
|
});
|
|
|
|
it("rejects durable sessions after the heartbeat run is no longer active", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "succeeded", finishedAt: new Date() })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
|
|
await expect(gateway.listToolsForSession(session.token)).rejects.toMatchObject({
|
|
status: 401,
|
|
reasonCode: "session_run_inactive",
|
|
});
|
|
await expect(gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
})).rejects.toMatchObject({
|
|
status: 403,
|
|
reasonCode: "run_inactive",
|
|
});
|
|
|
|
const [audit] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.session_rejected"));
|
|
expect(audit).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
expect(audit.details).toMatchObject({
|
|
decision: "deny",
|
|
reasonCode: "session_run_inactive",
|
|
runStatus: "succeeded",
|
|
});
|
|
expect(JSON.stringify(audit)).not.toContain(session.token);
|
|
});
|
|
|
|
it("binds runtime gateway tokens to active runs and preserves run attribution", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { project, issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "runtime-owner" }).where(eq(heartbeatRuns.id, run.id));
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:runtime_status"]);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const namedGateway = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: {
|
|
name: `Runtime gateway ${randomUUID()}`,
|
|
profileId: profile.id,
|
|
defaultProfileMode: "gateway_only",
|
|
},
|
|
});
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: namedGateway.id,
|
|
body: {
|
|
name: "Runtime token",
|
|
subjectType: "heartbeat_run",
|
|
subjectId: run.id,
|
|
clientLabel: "Heartbeat runtime",
|
|
ownerNote: "Run-bound regression token",
|
|
allowedActions: ["tools/list", "tools/call"],
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
actor: { agentId: agent.id },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
await expect(gateway.initializeNamedGatewayProtocol({
|
|
gatewayId: namedGateway.id,
|
|
bearerToken: token.token,
|
|
})).resolves.toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
projectId: project.id,
|
|
responsibleUserId: "runtime-owner",
|
|
});
|
|
|
|
await request(app)
|
|
.post(`/api/tool-gateway/gateways/${namedGateway.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(200);
|
|
await request(app)
|
|
.post(`/api/tool-gateway/gateways/${namedGateway.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: { name: "mcp-stdio-fixture:runtime_status", arguments: {} },
|
|
})
|
|
.expect(200);
|
|
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.runId, run.id))
|
|
.limit(1);
|
|
expect(invocation).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
});
|
|
const attributedActivity = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(and(eq(activityLog.runId, run.id), eq(activityLog.action, "tool_gateway.discovery")));
|
|
expect(attributedActivity).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
entityId: issue.id,
|
|
details: expect.objectContaining({ issueId: issue.id, runId: run.id }),
|
|
}),
|
|
]));
|
|
const attributedToolEvents = await db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(eq(toolCallEvents.runId, run.id));
|
|
expect(attributedToolEvents).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
metadata: expect.objectContaining({ projectId: project.id }),
|
|
}),
|
|
]));
|
|
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "succeeded", finishedAt: new Date() })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
|
|
const replay = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${namedGateway.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 3, method: "tools/list" })
|
|
.expect(401);
|
|
expect(replay.body.error.data.reasonCode).toBe("gateway_token_run_inactive");
|
|
});
|
|
|
|
it("rejects expired, revoked, and tampered durable sessions without auditing token values", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
|
|
const expired = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
await db
|
|
.update(toolGatewaySessions)
|
|
.set({ expiresAt: new Date(Date.now() - 1_000), updatedAt: new Date() })
|
|
.where(eq(toolGatewaySessions.id, expired.id));
|
|
await expect(gateway.listToolsForSession(expired.token)).rejects.toMatchObject({
|
|
status: 401,
|
|
reasonCode: "session_expired",
|
|
});
|
|
|
|
const revoked = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
await gateway.revokeSession({ companyId: company.id, sessionId: revoked.id });
|
|
await expect(gateway.listToolsForSession(revoked.token)).rejects.toMatchObject({
|
|
status: 401,
|
|
reasonCode: "session_revoked",
|
|
});
|
|
|
|
const tampered = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const badToken = tamperToken(tampered.token);
|
|
await expect(gateway.listToolsForSession(badToken)).rejects.toMatchObject({
|
|
status: 401,
|
|
reasonCode: "session_invalid",
|
|
});
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.session_rejected"));
|
|
expect(audits).toHaveLength(3);
|
|
const serializedAudits = JSON.stringify(audits);
|
|
expect(serializedAudits).toContain("session_expired");
|
|
expect(serializedAudits).toContain("session_revoked");
|
|
expect(serializedAudits).toContain("session_invalid");
|
|
expect(serializedAudits).not.toContain(expired.token);
|
|
expect(serializedAudits).not.toContain(revoked.token);
|
|
expect(serializedAudits).not.toContain(tampered.token);
|
|
expect(serializedAudits).not.toContain(badToken);
|
|
|
|
const dedicatedAudits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.action, "call_denied"));
|
|
expect(dedicatedAudits).toHaveLength(3);
|
|
expect(dedicatedAudits.every((event) => event.outcome === "denied")).toBe(true);
|
|
});
|
|
|
|
it("cleans up expired durable sessions explicitly", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const oldSession = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
await db
|
|
.update(toolGatewaySessions)
|
|
.set({ expiresAt: new Date(Date.now() - 1_000), updatedAt: new Date() })
|
|
.where(eq(toolGatewaySessions.id, oldSession.id));
|
|
|
|
await expect(gateway.cleanupExpiredSessions()).resolves.toEqual({ deletedCount: 1 });
|
|
|
|
const remaining = await db.select().from(toolGatewaySessions);
|
|
expect(remaining).toHaveLength(1);
|
|
expect(remaining[0]!.id).not.toBe(oldSession.id);
|
|
});
|
|
|
|
it("lazy-starts, reuses, and idles down the local stdio fixture slot", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
"mcp-stdio-fixture:increment_counter",
|
|
"mcp-stdio-fixture:runtime_status",
|
|
]);
|
|
// Drive idle-down off an injected clock so the assertions below do not
|
|
// depend on real wall-clock elapsing under 25ms (the source of the flake).
|
|
// The supervisor computes idleDeadlineAt = now() + idleTtlMs and reaps lazily
|
|
// on every listRuntimeSlots call, so a fixed clock keeps the slot alive until
|
|
// we deliberately advance past the TTL.
|
|
let clockMs = Date.now();
|
|
const gateway = createTestToolGatewayService(db, {
|
|
runtimeSupervisor: { idleTtlMs: 25, now: () => new Date(clockMs) },
|
|
});
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const second = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:runtime_status",
|
|
parameters: {},
|
|
});
|
|
|
|
const firstData = (first.result as { data: Record<string, unknown> }).data;
|
|
const secondData = (second.result as { data: Record<string, unknown> }).data;
|
|
expect(firstData).toMatchObject({ lazyStarted: true, reusedRuntimeSlot: false, counter: 1 });
|
|
expect(secondData).toMatchObject({ lazyStarted: false, reusedRuntimeSlot: true, counter: 1 });
|
|
expect(secondData.slotId).toBe(firstData.slotId);
|
|
// Clock has not advanced past the deadline, so the slot is deterministically present.
|
|
await expect(gateway.listRuntimeSlots(company.id)).resolves.toHaveLength(1);
|
|
const [idleSlot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.companyId, company.id));
|
|
expect(idleSlot).toMatchObject({
|
|
status: "idle",
|
|
commandTemplateKey: "paperclip.slow-stateful-stdio",
|
|
healthStatus: "ok",
|
|
});
|
|
expect(idleSlot.metadata).toMatchObject({
|
|
counter: 1,
|
|
useCount: 2,
|
|
process: expect.objectContaining({ simulated: true }),
|
|
resourceLimits: expect.objectContaining({ memoryCeilingSupported: expect.any(Boolean) }),
|
|
});
|
|
|
|
// Advance the injected clock past the idle TTL to deterministically reap the slot.
|
|
clockMs += 35;
|
|
await expect(gateway.listRuntimeSlots(company.id)).resolves.toEqual([]);
|
|
const [stoppedSlot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, idleSlot.id));
|
|
expect(stoppedSlot).toMatchObject({
|
|
status: "stopped",
|
|
healthMessage: "Stopped after idle TTL.",
|
|
});
|
|
});
|
|
|
|
it("supports explicit stop and restart actions for local stdio slots", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { restartBackoffMs: 0 } });
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const slotId = (first.result as { data: { slotId: string } }).data.slotId;
|
|
|
|
await expect(gateway.stopRuntimeSlot({ companyId: company.id, slotId, actor: { agentId: agent.id, runId: run.id } }))
|
|
.resolves.toMatchObject({ id: slotId, status: "stopped" });
|
|
await expect(gateway.listRuntimeSlots(company.id)).resolves.toEqual([]);
|
|
|
|
await expect(gateway.restartRuntimeSlot({ companyId: company.id, slotId, actor: { agentId: agent.id, runId: run.id } }))
|
|
.resolves.toMatchObject({ id: slotId, status: "running" });
|
|
});
|
|
|
|
it("returns structured runtime defer when local stdio host capacity is exhausted", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const { run: otherRun } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
|
await allowToolsForAgent(db, otherCompany.id, otherAgent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const gateway = createTestToolGatewayService(db, {
|
|
runtimeSupervisor: { idleTtlMs: 10_000, maxHostSlots: 1, hostId: "shared-host" },
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const otherSession = await gateway.createSession({ companyId: otherCompany.id, agentId: otherAgent.id, runId: otherRun.id });
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: otherSession.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected host capacity to defer the second stdio slot");
|
|
},
|
|
(error) => expectGatewayError(error, 429, "runtime_capacity_unavailable"),
|
|
);
|
|
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, otherCompany.id));
|
|
const [deferAudit] = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.action, "runtime_deferred"));
|
|
expect(invocation).toMatchObject({
|
|
status: "rate_limited",
|
|
errorCode: "runtime_capacity_unavailable",
|
|
});
|
|
expect(deferAudit).toMatchObject({
|
|
outcome: "failure",
|
|
reasonCode: "runtime_host_capacity_exhausted",
|
|
});
|
|
});
|
|
|
|
it("calls bundled Cognee in public mode and recovers from provider errors without runtime backoff", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const refs = await Promise.all(Object.entries({
|
|
COGNEE_BASE_URL: "https://fixture.aws.cognee.ai",
|
|
COGNEE_API_KEY: "fixture-key",
|
|
}).map(async ([key, value]) => {
|
|
const secret = await secretService(db).create(company.id, {
|
|
name: key, key: `${key}_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted", value,
|
|
});
|
|
return { secretId: secret.id, versionSelector: "latest" as const, configPath: `env.${key}`, required: true };
|
|
}));
|
|
const local = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "cognee", toolName: "recall",
|
|
connectionConfig: { templateId: "paperclip.cognee-cloud" }, credentialSecretRefs: refs,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const bridge = vi.spyOn(cogneeBridge, "callCogneeCloud")
|
|
.mockRejectedValueOnce(new Error("Cloud temporarily unavailable"))
|
|
.mockResolvedValue({ content: [{ type: "text", text: "recalled synthetic memory" }],
|
|
structuredContent: { result: "recalled synthetic memory" }, isError: false });
|
|
const gateway = createTestToolGatewayService(db, {
|
|
deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: null,
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === local.connection.id)!;
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
|
|
parameters: { message: "synthetic" } })).rejects.toThrow("Cloud temporarily unavailable");
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
|
|
parameters: { message: "synthetic" } })).resolves.toMatchObject({ status: "completed" });
|
|
expect(bridge).toHaveBeenCalledTimes(2);
|
|
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, local.connection.id))).toHaveLength(0);
|
|
bridge.mockRestore();
|
|
});
|
|
|
|
it("fails closed for hosted public local stdio unless a trusted runtime host is configured", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const hostedGateway = createTestToolGatewayService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
trustedLocalStdioRuntimeHost: null,
|
|
});
|
|
const session = await hostedGateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
|
|
await hostedGateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected public hosted local stdio to fail closed");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "local_stdio_unavailable_in_public_mode"),
|
|
);
|
|
|
|
const trustedGateway = createTestToolGatewayService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
trustedLocalStdioRuntimeHost: "trusted-worker-1",
|
|
runtimeSupervisor: { idleTtlMs: 10_000 },
|
|
});
|
|
const trustedSession = await trustedGateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await expect(trustedGateway.executeTool({
|
|
sessionToken: trustedSession.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
})).resolves.toMatchObject({ status: "completed" });
|
|
});
|
|
|
|
it("suppresses restart storms with backoff-visible slot health", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const gateway = createTestToolGatewayService(db, {
|
|
runtimeSupervisor: {
|
|
restartBackoffMs: 0,
|
|
restartStormLimit: 1,
|
|
restartStormWindowMs: 10_000,
|
|
},
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const slotId = (first.result as { data: { slotId: string } }).data.slotId;
|
|
|
|
await gateway.restartRuntimeSlot({ companyId: company.id, slotId, actor: { agentId: agent.id, runId: run.id } });
|
|
await gateway.restartRuntimeSlot({ companyId: company.id, slotId, actor: { agentId: agent.id, runId: run.id } }).then(
|
|
() => {
|
|
throw new Error("Expected restart storm suppression");
|
|
},
|
|
(error) => expectGatewayError(error, 429, "runtime_restart_suppressed"),
|
|
);
|
|
|
|
const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, slotId));
|
|
expect(slot).toMatchObject({
|
|
status: "failed",
|
|
healthStatus: "error",
|
|
lastError: "restart_storm_suppressed",
|
|
});
|
|
expect(slot.metadata).toMatchObject({
|
|
restartSuppressedUntil: expect.any(String),
|
|
});
|
|
});
|
|
|
|
it("recovers stuck local stdio slots before reuse", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
"mcp-stdio-fixture:increment_counter",
|
|
"mcp-stdio-fixture:runtime_status",
|
|
]);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { stuckSlotMs: 1, idleTtlMs: 10_000 } });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const slotId = (first.result as { data: { slotId: string } }).data.slotId;
|
|
const staleAt = new Date(Date.now() - 60_000);
|
|
await db
|
|
.update(toolRuntimeSlots)
|
|
.set({
|
|
status: "running",
|
|
lastUsedAt: staleAt,
|
|
startedAt: staleAt,
|
|
idleDeadlineAt: null,
|
|
idleExpiresAt: null,
|
|
updatedAt: staleAt,
|
|
})
|
|
.where(eq(toolRuntimeSlots.id, slotId));
|
|
|
|
const recovered = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:runtime_status",
|
|
parameters: {},
|
|
});
|
|
|
|
expect((recovered.result as { data: { slotId: string; reusedRuntimeSlot: boolean } }).data).toMatchObject({
|
|
slotId,
|
|
reusedRuntimeSlot: true,
|
|
});
|
|
const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, slotId));
|
|
expect(slot).toMatchObject({
|
|
status: "idle",
|
|
healthStatus: "ok",
|
|
});
|
|
expect(slot.metadata).toMatchObject({
|
|
stuckRecoveries: 1,
|
|
lastRestartReason: "stuck_slot_recovered",
|
|
});
|
|
});
|
|
|
|
it("defers write-risk tool calls into issue-thread approval requests", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
"mcp-remote-fixture:echo",
|
|
"mcp-remote-fixture:update_note",
|
|
]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review note updates",
|
|
policyType: "require_approval",
|
|
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
|
description: "Note updates require review.",
|
|
});
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const listedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.name === "mcp-remote-fixture:update_note");
|
|
expect(listedTool?.description).toBe(
|
|
"Remote HTTP MCP fixture that simulates a side-effecting write. Requires human approval: calling it posts an approval card on your task and you will be woken with the result once decided.",
|
|
);
|
|
expect((await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.name === "mcp-remote-fixture:echo")?.description).toBe(
|
|
"Remote HTTP MCP fixture that echoes a message without spawning a local process.",
|
|
);
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:update_note",
|
|
parameters: { noteId: "n1", body: "review this write" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected write-risk tool call to request approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db.select().from(toolActionRequests);
|
|
const [interaction] = await db.select().from(issueThreadInteractions);
|
|
expect(actionRequest).toMatchObject({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
status: "pending",
|
|
requestedByAgentId: agent.id,
|
|
});
|
|
expect(interaction).toMatchObject({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
});
|
|
});
|
|
|
|
it.each([1, 2])("wraps plugin tool discovery and execution behind the same gateway policy (%i copies)", async copies => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run, project } = await createIssueAndRun(db, company.id, agent.id);
|
|
const calls: unknown[] = [];
|
|
const dispatcher: PluginToolDispatcher = {
|
|
initialize: async () => {},
|
|
teardown: () => {},
|
|
listToolsForAgent: () => Array.from({ length: copies }, () => ({
|
|
name: "demo-plugin:read_status",
|
|
displayName: "Read status",
|
|
description: "Read status through a plugin tool.",
|
|
parametersSchema: { type: "object" },
|
|
pluginId: "demo-plugin",
|
|
})),
|
|
getTool: () => null,
|
|
executeTool: async (tool, parameters, runContext) => {
|
|
calls.push({ tool, parameters, runContext });
|
|
return {
|
|
pluginId: "demo-plugin",
|
|
toolName: "read_status",
|
|
result: { content: "plugin ok", data: { ok: true } },
|
|
};
|
|
},
|
|
registerPluginTools: () => {},
|
|
unregisterPluginTools: () => {},
|
|
toolCount: () => 1,
|
|
getRegistry: () => {
|
|
throw new Error("not used");
|
|
},
|
|
};
|
|
const gateway = createTestToolGatewayService(db, { pluginToolDispatcher: dispatcher });
|
|
|
|
await expect(gateway.listPluginToolsForAgent({ companyId: company.id, agentId: agent.id })).resolves.toEqual([]);
|
|
await gateway.executePluginTool({
|
|
actor: { type: "agent", companyId: company.id, agentId: agent.id, runId: run.id },
|
|
tool: "demo-plugin:read_status",
|
|
parameters: {},
|
|
runContext: { companyId: company.id, agentId: agent.id, runId: run.id, projectId: project.id },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected plugin tool call without profile to fail");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "deny_default"),
|
|
);
|
|
|
|
await allowToolsForAgent(db, company.id, agent.id, ["demo-plugin:read_status"]);
|
|
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, projectId: project.id });
|
|
expect((await gateway.listToolsForSession(session.token)).filter(tool => tool.pluginId === "demo-plugin"))
|
|
.toEqual([expect.objectContaining({ name: "demo-plugin:read_status" })]);
|
|
|
|
await expect(gateway.listPluginToolsForAgent({ companyId: company.id, agentId: agent.id })).resolves.toEqual([
|
|
expect.objectContaining({ name: "demo-plugin:read_status" }),
|
|
]);
|
|
await expect(gateway.executePluginTool({
|
|
actor: { type: "agent", companyId: company.id, agentId: agent.id, runId: run.id },
|
|
tool: "demo-plugin:read_status",
|
|
parameters: { id: "1" },
|
|
runContext: { companyId: company.id, agentId: agent.id, runId: run.id, projectId: project.id },
|
|
})).resolves.toMatchObject({
|
|
pluginId: "demo-plugin",
|
|
toolName: "read_status",
|
|
result: { content: "plugin ok", data: { ok: true } },
|
|
});
|
|
expect(calls).toEqual([
|
|
expect.objectContaining({
|
|
tool: "demo-plugin:read_status",
|
|
parameters: { id: "1" },
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("rejects caller-supplied issue context outside the run company", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const run = await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
contextSnapshot: {},
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const { issue: otherIssue } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
|
|
await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: otherIssue.id,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected cross-company issue context to fail");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "run_context_mismatch"),
|
|
);
|
|
});
|
|
});
|