Files
PaperClipAI/server/src/services/tool-gateway.ts
T
DottaandPaperclip b0e5fc2af6 fix: preserve installed tools on mixed gateways
Keep managed gateways that expose at least one installed connection, and scope heartbeat-run gateway discovery and calls to the agent's installed connection set.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-21 19:57:37 +00:00

6452 lines
248 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "node:child_process";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, lte, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
documents,
heartbeatRuns,
issueApprovals,
issueDocuments,
issueThreadInteractions,
issues,
projects,
toolActionRequests,
toolAccessAuditEvents,
toolApplications,
toolCallEvents,
toolCatalogEntries,
toolConnectionInstalls,
toolConnections,
toolGatewayRateLimitCounters,
toolGatewaySessions,
toolInvocations,
toolMcpGateways,
toolMcpGatewayTokens,
toolPolicies,
toolProfileBindings,
toolProfileEntries,
toolProfiles,
toolStdioCommandTemplates,
} from "@paperclipai/db";
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
import type {
CreateToolMcpGateway,
CreateToolMcpGatewayToken,
DeploymentExposure,
DeploymentMode,
McpConnectionCredentialRef,
SecretVersionSelector,
ToolAccessDecision,
ToolAccessDecisionInput,
ToolConnectionTestCallStatus,
ToolConnectionTestCallStatusPhase,
ToolCredentialSecretRef,
ToolMcpGateway,
ToolMcpGatewayClientSnippet,
ToolMcpGatewayToken,
ToolMcpGatewayTokenAction,
ToolMcpGatewayTokenCreated,
ToolMcpGatewayWithTokens,
UpdateToolMcpGateway,
} from "@paperclipai/shared";
import type { AgentToolDescriptor, PluginToolDispatcher } from "./plugin-tool-dispatcher.js";
import { logActivity, type LogActivityInput } from "./activity-log.js";
import { secretService } from "./secrets.js";
import { mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js";
import { assertPublicRemoteHttpEndpoint, parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
import { toolAccessPolicyService } from "./tool-access-policy.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import {
createToolRuntimeSupervisor,
ToolRuntimeSupervisorError,
type ToolRuntimeSupervisorOptions,
type ToolRuntimeSlotView,
} from "./tool-runtime-supervisor.js";
import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js";
import {
canonicalToolArguments,
readSignedToolArgumentsPayload,
signToolArguments,
summarizeToolValue,
ToolActionSigningSecretMissingError,
ToolContentValidationError,
validateToolContent,
verifyToolArgumentsSignature,
} from "./tool-content-guards.js";
const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000;
const MAX_SESSION_TTL_MS = 60 * 60 * 1000;
const DEFAULT_TOOL_TIMEOUT_MS = 10_000;
// When a human approves a parked write, the server carries it out on their
// behalf with no interactive caller left to raise `timeoutMs`. Remote write
// providers (e.g. Zapier Google Sheets `add_row`) routinely take longer than
// the 10s interactive default, so an approved action would otherwise abort with
// `tool_timeout` even though the approval succeeded. Give approved executions
// the full permitted headroom instead.
const APPROVED_EXECUTION_TIMEOUT_MS = 60_000;
// The gateway creates an ask-first request in two steps: it inserts the row
// with a null signature, then it signs the row and sets the expiry. A concurrent
// matching call can observe the row in this window. A null signature alone does
// not prove the create stopped; the create can still run in another request. Only
// treat an unsigned row as abandoned after this grace time from createdAt. This
// grace must exceed the normal sign path (approval-snapshot fetch + interaction
// create) so a live create keeps its own row.
const UNSIGNED_ACTION_REQUEST_ABANDON_MS = 2 * 60 * 1000;
const MAX_REMOTE_MCP_RESPONSE_BYTES = 1_000_000;
const ACTIVE_GATEWAY_RUN_STATUSES = new Set(["running"]);
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type McpGatewayProtocolMethod = "initialize" | "tools/list" | "tools/call";
type McpGatewayRateLimitConfig = { windowMs: number; max: number };
type McpGatewayRateLimitState = { limited: boolean; count: number; retryAfterMs: number };
type McpGatewayProtocolLimitOptions = {
authFailures: McpGatewayRateLimitConfig;
gatewayRequests: McpGatewayRateLimitConfig;
tokenRequests: McpGatewayRateLimitConfig;
sessionSetup: McpGatewayRateLimitConfig;
};
const DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS: McpGatewayProtocolLimitOptions = {
authFailures: { windowMs: 5 * 60 * 1000, max: 20 },
gatewayRequests: { windowMs: 60 * 1000, max: 300 },
tokenRequests: { windowMs: 60 * 1000, max: 120 },
sessionSetup: { windowMs: 60 * 1000, max: 30 },
};
const TOOL_APPROVAL_DESCRIPTION_SUFFIX =
"Requires human approval: calling it posts an approval card on your task and you will be woken with the result once decided.";
export type ToolGatewayProviderType =
| "mcp_http_fixture"
| "mcp_stdio_fixture"
| "mcp_remote_http"
| "mcp_local_stdio"
| "paperclip_self"
| "paperclip_plugin"
| "paperclip_virtual";
export interface ConnectedMcpGatewayMetadata {
applicationId: string;
applicationKey: string | null;
applicationDisplayName: string;
connectionId: string;
catalogEntryId: string;
transport: "mcp_remote" | "local_stdio";
gatewayToolName: string;
upstreamToolName: string;
catalogName: string;
inputSchema: Record<string, unknown>;
outputSchema: Record<string, unknown> | null;
annotations: Record<string, unknown>;
risk: {
level: string;
isReadOnly: boolean;
isWrite: boolean;
isDestructive: boolean;
};
onDemandTools?: boolean;
}
export interface ToolGatewayDescriptor extends AgentToolDescriptor {
providerType: ToolGatewayProviderType;
risk: "read" | "write" | "destructive";
applicationId?: string | null;
applicationKey?: string | null;
applicationDisplayName?: string | null;
connectionId?: string | null;
catalogEntryId?: string | null;
upstreamToolName?: string | null;
providerMetadata?: ConnectedMcpGatewayMetadata | Record<string, unknown>;
}
export interface ToolGatewaySession {
id: string;
token: string;
companyId: string;
agentId: string | null;
runId: string | null;
issueId: string | null;
projectId: string | null;
gatewayId?: string | null;
gatewayPublicId?: string | null;
gatewayName?: string | null;
gatewayTokenId?: string | null;
gatewayTokenAllowedActions?: ToolMcpGatewayTokenAction[];
enforceConnectionInstalls?: boolean;
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
createdAt: Date;
expiresAt: Date;
}
export type ToolGatewayRuntimeSlot = ToolRuntimeSlotView;
export class ToolGatewayHttpError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly reasonCode: string,
public readonly details: Record<string, unknown> = {},
) {
super(message);
}
}
interface ExecuteGatewayToolInput {
sessionToken: string;
gatewayId?: string | null;
gatewayPublicId?: string | null;
tool: string;
parameters?: unknown;
timeoutMs?: number;
approvedActionRequestId?: string | null;
idempotencyKey?: string | null;
callerHeaders?: Record<string, string | string[] | undefined>;
}
interface ExecuteTestCallInput {
companyId: string;
connectionId: string;
agentId: string;
userId: string;
toolName: string;
parameters?: unknown;
timeoutMs?: number;
}
interface ExecutePluginToolInput {
actor: { type: "agent" | "board"; agentId?: string | null; companyId?: string | null; userId?: string | null; runId?: string | null };
tool: string;
parameters: unknown;
runContext: ToolRunContext;
}
type HeaderPolicyConfig = {
staticHeaders: Array<{ name: string; value: string }>;
passthroughAllowlist: string[];
metadataHeaders: Array<"company_id" | "agent_id" | "issue_id" | "project_id" | "run_id" | "gateway_session_id" | "correlation_id">;
};
type HeaderPolicySummary = {
staticHeaderNames: string[];
credentialHeaderNames: string[];
passthroughHeaderNames: string[];
droppedPassthroughHeaderNames: string[];
metadataHeaderNames: string[];
collisionRules: Array<{ header: string; source: string; action: string }>;
};
type RemoteHttpExecutionResult = {
result: unknown;
headerSummary?: HeaderPolicySummary;
execution?: RemoteHttpExecutionAudit;
};
type RemoteHttpExecutionAudit = {
transport: "mcp_remote";
request: {
protocol: "MCP JSON-RPC 2.0";
httpMethod: "POST";
endpoint: string;
mcpMethod: "tools/call";
requestId: string;
upstreamToolName: string;
dispatched: true;
};
response?: {
httpStatus: number;
contentType: string | null;
bodySizeBytes: number;
upstreamRequestId: string | null;
};
};
type LocalStdioRuntimeTemplate = {
templateId: string;
command: string | null;
args: string[];
envKeys: string[];
};
const BUILTIN_LOCAL_STDIO_RUNTIME_TEMPLATES: Record<string, Omit<LocalStdioRuntimeTemplate, "templateId">> = {
"paperclip.google-sheets": {
command: "paperclip-google-sheets-mcp-server",
args: [],
envKeys: [
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON",
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON_PATH",
"GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS",
],
},
"paperclip.echo-calculator-time": {
command: null,
args: [],
envKeys: [],
},
"paperclip.synthetic-todo-kv": {
command: null,
args: [],
envKeys: [],
},
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
const sensitivePassthroughHeaderPattern = /(^|[-_])(auth|authorization|cookie|secret|session|token)([-_]|$)|(^|[-_])api[-_]?key([-_]|$)/i;
const sensitivePassthroughHeaderNames = new Set([
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-paperclip-tool-gateway-token",
]);
function isSensitivePassthroughHeader(name: string) {
return name.startsWith("x-paperclip-")
|| sensitivePassthroughHeaderNames.has(name)
|| sensitivePassthroughHeaderPattern.test(name);
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function auditSafeEndpoint(endpoint: string): string {
try {
const url = new URL(endpoint);
return `${url.origin}${url.pathname}`;
} catch {
return "configured remote MCP endpoint";
}
}
function executionAuditFromError(error: unknown): RemoteHttpExecutionAudit | undefined {
if (!(error instanceof ToolGatewayHttpError)) return undefined;
const execution = error.details.execution;
if (!execution || typeof execution !== "object" || Array.isArray(execution)) return undefined;
return execution as RemoteHttpExecutionAudit;
}
function generateGatewayToken(sessionId: string) {
return `pcgt_${sessionId}.${randomBytes(32).toString("base64url")}`;
}
function generateNamedGatewayToken(tokenId: string) {
return `pcgw_${tokenId}.${randomBytes(32).toString("base64url")}`;
}
function hashGatewayToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function sessionIdFromGatewayToken(token: string) {
const match = token.match(/^pcgt_([0-9a-fA-F-]{36})\.[A-Za-z0-9_-]+$/);
return match?.[1] ?? null;
}
function namedGatewayTokenId(token: string) {
const match = token.match(/^pcgw_([0-9a-fA-F-]{36})\.[A-Za-z0-9_-]+$/);
return match?.[1] ?? null;
}
function positiveInt(value: string | undefined, fallback: number) {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function mergeLimitConfig(
defaults: McpGatewayRateLimitConfig,
overrides: Partial<McpGatewayRateLimitConfig> | undefined,
): McpGatewayRateLimitConfig {
return {
windowMs: overrides?.windowMs && overrides.windowMs > 0 ? overrides.windowMs : defaults.windowMs,
max: overrides?.max && overrides.max > 0 ? overrides.max : defaults.max,
};
}
function mcpGatewayProtocolLimits(
overrides: Partial<{
authFailures: Partial<McpGatewayRateLimitConfig>;
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
tokenRequests: Partial<McpGatewayRateLimitConfig>;
sessionSetup: Partial<McpGatewayRateLimitConfig>;
}> | undefined,
): McpGatewayProtocolLimitOptions {
const envDefaults: McpGatewayProtocolLimitOptions = {
authFailures: {
windowMs: positiveInt(process.env.PAPERCLIP_MCP_GATEWAY_AUTH_FAILURE_WINDOW_MS, DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.authFailures.windowMs),
max: positiveInt(process.env.PAPERCLIP_MCP_GATEWAY_AUTH_FAILURE_LIMIT, DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.authFailures.max),
},
gatewayRequests: {
windowMs: positiveInt(process.env.PAPERCLIP_MCP_GATEWAY_REQUEST_WINDOW_MS, DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.gatewayRequests.windowMs),
max: positiveInt(process.env.PAPERCLIP_MCP_GATEWAY_REQUEST_LIMIT, DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.gatewayRequests.max),
},
tokenRequests: {
windowMs: positiveInt(process.env.PAPERCLIP_MCP_GATEWAY_TOKEN_REQUEST_WINDOW_MS, DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.tokenRequests.windowMs),
max: positiveInt(process.env.PAPERCLIP_MCP_GATEWAY_TOKEN_REQUEST_LIMIT, DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.tokenRequests.max),
},
sessionSetup: {
windowMs: positiveInt(process.env.PAPERCLIP_MCP_GATEWAY_SESSION_SETUP_WINDOW_MS, DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.sessionSetup.windowMs),
max: positiveInt(process.env.PAPERCLIP_MCP_GATEWAY_SESSION_SETUP_LIMIT, DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.sessionSetup.max),
},
};
return {
authFailures: mergeLimitConfig(envDefaults.authFailures, overrides?.authFailures),
gatewayRequests: mergeLimitConfig(envDefaults.gatewayRequests, overrides?.gatewayRequests),
tokenRequests: mergeLimitConfig(envDefaults.tokenRequests, overrides?.tokenRequests),
sessionSetup: mergeLimitConfig(envDefaults.sessionSetup, overrides?.sessionSetup),
};
}
function tokenPrefixFromNamedBearer(token: string) {
const tokenId = namedGatewayTokenId(token);
if (tokenId) return `pcgw_${tokenId.slice(0, 8)}`;
return token.startsWith("pcgw_") ? "pcgw_malformed" : "unknown";
}
function safeHeaderValue(headers: Record<string, string | string[] | undefined> | undefined, name: string, maxLength = 160) {
const value = headers?.[name] ?? headers?.[name.toLowerCase()];
const raw = Array.isArray(value) ? value[0] : value;
if (!raw) return null;
const sanitized = raw.replace(/[\r\n\t]/g, " ").trim();
return sanitized ? sanitized.slice(0, maxLength) : null;
}
function safeClientMetadata(headers: Record<string, string | string[] | undefined> | undefined) {
const clientName = safeHeaderValue(headers, "x-paperclip-client-name", 120)
?? safeHeaderValue(headers, "mcp-client-name", 120)
?? null;
const correlationId = safeHeaderValue(headers, "x-request-id", 120)
?? safeHeaderValue(headers, "x-correlation-id", 120)
?? null;
return {
clientName,
correlationId,
userAgent: safeHeaderValue(headers, "user-agent", 200),
};
}
function rateLimitWindowStart(current: number, windowMs: number) {
return new Date(Math.floor(current / windowMs) * windowMs);
}
function gatewaySessionFromRow(row: typeof toolGatewaySessions.$inferSelect): ToolGatewaySession {
return {
id: row.id,
token: "",
companyId: row.companyId,
agentId: row.agentId,
runId: row.runId,
issueId: row.issueId,
projectId: row.projectId,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
};
}
function timeoutMs(value: number | undefined) {
if (!Number.isFinite(value)) return DEFAULT_TOOL_TIMEOUT_MS;
return Math.max(1, Math.min(60_000, Math.floor(value ?? DEFAULT_TOOL_TIMEOUT_MS)));
}
function sessionTtlMs(value: number | undefined) {
if (!Number.isFinite(value)) return DEFAULT_SESSION_TTL_MS;
return Math.max(1_000, Math.min(MAX_SESSION_TTL_MS, Math.floor(value ?? DEFAULT_SESSION_TTL_MS)));
}
function summarizeResult(result: unknown): Record<string, unknown> {
const record = asRecord(result);
if (!record) return { type: typeof result };
const content = typeof record.content === "string" ? record.content : null;
return {
hasContent: content !== null,
contentLength: content?.length ?? 0,
hasData: record.data !== undefined,
hasError: Boolean(record.error),
};
}
function inferToolRisk(toolName: string): ToolGatewayDescriptor["risk"] {
const lower = toolName.toLowerCase();
if (/\b(delete|destroy|remove|drop|truncate|wipe|purge)\b|(^|[:._-])(delete|destroy|remove|drop|truncate|wipe|purge)([:._-]|$)/.test(lower)) {
return "destructive";
}
if (/\b(create|update|write|edit|patch|post|send|publish|merge|commit|apply)\b|(^|[:._-])(create|update|write|edit|patch|post|send|publish|merge|commit|apply)([:._-]|$)/.test(lower)) {
return "write";
}
return "read";
}
function riskFromCatalogEntry(entry: Pick<typeof toolCatalogEntries.$inferSelect, "riskLevel" | "isReadOnly" | "isWrite" | "isDestructive">): ToolGatewayDescriptor["risk"] {
if (entry.riskLevel === "destructive" || entry.isDestructive || entry.riskLevel === "critical" || entry.riskLevel === "high") {
return "destructive";
}
if (entry.riskLevel === "write" || entry.isWrite || entry.riskLevel === "medium") {
return "write";
}
return "read";
}
function slugSegment(value: string | null | undefined, fallback: string): string {
const slug = String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return slug || fallback;
}
function shortStableId(id: string): string {
return id.replace(/-/g, "").slice(0, 8);
}
function toolRequiresFormalApproval(tool: ToolGatewayDescriptor): boolean {
return tool.risk === "destructive";
}
function toolAuditMetadata(tool: ToolGatewayDescriptor): Record<string, unknown> {
return {
applicationId: tool.applicationId ?? null,
applicationKey: tool.applicationKey ?? null,
connectionId: tool.connectionId ?? null,
catalogEntryId: tool.catalogEntryId ?? null,
upstreamToolName: tool.upstreamToolName ?? tool.name,
providerType: tool.providerType,
risk: tool.risk,
riskLevel: tool.risk,
};
}
function stableSerialize(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
return `{${Object.keys(value as Record<string, unknown>).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize((value as Record<string, unknown>)[key])}`).join(",")}}`;
}
function stableHash(value: unknown): string {
return createHash("sha256").update(stableSerialize(value)).digest("hex");
}
function normalizeSignedApprovalSnapshot(value: unknown): Record<string, unknown> | null {
return asRecord(value);
}
function approvalSnapshotsMatch(reviewed: unknown, live: Record<string, unknown> | null): boolean {
const reviewedRecord = normalizeSignedApprovalSnapshot(reviewed);
if (!reviewedRecord && !live) return true;
if (!reviewedRecord || !live) return false;
return stableSerialize(reviewedRecord) === stableSerialize(live);
}
type ConnectedCredentialVersionSnapshot = {
refHash: string;
versionSelector: string;
resolvedVersion: number;
};
const REDACTED_ARGUMENT_SENTINEL = "***REDACTED***";
/** Turn a machine field key (`note_body`, `noteBody`, `note-body`) into a Title-Cased label. */
function humanizeArgumentKey(key: string): string {
const words = key
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean);
if (words.length === 0) return key;
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
}
/** Identifier-ish fields leak raw IDs into the prosumer card; the vocab gate forbids them. */
function isIdentifierArgumentKey(key: string): boolean {
return /(^|[_-])(id|ids|uuid|guid|key|token|hash|sha\d*)$/i.test(key);
}
/** Render a single argument value as short, plain text — or null if it shouldn't be shown. */
function humanizeArgumentValue(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed === REDACTED_ARGUMENT_SENTINEL) return "hidden for privacy";
return trimmed.length > 140 ? `${trimmed.slice(0, 137)}…` : trimmed;
}
if (typeof value === "number" || typeof value === "boolean") return String(value);
return null;
}
/**
* Build the prosumer-facing "Ask first" preview (M5/M7/M9). Deliberately free of the
* words tool/risk/transport/arguments and of raw JSON — those only belong on the
* Advanced surfaces (M8a/M8b) and the board-only formal-approval interaction.
*/
function buildHumanizedActionPreview(input: {
tool: ToolGatewayDescriptor;
argumentsSummary: ReturnType<typeof summarizeToolValue>;
}): string {
const trustLine =
input.tool.risk === "destructive"
? "It can permanently change or remove something, so we’re checking with you first."
: "It can change something, so we’re checking with you first.";
let parsed: unknown;
try {
parsed = JSON.parse(input.argumentsSummary.summary);
} catch {
return trustLine;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return trustLine;
const fieldLines: string[] = [];
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (fieldLines.length >= 6) break;
if (isIdentifierArgumentKey(key)) continue;
const rendered = humanizeArgumentValue(value);
if (rendered === null) continue;
fieldLines.push(`**${humanizeArgumentKey(key)}:** ${rendered}`);
}
if (fieldLines.length === 0) return trustLine;
return [trustLine, "", ...fieldLines].join("\n");
}
const BUILTIN_TOOLS: ToolGatewayDescriptor[] = [
{
name: "mcp-remote-fixture:echo",
displayName: "Remote fixture echo",
description: "Remote HTTP MCP fixture that echoes a message without spawning a local process.",
parametersSchema: {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
additionalProperties: false,
},
pluginId: "mcp-remote-fixture",
providerType: "mcp_http_fixture",
risk: "read",
},
{
name: "mcp-remote-fixture:add",
displayName: "Remote fixture add",
description: "Remote HTTP MCP fixture that adds two numbers without spawning a local process.",
parametersSchema: {
type: "object",
properties: { a: { type: "number" }, b: { type: "number" } },
required: ["a", "b"],
additionalProperties: false,
},
pluginId: "mcp-remote-fixture",
providerType: "mcp_http_fixture",
risk: "read",
},
{
name: "mcp-remote-fixture:update_note",
displayName: "Remote fixture update note",
description: "Remote HTTP MCP fixture that simulates a side-effecting write.",
parametersSchema: {
type: "object",
properties: { noteId: { type: "string" }, body: { type: "string" } },
required: ["noteId", "body"],
additionalProperties: false,
},
pluginId: "mcp-remote-fixture",
providerType: "mcp_http_fixture",
risk: "write",
},
{
name: "paperclip-self:list_my_issues",
displayName: "List my Paperclip issues",
description: "Paperclip self-MCP read fixture that lists the authenticated agent's current issues.",
parametersSchema: {
type: "object",
properties: { limit: { type: "number" } },
additionalProperties: false,
},
pluginId: "paperclip-self",
providerType: "paperclip_self",
risk: "read",
},
{
name: "paperclip-self:get_issue_context",
displayName: "Get issue context",
description: "Paperclip self-MCP read fixture that returns scoped issue context and plan document metadata.",
parametersSchema: {
type: "object",
properties: { issueId: { type: "string" } },
additionalProperties: false,
},
pluginId: "paperclip-self",
providerType: "paperclip_self",
risk: "read",
},
{
name: "mcp-stdio-fixture:increment_counter",
displayName: "Stdio runtime counter",
description: "Local stdio MCP fixture that lazy-starts a supervised runtime slot and increments slot-local state.",
parametersSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
pluginId: "mcp-stdio-fixture",
providerType: "mcp_stdio_fixture",
risk: "read",
},
{
name: "mcp-stdio-fixture:runtime_status",
displayName: "Stdio runtime status",
description: "Local stdio MCP fixture that reports the reused runtime slot state.",
parametersSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
pluginId: "mcp-stdio-fixture",
providerType: "mcp_stdio_fixture",
risk: "read",
},
];
const VIRTUAL_SEARCH_TOOLS: ToolGatewayDescriptor = {
name: "search_tools",
displayName: "Search available tools",
description: "Search the tools available through this Paperclip gateway without loading every target tool into the tool list.",
parametersSchema: {
type: "object",
properties: {
query: { type: "string" },
limit: { type: "number" },
},
additionalProperties: false,
},
pluginId: "paperclip-gateway",
providerType: "paperclip_virtual",
risk: "read",
};
const VIRTUAL_RUN_TOOL: ToolGatewayDescriptor = {
name: "run_tool",
displayName: "Run a selected tool",
description: "Run a target tool by name after Paperclip applies the target tool's profile, policy, approval, and rate-limit checks.",
parametersSchema: {
type: "object",
properties: {
tool: { type: "string" },
arguments: { type: "object" },
},
required: ["tool"],
additionalProperties: false,
},
pluginId: "paperclip-gateway",
providerType: "paperclip_virtual",
risk: "write",
};
const VIRTUAL_TOOLS = [VIRTUAL_SEARCH_TOOLS, VIRTUAL_RUN_TOOL];
export function createToolGatewayService(
db: Db,
options: {
pluginToolDispatcher?: PluginToolDispatcher;
deploymentMode?: DeploymentMode;
deploymentExposure?: DeploymentExposure;
trustedLocalStdioRuntimeHost?: string | null;
runtimeSupervisor?: ToolRuntimeSupervisorOptions;
toolActionSigningSecret?: string;
mcpGatewayProtocolLimits?: Partial<{
authFailures: Partial<McpGatewayRateLimitConfig>;
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
tokenRequests: Partial<McpGatewayRateLimitConfig>;
sessionSetup: Partial<McpGatewayRateLimitConfig>;
}>;
now?: () => number;
} = {},
) {
const runtimeSupervisor = createToolRuntimeSupervisor(db, {
deploymentMode: options.deploymentMode,
deploymentExposure: options.deploymentExposure,
trustedLocalStdioRuntimeHost: options.trustedLocalStdioRuntimeHost,
...options.runtimeSupervisor,
});
const pluginToolDispatcher = options.pluginToolDispatcher;
const interactions = issueThreadInteractionService(db);
const policyService = toolAccessPolicyService(db);
const secrets = secretService(db);
const protocolLimits = mcpGatewayProtocolLimits(options.mcpGatewayProtocolLimits);
let nextProtocolRateLimitPruneAt = 0;
async function pruneExpiredProtocolRateLimitCounters(current: number) {
if (current < nextProtocolRateLimitPruneAt) return;
nextProtocolRateLimitPruneAt = current + 60_000;
await db
.delete(toolGatewayRateLimitCounters)
.where(lte(toolGatewayRateLimitCounters.resetAt, new Date(current)));
}
async function consumeProtocolRateLimit(input: {
companyId: string;
counterKey: string;
config: McpGatewayRateLimitConfig;
}): Promise<McpGatewayRateLimitState> {
const current = options.now?.() ?? Date.now();
const windowStartAt = rateLimitWindowStart(current, input.config.windowMs);
const resetAt = new Date(windowStartAt.getTime() + input.config.windowMs);
const nowDate = new Date(current);
const windowStartIso = windowStartAt.toISOString();
const resetIso = resetAt.toISOString();
const nowIso = nowDate.toISOString();
await pruneExpiredProtocolRateLimitCounters(current);
const rows = Array.from(await db.execute(sql<{ count: number | string }>`
INSERT INTO "tool_gateway_rate_limit_counters" (
"company_id",
"counter_key",
"window_start_at",
"window_ms",
"limit",
"count",
"reset_at",
"created_at",
"updated_at"
)
VALUES (
${input.companyId},
${input.counterKey},
${windowStartIso}::timestamptz,
${input.config.windowMs},
${input.config.max},
1,
${resetIso}::timestamptz,
${nowIso}::timestamptz,
${nowIso}::timestamptz
)
ON CONFLICT ("company_id", "counter_key", "window_start_at")
DO UPDATE SET
"count" = "tool_gateway_rate_limit_counters"."count" + 1,
"window_ms" = ${input.config.windowMs},
"limit" = ${input.config.max},
"reset_at" = ${resetIso}::timestamptz,
"updated_at" = ${nowIso}::timestamptz
RETURNING "count"
`));
const count = Number(rows[0]?.count ?? 1);
return {
limited: count > input.config.max,
count,
retryAfterMs: Math.max(0, resetAt.getTime() - current),
};
}
function pluginTools(): ToolGatewayDescriptor[] {
return (pluginToolDispatcher?.listToolsForAgent() ?? []).map((tool) => ({
...tool,
providerType: "paperclip_plugin" as const,
risk: inferToolRisk(tool.name),
}));
}
function allTools(): ToolGatewayDescriptor[] {
return [...BUILTIN_TOOLS, ...pluginTools()];
}
async function connectedMcpToolsForCompany(companyId: string): Promise<ToolGatewayDescriptor[]> {
const rows = await db
.select({
catalogEntry: toolCatalogEntries,
connection: toolConnections,
application: toolApplications,
})
.from(toolCatalogEntries)
.innerJoin(toolConnections, eq(toolCatalogEntries.connectionId, toolConnections.id))
.innerJoin(toolApplications, eq(toolConnections.applicationId, toolApplications.id))
.where(and(
eq(toolCatalogEntries.companyId, companyId),
eq(toolCatalogEntries.entryKind, "tool"),
eq(toolCatalogEntries.status, "active"),
isNull(toolCatalogEntries.quarantinedAt),
eq(toolConnections.companyId, companyId),
inArray(toolConnections.transport, ["mcp_remote", "local_stdio"]),
eq(toolConnections.status, "active"),
eq(toolConnections.enabled, true),
inArray(toolConnections.healthStatus, ["ok", "healthy"]),
eq(toolApplications.companyId, companyId),
inArray(toolApplications.type, ["mcp_http", "mcp_stdio"]),
eq(toolApplications.status, "active"),
))
.orderBy(toolConnections.name, toolCatalogEntries.name);
const eligibleRows = rows.filter(({ connection, application }) =>
(connection.transport === "mcp_remote" && application.type === "mcp_http")
|| (connection.transport === "local_stdio" && application.type === "mcp_stdio")
);
const baseNames = eligibleRows.map(({ catalogEntry, connection, application }) => {
const applicationKey = application.applicationKey ?? null;
const connectionNamespace = `${slugSegment(applicationKey ?? connection.name ?? application.name, "mcp")}-${shortStableId(connection.id)}`;
const toolSlug = slugSegment(catalogEntry.toolName, "tool");
return `mcp.${connectionNamespace}:${toolSlug}`;
});
const baseNameCounts = baseNames.reduce<Map<string, number>>((counts, name) => {
counts.set(name, (counts.get(name) ?? 0) + 1);
return counts;
}, new Map());
return eligibleRows.map(({ catalogEntry, connection, application }, index) => {
if (connection.transport === "rest_api") {
throw new Error(`REST API connection ${connection.id} cannot be exposed through the MCP gateway`);
}
const baseName = baseNames[index]!;
const gatewayToolName = baseNameCounts.get(baseName)! > 1
? `${baseName}-${shortStableId(catalogEntry.id)}`
: baseName;
const applicationKey = application.applicationKey ?? null;
const inputSchema = catalogEntry.inputSchema ?? {};
const outputSchema = catalogEntry.outputSchema ?? null;
const annotations = catalogEntry.annotations ?? {};
const risk = riskFromCatalogEntry(catalogEntry);
const onDemandTools = readOnDemandToolsEnabled(connection);
const providerMetadata: ConnectedMcpGatewayMetadata = {
applicationId: application.id,
applicationKey,
applicationDisplayName: application.name,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
transport: connection.transport,
gatewayToolName,
upstreamToolName: catalogEntry.toolName,
catalogName: catalogEntry.name,
inputSchema,
outputSchema,
annotations,
risk: {
level: catalogEntry.riskLevel,
isReadOnly: catalogEntry.isReadOnly,
isWrite: catalogEntry.isWrite,
isDestructive: catalogEntry.isDestructive,
},
onDemandTools,
};
return {
name: gatewayToolName,
displayName: catalogEntry.title ?? catalogEntry.toolName,
description: catalogEntry.description ?? `Connected MCP tool ${catalogEntry.toolName} from ${connection.name}.`,
parametersSchema: inputSchema,
pluginId: `mcp:${applicationKey ?? application.id}`,
providerType: connection.transport === "local_stdio" ? "mcp_local_stdio" : "mcp_remote_http",
risk,
applicationId: application.id,
applicationKey,
applicationDisplayName: application.name,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
upstreamToolName: catalogEntry.toolName,
providerMetadata,
};
});
}
async function connectedMcpToolsForConnection(companyId: string, connectionId: string): Promise<ToolGatewayDescriptor[]> {
return (await connectedMcpToolsForCompany(companyId))
.filter((tool) => tool.connectionId === connectionId);
}
async function assertAgentInCompany(companyId: string, agentId: string): Promise<void> {
const [agent] = await db
.select({
companyId: agents.companyId,
})
.from(agents)
.where(eq(agents.id, agentId))
.limit(1);
if (!agent || agent.companyId !== companyId) {
throw new ToolGatewayHttpError(404, "Agent not found for company", "agent_not_found");
}
}
/**
* "Last changed by {Actor} · {relativeTime}" audit hint for the Test tab Off
* side panel. Looks across the configuration that governs this agent's access
* to the connection — the policies that matched, the profiles in effect, the
* entries that scope them to this connection, and the bindings that assigned
* those profiles to the agent — and reports the most recent edit. Only
* policies and bindings carry an actor, so the attributed agent name is best
* effort: when the latest edit was a profile/entry toggle (no actor column),
* the timestamp is still returned but the actor is null.
*/
async function summarizeAccessLastChange(input: {
companyId: string;
connectionId: string;
agentId: string;
policyIds: string[];
profileIds: string[];
}): Promise<{ lastChangedAt: string | null; lastChangedByAgentId: string | null; lastChangedByName: string | null }> {
const empty = { lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null };
const candidates: Array<{ updatedAt: Date; agentId: string | null }> = [];
if (input.policyIds.length > 0) {
const policies = await db
.select({ updatedAt: toolPolicies.updatedAt, agentId: toolPolicies.createdByAgentId })
.from(toolPolicies)
.where(and(eq(toolPolicies.companyId, input.companyId), inArray(toolPolicies.id, input.policyIds)));
candidates.push(...policies.map((row) => ({ updatedAt: row.updatedAt, agentId: row.agentId })));
}
if (input.profileIds.length > 0) {
const [profiles, entries, bindings] = await Promise.all([
db
.select({ updatedAt: toolProfiles.updatedAt })
.from(toolProfiles)
.where(and(eq(toolProfiles.companyId, input.companyId), inArray(toolProfiles.id, input.profileIds))),
db
.select({ updatedAt: toolProfileEntries.updatedAt })
.from(toolProfileEntries)
.where(and(
eq(toolProfileEntries.companyId, input.companyId),
inArray(toolProfileEntries.profileId, input.profileIds),
eq(toolProfileEntries.connectionId, input.connectionId),
)),
db
.select({ updatedAt: toolProfileBindings.updatedAt, agentId: toolProfileBindings.createdByAgentId })
.from(toolProfileBindings)
.where(and(
eq(toolProfileBindings.companyId, input.companyId),
inArray(toolProfileBindings.profileId, input.profileIds),
eq(toolProfileBindings.targetType, "agent"),
eq(toolProfileBindings.targetId, input.agentId),
)),
]);
candidates.push(...profiles.map((row) => ({ updatedAt: row.updatedAt, agentId: null })));
candidates.push(...entries.map((row) => ({ updatedAt: row.updatedAt, agentId: null })));
candidates.push(...bindings.map((row) => ({ updatedAt: row.updatedAt, agentId: row.agentId })));
}
if (candidates.length === 0) return empty;
const latest = candidates.reduce((a, b) => (b.updatedAt.getTime() > a.updatedAt.getTime() ? b : a));
let lastChangedByName: string | null = null;
if (latest.agentId) {
const [actor] = await db
.select({ name: agents.name })
.from(agents)
.where(eq(agents.id, latest.agentId))
.limit(1);
lastChangedByName = actor?.name ?? null;
}
return {
lastChangedAt: latest.updatedAt.toISOString(),
lastChangedByAgentId: latest.agentId,
lastChangedByName,
};
}
async function resolveRunContext(input: {
companyId: string;
agentId: string;
runId: string;
issueId?: string | null;
projectId?: string | null;
}): Promise<{ issueId: string | null; projectId: string | null }> {
const [run] = await db
.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.runId))
.limit(1);
if (!run || run.companyId !== input.companyId) {
throw new ToolGatewayHttpError(403, "Run does not belong to company", "run_company_mismatch");
}
if (run.agentId !== input.agentId) {
throw new ToolGatewayHttpError(403, "Run does not belong to agent", "run_agent_mismatch");
}
if (!ACTIVE_GATEWAY_RUN_STATUSES.has(run.status)) {
throw new ToolGatewayHttpError(403, "Run is not active", "run_inactive");
}
const snapshot = asRecord(run.contextSnapshot);
const snapshotIssueId = stringValue(snapshot?.issueId);
const snapshotProjectId = stringValue(snapshot?.projectId);
if ((input.issueId && snapshotIssueId && input.issueId !== snapshotIssueId)
|| (input.projectId && snapshotProjectId && input.projectId !== snapshotProjectId)) {
throw new ToolGatewayHttpError(403, "Supplied run context does not match stored heartbeat context", "run_context_mismatch");
}
const issueId = snapshotIssueId ?? input.issueId ?? null;
let projectId = snapshotProjectId ?? input.projectId ?? null;
if (issueId) {
const [issue] = await db
.select({ companyId: issues.companyId, projectId: issues.projectId })
.from(issues)
.where(eq(issues.id, issueId))
.limit(1);
if (!issue || issue.companyId !== input.companyId) {
throw new ToolGatewayHttpError(403, "Issue context is outside the run company", "run_context_mismatch");
}
if (projectId && issue.projectId && projectId !== issue.projectId) {
throw new ToolGatewayHttpError(403, "Project context does not match issue context", "run_context_mismatch");
}
projectId = projectId ?? issue.projectId;
}
if (projectId) {
const [project] = await db
.select({ companyId: projects.companyId })
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
if (!project || project.companyId !== input.companyId) {
throw new ToolGatewayHttpError(403, "Project context is outside the run company", "run_context_mismatch");
}
}
return {
issueId,
projectId,
};
}
async function writeAudit(input: {
session?: ToolGatewaySession | null;
companyId: string;
agentId: string | null;
runId: string | null;
issueId: string | null;
actorType?: LogActivityInput["actorType"];
actorId?: string;
action: string;
details: Record<string, unknown>;
}) {
const dedicatedAuditAction =
input.action === "tool_gateway.discovery"
? "discovery"
: input.action === "tool_gateway.session_revoked"
? "session_revoked"
: input.action === "tool_gateway.call_allowed" || input.action === "tool_gateway.session_created"
? "policy_decision"
: input.action === "tool_gateway.call_completed"
? "call_completed"
: input.action === "tool_gateway.call_denied" || input.action === "tool_gateway.session_rejected"
? "call_denied"
: input.action === "tool_gateway.call_deferred"
? "call_failed"
: "call_failed";
const dedicatedOutcome =
input.action === "tool_gateway.session_revoked"
? "success"
: input.action === "tool_gateway.call_denied" || input.action === "tool_gateway.session_rejected"
? "denied"
: input.action === "tool_gateway.call_deferred"
? "timeout"
: input.action === "tool_gateway.call_failed"
? "failure"
: "success";
try {
await db.insert(toolAccessAuditEvents).values({
companyId: input.companyId,
gatewayId: input.session?.gatewayId ?? (typeof input.details.gatewayId === "string" && uuidPattern.test(input.details.gatewayId) ? input.details.gatewayId : null),
gatewayTokenId: input.session?.gatewayTokenId && uuidPattern.test(input.session.gatewayTokenId)
? input.session.gatewayTokenId
: typeof input.details.gatewayTokenId === "string" && uuidPattern.test(input.details.gatewayTokenId)
? input.details.gatewayTokenId
: null,
gatewayPublicId: typeof input.details.gatewayPublicId === "string" ? input.details.gatewayPublicId : null,
clientName: typeof input.details.clientName === "string" ? input.details.clientName : null,
correlationId: typeof input.details.correlationId === "string" ? input.details.correlationId : null,
connectionId: typeof input.details.connectionId === "string" ? input.details.connectionId : null,
catalogEntryId: typeof input.details.catalogEntryId === "string" ? input.details.catalogEntryId : null,
actorType: input.actorType ?? input.session?.actorType ?? (input.agentId ? "agent" : "system"),
actorId: input.actorId ?? input.session?.actorId ?? input.agentId ?? input.session?.gatewayTokenId ?? input.companyId,
action: dedicatedAuditAction,
outcome: dedicatedOutcome,
reasonCode: typeof input.details.reasonCode === "string" ? input.details.reasonCode : null,
details: {
source: input.action,
agentId: input.agentId,
issueId: input.issueId,
projectId: input.session?.projectId ?? null,
runId: input.runId,
gatewaySessionId: input.session?.id ?? null,
gatewayId: input.session?.gatewayId ?? null,
gatewayPublicId: input.session?.gatewayPublicId ?? null,
gatewayName: input.session?.gatewayName ?? null,
gatewayTokenId: input.session?.gatewayTokenId ?? null,
...input.details,
},
});
} catch (error) {
await recordToolRuntimeAuditWriteFailure(db, input.companyId);
throw error;
}
const entityType = input.issueId ? "issue" : input.session?.gatewayId ? "tool_mcp_gateway" : "agent";
const entityId = input.issueId ?? input.session?.gatewayId ?? input.agentId ?? input.companyId;
await logActivity(db, {
companyId: input.companyId,
actorType: input.actorType ?? input.session?.actorType ?? (input.agentId ? "agent" : "system"),
actorId: input.actorId ?? input.session?.actorId ?? input.agentId ?? input.session?.gatewayTokenId ?? input.companyId,
action: input.action,
entityType,
entityId,
agentId: input.agentId,
runId: input.runId,
issueId: input.issueId,
details: {
gatewaySessionId: input.session?.id ?? null,
gatewayId: input.session?.gatewayId ?? null,
gatewayPublicId: input.session?.gatewayPublicId ?? null,
issueId: input.issueId,
projectId: input.session?.projectId ?? null,
runId: input.runId,
...input.details,
},
});
}
async function writeSessionAuthFailure(
row: typeof toolGatewaySessions.$inferSelect,
reasonCode: string,
details: Record<string, unknown> = {},
) {
const session = gatewaySessionFromRow(row);
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: "tool_gateway.session_rejected",
details: {
decision: "deny",
reasonCode,
expiresAt: session.expiresAt.toISOString(),
revokedAt: row.revokedAt?.toISOString() ?? null,
...details,
},
});
}
async function assertSessionRunIsActive(row: typeof toolGatewaySessions.$inferSelect) {
const [run] = await db
.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, row.runId))
.limit(1);
if (!run
|| run.companyId !== row.companyId
|| run.agentId !== row.agentId
|| !ACTIVE_GATEWAY_RUN_STATUSES.has(run.status)) {
await writeSessionAuthFailure(row, "session_run_inactive", {
runStatus: run?.status ?? null,
});
throw new ToolGatewayHttpError(401, "Tool gateway session is expired or invalid", "session_run_inactive");
}
}
async function getActiveSession(
sessionToken: string,
namedGatewayProtocol?: {
gatewayId?: string | null;
gatewayPublicId?: string | null;
protocolMethod: McpGatewayProtocolMethod;
callerHeaders?: Record<string, string | string[] | undefined>;
},
): Promise<ToolGatewaySession> {
const token = sessionToken.trim();
if (!token) {
throw new ToolGatewayHttpError(401, "Tool gateway session is expired or invalid", "session_invalid");
}
if (namedGatewayTokenId(token)) {
return namedGatewaySessionFromBearer({
gatewayId: namedGatewayProtocol?.gatewayId ?? null,
gatewayPublicId: namedGatewayProtocol?.gatewayPublicId ?? null,
bearerToken: token,
protocolMethod: namedGatewayProtocol?.protocolMethod ?? "tools/call",
callerHeaders: namedGatewayProtocol?.callerHeaders,
});
}
const tokenHash = hashGatewayToken(token);
const [row] = await db
.select()
.from(toolGatewaySessions)
.where(eq(toolGatewaySessions.tokenHash, tokenHash))
.limit(1);
if (!row) {
const sessionId = sessionIdFromGatewayToken(token);
if (sessionId) {
const [candidate] = await db
.select()
.from(toolGatewaySessions)
.where(eq(toolGatewaySessions.id, sessionId))
.limit(1);
if (candidate) {
await writeSessionAuthFailure(candidate, "session_invalid");
}
}
throw new ToolGatewayHttpError(401, "Tool gateway session is expired or invalid", "session_invalid");
}
if (row.revokedAt) {
await writeSessionAuthFailure(row, "session_revoked");
throw new ToolGatewayHttpError(401, "Tool gateway session is expired or invalid", "session_revoked");
}
if (row.expiresAt.getTime() <= Date.now()) {
await writeSessionAuthFailure(row, "session_expired");
throw new ToolGatewayHttpError(401, "Tool gateway session is expired or invalid", "session_expired");
}
await assertSessionRunIsActive(row);
const now = new Date();
await db
.update(toolGatewaySessions)
.set({ lastUsedAt: now, updatedAt: now })
.where(eq(toolGatewaySessions.id, row.id));
return gatewaySessionFromRow({ ...row, lastUsedAt: now, updatedAt: now });
}
function normalizeGatewayTokenActions(value: unknown): ToolMcpGatewayTokenAction[] {
const actions = Array.isArray(value)
? value.filter((action): action is ToolMcpGatewayTokenAction => action === "tools/list" || action === "tools/call")
: [];
return actions.length > 0 ? actions : ["tools/list", "tools/call"];
}
async function assertGatewayTokenAction(session: ToolGatewaySession, action: ToolMcpGatewayTokenAction) {
const allowedActions = session.gatewayTokenAllowedActions;
if (!allowedActions || allowedActions.includes(action)) return;
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: action === "tools/list" ? "tool_gateway.discovery" : "tool_gateway.call_denied",
details: {
decision: "deny",
reasonCode: "gateway_token_action_denied",
requestedAction: action,
allowedActions,
},
});
throw new ToolGatewayHttpError(403, "Gateway bearer token is not allowed to perform this MCP action", "gateway_token_action_denied", {
requestedAction: action,
});
}
async function writeToolCallEvent(input: {
invocationId?: string | null;
actionRequestId?: string | null;
session: ToolGatewaySession;
eventType: "policy_decision" | "invocation_created" | "approval_requested" | "approval_resolved" | "call_started" | "call_completed" | "call_failed" | "call_denied";
outcome: "pending" | "success" | "failure" | "denied" | "timeout" | "cancelled";
toolName: string;
policyDecision?: "allow" | "deny" | "require_approval" | "defer_runtime" | null;
reasonCode?: string | null;
argumentsSummary?: ReturnType<typeof summarizeToolValue> | null;
resultSummary?: ReturnType<typeof summarizeToolValue> | null;
metadata?: Record<string, unknown> | null;
tool?: ToolGatewayDescriptor | null;
}) {
const metadata = input.tool ? toolAuditMetadata(input.tool) : {};
await db.insert(toolCallEvents).values({
companyId: input.session.companyId,
invocationId: input.invocationId ?? null,
actionRequestId: input.actionRequestId ?? null,
eventType: input.eventType,
outcome: input.outcome,
actorType: input.session.actorType ?? (input.session.agentId ? "agent" : "system"),
actorId: input.session.actorId ?? input.session.agentId ?? input.session.gatewayTokenId ?? input.session.companyId,
agentId: input.session.agentId,
issueId: input.session.issueId,
runId: input.session.runId,
applicationId: input.tool?.applicationId ?? null,
connectionId: input.tool?.connectionId ?? null,
catalogEntryId: input.tool?.catalogEntryId ?? null,
toolName: input.toolName,
decision: input.policyDecision ?? null,
reasonCode: input.reasonCode ?? null,
matchedPolicyIds: [],
requestHash: input.argumentsSummary?.sha256 ?? null,
requestSummary: input.argumentsSummary ?? null,
resultHash: input.resultSummary?.sha256 ?? null,
resultSummary: input.resultSummary ?? null,
resultSizeBytes: input.resultSummary?.sizeBytes ?? null,
metadata: Object.keys(metadata).length > 0 || input.metadata || input.session.projectId
? {
...metadata,
gatewayId: input.session.gatewayId ?? null,
gatewayName: input.session.gatewayName ?? null,
projectId: input.session.projectId ?? null,
...(input.metadata ?? {}),
}
: null,
});
}
async function reflectToolActionInteractionLifecycle(input: {
actionRequestId: string;
status: "approved" | "executing" | "executed" | "failed" | "expired";
errorCode?: string | null;
errorMessage?: string | null;
resultSummary?: string | null;
}): Promise<void> {
const [linked] = await db
.select({
companyId: toolActionRequests.companyId,
interactionId: toolActionRequests.interactionId,
})
.from(toolActionRequests)
.where(eq(toolActionRequests.id, input.actionRequestId))
.limit(1);
if (!linked?.interactionId) return;
const [interaction] = await db
.select({
status: issueThreadInteractions.status,
result: issueThreadInteractions.result,
})
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.id, linked.interactionId),
eq(issueThreadInteractions.companyId, linked.companyId),
))
.limit(1);
if (!interaction) return;
const currentResult = interaction.result && typeof interaction.result === "object"
? interaction.result as unknown as Record<string, unknown>
: null;
const outcome = typeof currentResult?.outcome === "string"
? currentResult.outcome
: interaction.status === "accepted"
? "accepted"
: interaction.status === "rejected"
? "rejected"
: interaction.status === "expired" || input.status === "expired"
? "stale_target"
: null;
if (!outcome) return;
const now = new Date();
await db
.update(issueThreadInteractions)
.set({
...(input.status === "expired" && interaction.status === "pending"
? { status: "expired", resolvedAt: now }
: {}),
result: {
...(currentResult ?? { version: 1, outcome }),
toolAction: {
version: 1,
status: input.status,
errorCode: input.errorCode ?? null,
errorMessage: input.errorMessage ?? null,
resultSummary: input.resultSummary ?? null,
updatedAt: now.toISOString(),
},
} as unknown as NonNullable<typeof issueThreadInteractions.$inferInsert.result>,
updatedAt: now,
})
.where(eq(issueThreadInteractions.id, linked.interactionId));
}
async function approvalRequiredInstructions(issueId: string): Promise<string> {
const [issue] = await db
.select({ identifier: issues.identifier })
.from(issues)
.where(eq(issues.id, issueId))
.limit(1);
const task = issue?.identifier ?? issueId;
return `A human approval card was posted on task ${task}. Do not retry this call now. Wrap up other work and end your run noting you are waiting on tool approval (status in_review). You will be woken when it is decided; if approved, the action runs automatically and your wake includes the result.`;
}
async function throwApprovalRequired(input: {
invocationId: string;
actionRequestId: string;
interactionId?: string | null;
issueId: string;
toolName: string;
argumentsHash: string;
}): Promise<never> {
throw new ToolGatewayHttpError(409, "Tool action requires approval", "approval_required", {
invocationId: input.invocationId,
actionRequestId: input.actionRequestId,
interactionId: input.interactionId ?? null,
issueId: input.issueId,
tool: input.toolName,
argumentsHash: input.argumentsHash,
instructions: await approvalRequiredInstructions(input.issueId),
});
}
async function requestApprovalForRecordedToolCall(input: {
invocation: typeof toolInvocations.$inferSelect;
actionRequest: typeof toolActionRequests.$inferSelect | null;
session: ToolGatewaySession;
tool: ToolGatewayDescriptor;
parameters: unknown;
argumentsSummary: ReturnType<typeof summarizeToolValue>;
policyDecision: ToolAccessDecision;
}): Promise<never> {
const canonicalArguments = canonicalToolArguments(input.parameters);
const canonicalArgumentsHash = input.argumentsSummary.sha256 ?? "";
const approvalSnapshot = await connectedRemoteApprovalSnapshot(input.session, input.tool, {
requireResolvedCredentials: true,
});
if (!input.session.issueId) {
await db
.update(toolInvocations)
.set({
status: "denied",
approvalState: "required",
errorCode: "approval_path_missing",
errorMessage: "Approval-required tool calls need an issue-scoped gateway session",
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, input.invocation.id));
await writeToolCallEvent({
invocationId: input.invocation.id,
actionRequestId: input.actionRequest?.id ?? null,
session: input.session,
eventType: "call_denied",
outcome: "denied",
toolName: input.tool.name,
policyDecision: "deny",
reasonCode: "approval_path_missing",
argumentsSummary: input.argumentsSummary,
tool: input.tool,
});
throw new ToolGatewayHttpError(
409,
"Tool action requires approval, but this gateway session is not attached to an issue",
"approval_path_missing",
{
invocationId: input.invocation.id,
tool: input.tool.name,
instructions: "This session is not attached to a task, so an approval card cannot be posted. Re-run this action from a run that has the task checked out.",
},
);
}
if (!input.actionRequest) {
await db
.update(toolInvocations)
.set({
status: "denied",
errorCode: "approval_request_missing",
errorMessage: "Approval-required policy decision did not create an action request",
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, input.invocation.id));
throw new ToolGatewayHttpError(500, "Approval request was not created", "approval_request_missing", {
invocationId: input.invocation.id,
tool: input.tool.name,
});
}
const actionRequest = input.actionRequest;
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
let signedArguments: ReturnType<typeof signToolArguments>;
try {
signedArguments = signToolArguments({
invocationId: input.invocation.id,
toolName: input.tool.name,
canonicalArguments,
approvalSnapshot: approvalSnapshot ?? undefined,
executionOnApprove: true,
signingSecret: options.toolActionSigningSecret,
});
} catch (error) {
await db
.update(toolActionRequests)
.set({
status: "cancelled",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "pending")));
if (error instanceof ToolActionSigningSecretMissingError) {
await db
.update(toolInvocations)
.set({
status: "failed",
errorCode: "signing_secret_unconfigured",
errorMessage: error.message,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, input.invocation.id));
throw new ToolGatewayHttpError(500, error.message, "signing_secret_unconfigured", {
invocationId: input.invocation.id,
tool: input.tool.name,
});
}
throw error;
}
// Board-only technical detail for the formal-approval interaction (target=custom).
const detailsMarkdown = [
`Tool: \`${input.tool.name}\``,
`Risk: \`${input.tool.risk}\``,
"",
"Arguments reviewed for execution:",
"",
"```json",
input.argumentsSummary.summary,
"```",
].join("\n");
// Prosumer-facing card preview (M5/M7/M9). Respect an already-set custom preview
// (e.g. OpenClaw-supplied), otherwise emit plain language with no technical vocab.
const previewMarkdown =
actionRequest.previewMarkdown?.trim() ||
buildHumanizedActionPreview({ tool: input.tool, argumentsSummary: input.argumentsSummary });
let formalApprovalId: string | null = null;
if (toolRequiresFormalApproval(input.tool)) {
const [approval] = await db
.insert(approvals)
.values({
companyId: input.session.companyId,
type: "request_board_approval",
requestedByAgentId: input.session.agentId,
payload: {
title: `Approve high-risk tool action: ${input.tool.name}`,
summary: `${input.tool.name} is classified as ${input.tool.risk} and requires formal board approval before execution.`,
recommendedAction: "Approve only if the reviewed arguments match the intended operation.",
risks: [
"The tool may perform irreversible or externally visible side effects.",
"Execution will use the stored reviewed arguments exactly once.",
],
source: "tool_gateway",
invocationId: input.invocation.id,
actionRequestId: actionRequest.id,
tool: input.tool.name,
risk: input.tool.risk,
argumentsHash: canonicalArgumentsHash,
},
})
.returning();
formalApprovalId = approval.id;
await db
.insert(issueApprovals)
.values({
companyId: input.session.companyId,
issueId: input.session.issueId,
approvalId: approval.id,
linkedByAgentId: input.session.agentId,
})
.onConflictDoNothing();
}
const interaction = await interactions.create(
{ id: input.session.issueId, companyId: input.session.companyId },
{
kind: "request_confirmation",
idempotencyKey: `tool-action:${actionRequest.id}`,
title: "Approve tool action",
summary: `${input.tool.name} requires approval before Paperclip will execute it.`,
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: `Approve ${input.tool.name}?`,
acceptLabel: "Approve action",
rejectLabel: "Reject action",
rejectRequiresReason: false,
allowDeclineReason: true,
detailsMarkdown,
target: {
type: "custom",
key: `tool-action:${actionRequest.id}`,
revisionId: canonicalArgumentsHash,
label: input.tool.name,
},
toolAction: {
version: 1,
actionRequestId: actionRequest.id,
invocationId: input.invocation.id,
toolName: input.tool.name,
toolDisplayName: input.tool.displayName?.trim() || input.tool.name,
connectionId: input.tool.connectionId ?? null,
applicationId: input.tool.applicationId ?? null,
appDisplayName: input.tool.applicationDisplayName?.trim() || null,
risk: input.tool.risk === "destructive" ? "destructive" : "write",
previewMarkdown,
argumentsSummaryJson: input.argumentsSummary.summary,
argumentsHash: canonicalArgumentsHash,
expiresAt: expiresAt.toISOString(),
},
},
},
{ agentId: input.session.agentId },
);
// Sign the row only while it is still pending. A concurrent matching call can
// expire this row when the create runs longer than the abandon grace time.
// Guard the update with the pending status so the sign step never resurrects
// an expired row. When the guard matches no row, the row is already resolved.
// Do not emit an approval card for a dead row; throw a retriable conflict so
// the retry finds the live request or creates a fresh, signable one.
const signedRows = await db
.update(toolActionRequests)
.set({
interactionId: interaction.id,
canonicalArgumentsHash,
canonicalArgumentsSummary: input.argumentsSummary,
signedArguments,
previewMarkdown,
approvalId: formalApprovalId,
expiresAt,
updatedAt: new Date(),
})
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "pending")))
.returning({ id: toolActionRequests.id });
if (signedRows.length === 0) {
await db
.update(toolInvocations)
.set({
status: "failed",
approvalState: "expired",
errorCode: "approval_request_superseded",
errorMessage: "The approval request was resolved before it could be signed",
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, input.invocation.id));
throw new ToolGatewayHttpError(
409,
"The approval request was resolved before it could be signed",
"approval_request_superseded",
{
invocationId: input.invocation.id,
actionRequestId: actionRequest.id,
tool: input.tool.name,
instructions: "A parallel call already handled this approval. Retry the same call now to reach the live approval request.",
},
);
}
await writeToolCallEvent({
invocationId: input.invocation.id,
actionRequestId: actionRequest.id,
session: input.session,
eventType: "approval_requested",
outcome: "pending",
toolName: input.tool.name,
policyDecision: "require_approval",
reasonCode: "requires_approval_policy",
argumentsSummary: input.argumentsSummary,
metadata: { actionRequestId: actionRequest.id, interactionId: interaction.id, approvalId: formalApprovalId },
tool: input.tool,
});
await writeAudit({
session: input.session,
companyId: input.session.companyId,
agentId: input.session.agentId,
runId: input.session.runId,
issueId: input.session.issueId,
action: "tool_gateway.approval_requested",
details: {
invocationId: input.invocation.id,
actionRequestId: actionRequest.id,
interactionId: interaction.id,
approvalId: formalApprovalId,
decision: "require_approval",
reasonCode: "requires_approval_policy",
matchedPolicyIds: input.policyDecision.matchedPolicyIds,
tool: input.tool.name,
...toolAuditMetadata(input.tool),
argumentsSummary: input.argumentsSummary,
},
});
return throwApprovalRequired({
invocationId: input.invocation.id,
actionRequestId: actionRequest.id,
interactionId: interaction.id,
issueId: input.session.issueId,
toolName: input.tool.name,
argumentsHash: canonicalArgumentsHash,
});
}
function policyInputForTool(input: {
session: ToolGatewaySession;
tool: ToolGatewayDescriptor;
parameters?: unknown;
idempotencyKey?: string | null;
consumeRateLimit?: boolean;
}): ToolAccessDecisionInput {
return policyInputForAgentTool({
companyId: input.session.companyId,
agentId: input.session.agentId,
actorType: input.session.actorType,
actorId: input.session.actorId ?? input.session.gatewayTokenId ?? null,
tool: input.tool,
parameters: input.parameters,
idempotencyKey: input.idempotencyKey,
consumeRateLimit: input.consumeRateLimit,
heartbeatRunId: input.session.runId,
issueId: input.session.issueId,
projectId: input.session.projectId,
gatewayId: input.session.gatewayId ?? null,
});
}
function policyInputForAgentTool(input: {
companyId: string;
agentId: string | null;
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
tool: ToolGatewayDescriptor;
parameters?: unknown;
idempotencyKey?: string | null;
consumeRateLimit?: boolean;
heartbeatRunId?: string | null;
issueId?: string | null;
projectId?: string | null;
gatewayId?: string | null;
}): ToolAccessDecisionInput {
const actorType = input.actorType ?? (input.agentId ? "agent" : "system");
const actorId = input.actorId ?? input.agentId ?? input.gatewayId ?? input.companyId;
return {
companyId: input.companyId,
actor: {
actorType,
actorId,
agentId: input.agentId,
},
runContext: {
heartbeatRunId: input.heartbeatRunId ?? null,
issueId: input.issueId ?? null,
projectId: input.projectId ?? null,
gatewayId: input.gatewayId ?? null,
},
request: {
toolName: input.tool.name,
applicationId: input.tool.applicationId ?? null,
applicationKey: input.tool.applicationKey ?? null,
connectionId: input.tool.connectionId ?? null,
catalogEntryId: input.tool.catalogEntryId ?? null,
providerType: input.tool.providerType,
upstreamToolName: input.tool.upstreamToolName ?? input.tool.name,
riskLevel: input.tool.risk,
arguments: input.parameters ?? {},
idempotencyKey: input.idempotencyKey ?? null,
sideEffecting: input.tool.risk !== "read",
},
consumeRateLimit: input.consumeRateLimit === true,
};
}
function policyErrorStatus(decision: ToolAccessDecision) {
if (decision.decision === "rate_limited") return 429;
return 403;
}
function findStaticTool(toolName: string): ToolGatewayDescriptor {
const tool = allTools().find((candidate) => candidate.name === toolName);
if (!tool) {
throw new ToolGatewayHttpError(404, `Tool "${toolName}" not found`, "tool_not_found", { tool: toolName });
}
return tool;
}
async function findToolForSession(session: ToolGatewaySession, toolName: string): Promise<ToolGatewayDescriptor> {
const connectedTools = await connectedMcpToolsForCompany(session.companyId);
const hasOnDemandTargets = connectedTools.some(isOnDemandRemoteTool);
const virtualTools = hasOnDemandTargets ? VIRTUAL_TOOLS : [];
const tool = [...allTools(), ...connectedTools, ...virtualTools]
.filter((candidate) => session.agentId || (candidate.providerType !== "paperclip_self" && candidate.providerType !== "paperclip_plugin"))
.find((candidate) => candidate.name === toolName);
if (!tool) {
throw new ToolGatewayHttpError(404, `Tool "${toolName}" not found`, "tool_not_found", { tool: toolName });
}
return tool;
}
function virtualRunToolInput(parameters: unknown): { targetToolName: string; targetParameters: unknown } {
const params = asRecord(parameters) ?? {};
const targetToolName = typeof params.tool === "string" ? params.tool.trim() : "";
if (!targetToolName) {
throw new ToolGatewayHttpError(400, "run_tool requires a target tool name", "invalid_parameters");
}
return {
targetToolName,
targetParameters: params.arguments ?? {},
};
}
async function searchableOnDemandTools(session: ToolGatewaySession): Promise<ToolGatewayDescriptor[]> {
const tools = (await installedToolsForSession(
session,
await connectedMcpToolsForCompany(session.companyId),
)).filter(isOnDemandRemoteTool);
const decisions = await Promise.all(tools.map(async (tool) => ({
tool,
decision: await policyService.decide(policyInputForTool({ session, tool })),
})));
return decisions
.filter(({ decision }) => decision.allowed || decision.decision === "require_approval")
.map(({ tool }) => tool);
}
async function executeVirtualSearchTools(session: ToolGatewaySession, parameters: unknown) {
const params = asRecord(parameters) ?? {};
const query = typeof params.query === "string" ? params.query.trim().toLowerCase() : "";
const limit = Math.max(1, Math.min(50, Number(params.limit ?? 10) || 10));
const tools = (await searchableOnDemandTools(session))
.filter((tool) => {
if (!query) return true;
return [
tool.name,
tool.displayName,
tool.description,
tool.applicationKey,
tool.upstreamToolName,
].filter((value): value is string => typeof value === "string")
.some((value) => value.toLowerCase().includes(query));
})
.slice(0, limit)
.map((tool) => ({
name: tool.name,
displayName: tool.displayName ?? tool.name,
description: tool.description ?? null,
parametersSchema: tool.parametersSchema,
applicationId: tool.applicationId ?? null,
connectionId: tool.connectionId ?? null,
catalogEntryId: tool.catalogEntryId ?? null,
upstreamToolName: tool.upstreamToolName ?? tool.name,
risk: tool.risk,
}));
return {
content: JSON.stringify({ tools }),
data: { tools },
};
}
async function listToolsForContext(session: ToolGatewaySession): Promise<ToolGatewayDescriptor[]> {
if (session.agentId) {
await assertAgentInCompany(session.companyId, session.agentId);
}
const allConnectedTools = await installedToolsForSession(
session,
await connectedMcpToolsForCompany(session.companyId),
);
const onDemandTargets = allConnectedTools.filter(isOnDemandRemoteTool);
const tools = [...allTools(), ...allConnectedTools.filter((tool) => !isOnDemandRemoteTool(tool))].filter(
(tool) => session.agentId || (tool.providerType !== "paperclip_self" && tool.providerType !== "paperclip_plugin"),
);
const decisions = await Promise.all(tools.map(async (tool) => {
const decision = await policyService.decide(policyInputForTool({ session, tool }));
return { tool, decision };
}));
const visibleTools = decisions
.filter(({ decision }) => decision.allowed || decision.decision === "require_approval")
.map(({ tool, decision }) => decision.decision === "require_approval"
? {
...tool,
description: [tool.description?.trim(), TOOL_APPROVAL_DESCRIPTION_SUFFIX].filter(Boolean).join(" "),
}
: tool);
if (onDemandTargets.length > 0) {
const targetDecisions = await Promise.all(onDemandTargets.map(async (tool) => {
const decision = await policyService.decide(policyInputForTool({ session, tool }));
return { tool, decision };
}));
if (targetDecisions.some(({ decision }) => decision.allowed || decision.decision === "require_approval")) {
visibleTools.push(...VIRTUAL_TOOLS);
}
}
return visibleTools;
}
async function installedConnectionIdsForSession(session: ToolGatewaySession): Promise<Set<string> | null> {
if (!session.enforceConnectionInstalls || !session.agentId) return null;
const installs = await db
.select({ connectionId: toolConnectionInstalls.connectionId })
.from(toolConnectionInstalls)
.where(and(
eq(toolConnectionInstalls.companyId, session.companyId),
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${session.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${session.agentId}))`,
));
return new Set(installs.map((install) => install.connectionId));
}
async function installedToolsForSession(
session: ToolGatewaySession,
tools: ToolGatewayDescriptor[],
): Promise<ToolGatewayDescriptor[]> {
const installedConnectionIds = await installedConnectionIdsForSession(session);
if (!installedConnectionIds) return tools;
return tools.filter((tool) => !tool.connectionId || installedConnectionIds.has(tool.connectionId));
}
async function assertToolInstalledForSession(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
if (!tool.connectionId) return;
const installedConnectionIds = await installedConnectionIdsForSession(session);
if (!installedConnectionIds || installedConnectionIds.has(tool.connectionId)) return;
throw new ToolGatewayHttpError(
403,
"This connection is not installed for the current agent",
"installation_required",
{ connectionId: tool.connectionId },
);
}
async function executeBuiltinTool(session: ToolGatewaySession, tool: ToolGatewayDescriptor, parameters: unknown) {
const params = asRecord(parameters) ?? {};
if (tool.name === "mcp-remote-fixture:echo") {
return {
content: String(params.message ?? ""),
data: {
transport: "mcp_http",
spawnedLocalProcess: false,
},
};
}
if (tool.name === "mcp-remote-fixture:add") {
const a = Number(params.a);
const b = Number(params.b);
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw new ToolGatewayHttpError(400, "Parameters a and b must be finite numbers", "invalid_parameters");
}
return {
content: String(a + b),
data: {
result: a + b,
transport: "mcp_http",
spawnedLocalProcess: false,
},
};
}
if (tool.name === "mcp-remote-fixture:update_note") {
const noteId = typeof params.noteId === "string" ? params.noteId.trim() : "";
const body = typeof params.body === "string" ? params.body : "";
if (!noteId || !body) {
throw new ToolGatewayHttpError(400, "Parameters noteId and body are required", "invalid_parameters");
}
return {
content: JSON.stringify({ noteId, updated: true }),
data: {
noteId,
bodyLength: body.length,
transport: "mcp_http",
spawnedLocalProcess: false,
},
};
}
if (tool.name === "paperclip-self:list_my_issues") {
if (!session.agentId) {
throw new ToolGatewayHttpError(403, "Paperclip self tools require an agent-scoped gateway session", "agent_context_required");
}
const limit = Math.max(1, Math.min(50, Number(params.limit ?? 10) || 10));
const rows = await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
})
.from(issues)
.where(and(eq(issues.companyId, session.companyId), eq(issues.assigneeAgentId, session.agentId)))
.orderBy(desc(issues.updatedAt))
.limit(limit);
return {
content: JSON.stringify(rows),
data: { issues: rows },
};
}
if (tool.name === "paperclip-self:get_issue_context") {
if (!session.agentId) {
throw new ToolGatewayHttpError(403, "Paperclip self tools require an agent-scoped gateway session", "agent_context_required");
}
const issueId = typeof params.issueId === "string" ? params.issueId : session.issueId;
if (!issueId) {
throw new ToolGatewayHttpError(400, "issueId is required when the session is not issue-scoped", "missing_issue_id");
}
const [issue] = await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
description: issues.description,
status: issues.status,
priority: issues.priority,
})
.from(issues)
.where(and(eq(issues.companyId, session.companyId), eq(issues.id, issueId)))
.limit(1);
if (!issue) {
throw new ToolGatewayHttpError(404, "Issue not found", "issue_not_found");
}
const [planDocument] = await db
.select({
documentId: documents.id,
title: documents.title,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
.limit(1);
return {
content: JSON.stringify({ issue, planDocument: planDocument ?? null }),
data: { issue, planDocument: planDocument ?? null },
};
}
if (tool.providerType === "mcp_stdio_fixture") {
return runtimeSupervisor.useFixtureSlot(
{
companyId: session.companyId,
connectionKey: `${session.companyId}:mcp-stdio-fixture:default`,
runId: session.runId,
issueId: session.issueId,
agentId: session.agentId,
},
async (handle) => {
const priorUseCount = Number(handle.metadata.useCount ?? 0) || 0;
let counter = Number(handle.metadata.counter ?? 0) || 0;
if (tool.name === "mcp-stdio-fixture:increment_counter") {
counter += 1;
handle.metadata.counter = counter;
handle.appendLog("stdout", `increment_counter counter=${counter}`);
} else {
handle.appendLog("stdout", `runtime_status counter=${counter}`);
}
const nextUseCount = priorUseCount + 1;
return {
content: JSON.stringify({
slotId: handle.slot.id,
status: handle.slot.status,
counter,
useCount: nextUseCount,
}),
data: {
slotId: handle.slot.id,
status: handle.slot.status,
counter,
useCount: nextUseCount,
lazyStarted: priorUseCount === 0,
reusedRuntimeSlot: priorUseCount > 0,
},
};
},
);
}
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
}
function remoteEndpoint(config: Record<string, unknown>): string {
const value = config.url ?? config.endpoint ?? config.remoteUrl;
const parsed = parseRemoteHttpEndpoint(
value,
(message, code) => new ToolGatewayHttpError(422, message, code),
);
return parsed.toString();
}
function allowPrivateRemoteEndpoints() {
return options.deploymentMode !== "authenticated" || options.deploymentExposure !== "public";
}
async function assertRemoteEndpointAllowed(config: Record<string, unknown>): Promise<string> {
const endpoint = new URL(remoteEndpoint(config));
await assertPublicRemoteHttpEndpoint(
endpoint,
{ allowPrivateNetwork: allowPrivateRemoteEndpoints() },
(message, code) => new ToolGatewayHttpError(422, message, code),
);
return endpoint.toString();
}
function headerName(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(trimmed)) return null;
return trimmed.toLowerCase();
}
function headerValue(value: unknown): string | null {
if (typeof value !== "string") return null;
if (/[\r\n]/.test(value)) return null;
return value;
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}
function readHeaderPolicy(connection: typeof toolConnections.$inferSelect): HeaderPolicyConfig {
const config = asRecord(connection.config) ?? {};
const transportConfig = asRecord(connection.transportConfig) ?? {};
const rawPolicy =
asRecord(config.headerPolicy)
?? asRecord(transportConfig.headerPolicy)
?? {};
const passthrough = asRecord(rawPolicy.passthrough) ?? {};
const staticHeaders = rawPolicy.staticHeaders;
const parsedStaticHeaders: Array<{ name: string; value: string }> = [];
if (Array.isArray(staticHeaders)) {
for (const entry of staticHeaders) {
const record = asRecord(entry);
const name = headerName(record?.name);
const value = headerValue(record?.value);
if (name && value !== null) parsedStaticHeaders.push({ name, value });
}
} else {
const record = asRecord(staticHeaders);
if (record) {
for (const [rawName, rawValue] of Object.entries(record)) {
const name = headerName(rawName);
const value = headerValue(rawValue);
if (name && value !== null) parsedStaticHeaders.push({ name, value });
}
}
}
const passthroughAllowlist = [
...stringArray(passthrough.allow),
...stringArray(passthrough.allowedHeaders),
...stringArray(rawPolicy.allowedPassthroughHeaders),
]
.map(headerName)
.filter((name): name is string => Boolean(name))
.filter((name) => !isSensitivePassthroughHeader(name));
const metadata = asRecord(rawPolicy.metadata) ?? {};
const metadataHeaders = [
...stringArray(metadata.forward),
...stringArray(metadata.headers),
...stringArray(rawPolicy.forwardContextHeaders),
].filter((value): value is HeaderPolicyConfig["metadataHeaders"][number] =>
value === "company_id"
|| value === "agent_id"
|| value === "issue_id"
|| value === "project_id"
|| value === "run_id"
|| value === "gateway_session_id"
|| value === "correlation_id",
);
return {
staticHeaders: parsedStaticHeaders,
passthroughAllowlist: [...new Set(passthroughAllowlist)],
metadataHeaders: [...new Set(metadataHeaders)],
};
}
function readOnDemandToolsEnabled(connectionOrConfig: typeof toolConnections.$inferSelect | Record<string, unknown>): boolean {
const config = "config" in connectionOrConfig ? asRecord(connectionOrConfig.config) ?? {} : connectionOrConfig;
const raw = asRecord(config.onDemandTools) ?? asRecord(config.loadToolsOnDemand);
return config.onDemandTools === true || config.loadToolsOnDemand === true || raw?.enabled === true;
}
function isOnDemandRemoteTool(tool: ToolGatewayDescriptor): boolean {
const metadata = asRecord(tool.providerMetadata);
return tool.providerType === "mcp_remote_http" && metadata?.onDemandTools === true;
}
function normalizeCallerHeaders(input: ExecuteGatewayToolInput["callerHeaders"]): Record<string, string> {
const headers: Record<string, string> = {};
for (const [rawName, rawValue] of Object.entries(input ?? {})) {
const name = headerName(rawName);
if (!name) continue;
const value = Array.isArray(rawValue) ? rawValue.join(", ") : rawValue;
const normalizedValue = headerValue(value);
if (normalizedValue !== null) headers[name] = normalizedValue;
}
return headers;
}
function metadataHeadersForSession(session: ToolGatewaySession, policy: HeaderPolicyConfig): Record<string, string> {
const headers: Record<string, string> = {};
const values: Record<HeaderPolicyConfig["metadataHeaders"][number], string | null> = {
company_id: session.companyId,
agent_id: session.agentId,
issue_id: session.issueId,
project_id: session.projectId,
run_id: session.runId,
gateway_session_id: session.id,
correlation_id: randomUUID(),
};
for (const key of policy.metadataHeaders) {
const value = values[key];
if (value) headers[`x-paperclip-${key.replace(/_/g, "-")}`] = value;
}
return headers;
}
function buildRemoteHeaders(input: {
session: ToolGatewaySession;
connection: typeof toolConnections.$inferSelect;
credentialHeaders: Record<string, string>;
callerHeaders?: ExecuteGatewayToolInput["callerHeaders"];
}): { headers: Record<string, string>; summary: HeaderPolicySummary } {
const policy = readHeaderPolicy(input.connection);
const caller = normalizeCallerHeaders(input.callerHeaders);
const credentialHeaders: Record<string, string> = {};
for (const [name, value] of Object.entries(input.credentialHeaders)) {
const normalized = headerName(name);
if (normalized) credentialHeaders[normalized] = value;
}
const reservedHeaders = new Set(["accept", "content-type", "content-length", "host", "connection"]);
const managedCredentialHeaders = new Set(Object.keys(credentialHeaders));
const headers: Record<string, string> = {};
const summary: HeaderPolicySummary = {
staticHeaderNames: [],
credentialHeaderNames: Object.keys(credentialHeaders).sort(),
passthroughHeaderNames: [],
droppedPassthroughHeaderNames: [],
metadataHeaderNames: [],
collisionRules: [],
};
for (const [name, value] of Object.entries(caller)) {
if (reservedHeaders.has(name)) {
summary.droppedPassthroughHeaderNames.push(name);
summary.collisionRules.push({ header: name, source: "caller", action: "dropped_reserved_header" });
continue;
}
if (managedCredentialHeaders.has(name)) {
summary.droppedPassthroughHeaderNames.push(name);
summary.collisionRules.push({ header: name, source: "caller", action: "kept_managed_credential" });
continue;
}
if (isSensitivePassthroughHeader(name)) {
summary.droppedPassthroughHeaderNames.push(name);
summary.collisionRules.push({ header: name, source: "caller", action: "dropped_sensitive_header" });
continue;
}
if (!policy.passthroughAllowlist.includes(name)) {
summary.droppedPassthroughHeaderNames.push(name);
continue;
}
headers[name] = value;
summary.passthroughHeaderNames.push(name);
}
for (const { name, value } of policy.staticHeaders) {
if (reservedHeaders.has(name)) {
summary.collisionRules.push({ header: name, source: "static", action: "dropped_reserved_header" });
continue;
}
if (managedCredentialHeaders.has(name)) {
summary.collisionRules.push({ header: name, source: "static", action: "kept_managed_credential" });
continue;
}
if (headers[name] !== undefined) {
summary.collisionRules.push({ header: name, source: "static", action: "overrode_passthrough" });
}
headers[name] = value;
summary.staticHeaderNames.push(name);
}
const metadataHeaders = metadataHeadersForSession(input.session, policy);
for (const [name, value] of Object.entries(metadataHeaders)) {
if (reservedHeaders.has(name)) continue;
if (managedCredentialHeaders.has(name)) {
summary.collisionRules.push({ header: name, source: "metadata", action: "kept_managed_credential" });
continue;
}
if (headers[name] !== undefined) {
summary.collisionRules.push({ header: name, source: "metadata", action: "overrode_previous_header" });
}
headers[name] = value;
summary.metadataHeaderNames.push(name);
}
for (const [name, value] of Object.entries(credentialHeaders)) {
if (headers[name] !== undefined) {
summary.collisionRules.push({ header: name, source: "credential", action: "overrode_previous_header" });
}
headers[name] = value;
}
summary.staticHeaderNames.sort();
summary.passthroughHeaderNames.sort();
summary.droppedPassthroughHeaderNames = [...new Set(summary.droppedPassthroughHeaderNames)].sort();
summary.metadataHeaderNames.sort();
return { headers, summary };
}
async function markRemoteConnectionHealth(
connection: typeof toolConnections.$inferSelect,
status: "ok" | "error" | "missing_secret",
message: string | null,
) {
const now = new Date();
await db
.update(toolConnections)
.set({
healthStatus: status,
healthMessage: message,
healthCheckedAt: now,
lastHealthAt: now,
lastError: status === "ok" ? null : message,
updatedAt: now,
})
.where(eq(toolConnections.id, connection.id));
}
async function resolveCredentialHeaders(connection: typeof toolConnections.$inferSelect): Promise<Record<string, string>> {
const headers: Record<string, string> = {};
for (const ref of connection.credentialRefs ?? []) {
if (ref.placement !== "header") continue;
try {
const value = await secrets.resolveSecretValue(connection.companyId, ref.secretId, ref.version ?? "latest", {
consumerType: "tool_connection",
consumerId: connection.id,
configPath: `credentials.${ref.name}`,
actorType: "system",
});
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
} catch {
await markRemoteConnectionHealth(connection, "missing_secret", "A configured credential secret could not be resolved.");
throw new ToolGatewayHttpError(
422,
"A configured credential secret could not be resolved.",
"mcp_remote_missing_secret",
{ connectionId: connection.id, credential: ref.name },
);
}
}
return headers;
}
function credentialVersionRefHash(value: Record<string, unknown>): string {
return stableHash(value);
}
async function resolveConnectedCredentialVersion(
connection: typeof toolConnections.$inferSelect,
input: {
secretId: string;
versionSelector: SecretVersionSelector | undefined;
configPath: string;
refHash: string;
requireResolved: boolean;
},
): Promise<ConnectedCredentialVersionSnapshot> {
const versionSelector = input.versionSelector ?? "latest";
try {
const resolvedVersion = await secrets.resolveSecretVersion(connection.companyId, input.secretId, versionSelector, {
consumerType: "tool_connection",
consumerId: connection.id,
configPath: input.configPath,
actorType: "system",
});
return {
refHash: input.refHash,
versionSelector: String(versionSelector),
resolvedVersion,
};
} catch {
await markRemoteConnectionHealth(connection, "missing_secret", "A configured credential secret could not be resolved.");
if (input.requireResolved) {
throw new ToolGatewayHttpError(
422,
"A configured credential secret could not be resolved.",
"mcp_remote_missing_secret",
{ connectionId: connection.id, credential: input.configPath },
);
}
return {
refHash: input.refHash,
versionSelector: String(versionSelector),
resolvedVersion: -1,
};
}
}
async function connectedCredentialVersionSnapshots(
connection: typeof toolConnections.$inferSelect,
options: { requireResolved: boolean },
): Promise<{
headerCredentialVersions: ConnectedCredentialVersionSnapshot[];
credentialSecretVersions: ConnectedCredentialVersionSnapshot[];
}> {
const headerCredentialVersions: ConnectedCredentialVersionSnapshot[] = [];
const credentialSecretVersions: ConnectedCredentialVersionSnapshot[] = [];
for (const ref of connection.credentialRefs ?? []) {
if (ref.placement !== "header") continue;
const typedRef = ref as McpConnectionCredentialRef;
const configPath = `credentials.${typedRef.name}`;
headerCredentialVersions.push(await resolveConnectedCredentialVersion(connection, {
secretId: typedRef.secretId,
versionSelector: typedRef.version,
configPath,
refHash: credentialVersionRefHash({
kind: "header",
name: typedRef.name,
secretId: typedRef.secretId,
placement: typedRef.placement,
key: typedRef.key,
prefix: typedRef.prefix ?? null,
configPath,
}),
requireResolved: options.requireResolved,
}));
}
for (const ref of connection.credentialSecretRefs ?? []) {
const typedRef = ref as ToolCredentialSecretRef;
credentialSecretVersions.push(await resolveConnectedCredentialVersion(connection, {
secretId: typedRef.secretId,
versionSelector: typedRef.versionSelector,
configPath: typedRef.configPath,
refHash: credentialVersionRefHash({
kind: "secret_ref",
secretId: typedRef.secretId,
configPath: typedRef.configPath,
required: typedRef.required ?? true,
label: typedRef.label ?? null,
}),
requireResolved: options.requireResolved,
}));
}
return { headerCredentialVersions, credentialSecretVersions };
}
async function resolveConnectedRemoteTool(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
if (tool.providerType !== "mcp_remote_http" || !tool.connectionId || !tool.catalogEntryId) {
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
}
const [entry] = await db
.select()
.from(toolCatalogEntries)
.where(and(
eq(toolCatalogEntries.id, tool.catalogEntryId),
eq(toolCatalogEntries.companyId, session.companyId),
))
.limit(1);
if (!entry || entry.status !== "active" || entry.entryKind !== "tool") {
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
}
const [connection] = await db
.select()
.from(toolConnections)
.where(and(
eq(toolConnections.id, entry.connectionId),
eq(toolConnections.companyId, session.companyId),
))
.limit(1);
if (!connection || connection.transport !== "mcp_remote") {
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
}
if (!connection.enabled || connection.status !== "active") {
throw new ToolGatewayHttpError(403, "Connection is disabled.", "mcp_remote_connection_disabled", {
connectionId: connection.id,
});
}
return { entry, connection };
}
async function resolveConnectedLocalStdioTool(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
if (tool.providerType !== "mcp_local_stdio" || !tool.connectionId || !tool.catalogEntryId) {
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
}
const [entry] = await db
.select()
.from(toolCatalogEntries)
.where(and(
eq(toolCatalogEntries.id, tool.catalogEntryId),
eq(toolCatalogEntries.companyId, session.companyId),
))
.limit(1);
if (!entry || entry.status !== "active" || entry.entryKind !== "tool") {
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
}
const [connection] = await db
.select()
.from(toolConnections)
.where(and(
eq(toolConnections.id, entry.connectionId),
eq(toolConnections.companyId, session.companyId),
))
.limit(1);
if (!connection || connection.transport !== "local_stdio") {
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
}
if (!connection.enabled || connection.status !== "active") {
throw new ToolGatewayHttpError(403, "Connection is disabled.", "local_stdio_connection_disabled", {
connectionId: connection.id,
});
}
return { entry, connection };
}
function localStdioTemplateId(connection: typeof toolConnections.$inferSelect): string {
const config = asRecord(connection.config) ?? {};
const templateId = config.templateId;
if (typeof templateId !== "string" || templateId.trim().length === 0) {
throw new ToolGatewayHttpError(422, "Local stdio MCP connection requires an approved templateId", "local_stdio_template_missing", {
connectionId: connection.id,
});
}
return templateId.trim();
}
async function resolveLocalStdioRuntimeTemplate(connection: typeof toolConnections.$inferSelect): Promise<LocalStdioRuntimeTemplate> {
const templateId = localStdioTemplateId(connection);
const builtIn = BUILTIN_LOCAL_STDIO_RUNTIME_TEMPLATES[templateId];
if (builtIn) return { templateId, ...builtIn };
const [template] = await db
.select()
.from(toolStdioCommandTemplates)
.where(and(
eq(toolStdioCommandTemplates.companyId, connection.companyId),
eq(toolStdioCommandTemplates.templateKey, templateId),
))
.limit(1);
if (!template || template.status !== "active") {
throw new ToolGatewayHttpError(422, "Local stdio MCP connection requires an active approved template", "local_stdio_template_invalid", {
connectionId: connection.id,
templateId,
});
}
return {
templateId,
command: template.command,
args: template.args ?? [],
envKeys: template.envKeys ?? [],
};
}
function localStdioEnvironment(connection: typeof toolConnections.$inferSelect, template: LocalStdioRuntimeTemplate): NodeJS.ProcessEnv {
const config = asRecord(connection.config) ?? {};
const configEnv = asRecord(config.env) ?? {};
const env: NodeJS.ProcessEnv = {};
for (const key of ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"]) {
const value = process.env[key];
if (typeof value === "string" && value.length > 0) {
env[key] = value;
}
}
for (const key of template.envKeys) {
const configured = configEnv[key];
if (typeof configured === "string") {
env[key] = configured;
}
}
return env;
}
function stdioProtocolError(message: string, details: Record<string, unknown> = {}) {
return new ToolGatewayHttpError(502, message, "local_stdio_protocol_error", details);
}
async function callLocalStdioMcp(input: {
connection: typeof toolConnections.$inferSelect;
entry: typeof toolCatalogEntries.$inferSelect;
template: LocalStdioRuntimeTemplate;
parameters: unknown;
timeoutMs: number;
}): Promise<unknown> {
if (!input.template.command) {
throw new ToolGatewayHttpError(
501,
"Local stdio template does not define an executable command",
"local_stdio_command_unavailable",
{ connectionId: input.connection.id, templateId: input.template.templateId },
);
}
const child = spawn(input.template.command, input.template.args, {
env: localStdioEnvironment(input.connection, input.template),
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let nextId = 1;
const pending = new Map<number, {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}>();
const timer = setTimeout(() => {
child.kill("SIGTERM");
for (const { reject } of pending.values()) {
reject(new ToolGatewayHttpError(504, "Local stdio MCP tool call timed out", "tool_timeout", {
connectionId: input.connection.id,
catalogEntryId: input.entry.id,
}));
}
pending.clear();
}, input.timeoutMs);
timer.unref?.();
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
let newline = stdout.indexOf("\n");
while (newline >= 0) {
const line = stdout.slice(0, newline).trim();
stdout = stdout.slice(newline + 1);
if (line) {
try {
const message = JSON.parse(line) as Record<string, unknown>;
const id = typeof message.id === "number" ? message.id : null;
if (id !== null && pending.has(id)) {
const waiter = pending.get(id)!;
pending.delete(id);
if (message.error !== undefined) {
waiter.reject(stdioProtocolError("Local stdio MCP server returned a JSON-RPC error", {
connectionId: input.connection.id,
catalogEntryId: input.entry.id,
error: message.error,
}));
} else {
waiter.resolve(message.result);
}
}
} catch {
for (const { reject } of pending.values()) {
reject(stdioProtocolError("Local stdio MCP server returned invalid JSON", {
connectionId: input.connection.id,
catalogEntryId: input.entry.id,
}));
}
pending.clear();
}
}
newline = stdout.indexOf("\n");
}
});
child.stderr.on("data", (chunk: string) => {
stderr = `${stderr}${chunk}`.slice(-4_000);
});
const exitPromise = new Promise<void>((resolve, reject) => {
child.on("error", (error) => {
const gatewayError = new ToolGatewayHttpError(502, "Local stdio MCP command failed to start", "local_stdio_spawn_failed", {
connectionId: input.connection.id,
templateId: input.template.templateId,
message: error.message,
});
for (const { reject: rejectPending } of pending.values()) {
rejectPending(gatewayError);
}
pending.clear();
reject(gatewayError);
});
child.on("exit", (code, signal) => {
if (pending.size === 0) {
resolve();
return;
}
for (const { reject: rejectPending } of pending.values()) {
rejectPending(new ToolGatewayHttpError(502, "Local stdio MCP command exited before responding", "local_stdio_process_exited", {
connectionId: input.connection.id,
catalogEntryId: input.entry.id,
code,
signal,
stderr,
}));
}
pending.clear();
resolve();
});
});
const request = (method: string, params: Record<string, unknown>) => {
const id = nextId;
nextId += 1;
const promise = new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
});
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
return promise;
};
try {
await request("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "paperclip-tool-gateway", version: "0.3.1" },
});
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`);
return await request("tools/call", {
name: input.entry.toolName,
arguments: input.parameters ?? {},
});
} finally {
clearTimeout(timer);
child.stdin.end();
child.kill("SIGTERM");
await exitPromise.catch(() => undefined);
}
}
async function connectedRemoteApprovalSnapshot(
session: ToolGatewaySession,
tool: ToolGatewayDescriptor,
options: { requireResolvedCredentials?: boolean } = {},
): Promise<Record<string, unknown> | null> {
if (tool.providerType !== "mcp_remote_http" || !tool.connectionId || !tool.catalogEntryId) {
return null;
}
const [row] = await db
.select({
entry: toolCatalogEntries,
connection: toolConnections,
application: toolApplications,
})
.from(toolCatalogEntries)
.innerJoin(toolConnections, eq(toolCatalogEntries.connectionId, toolConnections.id))
.innerJoin(toolApplications, eq(toolConnections.applicationId, toolApplications.id))
.where(and(
eq(toolCatalogEntries.id, tool.catalogEntryId),
eq(toolCatalogEntries.companyId, session.companyId),
eq(toolConnections.id, tool.connectionId),
eq(toolConnections.companyId, session.companyId),
eq(toolApplications.companyId, session.companyId),
))
.limit(1);
if (!row) return null;
const credentialVersions = await connectedCredentialVersionSnapshots(row.connection, {
requireResolved: options.requireResolvedCredentials === true,
});
return {
applicationId: row.application.id,
applicationKey: row.application.applicationKey ?? null,
applicationStatus: row.application.status,
applicationType: row.application.type,
connectionId: row.connection.id,
connectionStatus: row.connection.status,
connectionEnabled: row.connection.enabled,
connectionTransport: row.connection.transport,
connectionConfigHash: stableHash(row.connection.config ?? {}),
connectionTransportConfigHash: stableHash(row.connection.transportConfig ?? {}),
credentialRefsHash: stableHash(row.connection.credentialRefs ?? []),
credentialSecretRefsHash: stableHash(row.connection.credentialSecretRefs ?? []),
headerCredentialVersions: credentialVersions.headerCredentialVersions,
credentialSecretVersions: credentialVersions.credentialSecretVersions,
catalogEntryId: row.entry.id,
catalogStatus: row.entry.status,
catalogEntryKind: row.entry.entryKind,
catalogVersionHash: row.entry.versionHash,
catalogSchemaHash: row.entry.schemaHash ?? null,
upstreamToolName: row.entry.toolName,
providerType: tool.providerType,
gatewayToolName: tool.name,
riskLevel: tool.risk,
};
}
function responseTooLargeError() {
return new ToolGatewayHttpError(
502,
"Remote MCP response exceeded the gateway size limit",
"mcp_remote_response_too_large",
{ maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES },
);
}
async function readBoundedRemoteResponse(response: Response): Promise<string> {
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_REMOTE_MCP_RESPONSE_BYTES) {
throw responseTooLargeError();
}
const body = await response.text();
if (Buffer.byteLength(body, "utf8") > MAX_REMOTE_MCP_RESPONSE_BYTES) {
throw responseTooLargeError();
}
return body;
}
function malformedRemoteMcpResponse(): ToolGatewayHttpError {
return new ToolGatewayHttpError(
502,
"Remote MCP server returned a malformed tools/call response",
"remote_mcp_malformed_response",
);
}
type McpElicitationRequest = {
message: string;
requestedSchema: Record<string, unknown> | null;
raw: Record<string, unknown>;
};
function extractMcpElicitationRequest(value: unknown): McpElicitationRequest | null {
const record = asRecord(value);
if (!record) return null;
const meta = asRecord(record._meta);
const candidate =
(record.method === "elicitation/create" ? asRecord(record.params) : null)
?? asRecord(record.elicitation)
?? asRecord(record.elicitationRequest)
?? asRecord(meta?.elicitation)
?? asRecord(meta?.elicitationRequest);
if (!candidate) return null;
const message =
stringValue(candidate.message)
?? stringValue(candidate.prompt)
?? stringValue(candidate.title)
?? "The MCP tool needs more information before it can continue.";
const requestedSchema = asRecord(candidate.requestedSchema ?? candidate.schema ?? candidate.inputSchema);
return { message, requestedSchema, raw: candidate };
}
function enumOptions(values: unknown[]): Array<{ id: string; label: string }> {
return values.slice(0, 10).map((value, index) => {
const label = typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? String(value)
: `Option ${index + 1}`;
return {
id: slugSegment(label, `option-${index + 1}`).slice(0, 120),
label: label.slice(0, 120),
};
});
}
function elicitationQuestions(request: McpElicitationRequest) {
const schema = request.requestedSchema;
const properties = asRecord(schema?.properties);
const required = Array.isArray(schema?.required)
? new Set(schema.required.filter((item): item is string => typeof item === "string"))
: new Set<string>();
const questions: Array<{
id: string;
prompt: string;
helpText?: string | null;
selectionMode: "single" | "multi";
required?: boolean;
options: Array<{ id: string; label: string; description?: string | null }>;
}> = [];
if (properties) {
for (const [key, rawProperty] of Object.entries(properties)) {
if (questions.length >= 10) break;
const property = asRecord(rawProperty) ?? {};
const enumValues = Array.isArray(property.enum) ? property.enum : [];
const options = enumValues.length > 0
? enumOptions(enumValues)
: [{ id: "answer", label: "Provide answer" }];
questions.push({
id: key.slice(0, 120),
prompt: (stringValue(property.title) ?? stringValue(property.description) ?? key).slice(0, 500),
helpText: enumValues.length > 0 ? null : "Use Other to enter the requested value.",
selectionMode: "single",
required: required.has(key),
options,
});
}
}
if (questions.length > 0) return questions;
return [{
id: "response",
prompt: request.message.slice(0, 500),
helpText: "Use Other to enter the requested response.",
selectionMode: "single" as const,
required: true,
options: [{ id: "answer", label: "Provide response" }],
}];
}
async function requestElicitationForRecordedToolCall(input: {
session: ToolGatewaySession;
tool: ToolGatewayDescriptor;
invocationId: string;
request: McpElicitationRequest;
}): Promise<never> {
if (!input.session.issueId) {
throw new ToolGatewayHttpError(
409,
"MCP elicitation is not supported for non-interactive gateway clients",
"elicitation_not_supported",
{ invocationId: input.invocationId, tool: input.tool.name },
);
}
const interaction = await interactions.create(
{ id: input.session.issueId, companyId: input.session.companyId },
{
kind: "ask_user_questions",
idempotencyKey: `mcp-elicitation:${input.invocationId}`,
title: "Tool needs input",
summary: `${input.tool.name} asked for more information before it can continue.`,
continuationPolicy: "wake_assignee",
payload: {
version: 1,
title: input.request.message.slice(0, 240),
submitLabel: "Send response",
questions: elicitationQuestions(input.request),
},
},
{ agentId: input.session.agentId },
);
const now = new Date();
await db
.update(toolInvocations)
.set({
status: "awaiting_approval",
errorCode: "elicitation_required",
errorMessage: "Remote MCP tool requested elicitation; Paperclip created an issue interaction for the response.",
updatedAt: now,
})
.where(eq(toolInvocations.id, input.invocationId));
await writeToolCallEvent({
invocationId: input.invocationId,
session: input.session,
eventType: "call_failed",
outcome: "pending",
toolName: input.tool.name,
policyDecision: "defer_runtime",
reasonCode: "elicitation_required",
metadata: { interactionId: interaction.id, elicitation: { message: input.request.message, requestedSchema: input.request.requestedSchema } },
tool: input.tool,
});
await writeAudit({
session: input.session,
companyId: input.session.companyId,
agentId: input.session.agentId,
runId: input.session.runId,
issueId: input.session.issueId,
action: "tool_gateway.elicitation_requested",
details: {
invocationId: input.invocationId,
interactionId: interaction.id,
decision: "defer_runtime",
reasonCode: "elicitation_required",
tool: input.tool.name,
...toolAuditMetadata(input.tool),
},
});
throw new ToolGatewayHttpError(409, "MCP tool requested additional input", "elicitation_required", {
invocationId: input.invocationId,
interactionId: interaction.id,
tool: input.tool.name,
});
}
function normalizeMcpContent(content: unknown): string {
if (!Array.isArray(content)) throw malformedRemoteMcpResponse();
return content.map((item) => {
const record = asRecord(item);
if (!record || typeof record.type !== "string") throw malformedRemoteMcpResponse();
if (record.type === "text") {
if (typeof record.text !== "string") throw malformedRemoteMcpResponse();
return record.text;
}
return JSON.stringify(record);
}).join("\n");
}
function normalizeMcpToolResult(
result: unknown,
transport: "mcp_http" | "local_stdio" = "mcp_http",
spawnedLocalProcess = false,
) {
const record = asRecord(result);
if (!record) throw malformedRemoteMcpResponse();
return {
content: normalizeMcpContent(record.content),
data: {
content: record.content,
structuredContent: record.structuredContent ?? null,
isError: record.isError === true,
transport,
spawnedLocalProcess,
},
...(record.isError === true ? { error: "MCP tool returned an error result" } : {}),
};
}
async function executeRemoteHttpTool(
session: ToolGatewaySession,
tool: ToolGatewayDescriptor,
parameters: unknown,
ms: number,
invocationId: string,
callerHeaders?: ExecuteGatewayToolInput["callerHeaders"],
): Promise<RemoteHttpExecutionResult> {
const { entry, connection } = await resolveConnectedRemoteTool(session, tool);
const endpoint = await assertRemoteEndpointAllowed(connection.config ?? {});
const credentialHeaders = await resolveCredentialHeaders(connection);
const { headers, summary: headerSummary } = buildRemoteHeaders({
session,
connection,
credentialHeaders,
callerHeaders,
});
const requestId = `paperclip-tool-${randomUUID()}`;
const execution: RemoteHttpExecutionAudit = {
transport: "mcp_remote",
request: {
protocol: "MCP JSON-RPC 2.0",
httpMethod: "POST",
endpoint: auditSafeEndpoint(endpoint),
mcpMethod: "tools/call",
requestId,
upstreamToolName: entry.toolName,
dispatched: true,
},
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
timer.unref?.();
try {
const response = await fetch(endpoint, {
method: "POST",
redirect: "manual",
// MCP Streamable HTTP requires the Accept header advertising both a JSON
// body and an SSE stream; spec-compliant servers 406 without it.
headers: mcpHttpRequestHeaders(headers),
signal: controller.signal,
body: JSON.stringify({
jsonrpc: "2.0",
id: requestId,
method: "tools/call",
params: {
name: entry.toolName,
arguments: parameters ?? {},
},
}),
});
const body = await readBoundedRemoteResponse(response);
execution.response = {
httpStatus: response.status,
contentType: response.headers.get("content-type"),
bodySizeBytes: Buffer.byteLength(body, "utf8"),
upstreamRequestId:
response.headers.get("x-request-id")
?? response.headers.get("x-zapier-request-id")
?? response.headers.get("traceparent"),
};
if (!response.ok) {
await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error.");
throw new ToolGatewayHttpError(502, "Remote MCP server returned an HTTP error", "mcp_remote_status", {
status: response.status,
connectionId: connection.id,
catalogEntryId: entry.id,
execution,
});
}
let payload: unknown;
try {
payload = parseMcpHttpResponseBody(body, response.headers.get("content-type"));
} catch {
await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned invalid JSON.");
throw new ToolGatewayHttpError(502, "Remote MCP server returned invalid JSON", "mcp_remote_invalid_json", {
connectionId: connection.id,
catalogEntryId: entry.id,
execution,
});
}
const payloadRecord = asRecord(payload);
if (!payloadRecord) throw malformedRemoteMcpResponse();
const topLevelElicitation = extractMcpElicitationRequest(payloadRecord);
if (topLevelElicitation) {
await requestElicitationForRecordedToolCall({ session, tool, invocationId, request: topLevelElicitation });
}
if (payloadRecord.error !== undefined) {
const errorRecord = asRecord(payloadRecord.error);
await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned a JSON-RPC error.");
throw new ToolGatewayHttpError(502, "Remote MCP server returned an error", "remote_mcp_error", {
code: typeof errorRecord?.code === "number" ? errorRecord.code : null,
connectionId: connection.id,
catalogEntryId: entry.id,
execution,
});
}
if (!Object.prototype.hasOwnProperty.call(payloadRecord, "result")) {
throw malformedRemoteMcpResponse();
}
const resultElicitation = extractMcpElicitationRequest(payloadRecord.result);
if (resultElicitation) {
await requestElicitationForRecordedToolCall({ session, tool, invocationId, request: resultElicitation });
}
const result = normalizeMcpToolResult(payloadRecord.result);
await markRemoteConnectionHealth(connection, "ok", "Remote MCP server responded to tools/call.");
return { result, headerSummary, execution };
} catch (error) {
if (error instanceof ToolGatewayHttpError) {
throw new ToolGatewayHttpError(error.status, error.message, error.reasonCode, {
...error.details,
execution: error.details.execution ?? execution,
});
}
if (error instanceof Error && error.name === "AbortError") {
await markRemoteConnectionHealth(connection, "error", "Remote MCP tool call timed out.");
throw new ToolGatewayHttpError(504, "Remote MCP tool call timed out", "tool_timeout", {
connectionId: connection.id,
catalogEntryId: entry.id,
execution,
});
}
await markRemoteConnectionHealth(connection, "error", "Remote MCP tool call failed.");
throw new ToolGatewayHttpError(502, "Remote MCP tool call failed", "mcp_remote_fetch_failed", {
connectionId: connection.id,
catalogEntryId: entry.id,
execution,
});
} finally {
clearTimeout(timer);
}
}
async function executeLocalStdioTool(
session: ToolGatewaySession,
tool: ToolGatewayDescriptor,
parameters: unknown,
ms: number,
): Promise<RemoteHttpExecutionResult> {
const { entry, connection } = await resolveConnectedLocalStdioTool(session, tool);
const template = await resolveLocalStdioRuntimeTemplate(connection);
const result = await runtimeSupervisor.useConnectionSlot(
{
companyId: session.companyId,
applicationId: tool.applicationId ?? null,
connectionId: connection.id,
connectionKey: `mcp:${session.companyId}:${connection.id}`,
runId: session.runId,
issueId: session.issueId,
agentId: session.agentId,
commandTemplateKey: template.templateId,
metadata: {
fixture: "connected-local-stdio",
applicationId: tool.applicationId ?? null,
connectionId: connection.id,
catalogEntryId: entry.id,
},
},
async (handle) => {
handle.appendLog("stdout", `calling ${entry.toolName}`);
return callLocalStdioMcp({
connection,
entry,
template,
parameters,
timeoutMs: ms,
});
},
);
return {
result: normalizeMcpToolResult(result, "local_stdio", true),
};
}
async function runWithTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new ToolGatewayHttpError(504, "Tool execution timed out", "tool_timeout"));
}, ms);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function gatewayEndpointPath(gatewayPublicId: string) {
return `/mcp/gateways/${gatewayPublicId}`;
}
function gatewayClientSnippets(gateway: Pick<typeof toolMcpGateways.$inferSelect, "gatewayPublicId" | "name">): ToolMcpGatewayClientSnippet[] {
const endpoint = gatewayEndpointPath(gateway.gatewayPublicId);
const bearerPlaceholder = "pcgw_...";
return [
{
client: "cursor",
label: "Cursor",
config: { mcpServers: { [gateway.name]: { url: endpoint, headers: { Authorization: `Bearer ${bearerPlaceholder}` } } } },
notes: ["Use the full Paperclip origin before the endpoint path."],
},
{
client: "claude_desktop",
label: "Claude Desktop",
config: { mcpServers: { [gateway.name]: { url: endpoint, headers: { Authorization: `Bearer ${bearerPlaceholder}` } } } },
notes: ["Recent Claude Desktop builds support remote HTTP MCP servers."],
},
{
client: "vscode",
label: "VS Code",
config: { servers: { [gateway.name]: { type: "http", url: endpoint, headers: { Authorization: `Bearer ${bearerPlaceholder}` } } } },
notes: ["Place this under your MCP extension or editor MCP settings."],
},
{
client: "claude_code",
label: "Claude Code",
config: { command: "claude", args: ["mcp", "add", gateway.name, endpoint, "--header", `Authorization: Bearer ${bearerPlaceholder}`] },
notes: ["Use the equivalent remote HTTP MCP add command for your installed version."],
},
{
client: "opencode",
label: "OpenCode",
config: { mcp: { [gateway.name]: { url: endpoint, headers: { Authorization: `Bearer ${bearerPlaceholder}` } } } },
notes: ["Use the full Paperclip origin before the endpoint path."],
},
];
}
function toGateway(row: typeof toolMcpGateways.$inferSelect): ToolMcpGateway {
return {
id: row.id,
companyId: row.companyId,
gatewayPublicId: row.gatewayPublicId,
name: row.name,
displaySlug: row.displaySlug || row.slug,
slug: row.slug,
description: row.description,
status: row.status,
profileId: row.profileId,
defaultProfileMode: row.defaultProfileMode,
contextScopeType: row.contextScopeType,
contextScopeId: row.contextScopeId,
agentId: row.agentId,
projectId: row.projectId,
issueId: row.issueId,
approvalIssueId: row.approvalIssueId,
endpointPath: gatewayEndpointPath(row.gatewayPublicId),
authConfig: row.authConfig,
headerPolicy: row.headerPolicy,
metadataPolicy: row.metadataPolicy,
onDemandToolsConfig: row.onDemandToolsConfig,
metadata: row.metadata ?? {},
createdByAgentId: row.createdByAgentId,
createdByUserId: row.createdByUserId,
archivedAt: row.archivedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function toGatewayToken(row: typeof toolMcpGatewayTokens.$inferSelect): ToolMcpGatewayToken {
return {
id: row.id,
companyId: row.companyId,
gatewayId: row.gatewayId,
name: row.name,
tokenPrefix: row.tokenPrefix,
subjectType: row.subjectType,
subjectId: row.subjectId,
clientLabel: row.clientLabel,
ownerNote: row.ownerNote,
allowedActions: row.allowedActions,
expiresAt: row.expiresAt,
expiryOverrideReason: row.expiryOverrideReason,
expiryOverrideByUserId: row.expiryOverrideByUserId,
expiryOverrideByAgentId: row.expiryOverrideByAgentId,
expiryOverrideAt: row.expiryOverrideAt,
lastUsedAt: row.lastUsedAt,
revokedAt: row.revokedAt,
createdByAgentId: row.createdByAgentId,
createdByUserId: row.createdByUserId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async function getGatewayWithTokens(companyId: string, gatewayId: string): Promise<ToolMcpGatewayWithTokens> {
const [gateway] = await db
.select()
.from(toolMcpGateways)
.where(and(eq(toolMcpGateways.companyId, companyId), eq(toolMcpGateways.id, gatewayId)))
.limit(1);
if (!gateway) {
throw new ToolGatewayHttpError(404, "MCP gateway not found", "gateway_not_found");
}
const tokens = await db
.select()
.from(toolMcpGatewayTokens)
.where(and(eq(toolMcpGatewayTokens.companyId, companyId), eq(toolMcpGatewayTokens.gatewayId, gatewayId)))
.orderBy(desc(toolMcpGatewayTokens.createdAt));
return {
...toGateway(gateway),
tokens: tokens.map(toGatewayToken),
clientSnippets: gatewayClientSnippets(gateway),
};
}
async function assertGatewayContext(input: {
companyId: string;
profileId?: string | null;
agentId?: string | null;
projectId?: string | null;
issueId?: string | null;
}) {
if (input.profileId) {
const [profile] = await db
.select({ id: toolProfiles.id })
.from(toolProfiles)
.where(and(eq(toolProfiles.companyId, input.companyId), eq(toolProfiles.id, input.profileId)))
.limit(1);
if (!profile) throw new ToolGatewayHttpError(422, "Gateway profile must belong to the company", "gateway_profile_invalid");
}
if (input.agentId) await assertAgentInCompany(input.companyId, input.agentId);
if (input.projectId) {
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.companyId, input.companyId), eq(projects.id, input.projectId)))
.limit(1);
if (!project) throw new ToolGatewayHttpError(422, "Gateway project must belong to the company", "gateway_project_invalid");
}
if (input.issueId) {
const [issue] = await db
.select({ id: issues.id, projectId: issues.projectId })
.from(issues)
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId)))
.limit(1);
if (!issue) throw new ToolGatewayHttpError(422, "Gateway issue must belong to the company", "gateway_issue_invalid");
if (input.projectId && issue.projectId && issue.projectId !== input.projectId) {
throw new ToolGatewayHttpError(422, "Gateway issue must belong to the selected project", "gateway_issue_project_mismatch");
}
}
}
async function findGatewayForProtocolLocator(input: { gatewayId?: string | null; gatewayPublicId?: string | null }) {
if (input.gatewayId) {
const [gateway] = await db
.select()
.from(toolMcpGateways)
.where(eq(toolMcpGateways.id, input.gatewayId))
.limit(1);
return gateway ?? null;
}
if (input.gatewayPublicId) {
const [gateway] = await db
.select()
.from(toolMcpGateways)
.where(eq(toolMcpGateways.gatewayPublicId, input.gatewayPublicId))
.limit(1);
return gateway ?? null;
}
return null;
}
function protocolLimiterKeyClass(method: McpGatewayProtocolMethod) {
return method === "initialize" ? "session_setup" : method;
}
async function writeProtocolRateLimitAudit(input: {
session: ToolGatewaySession;
method: McpGatewayProtocolMethod;
limiterKeyClass: "token" | "gateway";
count: number;
limit: McpGatewayRateLimitConfig;
retryAfterMs: number;
clientMetadata: ReturnType<typeof safeClientMetadata>;
}) {
await writeAudit({
session: input.session,
companyId: input.session.companyId,
agentId: input.session.agentId,
runId: input.session.runId,
issueId: input.session.issueId,
action: "tool_gateway.call_denied",
details: {
decision: "rate_limited",
reasonCode: "gateway_rate_limited",
reasonText: "The MCP gateway request was rate limited before the protocol action ran.",
limiterKeyClass: input.limiterKeyClass,
protocolMethod: input.method,
protocolAction: protocolLimiterKeyClass(input.method),
requestCount: input.count,
limit: input.limit.max,
windowMs: input.limit.windowMs,
retryAfterMs: input.retryAfterMs,
gatewayId: input.session.gatewayId ?? null,
gatewayPublicId: input.session.gatewayPublicId ?? null,
gatewayTokenId: input.session.gatewayTokenId ?? null,
tokenPrefix: typeof input.session.gatewayTokenId === "string" ? `pcgw_${input.session.gatewayTokenId.slice(0, 8)}` : null,
...input.clientMetadata,
},
});
}
async function assertNamedGatewayProtocolLimit(
session: ToolGatewaySession,
method: McpGatewayProtocolMethod,
clientMetadata: ReturnType<typeof safeClientMetadata>,
) {
const action = protocolLimiterKeyClass(method);
const tokenLimit = method === "initialize" ? protocolLimits.sessionSetup : protocolLimits.tokenRequests;
const tokenKey = `mcp_gateway_protocol:token:${session.gatewayTokenId ?? session.actorId ?? "unknown"}:${action}`;
const tokenState = await consumeProtocolRateLimit({ companyId: session.companyId, counterKey: tokenKey, config: tokenLimit });
if (tokenState.limited) {
await writeProtocolRateLimitAudit({
session,
method,
limiterKeyClass: "token",
count: tokenState.count,
limit: tokenLimit,
retryAfterMs: tokenState.retryAfterMs,
clientMetadata,
});
throw new ToolGatewayHttpError(429, "MCP gateway request was rate limited", "gateway_rate_limited", {
reasonText: "The MCP gateway request was rate limited before the protocol action ran.",
limiterKeyClass: "token",
protocolMethod: method,
retryAfterMs: tokenState.retryAfterMs,
});
}
const gatewayLimit = method === "initialize" ? protocolLimits.sessionSetup : protocolLimits.gatewayRequests;
const gatewayKey = `mcp_gateway_protocol:gateway:${session.gatewayId ?? session.gatewayPublicId ?? "unknown"}:${action}`;
const gatewayState = await consumeProtocolRateLimit({ companyId: session.companyId, counterKey: gatewayKey, config: gatewayLimit });
if (gatewayState.limited) {
await writeProtocolRateLimitAudit({
session,
method,
limiterKeyClass: "gateway",
count: gatewayState.count,
limit: gatewayLimit,
retryAfterMs: gatewayState.retryAfterMs,
clientMetadata,
});
throw new ToolGatewayHttpError(429, "MCP gateway request was rate limited", "gateway_rate_limited", {
reasonText: "The MCP gateway request was rate limited before the protocol action ran.",
limiterKeyClass: "gateway",
protocolMethod: method,
retryAfterMs: gatewayState.retryAfterMs,
});
}
}
async function recordNamedGatewayAuthFailure(input: {
gatewayId?: string | null;
gatewayPublicId?: string | null;
bearerToken: string;
reasonCode: string;
clientMetadata: ReturnType<typeof safeClientMetadata>;
}): Promise<never> {
const token = input.bearerToken.trim();
const tokenId = namedGatewayTokenId(token);
const gatewayKey = input.gatewayId ? `id:${input.gatewayId}` : `public:${input.gatewayPublicId ?? "unknown"}`;
const tokenKey = tokenId ? `id:${tokenId}` : `hash:${hashGatewayToken(token).slice(0, 24)}`;
const gateway = await findGatewayForProtocolLocator(input);
if (!gateway) {
throw new ToolGatewayHttpError(401, "Gateway bearer token is expired or invalid", input.reasonCode);
}
const gatewayState = await consumeProtocolRateLimit({
companyId: gateway.companyId,
counterKey: `mcp_gateway_auth_failure:gateway:${gatewayKey}`,
config: protocolLimits.authFailures,
});
const tokenState = await consumeProtocolRateLimit({
companyId: gateway.companyId,
counterKey: `mcp_gateway_auth_failure:token:${gatewayKey}:${tokenKey}`,
config: protocolLimits.authFailures,
});
const limited = gatewayState.limited || tokenState.limited;
if (limited) {
const limiterKeyClass = gatewayState.limited ? "gateway_auth" : "token_auth";
const count = gatewayState.limited ? gatewayState.count : tokenState.count;
const retryAfterMs = gatewayState.limited ? gatewayState.retryAfterMs : tokenState.retryAfterMs;
await writeAudit({
session: {
id: `gateway:${gateway.id}`,
token: "",
companyId: gateway.companyId,
agentId: gateway.agentId,
runId: null,
issueId: gateway.issueId,
projectId: gateway.projectId,
gatewayId: gateway.id,
gatewayPublicId: gateway.gatewayPublicId,
gatewayName: gateway.name,
gatewayTokenId: null,
actorType: "system",
actorId: gateway.id,
createdAt: new Date(),
expiresAt: new Date(),
},
companyId: gateway.companyId,
agentId: gateway.agentId,
runId: null,
issueId: gateway.issueId,
action: "tool_gateway.session_rejected",
details: {
decision: "deny",
reasonCode: "gateway_auth_throttled",
reasonText: "The MCP gateway authentication attempt was throttled after repeated failures.",
limiterKeyClass,
failedReasonCode: input.reasonCode,
requestCount: count,
limit: protocolLimits.authFailures.max,
windowMs: protocolLimits.authFailures.windowMs,
retryAfterMs,
gatewayId: gateway.id,
gatewayPublicId: gateway.gatewayPublicId,
tokenPrefix: tokenPrefixFromNamedBearer(token),
...input.clientMetadata,
},
});
}
if (limited) {
throw new ToolGatewayHttpError(429, "MCP gateway authentication was throttled", "gateway_auth_throttled", {
reasonText: "The MCP gateway authentication attempt was throttled after repeated failures.",
retryAfterMs: Math.max(gatewayState.retryAfterMs, tokenState.retryAfterMs),
});
}
throw new ToolGatewayHttpError(401, "Gateway bearer token is expired or invalid", input.reasonCode);
}
async function namedGatewaySessionFromBearer(input: {
gatewayId?: string | null;
gatewayPublicId?: string | null;
bearerToken: string;
protocolMethod: McpGatewayProtocolMethod;
callerHeaders?: Record<string, string | string[] | undefined>;
}): Promise<ToolGatewaySession> {
const clientMetadata = safeClientMetadata(input.callerHeaders);
const bearerToken = input.bearerToken.trim();
const tokenId = namedGatewayTokenId(bearerToken.trim());
const tokenHash = hashGatewayToken(bearerToken.trim());
const conditions = [eq(toolMcpGatewayTokens.tokenHash, tokenHash)];
if (input.gatewayId) conditions.push(eq(toolMcpGatewayTokens.gatewayId, input.gatewayId));
if (input.gatewayPublicId) conditions.push(eq(toolMcpGateways.gatewayPublicId, input.gatewayPublicId));
const [row] = await db
.select({ gateway: toolMcpGateways, token: toolMcpGatewayTokens })
.from(toolMcpGatewayTokens)
.innerJoin(toolMcpGateways, eq(toolMcpGatewayTokens.gatewayId, toolMcpGateways.id))
.where(and(...conditions))
.limit(1);
if (!row) {
await recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_token_invalid",
clientMetadata,
});
}
if (row.gateway.status !== "active") {
await recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_disabled",
clientMetadata,
});
}
if (row.token.revokedAt) {
await recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_token_revoked",
clientMetadata,
});
}
if (row.token.expiresAt && row.token.expiresAt.getTime() <= Date.now()) {
await recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_token_expired",
clientMetadata,
});
}
let agentId = row.gateway.agentId;
let runId: string | null = null;
let issueId = row.gateway.issueId;
let projectId = row.gateway.projectId;
if (row.token.subjectType === "heartbeat_run") {
const tokenRunId = row.token.subjectId;
if (!tokenRunId || !uuidPattern.test(tokenRunId)) {
return recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_token_run_invalid",
clientMetadata,
});
}
const [run] = await db
.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, tokenRunId))
.limit(1);
if (!run || run.companyId !== row.gateway.companyId) {
return recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_token_run_invalid",
clientMetadata,
});
}
if (!ACTIVE_GATEWAY_RUN_STATUSES.has(run.status)) {
return recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_token_run_inactive",
clientMetadata,
});
}
if (row.gateway.agentId && row.gateway.agentId !== run.agentId) {
return recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_token_run_context_invalid",
clientMetadata,
});
}
try {
const runContext = await resolveRunContext({
companyId: row.gateway.companyId,
agentId: run.agentId,
runId: tokenRunId,
issueId: row.gateway.issueId,
projectId: row.gateway.projectId,
});
agentId = run.agentId;
runId = tokenRunId;
issueId = runContext.issueId;
projectId = runContext.projectId;
} catch {
return recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId,
gatewayPublicId: input.gatewayPublicId,
bearerToken,
reasonCode: "gateway_token_run_context_invalid",
clientMetadata,
});
}
}
const now = new Date();
await db
.update(toolMcpGatewayTokens)
.set({ lastUsedAt: now, updatedAt: now })
.where(eq(toolMcpGatewayTokens.id, row.token.id));
const session: ToolGatewaySession = {
id: `gateway:${row.gateway.id}`,
token: "",
companyId: row.gateway.companyId,
agentId,
runId,
issueId,
projectId,
gatewayId: row.gateway.id,
gatewayPublicId: row.gateway.gatewayPublicId,
gatewayName: row.gateway.name,
gatewayTokenId: row.token.id || tokenId,
gatewayTokenAllowedActions: normalizeGatewayTokenActions(row.token.allowedActions),
enforceConnectionInstalls: row.token.subjectType === "heartbeat_run",
actorType: runId ? "agent" : "system",
actorId: runId ? agentId : row.token.id,
createdAt: row.token.createdAt,
expiresAt: row.token.expiresAt ?? new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000),
};
await assertNamedGatewayProtocolLimit(session, input.protocolMethod, clientMetadata);
return session;
}
/**
* A "test-origin" invocation is one created by the Apps → Test tab's
* impersonated test call: an out-of-band `actorType: "user"` invocation with
* no heartbeat run, issue, or gateway behind it. We key off the durable
* invocation columns rather than audit metadata so the signal survives a
* reload — and so the live test panel can drive an approved test call to
* completion without a real agent run re-invoking it.
*/
function isTestOriginInvocation(invocation: typeof toolInvocations.$inferSelect): boolean {
return (
invocation.actorType === "user"
&& invocation.runId === null
&& invocation.issueId === null
&& invocation.gatewayId === null
&& invocation.connectionId !== null
);
}
/**
* Execute an already-authorized test-tab tool call against the connected MCP
* server and record the result/error onto the invocation. Shared by
* {@link executeTestCall} (the allow path) and the approval-driven execution
* of a parked ask-first request, so both produce identical persistence,
* events, and audit entries.
*/
async function runTestToolInvocation(args: {
session: ToolGatewaySession;
tool: ToolGatewayDescriptor;
parameters: unknown;
invocationId: string;
companyId: string;
agentId: string;
userId: string;
argumentsSummary: ReturnType<typeof summarizeToolValue>;
reasonCode: string;
matchedPolicyIds: string[];
timeoutMs?: number;
}): Promise<
| { decision: "allowed"; invocationId: string; result: unknown }
| { decision: "allowed"; invocationId: string; error: { message: string; reasonCode: string } }
> {
await db
.update(toolInvocations)
.set({ status: "executing", startedAt: new Date(), updatedAt: new Date() })
.where(eq(toolInvocations.id, args.invocationId));
await writeAudit({
session: args.session,
companyId: args.companyId,
agentId: args.agentId,
runId: null,
issueId: null,
actorType: "user",
actorId: args.userId,
action: "tool_gateway.call_allowed",
details: {
source: "test",
invocationId: args.invocationId,
decision: "allow",
reasonCode: args.reasonCode,
matchedPolicyIds: args.matchedPolicyIds,
tool: args.tool.name,
...toolAuditMetadata(args.tool),
argumentsSummary: args.argumentsSummary,
},
});
const startedAt = Date.now();
try {
const executionTimeoutMs = timeoutMs(args.timeoutMs);
const connectedMcpExecution =
args.tool.providerType === "mcp_remote_http"
? await executeRemoteHttpTool(args.session, args.tool, args.parameters, executionTimeoutMs, args.invocationId)
: args.tool.providerType === "mcp_local_stdio"
? await executeLocalStdioTool(args.session, args.tool, args.parameters, executionTimeoutMs)
: null;
if (!connectedMcpExecution) {
throw new ToolGatewayHttpError(404, `Tool "${args.tool.name}" not found`, "tool_not_found", {
tool: args.tool.name,
});
}
const result = connectedMcpExecution.result;
const resultValidation = validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block",
});
await db
.update(toolInvocations)
.set({
status: "succeeded",
resultHash: resultValidation.summary.sha256 ?? null,
resultSummary: resultValidation.summary,
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, args.invocationId));
await writeToolCallEvent({
invocationId: args.invocationId,
session: args.session,
eventType: "call_completed",
outcome: "success",
toolName: args.tool.name,
policyDecision: "allow",
reasonCode: "tool_completed",
argumentsSummary: args.argumentsSummary,
resultSummary: resultValidation.summary,
metadata: {
source: "test",
headerSummary: connectedMcpExecution.headerSummary ?? undefined,
execution: connectedMcpExecution.execution,
},
tool: args.tool,
});
await writeAudit({
session: args.session,
companyId: args.companyId,
agentId: args.agentId,
runId: null,
issueId: null,
actorType: "user",
actorId: args.userId,
action: "tool_gateway.call_completed",
details: {
source: "test",
invocationId: args.invocationId,
decision: "allow",
reasonCode: "tool_completed",
tool: args.tool.name,
...toolAuditMetadata(args.tool),
durationMs: Date.now() - startedAt,
argumentsSummary: args.argumentsSummary,
result: summarizeResult(resultValidation.value),
resultSummary: resultValidation.summary,
headerSummary: connectedMcpExecution.headerSummary ?? undefined,
execution: connectedMcpExecution.execution,
},
});
return {
decision: "allowed" as const,
invocationId: args.invocationId,
result: resultValidation.value,
};
} catch (err) {
const status = err instanceof ToolGatewayHttpError ? err.status : 502;
const reasonCode =
err instanceof ToolContentValidationError
? err.reasonCode
: err instanceof ToolGatewayHttpError
? err.reasonCode
: "tool_execution_failed";
const message = err instanceof Error ? err.message : String(err);
await db
.update(toolInvocations)
.set({
status: status === 504 ? "timed_out" : status === 429 ? "rate_limited" : "failed",
errorCode: reasonCode,
errorMessage: message,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, args.invocationId));
await writeToolCallEvent({
invocationId: args.invocationId,
session: args.session,
eventType: "call_failed",
outcome: status === 504 ? "timeout" : "failure",
toolName: args.tool.name,
policyDecision: status === 504 ? "defer_runtime" : "deny",
reasonCode,
argumentsSummary: args.argumentsSummary,
metadata: {
source: "test",
...(err instanceof ToolContentValidationError ? { findings: err.findings } : {}),
...(executionAuditFromError(err) ? { execution: executionAuditFromError(err) } : {}),
},
tool: args.tool,
});
await writeAudit({
session: args.session,
companyId: args.companyId,
agentId: args.agentId,
runId: null,
issueId: null,
actorType: "user",
actorId: args.userId,
action: status === 504 ? "tool_gateway.call_deferred" : "tool_gateway.call_failed",
details: {
source: "test",
invocationId: args.invocationId,
decision: status === 504 ? "defer_runtime" : "deny",
reasonCode,
tool: args.tool.name,
...toolAuditMetadata(args.tool),
argumentsSummary: args.argumentsSummary,
durationMs: Date.now() - startedAt,
error: message,
...(executionAuditFromError(err) ? { execution: executionAuditFromError(err) } : {}),
},
});
return {
decision: "allowed" as const,
invocationId: args.invocationId,
error: { message, reasonCode },
};
}
}
/**
* Drive a freshly-approved test-origin ask-first request to completion. The
* Test tab has no agent run to re-invoke the parked call, so approving it in
* the Review tab is what executes it — the live status panel then surfaces the
* real result. Reconstructs the impersonated test session and the signed
* arguments, and never throws: any failure is recorded on the invocation so it
* shows up as an error in the panel rather than rolling back the approval.
*/
async function runApprovedTestInvocation(
invocation: typeof toolInvocations.$inferSelect,
parameters: unknown,
actionRequestId: string,
): Promise<void> {
const agentId = invocation.agentId;
if (!invocation.connectionId || !agentId) return;
const userId = invocation.actorId ?? "board";
const session: ToolGatewaySession = {
id: "test-call",
token: "test-call",
companyId: invocation.companyId,
agentId,
runId: null,
issueId: null,
projectId: null,
actorType: "user",
actorId: userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
let tool: ToolGatewayDescriptor | undefined;
try {
tool = (await connectedMcpToolsForConnection(invocation.companyId, invocation.connectionId)).find(
(candidate) =>
candidate.name === invocation.toolName || candidate.upstreamToolName === invocation.toolName,
);
} catch {
tool = undefined;
}
if (!tool) {
await db
.update(toolInvocations)
.set({
status: "failed",
errorCode: "tool_not_found",
errorMessage: `Tool "${invocation.toolName}" is no longer connected`,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, invocation.id));
await reflectToolActionInteractionLifecycle({
actionRequestId,
status: "failed",
errorCode: "tool_not_found",
errorMessage: `Tool "${invocation.toolName}" is no longer connected`,
});
return;
}
const argumentsSummary = validateToolContent({
value: parameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
}).summary;
try {
await runTestToolInvocation({
session,
tool,
parameters,
invocationId: invocation.id,
companyId: invocation.companyId,
agentId,
userId,
argumentsSummary,
reasonCode: "approval_granted",
matchedPolicyIds: invocation.matchedPolicyIds ?? [],
});
await reflectToolActionInteractionLifecycle({ actionRequestId, status: "executed" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await db
.update(toolInvocations)
.set({
status: "failed",
errorCode: "tool_execution_failed",
errorMessage: message,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, invocation.id));
await reflectToolActionInteractionLifecycle({
actionRequestId,
status: "failed",
errorCode: "tool_execution_failed",
errorMessage: message,
});
}
}
async function waitForActionRequestExecution(actionRequestId: string) {
for (let attempt = 0; attempt < 500; attempt += 1) {
const [row] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, actionRequestId))
.limit(1);
if (!row || row.status !== "executing") return row ?? null;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new ToolGatewayHttpError(409, "Approved tool action is still executing", "action_execution_in_progress", {
actionRequestId,
});
}
function storedInvocationResult(invocation: typeof toolInvocations.$inferSelect): unknown {
const summary = invocation.resultSummary?.summary;
if (typeof summary !== "string") return null;
try {
return JSON.parse(summary);
} catch {
return summary;
}
}
async function actionRequestResolution(actionRequest: typeof toolActionRequests.$inferSelect) {
if (actionRequest.status !== "executed" && actionRequest.status !== "failed") return actionRequest;
const [invocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, actionRequest.invocationId))
.limit(1);
return {
...actionRequest,
resultSummary: invocation?.resultSummary?.summary ?? null,
error: invocation?.errorMessage ?? null,
};
}
async function markApprovedActionFailed(input: {
actionRequestId: string;
invocationId: string;
error: unknown;
}) {
const reasonCode = input.error instanceof ToolGatewayHttpError
? input.error.reasonCode
: "tool_execution_failed";
const message = input.error instanceof Error ? input.error.message : String(input.error);
const now = new Date();
await db.update(toolInvocations).set({
status: "failed",
errorCode: reasonCode,
errorMessage: message,
completedAt: now,
updatedAt: now,
}).where(eq(toolInvocations.id, input.invocationId));
await db.update(toolActionRequests).set({
status: "failed",
resolvedAt: now,
updatedAt: now,
}).where(eq(toolActionRequests.id, input.actionRequestId));
await reflectToolActionInteractionLifecycle({
actionRequestId: input.actionRequestId,
status: "failed",
errorCode: reasonCode,
errorMessage: message,
});
return { reasonCode, message };
}
// Guard for approved-action execution: the issue must still be open. Expires
// the claimed request and reflects the interaction lifecycle when it is not.
// Called twice — after winning the executing claim, and again immediately
// before provider dispatch, because tool/snapshot resolution between the two
// involves network calls and leaves a seconds-wide window for the issue to
// close.
async function assertIssueOpenForApprovedAction(input: {
claimed: typeof toolActionRequests.$inferSelect;
invocation: typeof toolInvocations.$inferSelect;
}): Promise<{ projectId: string | null }> {
const { claimed, invocation } = input;
const [issue] = await db
.select({ status: issues.status, projectId: issues.projectId })
.from(issues)
.where(and(eq(issues.id, invocation.issueId!), eq(issues.companyId, invocation.companyId)))
.limit(1);
if (issue && issue.status !== "done" && issue.status !== "cancelled") {
return { projectId: issue.projectId };
}
const expiredAt = new Date();
await db
.update(toolActionRequests)
.set({ status: "expired", resolvedAt: expiredAt, updatedAt: expiredAt })
.where(eq(toolActionRequests.id, claimed.id));
await reflectToolActionInteractionLifecycle({ actionRequestId: claimed.id, status: "expired" });
throw new ToolGatewayHttpError(
409,
"The issue for this tool action is closed; the approval has expired",
"action_issue_closed",
{ actionRequestId: claimed.id, invocationId: invocation.id },
);
}
async function executeApprovedAgentInvocation(input: {
actionRequest: typeof toolActionRequests.$inferSelect;
invocation: typeof toolInvocations.$inferSelect;
}) {
const { actionRequest, invocation } = input;
if (!invocation.agentId || !invocation.issueId || isTestOriginInvocation(invocation)) {
throw new ToolGatewayHttpError(409, "Tool action request is not an agent-origin action", "action_origin_invalid");
}
const [claimed] = await db
.update(toolActionRequests)
.set({ status: "executing", updatedAt: new Date() })
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "approved")))
.returning();
if (!claimed) {
const settled = await waitForActionRequestExecution(actionRequest.id);
const [settledInvocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, invocation.id))
.limit(1);
if (settled?.status === "executed" && settledInvocation) {
return storedInvocationResult(settledInvocation);
}
if (settled?.status === "failed") {
throw new ToolGatewayHttpError(
502,
settledInvocation?.errorMessage ?? "Approved tool action failed",
settledInvocation?.errorCode ?? "tool_execution_failed",
{ actionRequestId: actionRequest.id, invocationId: invocation.id },
);
}
throw new ToolGatewayHttpError(409, "Tool action request was already consumed", "action_already_consumed");
}
// Terminal-issue expiry revokes pending/approved requests, but a claim that
// committed just before the issue closed slips past that revocation. Recheck
// the issue after winning the claim so a governed action never runs external
// side effects for an issue that is already done or cancelled.
const issue = await assertIssueOpenForApprovedAction({ claimed, invocation });
const signedPayload = readSignedToolArgumentsPayload({
signedArguments: claimed.signedArguments,
invocationId: invocation.id,
toolName: invocation.toolName,
signingSecret: options.toolActionSigningSecret,
});
if (!signedPayload) {
const error = new ToolGatewayHttpError(409, "Approved tool action arguments signature is invalid", "signed_arguments_invalid");
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
throw error;
}
if (signedPayload.executionOnApprove !== true) {
throw new ToolGatewayHttpError(
409,
"This approval predates execute-on-approve and must remain inert",
"legacy_approved_action_inert",
);
}
const session: ToolGatewaySession = {
id: `approved-action:${claimed.id}`,
token: "",
companyId: invocation.companyId,
agentId: invocation.agentId,
runId: invocation.runId,
issueId: invocation.issueId,
projectId: issue?.projectId ?? null,
gatewayId: invocation.gatewayId,
gatewayPublicId: invocation.gatewayPublicId,
gatewayTokenId: invocation.gatewayTokenId,
actorType: "agent",
actorId: invocation.agentId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
let tool: ToolGatewayDescriptor;
let liveApprovalSnapshot: Awaited<ReturnType<typeof connectedRemoteApprovalSnapshot>>;
try {
tool = await findToolForSession(session, invocation.toolName);
liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(session, tool);
} catch (error) {
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
throw error;
}
if (!approvalSnapshotsMatch(signedPayload.approvalSnapshot, liveApprovalSnapshot)) {
const error = new ToolGatewayHttpError(409, "Approved tool action target changed after review", "approved_tool_target_changed");
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
throw error;
}
const parameters = signedPayload.arguments;
const canonicalArguments = canonicalToolArguments(parameters);
if (
claimed.canonicalArgumentsHash !== summarizeToolValue(parameters).sha256
|| !verifyToolArgumentsSignature({
signedArguments: claimed.signedArguments,
invocationId: invocation.id,
toolName: invocation.toolName,
canonicalArguments,
approvalSnapshot: signedPayload.approvalSnapshot,
executionOnApprove: true,
signingSecret: options.toolActionSigningSecret,
})
) {
const error = new ToolGatewayHttpError(409, "Approved tool action arguments do not match reviewed hash", "signed_arguments_mismatch");
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
throw error;
}
const argumentsSummary = validateToolContent({
value: parameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
}).summary;
// Final recheck at the last DB write before dispatch: tool and snapshot
// resolution above involve network calls, so re-verify the issue is still
// open now that only the provider call remains. A close that commits after
// this read has raced an execution that was approved, claimed, and verified
// while the issue was open; that instant is irreducible for an external
// side effect gated by DB state (holding a DB lock across a remote provider
// call is not an option), and the accepted linearization is that the
// execution wins — its result still lands on the expired card via
// reflectToolActionInteractionLifecycle.
await assertIssueOpenForApprovedAction({ claimed, invocation });
const startedAt = Date.now();
await db
.update(toolInvocations)
.set({ status: "executing", approvalState: "approved", startedAt: new Date(), updatedAt: new Date() })
.where(eq(toolInvocations.id, invocation.id));
await reflectToolActionInteractionLifecycle({ actionRequestId: claimed.id, status: "executing" });
try {
const executionTimeoutMs = timeoutMs(APPROVED_EXECUTION_TIMEOUT_MS);
const result = tool.providerType === "mcp_remote_http"
? (await executeRemoteHttpTool(session, tool, parameters, executionTimeoutMs, invocation.id)).result
: tool.providerType === "mcp_local_stdio"
? (await executeLocalStdioTool(session, tool, parameters, executionTimeoutMs)).result
: tool.providerType !== "paperclip_plugin"
? await runWithTimeout(executeBuiltinTool(session, tool, parameters), executionTimeoutMs)
: (() => { throw new ToolGatewayHttpError(409, "Plugin actions cannot execute outside their originating run", "approved_execution_unsupported"); })();
const resultValidation = validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block",
});
const now = new Date();
await db.update(toolInvocations).set({
status: "succeeded",
resultHash: resultValidation.summary.sha256 ?? null,
resultSummary: resultValidation.summary,
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
completedAt: now,
updatedAt: now,
}).where(eq(toolInvocations.id, invocation.id));
await db.update(toolActionRequests).set({ status: "executed", resolvedAt: now, updatedAt: now }).where(eq(toolActionRequests.id, claimed.id));
await reflectToolActionInteractionLifecycle({
actionRequestId: claimed.id,
status: "executed",
resultSummary: resultValidation.summary.summary,
});
await writeToolCallEvent({
invocationId: invocation.id,
actionRequestId: claimed.id,
session,
eventType: "call_completed",
outcome: "success",
toolName: tool.name,
policyDecision: "allow",
reasonCode: "approved_action_executed",
argumentsSummary,
resultSummary: resultValidation.summary,
metadata: { durationMs: Date.now() - startedAt, timeoutMs: executionTimeoutMs },
tool,
});
return resultValidation.value;
} catch (error) {
const { reasonCode } = await markApprovedActionFailed({
actionRequestId: claimed.id,
invocationId: invocation.id,
error,
});
await writeToolCallEvent({
invocationId: invocation.id,
actionRequestId: claimed.id,
session,
eventType: "call_failed",
outcome: "failure",
toolName: tool.name,
policyDecision: "deny",
reasonCode,
argumentsSummary,
metadata: { durationMs: Date.now() - startedAt },
tool,
});
throw error;
}
}
async function matchingAgentActionRequest(input: {
session: ToolGatewaySession;
toolName: string;
argumentsHash: string;
}) {
if (!input.session.issueId || !input.session.agentId) return null;
const [match] = await db
.select({ actionRequest: toolActionRequests, invocation: toolInvocations })
.from(toolActionRequests)
.innerJoin(toolInvocations, eq(toolInvocations.id, toolActionRequests.invocationId))
.where(and(
eq(toolActionRequests.companyId, input.session.companyId),
eq(toolActionRequests.issueId, input.session.issueId),
eq(toolActionRequests.canonicalArgumentsHash, input.argumentsHash),
eq(toolInvocations.agentId, input.session.agentId),
eq(toolInvocations.toolName, input.toolName),
inArray(toolActionRequests.status, ["pending", "approved", "executing", "rejected", "executed"]),
))
.orderBy(desc(toolActionRequests.createdAt))
.limit(1);
if (!match) return null;
// 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 and sets the expiry. A concurrent matching call can observe the row in
// this window. A null signature does not prove the create stopped, because a
// parallel create can still be signing the same row right now. Only treat an
// unsigned row as abandoned after the grace time from createdAt has passed;
// before that, return the match so the retry replays approval_required and
// does not create a duplicate request or expire a live row. After the grace
// time an unsigned row stays pending forever and the review queue hides it,
// so it can never be approved. Do not replay it as a live approval. Expire
// the row and let the retry create a fresh, signable request. A null expiry
// alone (without this guard) also makes the getTime() check below unsafe.
const pendingRequest = match.actionRequest;
const pendingUnsigned =
pendingRequest.status === "pending"
&& pendingRequest.signedArguments === null
&& Date.now() - pendingRequest.createdAt.getTime() >= UNSIGNED_ACTION_REQUEST_ABANDON_MS;
const pendingExpired =
pendingRequest.status === "pending"
&& pendingRequest.expiresAt !== null
&& pendingRequest.expiresAt.getTime() <= Date.now();
if (pendingUnsigned || pendingExpired) {
const now = new Date();
await db.update(toolActionRequests).set({ status: "expired", resolvedAt: now, updatedAt: now }).where(and(
eq(toolActionRequests.id, match.actionRequest.id),
eq(toolActionRequests.status, "pending"),
));
await db.update(toolInvocations).set({
approvalState: "expired",
idempotencyKey: null,
updatedAt: now,
}).where(eq(toolInvocations.id, match.invocation.id));
await reflectToolActionInteractionLifecycle({ actionRequestId: match.actionRequest.id, status: "expired" });
return null;
}
return match;
}
async function replayMatchingAgentAction(input: {
session: ToolGatewaySession;
toolName: string;
argumentsHash: string;
}) {
const match = await matchingAgentActionRequest(input);
if (!match) return null;
const { actionRequest, invocation } = match;
if (actionRequest.status === "pending") {
await throwApprovalRequired({
invocationId: invocation.id,
actionRequestId: actionRequest.id,
interactionId: actionRequest.interactionId,
issueId: input.session.issueId!,
toolName: input.toolName,
argumentsHash: input.argumentsHash,
});
}
if (actionRequest.status === "rejected") {
throw new ToolGatewayHttpError(409, "This tool action was declined; do not retry the same call", "action_declined", {
invocationId: invocation.id,
actionRequestId: actionRequest.id,
instructions: "The action was declined. Do not retry the same call; adjust your approach or report the decline on the task.",
});
}
if (actionRequest.status === "executed") {
return { matched: true as const, result: storedInvocationResult(invocation), invocationId: invocation.id };
}
if (actionRequest.status === "executing") {
const settled = await waitForActionRequestExecution(actionRequest.id);
const [settledInvocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, invocation.id)).limit(1);
if (settled?.status === "executed" && settledInvocation) {
return { matched: true as const, result: storedInvocationResult(settledInvocation), invocationId: invocation.id };
}
throw new ToolGatewayHttpError(
502,
settledInvocation?.errorMessage ?? "Approved tool action failed",
settledInvocation?.errorCode ?? "tool_execution_failed",
);
}
if (actionRequest.status === "approved" && actionRequest.decidedAt) {
const signedPayload = readSignedToolArgumentsPayload({
signedArguments: actionRequest.signedArguments,
invocationId: invocation.id,
toolName: invocation.toolName,
signingSecret: options.toolActionSigningSecret,
});
if (signedPayload?.executionOnApprove !== true) return null;
const result = await executeApprovedAgentInvocation({ actionRequest, invocation });
return { matched: true as const, result, invocationId: invocation.id };
}
return null;
}
/**
* Project an ask-first test request + its invocation onto the lifecycle the
* Test tab panel renders. Recovers the redacted parameter snapshot (the
* "Where" row) and, once the call has run, the structured result or error.
*/
function buildTestCallStatus(
actionRequest: typeof toolActionRequests.$inferSelect,
invocation: typeof toolInvocations.$inferSelect,
): ToolConnectionTestCallStatus {
const invocationDone =
invocation.status === "succeeded"
|| invocation.status === "failed"
|| invocation.status === "timed_out"
|| invocation.status === "rate_limited"
|| invocation.status === "denied";
let phase: ToolConnectionTestCallStatusPhase;
if (actionRequest.status === "rejected") {
phase = "denied";
} else if (actionRequest.status === "cancelled") {
phase = "cancelled";
} else if (actionRequest.status === "expired") {
phase = "expired";
} else if (actionRequest.status === "approved" || actionRequest.status === "executed") {
phase = invocationDone ? "done" : "running";
} else {
phase = "waiting";
}
// Recover a redacted, structured snapshot of the parameters for the
// "Where" row — the test-call response never echoes them back.
let parameters: Record<string, unknown> | null = null;
const signed = readSignedToolArgumentsPayload({
signedArguments: actionRequest.signedArguments,
invocationId: invocation.id,
toolName: invocation.toolName,
signingSecret: options.toolActionSigningSecret,
});
if (signed && signed.arguments && typeof signed.arguments === "object" && !Array.isArray(signed.arguments)) {
const redacted = validateToolContent({
value: signed.arguments,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
}).value;
if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) {
parameters = redacted as Record<string, unknown>;
}
}
let result: unknown;
let error: ToolConnectionTestCallStatus["error"];
if (phase === "done") {
if (invocation.status === "succeeded") {
const summary = invocation.resultSummary?.summary;
if (typeof summary === "string") {
try {
result = JSON.parse(summary);
} catch {
result = summary;
}
} else {
result = null;
}
} else {
error = {
message: invocation.errorMessage ?? "The call didn't complete.",
reasonCode: invocation.errorCode ?? null,
};
}
}
const durationMs =
invocation.startedAt && invocation.completedAt
? Math.max(0, invocation.completedAt.getTime() - invocation.startedAt.getTime())
: null;
return {
actionRequestId: actionRequest.id,
invocationId: invocation.id,
phase,
parameters,
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),
durationMs,
requestedAt: actionRequest.createdAt.toISOString(),
resolvedAt: actionRequest.resolvedAt ? actionRequest.resolvedAt.toISOString() : null,
};
}
return {
async recordRuntimeMcpDeliveryDiagnostic(input: {
companyId: string;
agentId: string;
runId: string;
permittedNotInstalledConnections: Array<{ id: string; name: string }>;
}) {
if (input.permittedNotInstalledConnections.length === 0) return;
const [run] = await db
.select({ issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'` })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, input.agentId),
))
.limit(1);
await writeAudit({
companyId: input.companyId,
agentId: input.agentId,
runId: input.runId,
issueId: run?.issueId ?? null,
action: "tool_gateway.runtime_mcp_delivery",
details: {
decision: "diagnostic",
reasonCode: "permitted_connections_not_installed",
deliveredServerCount: 0,
permittedNotInstalledCount: input.permittedNotInstalledConnections.length,
permittedNotInstalledConnections: input.permittedNotInstalledConnections,
},
});
},
async listNamedGateways(companyId: string): Promise<ToolMcpGatewayWithTokens[]> {
// Archived gateways are retired — they must not appear in the list UI.
const gateways = await db
.select()
.from(toolMcpGateways)
.where(
and(
eq(toolMcpGateways.companyId, companyId),
ne(toolMcpGateways.status, "archived"),
),
)
.orderBy(desc(toolMcpGateways.createdAt));
const rows = await Promise.all(gateways.map((gateway) => getGatewayWithTokens(companyId, gateway.id)));
return rows;
},
async createNamedGateway(input: {
companyId: string;
body: CreateToolMcpGateway;
actor?: { agentId?: string | null; userId?: string | null };
}): Promise<ToolMcpGatewayWithTokens> {
await assertGatewayContext({
companyId: input.companyId,
profileId: input.body.profileId,
agentId: input.body.agentId ?? null,
projectId: input.body.projectId ?? null,
issueId: input.body.issueId ?? null,
});
const now = new Date();
const slug = input.body.displaySlug ?? input.body.slug ?? slugSegment(input.body.name, "gateway");
const [gateway] = await db
.insert(toolMcpGateways)
.values({
companyId: input.companyId,
name: input.body.name,
slug,
displaySlug: slug,
description: input.body.description ?? null,
defaultProfileMode: input.body.defaultProfileMode ?? "gateway_only",
contextScopeType: input.body.contextScopeType ?? "none",
contextScopeId: input.body.contextScopeId ?? null,
profileId: input.body.profileId,
agentId: input.body.agentId ?? null,
projectId: input.body.projectId ?? null,
issueId: input.body.issueId ?? null,
approvalIssueId: input.body.approvalIssueId ?? null,
...(input.body.authConfig !== undefined ? { authConfig: input.body.authConfig } : {}),
...(input.body.headerPolicy !== undefined ? { headerPolicy: input.body.headerPolicy } : {}),
...(input.body.metadataPolicy !== undefined ? { metadataPolicy: input.body.metadataPolicy } : {}),
...(input.body.onDemandToolsConfig !== undefined ? { onDemandToolsConfig: input.body.onDemandToolsConfig } : {}),
metadata: input.body.metadata ?? {},
createdByAgentId: input.actor?.agentId ?? null,
createdByUserId: input.actor?.userId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
await db
.insert(toolProfileBindings)
.values({
companyId: input.companyId,
profileId: input.body.profileId,
targetType: "gateway",
targetId: gateway.id,
priority: 10,
metadata: { source: "named_mcp_gateway" },
createdByAgentId: input.actor?.agentId ?? null,
createdByUserId: input.actor?.userId ?? null,
})
.onConflictDoNothing();
await writeAudit({
session: {
id: `gateway:${gateway.id}`,
token: "",
companyId: gateway.companyId,
agentId: gateway.agentId,
runId: null,
issueId: gateway.issueId,
projectId: gateway.projectId,
gatewayId: gateway.id,
gatewayName: gateway.name,
actorType: input.actor?.agentId ? "agent" : input.actor?.userId ? "user" : "system",
actorId: input.actor?.agentId ?? input.actor?.userId ?? gateway.id,
createdAt: now,
expiresAt: now,
},
companyId: input.companyId,
agentId: input.actor?.agentId ?? gateway.agentId,
runId: null,
issueId: gateway.issueId,
actorType: input.actor?.agentId ? "agent" : input.actor?.userId ? "user" : "system",
actorId: input.actor?.agentId ?? input.actor?.userId ?? gateway.id,
action: "tool_gateway.session_created",
details: {
decision: "allow",
reasonCode: "named_gateway_created",
gatewayId: gateway.id,
gatewayName: gateway.name,
profileId: gateway.profileId,
},
});
return getGatewayWithTokens(input.companyId, gateway.id);
},
async updateNamedGateway(input: {
companyId: string;
gatewayId: string;
body: UpdateToolMcpGateway;
}): Promise<ToolMcpGatewayWithTokens> {
const [existing] = await db
.select()
.from(toolMcpGateways)
.where(and(eq(toolMcpGateways.companyId, input.companyId), eq(toolMcpGateways.id, input.gatewayId)))
.limit(1);
if (!existing) throw new ToolGatewayHttpError(404, "MCP gateway not found", "gateway_not_found");
await assertGatewayContext({
companyId: input.companyId,
profileId: input.body.profileId ?? existing.profileId,
agentId: input.body.agentId === undefined ? existing.agentId : input.body.agentId,
projectId: input.body.projectId === undefined ? existing.projectId : input.body.projectId,
issueId: input.body.issueId === undefined ? existing.issueId : input.body.issueId,
});
const [updated] = await db
.update(toolMcpGateways)
.set({
...(input.body.name !== undefined ? { name: input.body.name } : {}),
...(input.body.slug !== undefined || input.body.displaySlug !== undefined ? { slug: input.body.displaySlug ?? input.body.slug } : {}),
...(input.body.slug !== undefined || input.body.displaySlug !== undefined ? { displaySlug: input.body.displaySlug ?? input.body.slug } : {}),
...(input.body.description !== undefined ? { description: input.body.description ?? null } : {}),
...(input.body.status !== undefined ? { status: input.body.status } : {}),
...(input.body.profileId !== undefined ? { profileId: input.body.profileId } : {}),
...(input.body.defaultProfileMode !== undefined ? { defaultProfileMode: input.body.defaultProfileMode } : {}),
...(input.body.contextScopeType !== undefined ? { contextScopeType: input.body.contextScopeType } : {}),
...(input.body.contextScopeId !== undefined ? { contextScopeId: input.body.contextScopeId ?? null } : {}),
...(input.body.agentId !== undefined ? { agentId: input.body.agentId ?? null } : {}),
...(input.body.projectId !== undefined ? { projectId: input.body.projectId ?? null } : {}),
...(input.body.issueId !== undefined ? { issueId: input.body.issueId ?? null } : {}),
...(input.body.approvalIssueId !== undefined ? { approvalIssueId: input.body.approvalIssueId ?? null } : {}),
...(input.body.authConfig !== undefined ? { authConfig: input.body.authConfig } : {}),
...(input.body.headerPolicy !== undefined ? { headerPolicy: input.body.headerPolicy } : {}),
...(input.body.metadataPolicy !== undefined ? { metadataPolicy: input.body.metadataPolicy } : {}),
...(input.body.onDemandToolsConfig !== undefined ? { onDemandToolsConfig: input.body.onDemandToolsConfig } : {}),
...(input.body.metadata !== undefined ? { metadata: input.body.metadata ?? {} } : {}),
updatedAt: new Date(),
})
.where(and(eq(toolMcpGateways.companyId, input.companyId), eq(toolMcpGateways.id, input.gatewayId)))
.returning();
if (input.body.profileId && input.body.profileId !== existing.profileId) {
await db
.insert(toolProfileBindings)
.values({
companyId: input.companyId,
profileId: input.body.profileId,
targetType: "gateway",
targetId: input.gatewayId,
priority: 10,
metadata: { source: "named_mcp_gateway" },
})
.onConflictDoNothing();
}
return getGatewayWithTokens(input.companyId, updated.id);
},
async createNamedGatewayToken(input: {
companyId: string;
gatewayId: string;
body: CreateToolMcpGatewayToken;
actor?: { agentId?: string | null; userId?: string | null };
}): Promise<ToolMcpGatewayTokenCreated> {
const [gateway] = await db
.select()
.from(toolMcpGateways)
.where(and(eq(toolMcpGateways.companyId, input.companyId), eq(toolMcpGateways.id, input.gatewayId)))
.limit(1);
if (!gateway) throw new ToolGatewayHttpError(404, "MCP gateway not found", "gateway_not_found");
const tokenId = randomUUID();
const token = generateNamedGatewayToken(tokenId);
const tokenPrefix = `pcgw_${tokenId.slice(0, 8)}`;
const now = new Date();
const [row] = await db
.insert(toolMcpGatewayTokens)
.values({
id: tokenId,
companyId: input.companyId,
gatewayId: input.gatewayId,
name: input.body.name,
tokenHash: hashGatewayToken(token),
tokenPrefix,
subjectType: input.body.subjectType ?? "gateway_client",
subjectId: input.body.subjectId ?? null,
clientLabel: input.body.clientLabel,
ownerNote: input.body.ownerNote,
allowedActions: input.body.allowedActions ?? ["tools/list", "tools/call"],
expiresAt: input.body.expiresAt ?? null,
expiryOverrideReason: input.body.expiryOverrideReason ?? null,
expiryOverrideByAgentId: input.actor?.agentId && input.body.expiryOverrideReason ? input.actor.agentId : null,
expiryOverrideByUserId: input.actor?.userId && input.body.expiryOverrideReason ? input.actor.userId : null,
expiryOverrideAt: input.body.expiryOverrideReason ? now : null,
createdByAgentId: input.actor?.agentId ?? null,
createdByUserId: input.actor?.userId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
return { ...toGatewayToken(row), token };
},
async revokeNamedGatewayToken(input: { companyId: string; tokenId: string; revokedAt?: Date }): Promise<ToolMcpGatewayToken> {
const now = input.revokedAt ?? new Date();
const [row] = await db
.update(toolMcpGatewayTokens)
.set({ revokedAt: now, updatedAt: now })
.where(and(eq(toolMcpGatewayTokens.companyId, input.companyId), eq(toolMcpGatewayTokens.id, input.tokenId)))
.returning();
if (!row) throw new ToolGatewayHttpError(404, "MCP gateway token not found", "gateway_token_not_found");
return toGatewayToken(row);
},
async initializeNamedGatewayProtocol(input: {
gatewayId?: string | null;
gatewayPublicId?: string | null;
bearerToken: string;
callerHeaders?: Record<string, string | string[] | undefined>;
}): Promise<ToolGatewaySession> {
return namedGatewaySessionFromBearer({
gatewayId: input.gatewayId ?? null,
gatewayPublicId: input.gatewayPublicId ?? null,
bearerToken: input.bearerToken,
protocolMethod: "initialize",
callerHeaders: input.callerHeaders,
});
},
async listToolsForNamedGateway(input: {
gatewayId?: string | null;
gatewayPublicId?: string | null;
bearerToken: string;
callerHeaders?: Record<string, string | string[] | undefined>;
}): Promise<ToolGatewayDescriptor[]> {
const session = await namedGatewaySessionFromBearer({
gatewayId: input.gatewayId ?? null,
gatewayPublicId: input.gatewayPublicId ?? null,
bearerToken: input.bearerToken,
protocolMethod: "tools/list",
callerHeaders: input.callerHeaders,
});
await assertGatewayTokenAction(session, "tools/list");
const tools = await listToolsForContext(session);
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: "tool_gateway.discovery",
details: {
decision: "allow",
reasonCode: "named_gateway_discovery_filtered",
visibleToolCount: tools.length,
visibleTools: tools.map((tool) => tool.name),
},
});
return tools;
},
async listAccessibleConnectionIdsForGatewayContext(input: {
companyId: string;
gatewayId: string;
agentId: string;
runId: string;
issueId?: string | null;
projectId?: string | null;
}): Promise<string[]> {
await assertAgentInCompany(input.companyId, input.agentId);
const now = new Date();
const session: ToolGatewaySession = {
id: `gateway:${input.gatewayId}`,
token: "",
companyId: input.companyId,
agentId: input.agentId,
runId: input.runId,
issueId: input.issueId ?? null,
projectId: input.projectId ?? null,
gatewayId: input.gatewayId,
actorType: "agent",
actorId: input.agentId,
createdAt: now,
expiresAt: new Date(now.getTime() + DEFAULT_SESSION_TTL_MS),
};
const connectedTools = await connectedMcpToolsForCompany(input.companyId);
const decisions = await Promise.all(connectedTools.map(async (tool) => ({
tool,
decision: await policyService.decide(policyInputForTool({ session, tool })),
})));
return [...new Set(decisions
.filter(({ decision }) => decision.allowed || decision.decision === "require_approval")
.map(({ tool }) => tool.connectionId)
.filter((connectionId): connectionId is string => Boolean(connectionId)))];
},
async createSession(input: {
companyId: string;
agentId: string;
runId: string;
issueId?: string | null;
projectId?: string | null;
ttlMs?: number;
actorType?: LogActivityInput["actorType"];
actorId?: string;
}): Promise<ToolGatewaySession> {
await assertAgentInCompany(input.companyId, input.agentId);
const { issueId, projectId } = await resolveRunContext(input);
const now = new Date();
const sessionId = randomUUID();
const token = generateGatewayToken(sessionId);
const session: ToolGatewaySession = {
id: sessionId,
token,
companyId: input.companyId,
agentId: input.agentId,
runId: input.runId,
issueId,
projectId,
createdAt: now,
expiresAt: new Date(now.getTime() + sessionTtlMs(input.ttlMs)),
};
await db.insert(toolGatewaySessions).values({
id: session.id,
companyId: session.companyId,
agentId: input.agentId,
runId: input.runId,
issueId: session.issueId,
projectId: session.projectId,
tokenHash: hashGatewayToken(token),
expiresAt: session.expiresAt,
createdAt: session.createdAt,
updatedAt: session.createdAt,
} as any);
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
actorType: input.actorType,
actorId: input.actorId,
action: "tool_gateway.session_created",
details: {
decision: "allow",
reasonCode: "session_created",
expiresAt: session.expiresAt.toISOString(),
},
});
return session;
},
async listToolsForSession(sessionToken: string): Promise<ToolGatewayDescriptor[]> {
const session = await getActiveSession(sessionToken);
const tools = await listToolsForContext(session);
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: "tool_gateway.discovery",
details: {
decision: "allow",
reasonCode: "discovery_filtered",
visibleToolCount: tools.length,
visibleTools: tools.map((tool) => tool.name),
},
});
return tools;
},
async listPluginToolsForAgent(input: { companyId: string; agentId: string }): Promise<AgentToolDescriptor[]> {
await assertAgentInCompany(input.companyId, input.agentId);
const decisions = await Promise.all(pluginTools().map(async (tool) => {
const decision = await policyService.decide(policyInputForAgentTool({
companyId: input.companyId,
agentId: input.agentId,
tool,
}));
return { tool, decision };
}));
return decisions
.filter(({ decision }) => decision.allowed || decision.decision === "require_approval")
.map(({ tool }) => {
const { providerType: _providerType, risk: _risk, ...descriptor } = tool;
return descriptor;
});
},
async summarizeConnectionAccessForAgent(input: { companyId: string; connectionId: string; agentId: string }) {
await assertAgentInCompany(input.companyId, input.agentId);
const tools = await connectedMcpToolsForConnection(input.companyId, input.connectionId);
const decisions = await Promise.all(tools.map(async (tool) => {
const decision = await policyService.decide(policyInputForAgentTool({
companyId: input.companyId,
agentId: input.agentId,
tool,
}));
const testDecision =
decision.decision === "require_approval"
? "ask_first"
: decision.allowed
? "allowed"
: "off";
return {
toolName: tool.upstreamToolName ?? tool.name,
gatewayToolName: tool.name,
displayName: tool.displayName,
risk: tool.risk,
decision: testDecision,
reasonCode: decision.reasonCode,
matchedPolicyIds: decision.matchedPolicyIds,
effectiveProfileIds: decision.effectiveProfileIds,
};
}));
const lastChange = await summarizeAccessLastChange({
companyId: input.companyId,
connectionId: input.connectionId,
agentId: input.agentId,
policyIds: [...new Set(decisions.flatMap((decision) => decision.matchedPolicyIds))],
profileIds: [...new Set(decisions.flatMap((decision) => decision.effectiveProfileIds))],
});
return {
connectionId: input.connectionId,
toolCount: decisions.length,
allowedCount: decisions.filter((decision) => decision.decision === "allowed").length,
askFirstCount: decisions.filter((decision) => decision.decision === "ask_first").length,
offCount: decisions.filter((decision) => decision.decision === "off").length,
lastChangedAt: lastChange.lastChangedAt,
lastChangedByAgentId: lastChange.lastChangedByAgentId,
lastChangedByName: lastChange.lastChangedByName,
tools: decisions.map(({ effectiveProfileIds: _effectiveProfileIds, ...tool }) => tool),
};
},
async executeTestCall(input: ExecuteTestCallInput) {
await assertAgentInCompany(input.companyId, input.agentId);
const session: ToolGatewaySession = {
id: "test-call",
token: "test-call",
companyId: input.companyId,
agentId: input.agentId,
runId: null,
issueId: null,
projectId: null,
actorType: "user",
actorId: input.userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
const tool = (await connectedMcpToolsForConnection(input.companyId, input.connectionId))
.find((candidate) =>
candidate.name === input.toolName
|| candidate.upstreamToolName === input.toolName
);
if (!tool) {
throw new ToolGatewayHttpError(404, `Tool "${input.toolName}" not found`, "tool_not_found", {
connectionId: input.connectionId,
tool: input.toolName,
});
}
const requestedParameters = input.parameters ?? {};
const argumentValidation = validateToolContent({
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
const decisionInput = policyInputForAgentTool({
companyId: input.companyId,
agentId: input.agentId,
actorType: "user",
actorId: input.userId,
tool,
parameters: requestedParameters,
idempotencyKey: `test-call:${randomUUID()}`,
consumeRateLimit: true,
});
const accessDecision = await policyService.decide(decisionInput);
const recorded = await policyService.recordInvocation(decisionInput, accessDecision);
await policyService.writeAudit(decisionInput, accessDecision);
const invocationId = recorded.invocation.id;
if (accessDecision.decision === "require_approval") {
if (!recorded.actionRequest) {
throw new ToolGatewayHttpError(500, "Approval request was not created", "approval_request_missing", {
invocationId,
tool: tool.name,
});
}
const canonicalArguments = canonicalToolArguments(requestedParameters);
const canonicalArgumentsHash = argumentValidation.summary.sha256 ?? "";
const approvalSnapshot = await connectedRemoteApprovalSnapshot(session, tool, {
requireResolvedCredentials: true,
});
const signedArguments = signToolArguments({
invocationId,
toolName: tool.name,
canonicalArguments,
approvalSnapshot: approvalSnapshot ?? undefined,
executionOnApprove: true,
signingSecret: options.toolActionSigningSecret,
});
const previewMarkdown = buildHumanizedActionPreview({ tool, argumentsSummary: argumentValidation.summary });
await db
.update(toolActionRequests)
.set({
canonicalArgumentsHash,
canonicalArgumentsSummary: argumentValidation.summary,
signedArguments,
previewMarkdown,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
updatedAt: new Date(),
})
.where(eq(toolActionRequests.id, recorded.actionRequest.id));
await writeToolCallEvent({
invocationId,
actionRequestId: recorded.actionRequest.id,
session,
eventType: "approval_requested",
outcome: "pending",
toolName: tool.name,
policyDecision: "require_approval",
reasonCode: accessDecision.reasonCode,
argumentsSummary: argumentValidation.summary,
metadata: { source: "test", actionRequestId: recorded.actionRequest.id },
tool,
});
await writeAudit({
session,
companyId: input.companyId,
agentId: input.agentId,
runId: null,
issueId: null,
actorType: "user",
actorId: input.userId,
action: "tool_gateway.approval_requested",
details: {
source: "test",
invocationId,
actionRequestId: recorded.actionRequest.id,
decision: "require_approval",
reasonCode: accessDecision.reasonCode,
matchedPolicyIds: accessDecision.matchedPolicyIds,
tool: tool.name,
...toolAuditMetadata(tool),
argumentsSummary: argumentValidation.summary,
},
});
return {
decision: "ask_first" as const,
invocationId,
actionRequestId: recorded.actionRequest.id,
};
}
if (!accessDecision.allowed) {
await writeAudit({
session,
companyId: input.companyId,
agentId: input.agentId,
runId: null,
issueId: null,
actorType: "user",
actorId: input.userId,
action: "tool_gateway.call_denied",
details: {
source: "test",
invocationId,
decision: accessDecision.decision,
reasonCode: accessDecision.reasonCode,
matchedPolicyIds: accessDecision.matchedPolicyIds,
tool: tool.name,
...toolAuditMetadata(tool),
argumentsSummary: argumentValidation.summary,
rateLimitState: accessDecision.rateLimitState ?? null,
},
});
return {
decision: "off" as const,
invocationId,
error: {
message: accessDecision.explanation,
reasonCode: accessDecision.reasonCode,
},
};
}
return runTestToolInvocation({
session,
tool,
parameters: requestedParameters,
invocationId,
companyId: input.companyId,
agentId: input.agentId,
userId: input.userId,
argumentsSummary: argumentValidation.summary,
reasonCode: accessDecision.reasonCode,
matchedPolicyIds: accessDecision.matchedPolicyIds,
timeoutMs: input.timeoutMs,
});
},
/**
* Live status of an ask-first test call, polled by the Test tab panel.
* Scoped to the connection the panel is bound to and to test-origin
* requests only, so it can't be used to read arbitrary action requests.
*/
async getTestCallStatus(input: {
companyId: string;
connectionId: string;
actionRequestId: string;
}): Promise<ToolConnectionTestCallStatus> {
const [actionRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, input.actionRequestId))
.limit(1);
if (!actionRequest || actionRequest.companyId !== input.companyId) {
throw new ToolGatewayHttpError(404, "Tool action request not found", "action_request_not_found");
}
const [invocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, actionRequest.invocationId))
.limit(1);
if (!invocation || invocation.companyId !== input.companyId) {
throw new ToolGatewayHttpError(404, "Tool invocation not found", "invocation_not_found");
}
if (invocation.connectionId !== input.connectionId || !isTestOriginInvocation(invocation)) {
throw new ToolGatewayHttpError(404, "Tool action request not found", "action_request_not_found");
}
return buildTestCallStatus(actionRequest, invocation);
},
async approveActionRequest(input: {
companyId: string;
issueId?: string;
interactionId?: string;
actionRequestId: string;
actor: { agentId?: string | null; userId?: string | null };
}) {
const [actionRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, input.actionRequestId))
.limit(1);
if (!actionRequest || actionRequest.companyId !== input.companyId) {
throw new ToolGatewayHttpError(404, "Tool action request not found", "action_request_not_found");
}
const [invocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, actionRequest.invocationId))
.limit(1);
if (!invocation || invocation.companyId !== input.companyId) {
throw new ToolGatewayHttpError(404, "Tool invocation not found", "invocation_not_found");
}
if (input.issueId !== undefined || input.interactionId !== undefined) {
if (
!input.issueId
|| !input.interactionId
|| actionRequest.issueId !== input.issueId
|| actionRequest.interactionId !== input.interactionId
|| invocation.issueId !== input.issueId
) {
throw new ToolGatewayHttpError(
409,
"Tool action request does not belong to this interaction",
"action_context_mismatch",
);
}
const [originatingInteraction] = await db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.id, input.interactionId),
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
))
.limit(1);
if (!originatingInteraction) {
throw new ToolGatewayHttpError(
409,
"Tool action request does not belong to this interaction",
"action_context_mismatch",
);
}
}
if (actionRequest.status !== "pending" && actionRequest.status !== "approved") {
throw new ToolGatewayHttpError(409, "Tool action request is no longer pending", "action_not_pending");
}
let signedPayload: ReturnType<typeof readSignedToolArgumentsPayload> = null;
try {
signedPayload = readSignedToolArgumentsPayload({
signedArguments: actionRequest.signedArguments,
invocationId: invocation.id,
toolName: invocation.toolName,
signingSecret: options.toolActionSigningSecret,
});
} catch {
signedPayload = null;
}
if (!signedPayload) {
if (actionRequest.status === "pending") {
await db
.update(toolActionRequests)
.set({ status: "cancelled", resolvedAt: new Date(), updatedAt: new Date() })
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "pending")));
}
throw new ToolGatewayHttpError(
409,
"Tool action request is no longer approvable; refresh the review queue",
"action_request_invalidated",
);
}
if (actionRequest.approvalId) {
const [formalApproval] = await db
.select({ status: approvals.status })
.from(approvals)
.where(and(
eq(approvals.id, actionRequest.approvalId),
eq(approvals.companyId, input.companyId),
))
.limit(1);
if (!formalApproval || formalApproval.status !== "approved") {
throw new ToolGatewayHttpError(
409,
"Tool action request requires formal board approval before execution",
"formal_approval_required",
{ approvalId: actionRequest.approvalId },
);
}
}
if (actionRequest.status === "approved") {
await reflectToolActionInteractionLifecycle({ actionRequestId: actionRequest.id, status: "approved" });
if (!isTestOriginInvocation(invocation) && signedPayload.executionOnApprove === true) {
try {
await executeApprovedAgentInvocation({ actionRequest, invocation });
} catch {
// The execution outcome is persisted on the invocation/request and
// reflected onto the accepted interaction for the continuation wake.
}
const [settled] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.id, actionRequest.id)).limit(1);
return actionRequestResolution(settled ?? actionRequest);
}
return actionRequest;
}
const now = new Date();
const [updated] = await db
.update(toolActionRequests)
.set({
status: "approved",
resolvedByAgentId: input.actor.agentId ?? null,
resolvedByUserId: input.actor.userId ?? null,
decidedByAgentId: input.actor.agentId ?? null,
decidedByUserId: input.actor.userId ?? null,
decidedAt: now,
resolvedAt: now,
updatedAt: now,
})
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "pending")))
.returning();
if (!updated) {
throw new ToolGatewayHttpError(409, "Tool action request has already been resolved", "action_already_resolved");
}
await db
.update(toolInvocations)
.set({ approvalState: "approved", updatedAt: now })
.where(eq(toolInvocations.id, invocation.id));
await reflectToolActionInteractionLifecycle({ actionRequestId: updated.id, status: "approved" });
// A test-tab ask-first request has no agent run to carry out the parked
// call, so approving it is what runs it. Execute against the signed
// arguments and record the result on the invocation for the live panel.
if (isTestOriginInvocation(invocation)) {
await runApprovedTestInvocation(
{ ...invocation, approvalState: "approved" },
signedPayload.arguments,
updated.id,
);
} else if (signedPayload.executionOnApprove === true) {
try {
await executeApprovedAgentInvocation({ actionRequest: updated, invocation });
} catch {
// Persisted failure is the approval result; accepting the card itself
// remains successful and the agent wake receives the failure context.
}
}
const [settled] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.id, updated.id)).limit(1);
return actionRequestResolution(settled ?? updated);
},
async declineActionRequest(input: {
companyId: string;
actionRequestId: string;
actor: { agentId?: string | null; userId?: string | null };
}) {
const [actionRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, input.actionRequestId))
.limit(1);
if (!actionRequest || actionRequest.companyId !== input.companyId) {
throw new ToolGatewayHttpError(404, "Tool action request not found", "action_request_not_found");
}
const [invocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, actionRequest.invocationId))
.limit(1);
if (!invocation || invocation.companyId !== input.companyId) {
throw new ToolGatewayHttpError(404, "Tool invocation not found", "invocation_not_found");
}
if (actionRequest.status === "rejected") {
return actionRequest;
}
if (actionRequest.status !== "pending") {
throw new ToolGatewayHttpError(409, "Tool action request is no longer pending", "action_not_pending");
}
const now = new Date();
const [updated] = await db
.update(toolActionRequests)
.set({
status: "rejected",
resolvedByAgentId: input.actor.agentId ?? null,
resolvedByUserId: input.actor.userId ?? null,
decidedByAgentId: input.actor.agentId ?? null,
decidedByUserId: input.actor.userId ?? null,
decidedAt: now,
resolvedAt: now,
updatedAt: now,
})
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "pending")))
.returning();
if (!updated) {
throw new ToolGatewayHttpError(409, "Tool action request has already been resolved", "action_already_resolved");
}
await db
.update(toolInvocations)
.set({ approvalState: "rejected", updatedAt: now })
.where(eq(toolInvocations.id, invocation.id));
return updated;
},
async executeTool(input: ExecuteGatewayToolInput) {
const session = await getActiveSession(input.sessionToken, {
gatewayId: input.gatewayId ?? null,
gatewayPublicId: input.gatewayPublicId ?? null,
protocolMethod: "tools/call",
callerHeaders: input.callerHeaders,
});
await assertGatewayTokenAction(session, "tools/call");
let invocationId = String(randomUUID());
const startedAt = Date.now();
let tool = await findToolForSession(session, input.tool);
let virtualToolName: string | null = null;
let requestedParameters: unknown = input.parameters ?? {};
if (tool.name === "search_tools" && tool.providerType === "paperclip_virtual") {
const argumentValidation = validateToolContent({
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
const result = await executeVirtualSearchTools(session, requestedParameters);
const resultValidation = validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block",
});
const [invocation] = await db.insert(toolInvocations).values({
companyId: session.companyId,
actorType: session.actorType ?? (session.agentId ? "agent" : "system"),
actorId: session.actorId ?? session.agentId ?? session.gatewayTokenId ?? session.companyId,
agentId: session.agentId,
issueId: session.issueId,
runId: session.runId,
providerType: "paperclip_virtual",
upstreamToolName: "search_tools",
riskLevel: "read",
toolName: "search_tools",
argumentsHash: argumentValidation.summary.sha256 ?? null,
argumentsSummary: argumentValidation.summary,
policyDecision: "allow",
matchedPolicyIds: [],
approvalState: "not_required",
status: "succeeded",
resultHash: resultValidation.summary.sha256 ?? null,
resultSummary: resultValidation.summary,
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
startedAt: new Date(),
completedAt: new Date(),
}).returning();
await writeToolCallEvent({
invocationId: invocation.id,
session,
eventType: "call_completed",
outcome: "success",
toolName: "search_tools",
policyDecision: "allow",
reasonCode: "virtual_tool_completed",
argumentsSummary: argumentValidation.summary,
resultSummary: resultValidation.summary,
metadata: { virtualToolName: "search_tools" },
tool,
});
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: "tool_gateway.call_completed",
details: {
invocationId: invocation.id,
decision: "allow",
reasonCode: "virtual_tool_completed",
tool: "search_tools",
virtualToolName: "search_tools",
durationMs: Date.now() - startedAt,
argumentsSummary: argumentValidation.summary,
result: summarizeResult(resultValidation.value),
resultSummary: resultValidation.summary,
},
});
return {
invocationId: invocation.id,
status: "completed" as const,
tool: "search_tools",
result: resultValidation.value,
};
}
if (tool.name === "run_tool" && tool.providerType === "paperclip_virtual") {
const { targetToolName, targetParameters } = virtualRunToolInput(requestedParameters);
const targetTool = await findToolForSession(session, targetToolName);
if (!isOnDemandRemoteTool(targetTool)) {
throw new ToolGatewayHttpError(404, `Tool "${targetToolName}" not found`, "tool_not_found", { tool: targetToolName });
}
virtualToolName = "run_tool";
tool = targetTool;
requestedParameters = targetParameters;
}
await assertToolInstalledForSession(session, tool);
const argumentValidation = validateToolContent({
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
let effectiveParameters: unknown = requestedParameters;
let effectiveArgumentsSummary = argumentValidation.summary;
if (!input.approvedActionRequestId) {
const replay = await replayMatchingAgentAction({
session,
toolName: tool.name,
argumentsHash: argumentValidation.summary.sha256 ?? "",
});
if (replay?.matched) {
return {
invocationId: replay.invocationId,
status: "replayed" as const,
tool: virtualToolName ?? tool.name,
targetTool: virtualToolName ? tool.name : undefined,
result: replay.result,
};
}
}
if (input.approvedActionRequestId) {
let [actionRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, input.approvedActionRequestId))
.limit(1);
if (!actionRequest || actionRequest.companyId !== session.companyId) {
throw new ToolGatewayHttpError(404, "Tool action request not found", "action_request_not_found");
}
const [storedInvocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, actionRequest.invocationId))
.limit(1);
if (!storedInvocation || storedInvocation.companyId !== session.companyId) {
throw new ToolGatewayHttpError(404, "Tool invocation not found", "invocation_not_found");
}
if (
actionRequest.issueId !== session.issueId
|| storedInvocation.issueId !== session.issueId
|| storedInvocation.agentId !== session.agentId
|| storedInvocation.runId !== session.runId
|| actionRequest.requestedByAgentId !== session.agentId
) {
throw new ToolGatewayHttpError(403, "Approved action request is not scoped to this gateway session", "action_scope_mismatch");
}
if (!actionRequest.issueId || !actionRequest.interactionId) {
throw new ToolGatewayHttpError(403, "Approved action request is missing issue scope", "action_scope_mismatch");
}
const actionIssueId: string = actionRequest.issueId;
const [linkedInteraction] = await db
.select({
id: issueThreadInteractions.id,
issueId: issueThreadInteractions.issueId,
companyId: issueThreadInteractions.companyId,
})
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.id, actionRequest.interactionId),
eq(issueThreadInteractions.companyId, session.companyId),
eq(issueThreadInteractions.issueId, actionIssueId),
))
.limit(1);
if (!linkedInteraction) {
throw new ToolGatewayHttpError(403, "Approved action request is not linked to its originating interaction", "action_scope_mismatch");
}
if (storedInvocation.toolName !== tool.name) {
throw new ToolGatewayHttpError(409, "Approved action request is for a different tool", "action_tool_mismatch");
}
if (actionRequest.expiresAt && actionRequest.expiresAt.getTime() <= Date.now()) {
const expiredAt = new Date();
const [expired] = await db
.update(toolActionRequests)
.set({ status: "expired", resolvedAt: expiredAt, updatedAt: expiredAt })
.where(and(
eq(toolActionRequests.id, actionRequest.id),
inArray(toolActionRequests.status, ["pending", "approved"]),
))
.returning({ id: toolActionRequests.id });
if (expired) {
await reflectToolActionInteractionLifecycle({ actionRequestId: expired.id, status: "expired" });
}
throw new ToolGatewayHttpError(409, "Tool action request approval has expired", "action_expired");
}
if (actionRequest.status === "pending" && actionRequest.interactionId) {
const [interaction] = await db
.select({
status: issueThreadInteractions.status,
kind: issueThreadInteractions.kind,
resolvedByAgentId: issueThreadInteractions.resolvedByAgentId,
resolvedByUserId: issueThreadInteractions.resolvedByUserId,
resolvedAt: issueThreadInteractions.resolvedAt,
})
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.id, actionRequest.interactionId),
eq(issueThreadInteractions.companyId, session.companyId),
eq(issueThreadInteractions.issueId, actionIssueId),
))
.limit(1);
if (interaction?.kind === "request_confirmation" && interaction.status === "accepted") {
const [approved] = await db
.update(toolActionRequests)
.set({
status: "approved",
resolvedByAgentId: interaction.resolvedByAgentId ?? null,
resolvedByUserId: interaction.resolvedByUserId ?? null,
decidedByAgentId: interaction.resolvedByAgentId ?? null,
decidedByUserId: interaction.resolvedByUserId ?? null,
decidedAt: interaction.resolvedAt ?? new Date(),
resolvedAt: interaction.resolvedAt ?? new Date(),
updatedAt: new Date(),
})
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "pending")))
.returning();
if (!approved) {
throw new ToolGatewayHttpError(409, "Tool action request has already been resolved", "action_already_resolved");
}
actionRequest = approved;
await reflectToolActionInteractionLifecycle({ actionRequestId: approved.id, status: "approved" });
await writeToolCallEvent({
invocationId: storedInvocation.id,
actionRequestId: actionRequest.id,
session,
eventType: "approval_resolved",
outcome: "success",
toolName: tool.name,
policyDecision: "require_approval",
reasonCode: "interaction_accepted",
metadata: { actionRequestId: actionRequest.id, interactionId: actionRequest.interactionId },
tool,
});
}
}
if (actionRequest.status !== "approved") {
throw new ToolGatewayHttpError(409, "Tool action request is not approved or was already consumed", "action_not_approved");
}
if (actionRequest.approvalId) {
const [formalApproval] = await db
.select({ status: approvals.status })
.from(approvals)
.where(and(
eq(approvals.id, actionRequest.approvalId),
eq(approvals.companyId, session.companyId),
))
.limit(1);
if (!formalApproval || formalApproval.status !== "approved") {
throw new ToolGatewayHttpError(
409,
"Tool action request requires formal board approval before execution",
"formal_approval_required",
{ approvalId: actionRequest.approvalId },
);
}
}
const signedPayload = readSignedToolArgumentsPayload({
signedArguments: actionRequest.signedArguments,
invocationId: storedInvocation.id,
toolName: storedInvocation.toolName,
signingSecret: options.toolActionSigningSecret,
});
if (!signedPayload) {
throw new ToolGatewayHttpError(409, "Approved tool action arguments signature is invalid", "signed_arguments_invalid");
}
const liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(session, tool);
if (!approvalSnapshotsMatch(signedPayload.approvalSnapshot, liveApprovalSnapshot)) {
throw new ToolGatewayHttpError(
409,
"Approved tool action target changed after review",
"approved_tool_target_changed",
{
invocationId: storedInvocation.id,
actionRequestId: actionRequest.id,
tool: tool.name,
},
);
}
const storedParameters = signedPayload.arguments;
const storedArgumentValidation = validateToolContent({
value: storedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
const storedCanonical = canonicalToolArguments(storedParameters);
if (
actionRequest.canonicalArgumentsHash !== summarizeToolValue(storedParameters).sha256
|| !verifyToolArgumentsSignature({
signedArguments: actionRequest.signedArguments,
invocationId: storedInvocation.id,
toolName: storedInvocation.toolName,
canonicalArguments: storedCanonical,
approvalSnapshot: signedPayload.approvalSnapshot,
executionOnApprove: signedPayload.executionOnApprove,
signingSecret: options.toolActionSigningSecret,
})
) {
throw new ToolGatewayHttpError(409, "Approved tool action arguments do not match reviewed hash", "signed_arguments_mismatch");
}
const [consumed] = await db
.update(toolActionRequests)
.set({
status: "executed",
resolvedByAgentId: session.agentId,
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "approved")))
.returning();
if (!consumed) {
throw new ToolGatewayHttpError(409, "Tool action request was already consumed", "action_already_consumed");
}
await reflectToolActionInteractionLifecycle({ actionRequestId: consumed.id, status: "executing" });
invocationId = storedInvocation.id as typeof invocationId;
effectiveParameters = storedParameters;
effectiveArgumentsSummary = storedArgumentValidation.summary;
await db
.update(toolInvocations)
.set({ status: "executing", approvalState: "approved", startedAt: new Date(), updatedAt: new Date() })
.where(eq(toolInvocations.id, invocationId));
} else {
const decisionInput = policyInputForTool({
session,
tool,
parameters: effectiveParameters,
idempotencyKey: input.idempotencyKey,
consumeRateLimit: true,
});
const accessDecision = await policyService.decide(decisionInput);
const recorded = await policyService.recordInvocation(decisionInput, accessDecision);
await policyService.writeAudit(decisionInput, accessDecision);
invocationId = recorded.invocation.id;
if (recorded.replayed) {
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: "tool_gateway.call_completed",
details: {
invocationId,
decision: "allow",
reasonCode: "idempotent_replay",
tool: tool.name,
...toolAuditMetadata(tool),
replayed: true,
},
});
return {
invocationId,
status: "replayed" as const,
tool: tool.name,
result: recorded.invocation.resultSummary ?? null,
};
}
if (accessDecision.decision === "require_approval") {
await requestApprovalForRecordedToolCall({
invocation: recorded.invocation,
actionRequest: recorded.actionRequest,
session,
tool,
parameters: effectiveParameters,
argumentsSummary: argumentValidation.summary,
policyDecision: accessDecision,
});
}
if (!accessDecision.allowed) {
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: "tool_gateway.call_denied",
details: {
invocationId,
decision: accessDecision.decision,
reasonCode: accessDecision.reasonCode,
matchedPolicyIds: accessDecision.matchedPolicyIds,
tool: tool.name,
virtualToolName,
targetToolName: virtualToolName ? tool.name : undefined,
...toolAuditMetadata(tool),
argumentsSummary: effectiveArgumentsSummary,
rateLimitState: accessDecision.rateLimitState ?? null,
},
});
throw new ToolGatewayHttpError(
policyErrorStatus(accessDecision),
accessDecision.explanation,
accessDecision.reasonCode,
{
invocationId,
tool: tool.name,
decision: accessDecision.decision,
matchedPolicyIds: accessDecision.matchedPolicyIds,
rateLimitState: accessDecision.rateLimitState ?? null,
},
);
}
await db
.update(toolInvocations)
.set({ status: "executing", startedAt: new Date(), updatedAt: new Date() })
.where(eq(toolInvocations.id, invocationId));
}
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: "tool_gateway.call_allowed",
details: {
invocationId,
decision: input.approvedActionRequestId ? "approved" : "allow",
reasonCode: input.approvedActionRequestId ? "approved_action_request" : "profile_allows_tool",
tool: tool.name,
virtualToolName,
targetToolName: virtualToolName ? tool.name : undefined,
...toolAuditMetadata(tool),
argumentsSummary: effectiveArgumentsSummary,
},
});
try {
const executionTimeoutMs = timeoutMs(input.timeoutMs);
if (tool.providerType === "paperclip_plugin" && (!session.agentId || !session.runId)) {
throw new ToolGatewayHttpError(403, "Plugin tools require an agent run context", "agent_context_required");
}
const connectedMcpExecution =
tool.providerType === "mcp_remote_http"
? await executeRemoteHttpTool(session, tool, effectiveParameters, executionTimeoutMs, invocationId, input.callerHeaders)
: tool.providerType === "mcp_local_stdio"
? await executeLocalStdioTool(session, tool, effectiveParameters, executionTimeoutMs)
: null;
const result =
connectedMcpExecution
? connectedMcpExecution.result
: tool.providerType === "paperclip_plugin"
? await runWithTimeout(
pluginToolDispatcher!.executeTool(
tool.name,
effectiveParameters,
{
agentId: session.agentId!,
runId: session.runId!,
companyId: session.companyId,
projectId: session.projectId ?? "",
},
),
executionTimeoutMs,
)
: await runWithTimeout(executeBuiltinTool(session, tool, effectiveParameters), executionTimeoutMs);
const resultValidation = validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block",
});
await db
.update(toolInvocations)
.set({
status: "succeeded",
resultHash: resultValidation.summary.sha256 ?? null,
resultSummary: resultValidation.summary,
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, invocationId));
if (input.approvedActionRequestId) {
await reflectToolActionInteractionLifecycle({
actionRequestId: input.approvedActionRequestId,
status: "executed",
});
}
await writeToolCallEvent({
invocationId,
actionRequestId: input.approvedActionRequestId ?? null,
session,
eventType: "call_completed",
outcome: "success",
toolName: tool.name,
policyDecision: input.approvedActionRequestId ? "allow" : "allow",
reasonCode: "tool_completed",
argumentsSummary: effectiveArgumentsSummary,
resultSummary: resultValidation.summary,
metadata: {
...(virtualToolName ? { virtualToolName, targetToolName: tool.name } : {}),
...(connectedMcpExecution?.headerSummary ? { headerSummary: connectedMcpExecution.headerSummary } : {}),
...(connectedMcpExecution ? { execution: connectedMcpExecution.execution } : {}),
},
tool,
});
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: "tool_gateway.call_completed",
details: {
invocationId,
decision: "allow",
reasonCode: "tool_completed",
tool: tool.name,
virtualToolName,
targetToolName: virtualToolName ? tool.name : undefined,
...toolAuditMetadata(tool),
durationMs: Date.now() - startedAt,
argumentsSummary: effectiveArgumentsSummary,
result: summarizeResult(resultValidation.value),
resultSummary: resultValidation.summary,
headerSummary: connectedMcpExecution?.headerSummary ?? undefined,
execution: connectedMcpExecution?.execution ?? undefined,
},
});
return {
invocationId,
status: "completed" as const,
tool: virtualToolName ?? tool.name,
targetTool: virtualToolName ? tool.name : undefined,
result: resultValidation.value,
};
} catch (err) {
const normalizedError = err instanceof ToolRuntimeSupervisorError
? new ToolGatewayHttpError(err.status, err.message, err.reasonCode, err.details)
: err;
const status = normalizedError instanceof ToolGatewayHttpError ? normalizedError.status : 502;
const reasonCode =
normalizedError instanceof ToolContentValidationError
? normalizedError.reasonCode
: normalizedError instanceof ToolGatewayHttpError
? normalizedError.reasonCode
: "tool_execution_failed";
const isRuntimeDeferred =
status === 429
&& (
reasonCode === "runtime_capacity_unavailable"
|| reasonCode === "runtime_restart_backoff"
|| reasonCode === "runtime_restart_suppressed"
);
const isDeferred = status === 504 || isRuntimeDeferred;
const message = normalizedError instanceof Error ? normalizedError.message : String(normalizedError);
if (reasonCode === "elicitation_required") {
throw normalizedError;
}
await db
.update(toolInvocations)
.set({
status: status === 504 ? "timed_out" : status === 429 ? "rate_limited" : "failed",
errorCode: reasonCode,
errorMessage: message,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, invocationId));
if (input.approvedActionRequestId) {
await reflectToolActionInteractionLifecycle({
actionRequestId: input.approvedActionRequestId,
status: "failed",
errorCode: reasonCode,
errorMessage: message,
});
}
await writeToolCallEvent({
invocationId,
actionRequestId: input.approvedActionRequestId ?? null,
session,
eventType: status === 504 ? "call_failed" : "call_failed",
outcome: status === 504 ? "timeout" : "failure",
toolName: tool.name,
policyDecision: isDeferred ? "defer_runtime" : "deny",
reasonCode,
argumentsSummary: effectiveArgumentsSummary,
metadata: {
...(virtualToolName ? { virtualToolName, targetToolName: tool.name } : {}),
...(normalizedError instanceof ToolContentValidationError ? { findings: normalizedError.findings } : {}),
...(executionAuditFromError(normalizedError) ? { execution: executionAuditFromError(normalizedError) } : {}),
},
tool,
});
await writeAudit({
session,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
issueId: session.issueId,
action: isDeferred ? "tool_gateway.call_deferred" : "tool_gateway.call_failed",
details: {
invocationId,
decision: isDeferred ? "defer_runtime" : "deny",
reasonCode,
tool: tool.name,
virtualToolName,
targetToolName: virtualToolName ? tool.name : undefined,
...toolAuditMetadata(tool),
argumentsSummary: effectiveArgumentsSummary,
durationMs: Date.now() - startedAt,
error: message,
...(executionAuditFromError(normalizedError) ? { execution: executionAuditFromError(normalizedError) } : {}),
},
});
if (normalizedError instanceof ToolContentValidationError) {
throw new ToolGatewayHttpError(422, message, reasonCode, { findings: normalizedError.findings });
}
throw normalizedError;
}
},
async executePluginTool(input: ExecutePluginToolInput) {
if (!pluginToolDispatcher) {
throw new ToolGatewayHttpError(501, "Plugin tool dispatch is not enabled", "plugin_tools_disabled");
}
if (input.actor.type === "agent") {
if (input.actor.companyId !== input.runContext.companyId) {
throw new ToolGatewayHttpError(403, "Agent key cannot access another company", "actor_company_mismatch");
}
if (input.actor.agentId !== input.runContext.agentId) {
throw new ToolGatewayHttpError(403, "Agent cannot execute tools as another agent", "actor_agent_mismatch");
}
if (input.actor.runId && input.actor.runId !== input.runContext.runId) {
throw new ToolGatewayHttpError(403, "Agent cannot execute tools for another run", "actor_run_mismatch");
}
}
const context = await resolveRunContext({
companyId: input.runContext.companyId,
agentId: input.runContext.agentId,
runId: input.runContext.runId,
projectId: input.runContext.projectId,
});
let invocationId = String(randomUUID());
const sessionLike: ToolGatewaySession = {
id: "plugin-route",
token: "plugin-route",
companyId: input.runContext.companyId,
agentId: input.runContext.agentId,
runId: input.runContext.runId,
issueId: context.issueId,
projectId: input.runContext.projectId ?? context.projectId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
const tool = findStaticTool(input.tool);
if (tool.providerType !== "paperclip_plugin") {
throw new ToolGatewayHttpError(404, `Tool "${input.tool}" is not a plugin tool`, "tool_not_found");
}
const requestedParameters = input.parameters ?? {};
const argumentValidation = validateToolContent({
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
const decisionInput = policyInputForTool({
session: sessionLike,
tool,
parameters: requestedParameters,
consumeRateLimit: true,
});
const accessDecision = await policyService.decide(decisionInput);
const recorded = await policyService.recordInvocation(decisionInput, accessDecision);
await policyService.writeAudit(decisionInput, accessDecision);
invocationId = recorded.invocation.id;
if (recorded.replayed) {
return recorded.invocation.resultSummary;
}
if (accessDecision.decision === "require_approval") {
await requestApprovalForRecordedToolCall({
invocation: recorded.invocation,
actionRequest: recorded.actionRequest,
session: sessionLike,
tool,
parameters: requestedParameters,
argumentsSummary: argumentValidation.summary,
policyDecision: accessDecision,
});
}
if (!accessDecision.allowed) {
await writeAudit({
session: sessionLike,
companyId: input.runContext.companyId,
agentId: input.runContext.agentId,
runId: input.runContext.runId,
issueId: context.issueId,
action: "tool_gateway.call_denied",
details: {
invocationId,
decision: accessDecision.decision,
reasonCode: accessDecision.reasonCode,
matchedPolicyIds: accessDecision.matchedPolicyIds,
tool: input.tool,
...toolAuditMetadata(tool),
argumentsSummary: argumentValidation.summary,
rateLimitState: accessDecision.rateLimitState ?? null,
},
});
throw new ToolGatewayHttpError(
policyErrorStatus(accessDecision),
accessDecision.explanation,
accessDecision.reasonCode,
{
invocationId,
tool: input.tool,
decision: accessDecision.decision,
matchedPolicyIds: accessDecision.matchedPolicyIds,
rateLimitState: accessDecision.rateLimitState ?? null,
},
);
}
await db
.update(toolInvocations)
.set({ status: "executing", startedAt: new Date(), updatedAt: new Date() })
.where(eq(toolInvocations.id, invocationId));
await writeAudit({
session: sessionLike,
companyId: input.runContext.companyId,
agentId: input.runContext.agentId,
runId: input.runContext.runId,
issueId: context.issueId,
action: "tool_gateway.call_allowed",
details: {
invocationId,
decision: "allow",
reasonCode: "profile_allows_tool",
tool: input.tool,
...toolAuditMetadata(tool),
argumentsSummary: argumentValidation.summary,
},
});
const startedAt = Date.now();
try {
const result = await pluginToolDispatcher.executeTool(input.tool, requestedParameters, input.runContext);
const resultValidation = validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block",
});
await db
.update(toolInvocations)
.set({
status: "succeeded",
resultHash: resultValidation.summary.sha256 ?? null,
resultSummary: resultValidation.summary,
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, invocationId));
await writeToolCallEvent({
invocationId,
session: sessionLike,
eventType: "call_completed",
outcome: "success",
toolName: tool.name,
policyDecision: "allow",
reasonCode: "tool_completed",
argumentsSummary: argumentValidation.summary,
resultSummary: resultValidation.summary,
tool,
});
await writeAudit({
session: sessionLike,
companyId: input.runContext.companyId,
agentId: input.runContext.agentId,
runId: input.runContext.runId,
issueId: context.issueId,
action: "tool_gateway.call_completed",
details: {
invocationId,
decision: "allow",
reasonCode: "tool_completed",
tool: input.tool,
...toolAuditMetadata(tool),
durationMs: Date.now() - startedAt,
result: summarizeResult((resultValidation.value as typeof result).result),
resultSummary: resultValidation.summary,
},
});
return resultValidation.value as typeof result;
} catch (err) {
const status = err instanceof ToolGatewayHttpError ? err.status : 502;
const reasonCode =
err instanceof ToolContentValidationError
? err.reasonCode
: err instanceof ToolGatewayHttpError
? err.reasonCode
: "tool_execution_failed";
const message = err instanceof Error ? err.message : String(err);
await db
.update(toolInvocations)
.set({
status: status === 504 ? "timed_out" : "failed",
errorCode: reasonCode,
errorMessage: message,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(toolInvocations.id, invocationId));
await writeToolCallEvent({
invocationId,
session: sessionLike,
eventType: "call_failed",
outcome: status === 504 ? "timeout" : "failure",
toolName: tool.name,
policyDecision: status === 504 ? "defer_runtime" : "deny",
reasonCode,
argumentsSummary: argumentValidation.summary,
metadata: err instanceof ToolContentValidationError ? { findings: err.findings } : null,
tool,
});
await writeAudit({
session: sessionLike,
companyId: input.runContext.companyId,
agentId: input.runContext.agentId,
runId: input.runContext.runId,
issueId: context.issueId,
action: "tool_gateway.call_failed",
details: {
invocationId,
decision: "deny",
reasonCode,
tool: input.tool,
...toolAuditMetadata(tool),
argumentsSummary: argumentValidation.summary,
durationMs: Date.now() - startedAt,
error: message,
},
});
if (err instanceof ToolContentValidationError) {
throw new ToolGatewayHttpError(422, message, reasonCode, { findings: err.findings });
}
throw err;
}
},
async revokeSession(input: {
companyId: string;
sessionId: string;
revokedAt?: Date;
actor?: {
actorType?: LogActivityInput["actorType"];
actorId?: string;
agentId?: string | null;
runId?: string | null;
};
agentScope?: { agentId: string; runId?: string | null } | null;
}) {
const now = input.revokedAt ?? new Date();
const [existing] = await db
.select()
.from(toolGatewaySessions)
.where(and(eq(toolGatewaySessions.companyId, input.companyId), eq(toolGatewaySessions.id, input.sessionId)))
.limit(1);
if (!existing) {
throw new ToolGatewayHttpError(404, "Tool gateway session not found", "session_not_found");
}
if (input.agentScope) {
const runMatches = input.agentScope.runId ? existing.runId === input.agentScope.runId : true;
if (existing.agentId !== input.agentScope.agentId || !runMatches) {
throw new ToolGatewayHttpError(
403,
"Tool gateway session is outside the authenticated agent scope",
"session_scope_mismatch",
);
}
}
const [session] = await db
.update(toolGatewaySessions)
.set({ revokedAt: now, updatedAt: now })
.where(and(eq(toolGatewaySessions.companyId, input.companyId), eq(toolGatewaySessions.id, input.sessionId)))
.returning();
const sessionView = gatewaySessionFromRow(session!);
await writeAudit({
session: sessionView,
companyId: sessionView.companyId,
agentId: sessionView.agentId,
runId: sessionView.runId,
issueId: sessionView.issueId,
actorType: input.actor?.actorType,
actorId: input.actor?.actorId,
action: "tool_gateway.session_revoked",
details: {
decision: "revoke",
reasonCode: "session_revoked",
revokedAt: now.toISOString(),
previousRevokedAt: existing.revokedAt?.toISOString() ?? null,
},
});
return { ...sessionView, revokedAt: session!.revokedAt ?? now };
},
async cleanupExpiredSessions(input: { now?: Date } = {}) {
const now = input.now ?? new Date();
const rows = await db
.delete(toolGatewaySessions)
.where(lte(toolGatewaySessions.expiresAt, now))
.returning({ id: toolGatewaySessions.id });
return { deletedCount: rows.length };
},
async listRuntimeSlots(companyId?: string) {
return runtimeSupervisor.listSlots(companyId);
},
async stopRuntimeSlot(input: {
companyId: string;
slotId: string;
actor?: { agentId?: string | null; runId?: string | null };
}) {
try {
return await runtimeSupervisor.stopSlot({
companyId: input.companyId,
slotId: input.slotId,
agentId: input.actor?.agentId ?? null,
runId: input.actor?.runId ?? null,
});
} catch (err) {
if (err instanceof ToolRuntimeSupervisorError) {
throw new ToolGatewayHttpError(err.status, err.message, err.reasonCode, err.details);
}
throw err;
}
},
async restartRuntimeSlot(input: {
companyId: string;
slotId: string;
actor?: { agentId?: string | null; runId?: string | null };
}) {
try {
return await runtimeSupervisor.restartSlot({
companyId: input.companyId,
slotId: input.slotId,
agentId: input.actor?.agentId ?? null,
runId: input.actor?.runId ?? null,
});
} catch (err) {
if (err instanceof ToolRuntimeSupervisorError) {
throw new ToolGatewayHttpError(err.status, err.message, err.reasonCode, err.details);
}
throw err;
}
},
};
}
export type ToolGatewayService = ReturnType<typeof createToolGatewayService>;