mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents call remote MCP tools through the Paperclip tool gateway > - The gateway keeps a health status for each remote MCP connection, and it hides the tools of a connection that has `healthStatus = "error"` > - One oversized, malformed, or invalid-JSON `tools/call` reply set the whole connection to `error` > - Health goes back to `ok` only after a successful call, but the hidden tools prevent that call, so the connection stayed locked until a person reconnected it > - This pull request makes these reply errors fail only the one call, and it starts a new MCP session for the caller on the next call > - The benefit is that one large or bad reply no longer disconnects a working connection for every agent ## Linked Issues or Issue Description No public issue exists. Related open PRs fix other health downgrades in the same function. They do not overlap with this change: - Refs #11910 (timeouts and JSON-RPC errors) - Refs #15325 (transport errors) **What happened?** An agent called a remote MCP tool that returned more than `MAX_REMOTE_MCP_RESPONSE_BYTES` (1 MB). The gateway returned 502 `mcp_remote_response_too_large`. It also set the connection to `healthStatus = "error"`. Then `connectedMcpConnectionFilter` hid all tools of the connection (except `per_user` connections), and `connections_search` showed the connection as `needs_user_action`. The `malformed_response` and `invalid_json` errors from `readMcpHttpResponse` did the same thing. `readMcpHttpResponse` can also cancel the reply stream before the end. The remote server can then close its MCP session. The gateway kept the cached `mcp-session-id` for up to 30 minutes and cleared it only after a 404. The next calls then failed with "Remote MCP session expired". **Expected behavior** A reply that is too large or not correct fails only that call. The connection stays healthy, and its tools stay visible. The next call uses a new MCP session. **Steps to reproduce** 1. Add a remote MCP connection with `mcpSessionRequired: true`. 2. Call a tool that returns a reply larger than 1 MB. 3. Look at the connection: `healthStatus` is `error`. 4. Start a new gateway session: the tools of the connection are not in the list. **Paperclip version or commit** `master` at `99a9de9940bf5974352d9dbfbb2f21e62e89689f` **Deployment mode** All modes. The fault is in the server tool gateway. ## What Changed - `server/src/services/tool-gateway.ts`: `too_large`, `malformed_response`, and `invalid_json` from `readMcpHttpResponse` now fail only the call. The caller gets the same 502 reason code as before. The gateway does not call `markRemoteConnectionHealth(…, "error")` for these errors. The `invalid_json` branch after `JSON.parse(body)` also does not change health now, so all `invalid_json` paths are the same. - The `mcp_remote_response_too_large` message now tells the caller to request a smaller result, for example a narrower query or a smaller page size. The error details now include `maxBytes`. - `server/src/services/mcp-http.ts`: new `forgetMcpHttpSession()`. It removes only the cached session for one scope and one credential set, and only while that entry still holds the session ID that failed. It does not touch other agents, sessions that are initializing, or a newer session that replaced the failed one. - The gateway calls `forgetMcpHttpSession()` after these reply errors, so the next call from that caller initializes a new session. The existing 404 "session expired" path now uses the same function. Before, both paths cleared all sessions and all pending initializations on the connection. That made a concurrent initialization by another agent fail with "MCP connection changed while initializing", which the gateway reported as a fetch failure and marked as a connection `error`. - `forgetMcpHttpSessions(connectionId)` is not changed. Disconnect and revocation still clear the full connection. Why `malformed_response` and `invalid_json` are also per-call errors: - Each error is about one reply body. The server was reachable, accepted the credentials, and sent HTTP 2xx. A reply can be too large or bad because of the tool and its arguments. That is not a fault of the connection. - The other malformed-reply checks in the same function (payload is not an object, or has no `result`) already throw `remote_mcp_malformed_response` and do not change health. Only the reader-level errors changed health. - Health recovers only after a successful call. An `error` status for one bad reply therefore locks the connection until a person reconnects it. - Other failures (HTTP errors, fetch failures, timeouts, JSON-RPC errors) still change health. This PR does not change them. #11910 and #15325 address some of them. ## Verification - `server/src/__tests__/tool-gateway.test.ts`: the recovery test now runs for three replies: oversized, invalid JSON, and a reply without the requested message ID. Each run uses a fake HTTP MCP server with `mcpSessionRequired: true` that gives `session-N` for each `initialize`. Each run checks that: - the call fails with the correct 502 reason code (and, for the oversized reply, the smaller-page hint and `maxBytes: 1000000`) - `healthStatus` stays `ok` - a new gateway session still lists the tool and can call it - the second `tools/call` uses `session-2`, not `session-1` - New test: agent A gets an oversized reply while agent B initializes a session on the same connection. Agent B's call completes, health stays `ok`, and the two calls use `session-1` and `session-2`. With the old connection-wide reset, this test fails: agent B gets 502 `mcp_remote_fetch_failed`. - `server/src/__tests__/remote-mcp-protocol.test.ts`: new unit test. `forgetMcpHttpSession()` keeps the session of a different identity, and a late failure from an old session does not remove the newer session. - Each regression check fails without its fix. Without the health change, the test fails on `healthStatus: 'error'`. Without the session reset, it fails with `['session-1', 'session-1']`. ``` cd server npx vitest run src/__tests__/tool-gateway.test.ts src/__tests__/tool-gateway-service.test.ts src/__tests__/remote-mcp-protocol.test.ts Test Files 3 passed (3) Tests 132 passed (132) ``` - I did not run the full server `tsc --noEmit` locally because the sandbox does not have sufficient memory. The CI typecheck covers it. ## Risks - Low risk. The change affects only the error path of remote MCP `tools/call`. - A remote server that always sends bad replies now keeps `healthStatus = "ok"`. Each call still fails with a clear 502 reason code and an audit record, so the failure stays visible. Only the connection-wide hiding of tools stops. - After one of these errors, the next call from the same caller sends one more `initialize` request. This adds one round trip. - A 404 "session expired" now clears only the session of the caller that got the 404. Before, it cleared the cached sessions of all agents on the connection. If the remote server restarts, each agent now gets its own "session expired" error one time and then initializes again. A 404 is about one session, and the old connection-wide clear also cancelled other agents' initializations. - #11910 and #15325 change the same catch block. The PR that merges last can have a small merge conflict. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, 1M-token context window. - Run as an agent in Claude Code with tool use (shell, file edit, and local test runs). ## 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 (no user-facing documentation changes are necessary) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge This PR replaces #15418. It keeps the same commits on a branch name without an internal ticket ID, and adds a fix for the Greptile review on #15418. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
5949 lines
240 KiB
TypeScript
5949 lines
240 KiB
TypeScript
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,
|
|
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 embeddedPostgresSupport = 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;
|
|
}> | {
|
|
status?: number;
|
|
headers?: Record<string, string>;
|
|
body?: unknown;
|
|
rawBody?: string;
|
|
delayMs?: 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.rawBody !== undefined) {
|
|
res.end(response.rawBody);
|
|
} else {
|
|
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 () => {
|
|
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(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 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" },
|
|
});
|
|
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("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" },
|
|
});
|
|
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" },
|
|
});
|
|
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" },
|
|
});
|
|
const tokenB = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Client B", clientLabel: "Client B" },
|
|
});
|
|
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("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]!, {}))
|
|
.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]!, {}))
|
|
.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, {
|
|
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
|
|
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",
|
|
transport: "mcp_remote",
|
|
config: { url: `http://127.0.0.1:${port}/mcp` },
|
|
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: "completed",
|
|
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: "completed",
|
|
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: approvalRequest.expiresAt!.toISOString(),
|
|
},
|
|
},
|
|
});
|
|
const [approvalInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, approvalRequest.invocationId));
|
|
expect(approvalInvocation).toMatchObject({
|
|
status: "awaiting_approval",
|
|
policyDecision: "require_approval",
|
|
connectionId: approvalTool.connection.id,
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "shopify",
|
|
upstreamToolName: "kv_set",
|
|
});
|
|
|
|
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;
|
|
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",
|
|
});
|
|
|
|
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).not.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();
|
|
}
|
|
});
|
|
|
|
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: "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" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected the remote MCP call to fail");
|
|
},
|
|
(error) => {
|
|
expectGatewayError(error, 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" }]);
|
|
|
|
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 agent actors from runtime control and raw 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);
|
|
expect(list.body.error).toBe("Board access required");
|
|
|
|
const stop = await request(app)
|
|
.post("/api/tool-gateway/runtime-slots/slot-1/stop")
|
|
.send({ companyId: company.id });
|
|
expect(stop.status).toBe(403);
|
|
expect(stop.body.error).toBe("Board access required");
|
|
|
|
const restart = await request(app)
|
|
.post("/api/tool-gateway/runtime-slots/slot-1/restart")
|
|
.send({ companyId: company.id });
|
|
expect(restart.status).toBe(403);
|
|
expect(restart.body.error).toBe("Board access required");
|
|
|
|
const audit = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id });
|
|
expect(audit.status).toBe(403);
|
|
expect(audit.body.error).toBe("Board access required");
|
|
});
|
|
|
|
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", completedAt: 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", completedAt: 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", 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("wraps plugin tool discovery and execution behind the same gateway policy", async () => {
|
|
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: () => [
|
|
{
|
|
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"]);
|
|
|
|
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"),
|
|
);
|
|
});
|
|
});
|