Files
PaperClipAI/server/src/services/tool-gateway.ts
T
DottaandPaperclip 66424362a8 perf(runtime): skip discovery without a Browser Use connection (#16345)
Skip full tool discovery when no enabled, active Browser Use connection exists for the run company. Configured connections retain existing authorization and discovery.

Verified exact head 7fdf51eae8: full CI, E2E, Superagent and Apex 5/5; 29 affected lifecycle tests and full repository typecheck/build pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-14 18:18:00 -05:00

11408 lines
390 KiB
TypeScript

import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { composeConnectionInstructions } from "./connection-instructions.js";
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
import { boundedMcpToolName } from "./mcp-tool-names.js";
import { browserUseService } from "./browser-use.js";
import { isBrowserUseConnection } from "./browser-use-client.js";
import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "./cognee-connection.js";
import { HttpError } from "../errors.js";
import { claimSlackRateLimitRetry } from "./connectors/slack-retry.js";
import { resolveSlackTaskAuthority } from "./connectors/slack-authority.js";
import { SLACK_TOOLS } from "@paperclipai/shared";
import { slackToolsForSession } from "./connectors/slack-catalog.js";
import { executeSlackTool } from "./connectors/slack.js";
import { githubGuestBotConnectionForSession, githubBotToolsForSession } from "./chat-github-tools.js";
import { githubChatReviewService } from "./chat-github-reviews.js";
import { runIdentityContexts } from "@paperclipai/db";
import { executeSpekoVoiceTool, spekoToolsForSession } from "./voice/speko-agent-tools.js";
import { captureRunIdentity } from "./run-identity.js";
import { emitConnectionInvoked } from "./connector-telemetry.js";
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
import { extractRemoteMcpPending } from "./remote-mcp-pending.js";
import { logger } from "../middleware/logger.js";
import { spawn } from "node:child_process";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import {
and,
asc,
desc,
eq,
gt,
inArray,
isNull,
lte,
ne,
or,
sql,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
companies,
companyMemberships,
companySecrets,
connectionGrantMembers,
connectionGrantDelegations,
connectionGrants,
documents,
heartbeatRuns,
issueApprovals,
issueDocuments,
issueThreadInteractions,
issues,
projects,
toolActionRequests,
toolActionDeliveries,
toolAccessAuditEvents,
toolApplications,
toolCallEvents,
toolCatalogEntries,
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,
ToolUpstreamPending,
ToolConnectionTestCallStatusPhase,
ToolCredentialSecretRef,
ToolMcpGateway,
ToolMcpGatewayClientSnippet,
ToolMcpGatewayToken,
ToolMcpGatewayTokenAction,
ToolMcpGatewayTokenCreated,
ToolMcpGatewayWithTokens,
UpdateToolMcpGateway,
} from "@paperclipai/shared";
import {
getConnectableAppDefinition,
isGitHubConnectorProfileId,
isGoogleWorkspaceConnectorProfileId,
type GitHubConnectorProfileId,
type GoogleWorkspaceConnectorProfileId,
} 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 { connectionGrantCredentialRef, resolveConnectionGrantSecret } from "./connection-credentials.js";
import { railwayCommandBudgetMs, createRailwayClient, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_API_URL, RAILWAY_TOOL_PREFIX, RailwayError } from "./railway.js";
import { RAILWAY_SSH_SECRET_PATH, runRailwaySshCommand } from "./railway-ssh.js";
import {
initializeMcpHttpSession,
getMcpHttpSession,
forgetMcpHttpSession,
readMcpHttpResponse,
McpHttpResponseError,
mcpHttpRequestHeaders,
parseMcpHttpResponseBody,
} from "./mcp-http.js";
import {
projectedConnectionHeaders,
projectedConnectionToolArguments,
projectedConnectionToolInputSchema,
} from "./tool-access.js";
import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription } from "./google-chat-tool-policy.js";
import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
import {
guardedRemoteHttpFetch,
type GuardedRemoteHttpFetchOptions,
} from "./remote-http-fetch.js";
import {
REMOTE_URL_SECRET_CONFIG_PATH,
remoteUrlCredentialMatchesPublicUrl,
} from "./remote-url-credentials.js";
import {
createToolAccessDecisionCache,
runContextSnapshotString,
toolAccessPolicyService,
type ToolAccessDecisionCache,
} from "./tool-access-policy.js";
import { toolDiscoveryScheduler, ToolDiscoveryBusyError } from "./tool-discovery-scheduler.js";
import { commitToolActionReview } from "./tool-action-review.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 { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared";
import {
createPaperclipCloudConnector,
isPaperclipCloudConnectorStrategy,
paperclipCloudConnectorConfigFromEnv,
PaperclipCloudConnectorError,
type PaperclipCloudConnector,
} from "./paperclip-cloud-connector.js";
import {
createVercelConnectClient,
vercelGrantReference,
vercelTokenRequest,
VercelConnectClientError,
type VercelConnectClient,
} from "./vercel-connect.js";
import {
canonicalToolArguments,
readSignedToolArgumentsPayload,
signToolArguments,
summarizeToolValue,
TOOL_ACTION_REQUEST_SIGNING_GRACE_MS,
ToolActionSigningSecretMissingError,
ToolContentValidationError,
validateToolContent,
verifyToolArgumentsSignature,
} from "./tool-content-guards.js";
import { extendApprovedExecutionWaitDeadline } from "./approved-execution-wait.js";
const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000;
const MAX_SESSION_TTL_MS = 60 * 60 * 1000;
const DEFAULT_TOOL_TIMEOUT_MS = 10_000;
export function resolveCredentialGrantKind(
policy: "shared" | "per_user" | "per_user_with_fallback" | "per_agent",
actingUserId: string | null,
hasUserGrant: boolean,
): "organization" | "user" | "user_authorization_required" {
if (policy === "per_agent") return "user_authorization_required";
if (policy === "shared") return "organization";
if (actingUserId && hasUserGrant) return "user";
return policy === "per_user" ? "user_authorization_required" : "organization";
}
export function isConnectionGrantAudienceAllowed(
memberUserIds: string[],
actingUserId: string | null,
actingUserIsActiveMember: boolean,
): boolean {
if (actingUserId !== null && !actingUserIsActiveMember) return false;
return (
memberUserIds.length === 0 ||
(actingUserId !== null && memberUserIds.includes(actingUserId))
);
}
// 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;
const GITHUB_BOT_TOOL_TIMEOUT_MS = 60_000;
const ACTION_REQUEST_EXECUTION_POLL_MS = 25;
// Approval execution performs live target, signature, managed-argument, and
// issue-state checks before provider dispatch. Give that preparation a
// separate bounded window so it cannot consume the provider's execution
// budget for concurrent consumers.
const ACTION_REQUEST_PREPARATION_WAIT_MS = 2 * 60 * 1000;
// Concurrent consumers must wait at least as long as the provider execution
// they are joining. The extra grace lets the owner persist the terminal request
// state after the provider timeout/result settles.
const ACTION_REQUEST_EXECUTION_WAIT_MS = APPROVED_EXECUTION_TIMEOUT_MS + 5_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 MAX_REMOTE_MCP_RESPONSE_BYTES = 1_000_000;
const ACTIVE_GATEWAY_RUN_STATUSES = new Set(["running"]);
// A tool listing decides access for every tool in the company. The decisions
// of one listing share a read cache, so this cap only limits how many of them
// (and their uncached reads, such as rate-limit counters) run at the same time.
const LISTING_DECISION_CONCURRENCY = 16;
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"
| "resources/list"
| "resources/read"
| "prompts/list"
| "prompts/get";
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"
| "provider_rest"
| "mcp_remote_http"
| "mcp_local_stdio"
| "paperclip_self"
| "paperclip_plugin"
| "paperclip_virtual"
| "paperclip_github_chat"
| "paperclip_slack_chat"
| "paperclip_speko_voice";
export interface ConnectedMcpGatewayMetadata {
applicationId: string;
applicationKey: string | null;
applicationDisplayName: string;
connectionId: string;
catalogEntryId: string;
transport: "mcp_remote" | "local_stdio" | "rest_api";
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;
gatewayProfileId?: string | null;
gatewayTokenId?: string | null;
gatewayTokenAllowedActions?: ToolMcpGatewayTokenAction[];
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
/** Human whose personal connection grant applies to this execution. */
responsibleUserId?: string | null;
/** Captured by the controller for this request, never accepted from tool arguments. */
identityContextId?: string | null;
/** Set only after verifying the signed approved action. */
approvedSlackInvocationId?: string;
/** Set only after the signed review is verified. */
approvedInvocationId?: string;
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);
}
}
/** Repeated discovery rows are one tool; conflicting bindings stay ambiguous. */
export function deduplicateToolDescriptors(tools: readonly ToolGatewayDescriptor[]): ToolGatewayDescriptor[] {
const unique = new Map<string, ToolGatewayDescriptor>();
for (const tool of tools) {
const previous = unique.get(tool.name);
if (previous) {
if (!isDeepStrictEqual(previous, tool)) {
throw new ToolGatewayHttpError(409,
`Conflicting tool definitions share the name ${JSON.stringify(tool.name)} (connections ${JSON.stringify([previous.connectionId ?? null, tool.connectionId ?? null])}).`,
"tool_name_collision");
}
continue;
}
unique.set(tool.name, tool);
}
return [...unique.values()];
}
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" | "Railway GraphQL" | "Railway GraphQL + SSH";
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.cognee-cloud": COGNEE_STDIO_TEMPLATE,
"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>;
}
function connectionRequiresMcpSession(connection: { config: unknown }): boolean {
const config = asRecord(connection.config);
if (config?.mcpSessionRequired === true) return true;
const sourceTemplateKey = typeof config?.sourceTemplateKey === "string"
? config.sourceTemplateKey
: null;
const connectionMethodKey = typeof config?.connectionMethodKey === "string"
? config.connectionMethodKey
: null;
if (!sourceTemplateKey || !connectionMethodKey) return false;
return getConnectableAppDefinition(sourceTemplateKey)?.methods.some((method) =>
method.key === connectionMethodKey && method.defaults?.mcpSessionRequired === true
) ?? false;
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
mapper: (item: T) => Promise<R>,
signal?: AbortSignal,
): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
let failed = false;
async function worker() {
while (!failed && nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
try {
signal?.throwIfAborted();
results[index] = await mapper(items[index]!);
} catch (error) {
// The batch fails with this error, so the other workers stop.
failed = true;
throw error;
}
}
}
const workers = await Promise.allSettled(
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
);
const failure = workers.find((result) => result.status === "rejected");
if (failure?.status === "rejected") throw failure.reason;
signal?.throwIfAborted();
return results;
}
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, tool?: ToolGatewayDescriptor) {
if (!Number.isFinite(value)) return tool?.providerType === "paperclip_github_chat"
? GITHUB_BOT_TOOL_TIMEOUT_MS
: 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 actionName = input.tool.displayName?.trim() || input.tool.name;
const trustLine =
input.tool.risk === "destructive"
? `${actionName}. This can permanently change or remove data.`
: actionName;
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(" · ");
}
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;
onToolActionSettled?: (actionRequestId: string) => Promise<unknown>;
/** Test seam for deterministic remote MCP protocol fixtures. */
remoteHttpRequest?: (url: string, init: RequestInit) => Promise<Response>;
/** Test seam for refreshing personal Gmail grants. */
paperclipCloudConnector?: PaperclipCloudConnector | null;
/** @deprecated Use paperclipCloudConnector. */
paperclipIdGmailConnector?: PaperclipCloudConnector | null;
/** Refreshes customer-owned/DCR OAuth grants before remote MCP execution. */
oauthGrantRefresher?: (input: {
companyId: string;
connectionId: string;
grantId: string;
forceRefresh?: boolean;
actor?: {
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
};
issueId?: string | null;
heartbeatRunId?: string | null;
}) => Promise<typeof connectionGrants.$inferSelect>;
/** Test seam for resolving Vercel Connect credentials. */
vercelConnectClient?: VercelConnectClient | null;
/** Test seam for reproducing the managed-argument drift expiry race. */
beforeManagedArgumentDriftExpiry?: () => Promise<void>;
/** Test seam for pausing a legacy approved request before its execution claim. */
beforeLegacyApprovedActionClaim?: () => Promise<void>;
mcpGatewayProtocolLimits?: Partial<{
authFailures: Partial<McpGatewayRateLimitConfig>;
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
tokenRequests: Partial<McpGatewayRateLimitConfig>;
sessionSetup: Partial<McpGatewayRateLimitConfig>;
}>;
now?: () => number;
} = {},
) {
// Authorization links can contain one-time codes. Keep them briefly in memory;
// persist only the redacted request and execution identifiers for recovery.
const upstreamHandoffs = new Map<string, { pending: ToolUpstreamPending; expires: number }>();
async function retainUpstreamHandoff(invocationId: string, pending: ToolUpstreamPending) {
for (const [id, value] of upstreamHandoffs) if (value.expires <= Date.now()) upstreamHandoffs.delete(id);
while (upstreamHandoffs.size >= 256) upstreamHandoffs.delete(upstreamHandoffs.keys().next().value!);
upstreamHandoffs.set(invocationId, { pending, expires: Date.now() + 15 * 60_000 });
const summary = validateToolContent({
value: { upstreamPending: { ...pending, links: [] } }, direction: "result", sensitiveMode: "redact", promptInjectionMode: "ignore",
}).summary;
await db.update(toolInvocations).set({ resultSummary: summary }).where(eq(toolInvocations.id, invocationId));
}
function recoverUpstreamHandoff(invocation: typeof toolInvocations.$inferSelect): ToolUpstreamPending | undefined {
if (invocation.errorCode !== "provider_interaction_required") return undefined;
const cached = upstreamHandoffs.get(invocation.id);
if (cached && cached.expires > Date.now()) return cached.pending;
upstreamHandoffs.delete(invocation.id);
const stored = asRecord(storedInvocationResult(invocation));
const pending = asRecord(stored?.upstreamPending);
if (!pending || !["approval", "authorization"].includes(String(pending.kind))) return undefined;
return { ...pending, links: [] } as unknown as ToolUpstreamPending;
}
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);
// Authentication produces a new session object for every operation. Keep
// credential acquisition scoped to that object and out of persisted inputs.
const githubOperationCredentials = new WeakMap<
ToolGatewaySession,
{
grant: typeof connectionGrants.$inferSelect;
headers: Record<string, string>;
}
>();
const configuredCloudConnector =
options.paperclipCloudConnector ?? options.paperclipIdGmailConnector;
const connectorWasProvided =
options.paperclipCloudConnector !== undefined ||
options.paperclipIdGmailConnector !== undefined;
let cachedCloudConnector = configuredCloudConnector ?? null;
const currentCloudConnector = (): PaperclipCloudConnector | null => {
if (cachedCloudConnector || connectorWasProvided)
return cachedCloudConnector;
const config = paperclipCloudConnectorConfigFromEnv();
cachedCloudConnector = config
? createPaperclipCloudConnector({ config, now: options.now })
: null;
return cachedCloudConnector;
};
const gmailRefreshFlights = new Map<
string,
Promise<typeof connectionGrants.$inferSelect>
>();
const vercelConnect =
options.vercelConnectClient === undefined
? createVercelConnectClient()
: options.vercelConnectClient;
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()];
}
function connectedMcpConnectionFilter(companyId: string) {
return and(
eq(toolConnections.companyId, companyId),
inArray(toolConnections.transport, ["mcp_remote", "local_stdio", "rest_api"]),
eq(toolConnections.status, "active"),
eq(toolConnections.enabled, true),
// A personal connection has no company-level credential to probe. A
// credential-less health sweep can therefore mark it as errored even
// while the responsible user's grant is valid. Keep its cached active
// catalog discoverable; execution resolves and validates that user's
// grant, and a successful call restores the shared health indicator.
or(
inArray(toolConnections.healthStatus, ["ok", "healthy"]),
eq(toolConnections.credentialPolicy, "per_user"),
),
eq(toolApplications.companyId, companyId),
inArray(toolApplications.type, ["mcp_http", "mcp_stdio", "rest_api"]),
eq(toolApplications.status, "active"),
);
}
async function connectedMcpToolsForCompany(
companyId: string,
): Promise<ToolGatewayDescriptor[]> {
// Read each connection and application row once. Selecting them in the
// catalog join repeats the full connection row (config included) for
// every tool, and one connection can expose hundreds of tools. Both reads
// share one read-only snapshot, so they agree like a single join, and the
// second read gets only the connections that have listed tools.
const { connectionRows, catalogRows } = await db.transaction(
async (tx) => {
const catalogRows = await tx
.select({ catalogEntry: toolCatalogEntries })
.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),
connectedMcpConnectionFilter(companyId),
),
)
.orderBy(toolConnections.name, toolCatalogEntries.name);
const connectionIds = [
...new Set(
catalogRows.map(({ catalogEntry }) => catalogEntry.connectionId),
),
];
if (connectionIds.length === 0)
return { connectionRows: [], catalogRows };
const connectionRows = await tx
.select({
connection: toolConnections,
application: toolApplications,
})
.from(toolConnections)
.innerJoin(
toolApplications,
eq(toolConnections.applicationId, toolApplications.id),
)
.where(
and(
inArray(toolConnections.id, connectionIds),
connectedMcpConnectionFilter(companyId),
),
);
return { connectionRows, catalogRows };
},
{ isolationLevel: "repeatable read", accessMode: "read only" },
);
const connectionRowsById = new Map(
connectionRows.map((row) => [row.connection.id, row]),
);
const rows = catalogRows.flatMap(({ catalogEntry }) => {
const connectionRow = connectionRowsById.get(catalogEntry.connectionId);
return connectionRow ? [{ catalogEntry, ...connectionRow }] : [];
});
const eligibleRows = rows.filter(
({ catalogEntry, connection, application }) =>
!isRetiredComposioConnection(connection) &&
!(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) &&
((isBrowserUseConnection(connection) && application.type === "rest_api") ||
(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 `${connection.transport === "rest_api" ? "app" : "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 !== "mcp_remote" &&
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection)
) {
throw new Error(
`Non-MCP connection ${connection.id} cannot be exposed through the MCP gateway`,
);
}
const baseName = baseNames[index]!;
const unboundedGatewayToolName =
baseNameCounts.get(baseName)! > 1
? `${baseName}-${shortStableId(catalogEntry.id)}`
: baseName;
const gatewayToolName = boundedMcpToolName(unboundedGatewayToolName, [
connection.id, application.id, catalogEntry.toolName,
...(baseNameCounts.get(baseName)! > 1 ? [catalogEntry.id] : []),
]);
const applicationKey = application.applicationKey ?? null;
const inputSchema = projectedConnectionToolInputSchema(
connection,
catalogEntry.inputSchema ?? {},
catalogEntry.toolName,
);
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 as ConnectedMcpGatewayMetadata["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:
googleChatToolDescription(connection, catalogEntry.toolName, catalogEntry.description) ??
`Connected MCP tool ${catalogEntry.toolName} from ${connection.name}.`,
parametersSchema: inputSchema,
pluginId: `mcp:${applicationKey ?? application.id}`,
providerType:
connection.transport === "rest_api" ? "provider_rest" : 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,
issueId: runContextSnapshotString("issueId"),
projectId: runContextSnapshotString("projectId"),
})
.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 snapshotIssueId = stringValue(run.issueId);
const snapshotProjectId = stringValue(run.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 {
const tokenId = 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;
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),
// Cleanup can remove a token after request admission. Resolve the FK
// inside this INSERT and lock a surviving row until the statement ends.
// A plain existence read before insertion still races with deletion.
gatewayTokenId: tokenId ? sql`(select ${toolMcpGatewayTokens.id} from ${toolMcpGatewayTokens}
where ${toolMcpGatewayTokens.id} = ${tokenId} and ${toolMcpGatewayTokens.companyId} = ${input.companyId}
for key share)` : 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,
identityContextId: input.session?.identityContextId ?? 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));
const session = gatewaySessionFromRow({
...row,
lastUsedAt: now,
updatedAt: now,
});
return captureSessionIdentity(session);
}
async function captureSessionIdentity(
session: ToolGatewaySession,
): Promise<ToolGatewaySession> {
// Authentication creates a fresh operation snapshot on every invocation.
// Never trust a previously attached context on a reusable transport session.
// Approved operations restore their signed origin after authentication.
if (!session.runId || !session.agentId) return session;
const [run] = await db
.select({
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, session.runId),
eq(heartbeatRuns.companyId, session.companyId),
),
);
if (!run?.activeIdentityContextId) return session;
const captured = await captureRunIdentity(db, {
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
});
return {
...session,
identityContextId: captured.context?.id,
responsibleUserId:
captured.context?.cause === "company_default"
? null
: captured.context?.responsibleUserId,
};
}
function normalizeGatewayTokenActions(
value: unknown,
): ToolMcpGatewayTokenAction[] {
const known = new Set<ToolMcpGatewayTokenAction>([
"tools/list",
"tools/call",
"resources/list",
"resources/read",
"prompts/list",
"prompts/get",
]);
const actions = Array.isArray(value)
? value.filter((action): action is ToolMcpGatewayTokenAction =>
known.has(action as ToolMcpGatewayTokenAction),
)
: [];
return actions.length > 0 ? actions : [...known];
}
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.endsWith("/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 ||
input.session.identityContextId
? {
...metadata,
identityContextId: input.session.identityContextId ?? null,
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"
| "cancelled";
errorCode?: string | null;
errorMessage?: string | null;
resultSummary?: string | null;
}): Promise<void> {
const [linked] = await db
.select({
companyId: toolActionRequests.companyId,
interactionId: toolActionRequests.interactionId,
issueId: toolActionRequests.issueId,
})
.from(toolActionRequests)
.where(eq(toolActionRequests.id, input.actionRequestId))
.limit(1);
if (!linked?.interactionId) return;
const interactionId = linked.interactionId;
const changed = await db.transaction(async (tx) => {
const [interaction] = await tx
.select({
status: issueThreadInteractions.status,
result: issueThreadInteractions.result,
})
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.companyId, linked.companyId),
),
)
.for("update")
.limit(1);
const [currentRequest] = await tx
.select({ status: toolActionRequests.status })
.from(toolActionRequests)
.where(eq(toolActionRequests.id, input.actionRequestId))
.for("update");
if (currentRequest?.status !== input.status) return false;
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" ||
input.status === "cancelled"
? "stale_target"
: null;
if (!outcome) return;
const now = new Date();
await tx
.update(issueThreadInteractions)
.set({
...(["expired", "cancelled"].includes(input.status) &&
interaction.status === "pending"
? { status: input.status, resolvedAt: now }
: {}),
result: {
...(currentResult ?? { version: 1, outcome }),
toolAction: {
...asRecord(currentResult?.toolAction),
version: 1,
status: input.status === "cancelled" ? "expired" : 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, interactionId));
return true;
});
if (!changed) return;
await logActivity(db, {
companyId: linked.companyId,
actorType: "system",
actorId: "tool-gateway",
action: "issue.thread_interaction_updated",
entityType: "issue",
entityId: linked.issueId!,
details: {
interactionId: linked.interactionId,
actionRequestId: input.actionRequestId,
executionStatus: input.status,
},
});
if (["executed", "failed", "expired", "cancelled"].includes(input.status))
await options
.onToolActionSettled?.(input.actionRequestId)
.catch((error) =>
logger.warn(
{ err: error, actionRequestId: input.actionRequestId },
"Tool review continuation will be retried",
),
);
}
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));
void emitConnectionInvoked(db, 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));
void emitConnectionInvoked(db, 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,
identityContextId: input.session.identityContextId ?? undefined,
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));
void emitConnectionInvoked(db, 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",
resolverPolicy: "human_only",
sourceRunId: input.session.runId ?? undefined,
summary: "This action needs your approval before it runs.",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: `Approve ${input.tool.displayName?.trim() || input.tool.name}?`,
acceptLabel: "Approve action",
rejectLabel: "Reject action",
rejectRequiresReason: false,
allowDeclineReason: true,
supersedeOnUserComment: false,
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 === "read"
? "read"
: input.tool.risk === "destructive"
? "destructive"
: "write",
...(!formalApprovalId &&
input.session.agentId &&
input.tool.connectionId
? {
rememberActionScope: `This agent may use this action with different arguments on this connection${input.session.projectId ? " within this project" : ""}.`,
}
: {}),
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));
void emitConnectionInvoked(db, 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 db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.toolAction);
await tx
.insert(toolActionDeliveries)
.values({
companyId: input.session.companyId,
actionRequestId: actionRequest.id,
issueId: input.session.issueId!,
interactionId: interaction.id,
})
.onConflictDoNothing();
});
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,
};
}
/**
* Decides access for each tool of one listing. All decisions of the listing
* share one actor and run context, so they share one read cache: a listing
* reads the agent, run, profiles and policies once, not once per tool.
*/
async function decideToolsForListing<T extends ToolGatewayDescriptor>(
tools: readonly T[],
inputForTool: (tool: T) => ToolAccessDecisionInput,
cache: ToolAccessDecisionCache = createToolAccessDecisionCache(),
signal?: AbortSignal,
): Promise<Array<{ tool: T; decision: ToolAccessDecision }>> {
return mapWithConcurrency(tools, LISTING_DECISION_CONCURRENCY, async (tool) => ({
tool,
decision: await policyService.decide(inputForTool(tool), { cache }),
}), signal);
}
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 githubBotTools = await githubBotToolsForSession(db, session);
const tool = [...allTools(), ...connectedTools, ...virtualTools, ...githubBotTools, ...await slackToolsForSession(db, session), ...await spekoToolsForSession(db, session)]
.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 },
);
}
const guestBotConnection = await githubGuestBotConnectionForSession(db, session);
if (guestBotConnection && tool.connectionId && (tool.connectionId !== guestBotConnection || tool.providerType !== "paperclip_github_chat"))
throw new ToolGatewayHttpError(403, "Sponsored GitHub runs can only use their bot's governed connection; sponsorship does not grant personal credentials", "guest_connection_denied");
if (session.identityContextId && session.agentId && tool.connectionId) {
const [connection] = await db
.select()
.from(toolConnections)
.where(
and(
eq(toolConnections.id, tool.connectionId),
eq(toolConnections.companyId, session.companyId),
),
);
if (
connection?.config.sourceTemplateKey === "github" ||
connection?.transportConfig?.sourceTemplateKey === "github"
) {
let selected = await resolveManagedGitHubIdentitySelection(
db,
session.companyId,
{
agentId: session.agentId,
responsibleUserId: session.responsibleUserId,
allowStandingDelegation: false,
},
);
if (!selected.grant)
throw new ToolGatewayHttpError(
409,
selected.error ?? "No GitHub identity connected",
"github_identity_unavailable",
);
const original = selected.grant;
// Acquire before policy evaluation or dispatch. An alternate connection
// gets its own catalog descriptor and policy checks; never replay a call.
for (let attempt = 0; attempt < 2; attempt += 1) {
const grant = selected.grant!;
const target = connectedTools.find(
(candidate) =>
candidate.connectionId === grant.connectionId &&
candidate.upstreamToolName === tool.upstreamToolName &&
candidate.providerType === tool.providerType,
);
if (!target)
throw new ToolGatewayHttpError(
404,
"This GitHub tool is unavailable for the responsible person",
"github_tool_unavailable",
);
const [selectedConnection] = await db
.select()
.from(toolConnections)
.where(
and(
eq(toolConnections.id, grant.connectionId),
eq(toolConnections.companyId, session.companyId),
),
);
if (!selectedConnection)
throw new ToolGatewayHttpError(
409,
"GitHub connection is unavailable",
"github_identity_unavailable",
);
if (attempt === 0)
await resolveConnectionGrant(session, selectedConnection);
try {
const headers = await resolveCredentialHeaders(
session,
selectedConnection,
grant,
);
githubOperationCredentials.set(session, { grant, headers });
return target;
} catch (error) {
if (attempt !== 0) throw error;
const alternate = await resolveManagedGitHubIdentitySelection(
db,
session.companyId,
{
agentId: session.agentId,
responsibleUserId: session.responsibleUserId,
allowStandingDelegation: false,
excludeGrantId: original.id,
},
);
const accountId = original.providerTenant?.github?.userId;
if (
!accountId ||
!alternate.grant ||
alternate.grant.providerTenant?.github?.userId !== accountId ||
alternate.grant.subjectUserId !== original.subjectUserId ||
alternate.grant.subjectAgentId !== original.subjectAgentId
)
throw error;
selected = alternate;
}
}
throw new ToolGatewayHttpError(
409,
"GitHub credentials are unavailable",
"github_identity_unavailable",
);
}
}
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 connectedMcpToolsForCompany(session.companyId)).filter(
isOnDemandRemoteTool,
);
const decisions = await decideToolsForListing(tools, (tool) =>
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,
signal?: AbortSignal,
): Promise<ToolGatewayDescriptor[]> {
try {
return await toolDiscoveryScheduler.run(() => buildToolsForContext(session, signal), signal);
} catch (error) {
if (error instanceof ToolDiscoveryBusyError) {
throw new ToolGatewayHttpError(503, error.message, "tool_discovery_busy");
}
throw error;
}
}
/** Shared connection descriptors and task restrictions; no provider calls. */
async function connectionToolsForContext(session: ToolGatewaySession): Promise<ToolGatewayDescriptor[]> {
const guestBotConnection = await githubGuestBotConnectionForSession(db, session);
const connectedTools = (await connectedMcpToolsForCompany(session.companyId))
.filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat"));
return [
...await githubBotToolsForSession(db, session),
...await slackToolsForSession(db, session),
...await spekoToolsForSession(db, session),
...connectedTools,
];
}
async function buildToolsForContext(
session: ToolGatewaySession,
signal?: AbortSignal,
): Promise<ToolGatewayDescriptor[]> {
signal?.throwIfAborted();
if (session.agentId) {
await assertAgentInCompany(session.companyId, session.agentId);
}
const connectionTools = await connectionToolsForContext(session);
const onDemandTargets = connectionTools.filter(isOnDemandRemoteTool);
const tools = [
...allTools(),
...connectionTools.filter((tool) => !isOnDemandRemoteTool(tool)),
].filter(
(tool) =>
session.agentId ||
(tool.providerType !== "paperclip_self" &&
tool.providerType !== "paperclip_plugin"),
);
const decisionCache = createToolAccessDecisionCache();
const decisions = await decideToolsForListing(
tools,
(tool) => policyInputForTool({ session, tool }),
decisionCache,
signal,
);
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 decideToolsForListing(
onDemandTargets,
(tool) => policyInputForTool({ session, tool }),
decisionCache,
signal,
);
if (
targetDecisions.some(
({ decision }) =>
decision.allowed || decision.decision === "require_approval",
)
) {
visibleTools.push(...VIRTUAL_TOOLS);
}
}
return deduplicateToolDescriptors(visibleTools);
}
async function executeBuiltinTool(
session: ToolGatewaySession,
tool: ToolGatewayDescriptor,
parameters: unknown,
invocationId?: string,
) {
const params = asRecord(parameters) ?? {};
if (tool.providerType === "paperclip_speko_voice") {
try {
const data = await executeSpekoVoiceTool(db, session, String(asRecord(tool.providerMetadata)?.endpointId ?? ""), parameters, invocationId);
return { content: JSON.stringify(data), data };
} catch (error) {
if (error instanceof HttpError) throw new ToolGatewayHttpError(error.status, error.message, "speko_operation_rejected", asRecord(error.details) ?? {});
throw error;
}
}
if (tool.providerType === "paperclip_slack_chat") {
if (!session.agentId || !session.runId || !session.issueId) throw new ToolGatewayHttpError(403, "Slack task binding required", "slack_task_required");
try {
const data = await executeSlackTool(db, { companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId, endpointId: String(asRecord(tool.providerMetadata)?.endpointId ?? ""), identityContextId: session.identityContextId, approvedInvocationId: session.approvedSlackInvocationId }, tool.upstreamToolName ?? "", parameters, fetch, invocationId);
return { content: JSON.stringify(data), data };
} catch (error) {
if (error instanceof HttpError) {
const details = asRecord(error.details) ?? {};
throw new ToolGatewayHttpError(error.status, error.message, typeof details.code === "string" ? details.code : "slack_operation_rejected", details);
}
throw error;
}
}
if (tool.providerType === "paperclip_github_chat") {
const data = await githubChatReviewService(db).execute(session, tool.upstreamToolName ?? "", parameters, invocationId);
return { content: JSON.stringify(data), data };
}
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();
}
async function resolvedRemoteEndpoint(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
): Promise<string> {
const publicEndpoint = remoteEndpoint(connection.config ?? {});
const ref = (connection.credentialRefs ?? []).find(
(candidate) => candidate.placement === "url",
);
if (!ref) return publicEndpoint;
const grantRef = grantRefForCredential(grant, ref);
if (!grantRef) {
throw new ToolGatewayHttpError(
422,
"A configured credential secret could not be resolved.",
"mcp_remote_missing_secret",
{
connectionId: connection.id,
credential: REMOTE_URL_SECRET_CONFIG_PATH,
},
);
}
const value = await resolveGrantSecretValue(
session,
connection,
grant,
grantRef,
);
if (!remoteUrlCredentialMatchesPublicUrl(publicEndpoint, value)) {
throw new ToolGatewayHttpError(
422,
"The stored MCP URL credential no longer matches this connection.",
"mcp_remote_url_credential_mismatch",
{ connectionId: connection.id },
);
}
return parseRemoteHttpEndpoint(
value,
(message, code) => new ToolGatewayHttpError(422, message, code),
).toString();
}
function allowPrivateRemoteEndpoints() {
return (
options.deploymentMode !== "authenticated" ||
options.deploymentExposure !== "public"
);
}
/**
* Guard options for every outbound call to an operator-supplied MCP endpoint.
*
* The private-network check no longer runs as a standalone pre-flight: it is
* part of `guardedRemoteHttpFetch`, which keeps the approved address and dials
* it directly. Splitting validation from dispatch is what created the
* DNS-rebinding TOCTOU in PAP-17098.
*/
function remoteHttpFetchOptions(): GuardedRemoteHttpFetchOptions {
return {
allowPrivateNetwork: allowPrivateRemoteEndpoints(),
error: (message, code) => new ToolGatewayHttpError(422, message, code),
};
}
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" | "degraded",
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));
}
function grantRefForCredential(
grant: typeof connectionGrants.$inferSelect,
ref: McpConnectionCredentialRef,
): ToolCredentialSecretRef | undefined {
return connectionGrantCredentialRef(grant, ref);
}
async function resolveGrantSecretValue(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
ref: ToolCredentialSecretRef,
): Promise<string> {
try {
return (await resolveConnectionGrantSecret(db, connection, grant, ref, {
consumerType: "tool_connection", consumerId: connection.id,
actorType: "system", actorId: session.agentId,
responsibleUserId: grant.subjectUserId, issueId: session.issueId, heartbeatRunId: session.runId,
})).value;
} catch (error) {
if (error instanceof HttpError) throw new ToolGatewayHttpError(error.status, error.message,
String(asRecord(error.details)?.code ?? "mcp_remote_missing_secret"), asRecord(error.details) ?? {});
throw error;
}
}
async function maybeRefreshPaperclipCloudGrant(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
forceRefresh = false,
): Promise<typeof connectionGrants.$inferSelect> {
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (!oauth || !isPaperclipCloudConnectorStrategy(oauth.strategy))
return grant;
const configuredProfile = oauth.connectorProfile;
const connectorProfile:
GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId =
configuredProfile === undefined
? "gmail.draft"
: typeof configuredProfile === "string" &&
(isGoogleWorkspaceConnectorProfileId(configuredProfile) ||
isGitHubConnectorProfileId(configuredProfile))
? configuredProfile
: (() => {
throw new ToolGatewayHttpError(
422,
"Managed authorization has an invalid connector profile",
"connector_profile_invalid",
{
connectionId: connection.id,
grantId: grant.id,
},
);
})();
const connectorSubject =
typeof oauth.connectorSubjectAgentId === "string"
? `agent:${oauth.connectorSubjectAgentId}`
: typeof oauth.connectorSubjectUserId === "string"
? oauth.connectorSubjectUserId
: grant.kind === "agent" && grant.subjectAgentId
? `agent:${grant.subjectAgentId}`
: grant.subjectUserId;
const grantOauth = asRecord(asRecord(grant.providerTenant)?.oauth);
const expiresAt =
typeof grantOauth?.accessTokenExpiresAt === "string"
? Date.parse(grantOauth.accessTokenExpiresAt)
: Number.NaN;
const currentTime = options.now?.() ?? Date.now();
// The preferred GitHub App policy yields a non-expiring ghu_ token and no
// refresh token. Absence of an expiry is deliberate, not an invitation to
// enter the rotation path.
if (
grantOauth?.accessTokenExpiresAt === null ||
grantOauth?.accessTokenExpiresAt === undefined
)
return grant;
const refreshedAt =
typeof grantOauth.refreshedAt === "string"
? Date.parse(grantOauth.refreshedAt)
: Number.NaN;
const rotationDue =
!Number.isFinite(refreshedAt) ||
refreshedAt <= currentTime - 30 * 24 * 60 * 60_000;
if (
!forceRefresh &&
Number.isFinite(expiresAt) &&
expiresAt > currentTime + 60 * 60_000 &&
!rotationDue
)
return grant;
if (oauth.strategy === "paperclip_id_connector") {
// Paperclip ID used different endpoints, signing metadata, envelope
// purposes, and a different Google client. Its refresh token cannot be
// exchanged through Paperclip Cloud. Let an unexpired access token finish
// its useful life, then require an explicit managed-connector enrollment
// and provider reconnect instead of sending it to the wrong client.
await db
.update(connectionGrants)
.set({
status: "needs_reauthorization",
updatedAt: new Date(currentTime),
})
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(
409,
"Legacy authorization must be reconnected through Paperclip Cloud",
"connector_reauthorization_required",
{
connectionId: connection.id,
grantId: grant.id,
},
);
}
const existingFlight = gmailRefreshFlights.get(grant.id);
if (existingFlight) return existingFlight;
const refresh = (async () => {
const cloudConnector = currentCloudConnector();
if (!cloudConnector || !connectorSubject) {
await db
.update(connectionGrants)
.set({
status: "needs_reauthorization",
updatedAt: new Date(currentTime),
})
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(
409,
"Managed authorization must be reconnected",
"connector_reauthorization_required",
{
connectionId: connection.id,
grantId: grant.id,
},
);
}
const accessRef = grant.credentialSecretRefs.find(
(ref) => ref.configPath === "oauth.access_token",
);
const refreshRef = grant.credentialSecretRefs.find(
(ref) => ref.configPath === "oauth.refresh_token",
);
if (!accessRef || !refreshRef) {
await db
.update(connectionGrants)
.set({
status: "needs_reauthorization",
updatedAt: new Date(currentTime),
})
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(
409,
"Managed authorization must be reconnected",
"connector_reauthorization_required",
{
connectionId: connection.id,
grantId: grant.id,
},
);
}
const refreshToken = await resolveGrantSecretValue(
session,
connection,
grant,
refreshRef,
);
try {
const credentials = await cloudConnector.refresh({
subject: connectorSubject,
companyId: connection.companyId,
profile: connectorProfile,
refreshToken,
});
await secrets.rotate(accessRef.secretId, {
value: credentials.accessToken,
});
if (credentials.refreshToken) {
await secrets.rotate(refreshRef.secretId, {
value: credentials.refreshToken,
});
}
const providerTenant = {
...(grant.providerTenant ?? {}),
oauth: {
...(grant.providerTenant?.oauth ?? {}),
strategy: "paperclip_cloud_connector",
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
scopes: credentials.scopes,
tokenType: credentials.tokenType,
refreshedAt: new Date(options.now?.() ?? Date.now()).toISOString(),
...(credentials.refreshTokenExpiresAt
? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt }
: {}),
},
};
const [updated] = await db
.update(connectionGrants)
.set({
providerTenant,
updatedAt: new Date(options.now?.() ?? Date.now()),
})
.where(
and(
eq(connectionGrants.id, grant.id),
eq(connectionGrants.status, "active"),
),
)
.returning();
if (!updated) {
throw new ToolGatewayHttpError(
409,
"Managed authorization is no longer active",
"connector_reauthorization_required",
{
connectionId: connection.id,
grantId: grant.id,
},
);
}
return updated;
} catch (error) {
if (error instanceof ToolGatewayHttpError) throw error;
if (
error instanceof PaperclipCloudConnectorError &&
error.code === "REAUTHORIZATION_REQUIRED"
) {
await db
.update(connectionGrants)
.set({
status: "needs_reauthorization",
updatedAt: new Date(options.now?.() ?? Date.now()),
})
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(
409,
"Managed authorization must be reconnected",
"connector_reauthorization_required",
{
connectionId: connection.id,
grantId: grant.id,
},
);
}
throw new ToolGatewayHttpError(
502,
"Managed authorization could not be refreshed",
"connector_refresh_failed",
{
connectionId: connection.id,
grantId: grant.id,
},
);
}
})();
gmailRefreshFlights.set(grant.id, refresh);
try {
return await refresh;
} finally {
if (gmailRefreshFlights.get(grant.id) === refresh)
gmailRefreshFlights.delete(grant.id);
}
}
async function resolveCredentialHeaders(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
resolveOptions: { forceRefresh?: boolean } = {},
): Promise<Record<string, string>> {
const tracked =
session.identityContextId &&
(connection.config.sourceTemplateKey === "github" ||
connection.transportConfig?.sourceTemplateKey === "github");
try {
const captured = githubOperationCredentials.get(session);
const headers =
!resolveOptions.forceRefresh && captured?.grant.id === grant.id
? captured.headers
: await resolveCredentialHeadersUnrecorded(
session,
connection,
grant,
resolveOptions,
);
if (tracked)
await db
.update(runIdentityContexts)
.set({
github: {
status: "available",
login: grant.providerTenant?.github?.login,
source: grant.kind === "agent" ? "dedicated" : "personal",
connectionId: connection.id,
grantId: grant.id,
authenticationMode: "managed",
},
})
.where(
and(
eq(runIdentityContexts.id, session.identityContextId!),
eq(runIdentityContexts.companyId, session.companyId),
),
);
return headers;
} catch (error) {
if (tracked)
await db
.update(runIdentityContexts)
.set({
github: {
status: "unavailable",
reason: "GitHub authorization is unavailable",
source: grant.kind === "agent" ? "dedicated" : "personal",
},
})
.where(
and(
eq(runIdentityContexts.id, session.identityContextId!),
eq(runIdentityContexts.companyId, session.companyId),
),
);
throw error;
}
}
async function resolveCredentialHeadersUnrecorded(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
resolveOptions: { forceRefresh?: boolean } = {},
): Promise<Record<string, string>> {
if (connection.credentialSource === "vercel_connect") {
if (!connection.externalCredential || !vercelConnect) {
throw new ToolGatewayHttpError(
503,
"Vercel Connect is not configured",
"vercel_connect_unavailable",
{
connectionId: connection.id,
grantId: grant.id,
},
);
}
const request = vercelTokenRequest({
credential: connection.externalCredential,
grant,
connectionId: connection.id,
companyId: connection.companyId,
});
try {
const token = await vercelConnect.getToken(request, resolveOptions);
if (
token.connector.id !== connection.externalCredential.connectorId &&
token.connector.uid !== connection.externalCredential.connectorUid
) {
throw new VercelConnectClientError(
"vercel_connect_request_failed",
502,
);
}
await db
.update(connectionGrants)
.set({
externalCredential: vercelGrantReference({
credential: connection.externalCredential,
token,
subjectId: grant.externalCredential?.subjectId,
verifiedAt: new Date(options.now?.() ?? Date.now()),
}),
status: "active",
revokedAt: null,
updatedAt: new Date(options.now?.() ?? Date.now()),
})
.where(
and(
eq(connectionGrants.id, grant.id),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
),
);
return {
[connection.externalCredential.headerName]:
`${connection.externalCredential.headerPrefix ?? ""}${token.token}`,
};
} catch (error) {
if (
error instanceof VercelConnectClientError &&
error.code === "vercel_connect_authorization_required"
) {
await db
.update(connectionGrants)
.set({
status: "needs_reauthorization",
updatedAt: new Date(options.now?.() ?? Date.now()),
})
.where(
and(
eq(connectionGrants.id, grant.id),
eq(connectionGrants.companyId, connection.companyId),
),
);
const responsibleUserId =
grant.kind === "user"
? grant.subjectUserId
: session.responsibleUserId;
if (responsibleUserId) {
await createUserAuthorizationInteraction(
session,
connection,
responsibleUserId,
grant.kind === "user" ? "user" : "organization",
);
}
}
const code =
error instanceof VercelConnectClientError
? error.code
: "vercel_connect_request_failed";
const status =
error instanceof VercelConnectClientError ? error.status : 502;
const message =
error instanceof VercelConnectClientError
? error.message
: "Vercel Connect could not resolve this credential.";
await markRemoteConnectionHealth(
connection,
code === "vercel_connect_unavailable" ||
code === "vercel_connect_auth_failed" ||
code === "vercel_connect_installation_required"
? "degraded"
: "error",
message,
);
throw new ToolGatewayHttpError(status, message, code, {
connectionId: connection.id,
grantId: grant.id,
});
}
}
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (
isPaperclipCloudConnectorStrategy(oauth?.strategy) &&
!options.oauthGrantRefresher
) {
// Compatibility fallback for isolated service consumers. The production
// app supplies tool-access's lease/CAS refresher below.
grant = await maybeRefreshPaperclipCloudGrant(
session,
connection,
grant,
resolveOptions.forceRefresh === true,
);
}
if (
connection.authKind === "oauth" &&
connection.credentialSource === "paperclip_vault" &&
options.oauthGrantRefresher
) {
try {
grant = await options.oauthGrantRefresher({
companyId: connection.companyId,
connectionId: connection.id,
grantId: grant.id,
forceRefresh: resolveOptions.forceRefresh,
actor: {
actorType:
session.actorType ?? (session.agentId ? "agent" : "system"),
actorId: session.actorId ?? session.agentId,
},
issueId: session.issueId,
heartbeatRunId: session.runId,
});
} catch (error) {
if (error instanceof ToolGatewayHttpError) throw error;
const record = asRecord(error);
const details = asRecord(record?.details) ?? {};
const status = typeof record?.status === "number" ? record.status : 502;
const reasonCode =
typeof details.code === "string"
? details.code
: "oauth_refresh_failed";
const message =
error instanceof Error
? error.message
: "OAuth authorization could not be refreshed";
throw new ToolGatewayHttpError(status, message, reasonCode, {
...details,
connectionId: connection.id,
grantId: grant.id,
});
}
}
const headers: Record<string, string> = {};
for (const ref of connection.credentialRefs ?? []) {
if (ref.placement !== "header") continue;
const grantRef = grantRefForCredential(grant, ref);
if (!grantRef) continue;
try {
const value = await resolveGrantSecretValue(
session,
connection,
grant,
grantRef,
);
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
} catch (error) {
await markRemoteConnectionHealth(
connection,
"missing_secret",
"A configured credential secret could not be resolved.",
);
if (error instanceof ToolGatewayHttpError && error.reasonCode === "grant_credential_invalid") throw error;
throw new ToolGatewayHttpError(
422,
"A configured credential secret could not be resolved.",
"mcp_remote_missing_secret",
{ connectionId: connection.id, credential: ref.name },
);
}
}
const oauthAccessRef = grant.credentialSecretRefs.find(
(ref) => ref.configPath === "oauth.access_token",
);
if (oauthAccessRef && headers.Authorization === undefined) {
try {
const value = await resolveGrantSecretValue(
session,
connection,
grant,
oauthAccessRef,
);
headers.Authorization = `Bearer ${value}`;
} catch (error) {
await markRemoteConnectionHealth(
connection,
"missing_secret",
"A configured credential secret could not be resolved.",
);
if (error instanceof ToolGatewayHttpError && error.reasonCode === "grant_credential_invalid") throw error;
throw new ToolGatewayHttpError(
422,
"A configured credential secret could not be resolved.",
"mcp_remote_missing_secret",
{
connectionId: connection.id,
credential: oauthAccessRef.configPath,
},
);
}
}
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,
);
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,
grant: typeof connectionGrants.$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" && ref.placement !== "url") continue;
const typedRef = ref as McpConnectionCredentialRef;
const grantRef = grantRefForCredential(grant, typedRef);
if (!grantRef) continue;
const configPath =
typedRef.placement === "url"
? REMOTE_URL_SECRET_CONFIG_PATH
: grantRef.configPath;
headerCredentialVersions.push(
await resolveConnectedCredentialVersion(connection, {
secretId: grantRef.secretId,
versionSelector: grantRef.versionSelector,
configPath,
refHash: credentialVersionRefHash({
kind: typedRef.placement === "url" ? "url" : "header",
name: typedRef.name,
secretId: grantRef.secretId,
placement: typedRef.placement,
key: typedRef.key,
prefix: typedRef.prefix ?? null,
configPath,
}),
requireResolved: options.requireResolved,
}),
);
}
for (const ref of grant.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 createUserAuthorizationInteraction(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
userId: string,
grantKind: "organization" | "user" = "user",
) {
if (!session.issueId || !session.agentId || !session.runId) return;
const [company] = await db
.select({ issuePrefix: companies.issuePrefix })
.from(companies)
.where(eq(companies.id, session.companyId))
.limit(1);
const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/permissions`;
const idempotencyKey = `connection-authorization:${connection.id}:${userId}`;
const payload = {
version: 1 as const,
prompt:
grantKind === "organization"
? `Reconnect the ${connection.name} organization identity to continue`
: `Connect your ${connection.name} account to continue`,
acceptLabel:
grantKind === "organization"
? "Reconnect organization"
: "Connect account",
rejectLabel: "Not now",
detailsMarkdown:
grantKind === "organization"
? "Vercel Connect reports that the shared organization identity needs authorization."
: "This run needs your personal authorization. Paperclip will not use another user's identity.",
target: {
type: "custom" as const,
key: `connection:${connection.uid}:user:${userId}`,
revisionId: connection.updatedAt.toISOString(),
label: `Connect ${connection.name}`,
href,
},
};
const [existing] = await db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, session.companyId),
eq(issueThreadInteractions.issueId, session.issueId),
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
),
)
.limit(1);
if (existing) {
await db
.update(issueThreadInteractions)
.set({
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
addresseeUserId: userId,
payload,
result: null,
resolvedAt: null,
updatedAt: new Date(),
})
.where(eq(issueThreadInteractions.id, existing.id));
return;
}
await db.insert(issueThreadInteractions).values({
companyId: session.companyId,
issueId: session.issueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
idempotencyKey,
sourceRunId: session.runId,
title:
grantKind === "organization"
? `Reconnect ${connection.name}`
: `Connect your ${connection.name}`,
summary:
grantKind === "organization"
? "Organization authorization is required before this run can continue."
: "Personal authorization is required before this run can continue.",
createdByAgentId: session.agentId,
addresseeUserId: userId,
payload,
});
}
async function resolveConnectionGrant(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
allowInteraction = true,
): Promise<typeof connectionGrants.$inferSelect> {
const [run] =
session.runId && !session.identityContextId
? await db
.select({
responsibleUserId: heartbeatRuns.responsibleUserId,
invocationSource: heartbeatRuns.invocationSource,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, session.runId),
eq(heartbeatRuns.companyId, session.companyId),
),
)
.limit(1)
: [];
const actingUserId = session.identityContextId
? (session.responsibleUserId ?? null)
: (run?.responsibleUserId ?? session.responsibleUserId ?? null);
if (
session.identityContextId &&
session.agentId &&
(connection.config.sourceTemplateKey === "github" ||
connection.transportConfig?.sourceTemplateKey === "github")
) {
const captured = githubOperationCredentials.get(session);
const selected = captured
? { grant: captured.grant, error: undefined }
: await resolveManagedGitHubIdentitySelection(db, session.companyId, {
agentId: session.agentId,
responsibleUserId: session.responsibleUserId,
allowStandingDelegation: false,
});
if (!selected.grant || selected.grant.connectionId !== connection.id) {
throw new ToolGatewayHttpError(
409,
selected.error ??
"GitHub identity changed; retry through the managed tool",
"github_identity_unavailable",
);
}
if (selected.grant.kind === "user") {
const [member] = await db
.select({ role: companyMemberships.membershipRole })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, session.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, selected.grant.subjectUserId!),
eq(companyMemberships.status, "active"),
),
)
.limit(1);
if (!member || member.role === "viewer") {
throw new ToolGatewayHttpError(
403,
"The personal grant owner is not an authorized company member",
"grant_owner_membership_inactive",
);
}
}
// Managed GitHub selection is final: a legacy shared policy cannot replace
// the captured person's grant with an organization or teammate's account.
return selected.grant;
}
const autonomous =
!session.identityContextId &&
(run?.invocationSource === "automation" ||
run?.invocationSource === "timer");
const findUserGrant = async () => {
if (!actingUserId) return undefined;
const [membership] = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, actingUserId),
eq(companyMemberships.status, "active"),
),
)
.limit(1);
if (!membership) {
throw new ToolGatewayHttpError(
403,
"The personal grant owner is not an active company member",
"grant_owner_membership_inactive",
{
connectionId: connection.id,
actingUserId,
remediation: { action: "restore_membership_or_reconnect" },
},
);
}
const [grant] = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, actingUserId),
eq(connectionGrants.status, "active"),
),
)
.limit(1);
return grant;
};
const findOrganizationGrant = async () => {
const [grant] = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "organization"),
eq(connectionGrants.isDefault, true),
eq(connectionGrants.status, "active"),
),
)
.limit(1);
if (!grant) {
throw new ToolGatewayHttpError(
409,
"Organization authorization is required",
"organization_authorization_required",
{
connectionId: connection.id,
},
);
}
const members = await db
.select({ subjectId: connectionGrantMembers.subjectId })
.from(connectionGrantMembers)
.where(
and(
eq(connectionGrantMembers.companyId, connection.companyId),
eq(connectionGrantMembers.grantId, grant.id),
eq(connectionGrantMembers.subjectType, "user"),
),
);
const activeAudienceMember = actingUserId
? await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, actingUserId),
eq(companyMemberships.status, "active"),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: null;
if (
!isConnectionGrantAudienceAllowed(
members.map((member) => member.subjectId),
actingUserId,
Boolean(activeAudienceMember),
)
) {
throw new ToolGatewayHttpError(
403,
"The acting user is not in this grant's audience",
"grant_audience_denied",
{
connectionId: connection.id,
grantId: grant.id,
actingUserId,
},
);
}
return grant;
};
if (connection.credentialPolicy === "per_agent") {
if (!session.agentId) {
throw new ToolGatewayHttpError(
409,
"A dedicated agent authorization is required",
"agent_authorization_required",
{
connectionId: connection.id,
},
);
}
const [agentGrant] = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "agent"),
eq(connectionGrants.subjectAgentId, session.agentId),
eq(connectionGrants.status, "active"),
),
)
.limit(1);
if (!agentGrant) {
throw new ToolGatewayHttpError(
409,
"This agent's dedicated authorization is not connected",
"agent_authorization_required",
{
connectionId: connection.id,
agentId: session.agentId,
},
);
}
return agentGrant;
}
// The owner-selected connection install is the consent boundary for agent use.
// `responsibleUserId` is resolved and persisted by the control plane, never
// accepted from agent input, so a run carrying it uses that owner's grant
// directly. Delegation is reserved for genuinely ownerless unattended runs.
let userGrant =
connection.credentialPolicy === "shared"
? undefined
: await findUserGrant();
if (
!userGrant &&
!actingUserId &&
autonomous &&
session.agentId &&
connection.credentialPolicy !== "shared"
) {
const delegated = await db
.select({ grant: connectionGrants })
.from(connectionGrantDelegations)
.innerJoin(
connectionGrants,
and(
eq(connectionGrants.id, connectionGrantDelegations.grantId),
eq(
connectionGrants.companyId,
connectionGrantDelegations.companyId,
),
),
)
.where(
and(
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.agentId, session.agentId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.status, "active"),
),
);
if (delegated.length > 1) {
throw new ToolGatewayHttpError(
409,
"More than one delegated personal authorization matches this autonomous run",
"ambiguous_personal_grant",
{
connectionId: connection.id,
agentId: session.agentId,
},
);
}
userGrant = delegated[0]?.grant;
if (userGrant?.subjectUserId) {
const [membership] = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userGrant.subjectUserId),
eq(companyMemberships.status, "active"),
),
)
.limit(1);
if (!membership) {
throw new ToolGatewayHttpError(
403,
"The delegated personal grant owner is not an active company member",
"grant_owner_membership_inactive",
{
connectionId: connection.id,
grantId: userGrant.id,
},
);
}
}
}
const resolution = userGrant
? "user"
: resolveCredentialGrantKind(
connection.credentialPolicy,
actingUserId,
false,
);
if (resolution === "user" && userGrant) return userGrant;
if (resolution === "user_authorization_required") {
if (actingUserId && allowInteraction)
await createUserAuthorizationInteraction(
session,
connection,
actingUserId,
);
throw new ToolGatewayHttpError(
409,
"User authorization is required",
"user_authorization_required",
{
connectionId: connection.id,
actingUserId,
},
);
}
return findOrganizationGrant();
}
async function resolveConnectedRemoteTool(
session: ToolGatewaySession,
tool: ToolGatewayDescriptor,
) {
if (
(tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") ||
!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" && !isBrowserUseConnection(connection))) {
throw new ToolGatewayHttpError(
404,
`Tool "${tool.name}" not found`,
"tool_not_found",
);
}
if (isRetiredComposioConnection(connection)) {
throw new ToolGatewayHttpError(422, RETIRED_COMPOSIO_MESSAGE, "composio_broker_retired");
}
if (!connection.enabled || connection.status !== "active") {
throw new ToolGatewayHttpError(
403,
"Connection is disabled.",
"mcp_remote_connection_disabled",
{
connectionId: connection.id,
},
);
}
if (isRailwayEndpoint(connection.config.url) && isRailwayToolBlocked(entry.toolName)) {
throw new ToolGatewayHttpError(403, "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", "railway_action_blocked");
}
return { entry, connection };
}
async function governedToolArguments(
session: ToolGatewaySession,
tool: ToolGatewayDescriptor,
parameters: unknown,
): Promise<unknown> {
if (tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") return parameters;
const { connection, entry } = await resolveConnectedRemoteTool(session, tool);
return projectedConnectionToolArguments(connection, parameters, entry.toolName, entry.inputSchema ?? {});
}
async function approvedManagedArgumentsRemainCurrent(
session: ToolGatewaySession,
tool: ToolGatewayDescriptor,
reviewedParameters: unknown,
): Promise<boolean> {
const currentParameters = await governedToolArguments(
session,
tool,
reviewedParameters,
);
return (
stableSerialize(currentParameters) === stableSerialize(reviewedParameters)
);
}
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 (isRetiredComposioConnection(connection)) {
throw new ToolGatewayHttpError(422, RETIRED_COMPOSIO_MESSAGE, "composio_broker_retired");
}
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 ?? [],
};
}
async function localStdioEnvironment(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
template: LocalStdioRuntimeTemplate,
grant: typeof connectionGrants.$inferSelect,
): Promise<NodeJS.ProcessEnv> {
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 grantRef = grant.credentialSecretRefs.find(
(ref) => ref.configPath === `env.${key}`,
);
if (!grantRef) continue;
try {
env[key] = await resolveGrantSecretValue(
session,
connection,
grant,
grantRef,
);
} catch {
await markRemoteConnectionHealth(
connection,
"missing_secret",
"A configured local stdio credential could not be resolved.",
);
throw new ToolGatewayHttpError(
422,
"A configured local stdio credential could not be resolved.",
"local_stdio_missing_secret",
{ connectionId: connection.id, credential: grantRef.configPath },
);
}
}
if (template.templateId === "paperclip.cognee-cloud") {
try { cogneeCloudUrl(env.COGNEE_BASE_URL ?? ""); }
catch {
throw new ToolGatewayHttpError(422, "Reconnect Cognee with the tenant API Base URL from its API Keys page.", "cognee_cloud_url_invalid");
}
}
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;
env: NodeJS.ProcessEnv;
parameters?: unknown;
protocolMethod?: string;
protocolParams?: Record<string, unknown>;
timeoutMs: number;
}): Promise<unknown> {
if (input.template.templateId === "paperclip.cognee-cloud") {
if (input.protocolMethod === "resources/list") return { resources: [] };
if (input.protocolMethod === "prompts/list") return { prompts: [] };
if (input.protocolMethod && input.protocolMethod !== "tools/call") {
throw stdioProtocolError("Cognee does not expose this context operation");
}
return callCogneeCloud({
baseUrl: input.env.COGNEE_BASE_URL ?? "", apiKey: input.env.COGNEE_API_KEY ?? "",
tool: input.entry?.toolName ?? "", parameters: asRecord(input.parameters) ?? {},
signal: AbortSignal.timeout(input.timeoutMs),
request: async (url, init) => {
const response = await guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions());
const body = await readBoundedRemoteResponse(response);
if (!response.ok) throw new ToolGatewayHttpError(502,
`Cognee Cloud request failed (HTTP ${response.status}).`, "cognee_api_error");
if (!body) return { status: "success" };
try { return JSON.parse(body); }
catch { throw new ToolGatewayHttpError(502, "Cognee Cloud returned invalid JSON.", "cognee_api_error"); }
},
});
}
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: input.env,
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 ?? null,
},
),
);
}
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 ?? null,
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 ?? null,
},
),
);
}
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 ?? null,
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(
input.protocolMethod ?? "tools/call",
input.protocolParams ?? {
name: input.entry?.toolName,
arguments: input.parameters ?? {},
},
);
} finally {
clearTimeout(timer);
child.stdin.end();
child.kill("SIGTERM");
await exitPromise.catch(() => undefined);
}
}
async function fullyAssignedMcpConnections(session: ToolGatewaySession) {
if (!session.gatewayProfileId) return [];
return db
.select({ connection: toolConnections })
.from(toolProfileEntries)
.innerJoin(
toolConnections,
eq(toolProfileEntries.connectionId, toolConnections.id),
)
.where(
and(
eq(toolProfileEntries.companyId, session.companyId),
eq(toolProfileEntries.profileId, session.gatewayProfileId),
eq(toolProfileEntries.selectorType, "connection"),
eq(toolProfileEntries.effect, "include"),
eq(toolConnections.companyId, session.companyId),
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
inArray(toolConnections.transport, ["mcp_remote", "local_stdio"]),
),
)
.then((rows) => rows.map((row) => row.connection));
}
async function callRemoteConnectionProtocol(input: {
session: ToolGatewaySession;
connection: typeof toolConnections.$inferSelect;
method: string;
params: Record<string, unknown>;
callerHeaders?: Record<string, string | string[] | undefined>;
}): Promise<unknown> {
const grant = await resolveConnectionGrant(input.session, input.connection);
const endpoint = await resolvedRemoteEndpoint(
input.session,
input.connection,
grant,
);
const credentialHeaders = {
...projectedConnectionHeaders(input.connection),
...(await resolveCredentialHeaders(
input.session,
input.connection,
grant,
)),
};
const { headers } = buildRemoteHeaders({
session: input.session,
connection: input.connection,
credentialHeaders,
callerHeaders: input.callerHeaders,
});
const response = await guardedRemoteHttpFetch(
endpoint,
{
method: "POST",
redirect: "manual",
headers: mcpHttpRequestHeaders(headers),
body: JSON.stringify({
jsonrpc: "2.0",
id: `paperclip-context-${randomUUID()}`,
method: input.method,
params: input.params,
}),
},
remoteHttpFetchOptions(),
);
const body = await readBoundedRemoteResponse(response);
if (!response.ok) {
await markRemoteConnectionHealth(
input.connection,
"error",
`Remote MCP server failed ${input.method}.`,
);
throw new ToolGatewayHttpError(
502,
"Remote MCP context request failed",
"mcp_remote_status",
{
status: response.status,
connectionId: input.connection.id,
method: input.method,
},
);
}
let payload: unknown;
try {
payload = parseMcpHttpResponseBody(
body,
response.headers.get("content-type"),
);
} catch {
throw new ToolGatewayHttpError(
502,
"Remote MCP context response was invalid",
"mcp_remote_invalid_json",
{
connectionId: input.connection.id,
method: input.method,
},
);
}
const record = asRecord(payload);
if (
!record ||
record.error !== undefined ||
!Object.prototype.hasOwnProperty.call(record, "result")
) {
throw new ToolGatewayHttpError(
502,
"Remote MCP context request returned an error",
"remote_mcp_error",
{
connectionId: input.connection.id,
method: input.method,
},
);
}
await markRemoteConnectionHealth(
input.connection,
"ok",
`Remote MCP server responded to ${input.method}.`,
);
return record.result;
}
async function callAssignedConnectionProtocol(input: {
session: ToolGatewaySession;
connection: typeof toolConnections.$inferSelect;
method: string;
params?: Record<string, unknown>;
callerHeaders?: Record<string, string | string[] | undefined>;
}): Promise<unknown> {
if (input.connection.transport === "mcp_remote") {
return callRemoteConnectionProtocol({
...input,
params: input.params ?? {},
});
}
if (input.connection.transport !== "local_stdio") {
throw new ToolGatewayHttpError(
501,
"Assigned MCP connection transport is unsupported",
"mcp_transport_unsupported",
);
}
const template = await resolveLocalStdioRuntimeTemplate(input.connection);
const grant = await resolveConnectionGrant(input.session, input.connection);
const env = await localStdioEnvironment(
input.session,
input.connection,
template,
grant,
);
if (template.templateId === "paperclip.cognee-cloud") {
return callLocalStdioMcp({ connection: input.connection, template, env,
protocolMethod: input.method, protocolParams: input.params ?? {}, timeoutMs: DEFAULT_TOOL_TIMEOUT_MS });
}
return runtimeSupervisor.useConnectionSlot(
{
companyId: input.session.companyId,
applicationId: input.connection.applicationId,
connectionId: input.connection.id,
connectionKey: `mcp:${input.session.companyId}:${input.connection.id}`,
runId: input.session.runId,
issueId: input.session.issueId,
agentId: input.session.agentId,
commandTemplateKey: template.templateId,
metadata: {
source: "native-runtime-context",
protocolMethod: input.method,
},
},
async () =>
callLocalStdioMcp({
connection: input.connection,
template,
env,
protocolMethod: input.method,
protocolParams: input.params ?? {},
timeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
}),
);
}
function contextHandle(
kind: "resource" | "prompt",
connectionId: string,
value: string,
) {
return `paperclip-${kind}://${connectionId}/${Buffer.from(value, "utf8").toString("base64url")}`;
}
function parseContextHandle(kind: "resource" | "prompt", value: unknown) {
if (typeof value !== "string") return null;
const match = value.match(
new RegExp(`^paperclip-${kind}://([0-9a-f-]{36})/([A-Za-z0-9_-]+)$`, "i"),
);
if (!match) return null;
try {
return {
connectionId: match[1]!,
value: Buffer.from(match[2]!, "base64url").toString("utf8"),
};
} catch {
return null;
}
}
async function namedGatewayContextProtocol(input: {
gatewayId?: string | null;
gatewayPublicId?: string | null;
bearerToken: string;
method:
"resources/list" | "resources/read" | "prompts/list" | "prompts/get";
params?: Record<string, unknown>;
callerHeaders?: Record<string, string | string[] | undefined>;
}) {
const session = await namedGatewaySessionFromBearer({
gatewayId: input.gatewayId ?? null,
gatewayPublicId: input.gatewayPublicId ?? null,
bearerToken: input.bearerToken,
protocolMethod: input.method,
callerHeaders: input.callerHeaders,
});
await assertGatewayTokenAction(session, input.method);
const connections = await fullyAssignedMcpConnections(session);
if (input.method === "resources/list") {
const resources = [] as Array<Record<string, unknown>>;
for (const connection of connections) {
const result = asRecord(
await callAssignedConnectionProtocol({
...input,
session,
connection,
method: input.method,
}),
);
for (const resource of Array.isArray(result?.resources)
? result.resources
: []) {
const record = asRecord(resource);
if (!record || typeof record.uri !== "string") continue;
resources.push({
...record,
uri: contextHandle("resource", connection.id, record.uri),
name: `${connection.name}: ${typeof record.name === "string" ? record.name : record.uri}`,
});
}
}
return { resources };
}
if (input.method === "prompts/list") {
const prompts = [] as Array<Record<string, unknown>>;
for (const connection of connections) {
const result = asRecord(
await callAssignedConnectionProtocol({
...input,
session,
connection,
method: input.method,
}),
);
for (const prompt of Array.isArray(result?.prompts)
? result.prompts
: []) {
const record = asRecord(prompt);
if (!record || typeof record.name !== "string") continue;
prompts.push({
...record,
name: contextHandle("prompt", connection.id, record.name),
title: `${connection.name}: ${typeof record.title === "string" ? record.title : record.name}`,
});
}
}
return { prompts };
}
const kind = input.method === "resources/read" ? "resource" : "prompt";
const handle = parseContextHandle(
kind,
input.params?.[kind === "resource" ? "uri" : "name"],
);
const connection = handle
? connections.find((candidate) => candidate.id === handle.connectionId)
: null;
if (!handle || !connection) {
throw new ToolGatewayHttpError(
404,
`Assigned MCP ${kind} was not found`,
`mcp_${kind}_not_found`,
);
}
const params =
kind === "resource"
? { uri: handle.value }
: { name: handle.value, arguments: input.params?.arguments ?? {} };
const result = asRecord(
await callAssignedConnectionProtocol({
...input,
session,
connection,
method: input.method,
params,
}),
);
if (kind === "resource" && Array.isArray(result?.contents)) {
return {
...result,
contents: result.contents.map((content) => {
const record = asRecord(content);
return record && typeof record.uri === "string"
? {
...record,
uri: contextHandle("resource", connection.id, record.uri),
}
: content;
}),
};
}
return result ?? {};
}
async function connectedRemoteApprovalSnapshot(
session: ToolGatewaySession,
tool: ToolGatewayDescriptor,
options: { requireResolvedCredentials?: boolean } = {},
): Promise<Record<string, unknown> | null> {
if (tool.providerType === "paperclip_slack_chat") {
if (!session.agentId || !session.runId || !session.issueId) throw new ToolGatewayHttpError(403, "Slack task binding required", "slack_task_required");
const authority = await resolveSlackTaskAuthority(db, { companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId, endpointId: String(asRecord(tool.providerMetadata)?.endpointId ?? ""), identityContextId: session.identityContextId, approvedInvocationId: session.approvedSlackInvocationId });
return { endpointId: authority.endpoint.id, userId: authority.userId, revision: authority.revision, identityContextId: authority.identityContextId };
}
if (
(tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") ||
!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 grant = await resolveConnectionGrant(session, row.connection);
const credentialVersions = await connectedCredentialVersionSnapshots(
row.connection,
grant,
{
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 ?? [],
),
...(isBrowserUseConnection(row.connection) ? { browserSettingsHash: stableHash(await browserUseService(db).getSettings(session.companyId, grant.id)) } : {}),
credentialGrantId: grant.id,
credentialGrantRefsHash: stableHash(grant.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. Request a smaller result, for example a narrower query or a smaller page size.",
"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" | "cognee_cloud" = "mcp_http",
spawnedLocalProcess = false,
sourceTemplateKey?: string | null,
) {
const record = asRecord(result);
if (!record) throw malformedRemoteMcpResponse();
const providerContent = normalizeMcpContent(record.content);
const googleWorkspacePermissionDenied =
record.isError === true &&
(sourceTemplateKey === "gmail" ||
sourceTemplateKey?.startsWith("google-") === true) &&
/caller does not have permission/i.test(providerContent);
const content = googleWorkspacePermissionDenied
? "Google rejected this call. Google Workspace MCP is a Developer Preview: enroll the signed-in Workspace account and this OAuth client's Google Cloud project in Google's Developer Preview Program, wait for Google's registration confirmation, then reconnect and try again."
: providerContent;
return {
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"],
useDefaultTimeout = false,
): Promise<RemoteHttpExecutionResult> {
const { entry, connection } = await resolveConnectedRemoteTool(
session,
tool,
);
// Recheck immediately before dispatch, including previously approved calls
// and connections whose stored grant/catalog predates scope reduction.
assertGoogleChatToolArgumentsSupported(connection, entry.toolName, parameters);
if (useDefaultTimeout && isRailwayConnection(connection) && entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`) {
ms = railwayCommandBudgetMs(parameters);
}
const grant = await resolveConnectionGrant(session, connection);
if (isBrowserUseConnection(connection)) {
const data = await browserUseService(db, options.remoteHttpRequest).execute(session, grant, invocationId!, entry.toolName, parameters);
return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } };
}
const endpoint = await resolvedRemoteEndpoint(session, connection, grant);
// Method-defined headers are trusted catalog configuration. Treat them as
// managed headers so callers cannot override the scope that was reviewed
// during tools/list. Credentials remain authoritative on collisions.
let credentialHeaders = {
...projectedConnectionHeaders(connection),
...(await resolveCredentialHeaders(session, connection, grant)),
};
let builtHeaders = buildRemoteHeaders({
session,
connection,
credentialHeaders,
callerHeaders,
});
let headers = builtHeaders.headers;
let headerSummary = builtHeaders.summary;
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?.();
// The cached MCP session this call used, so a failure resets only this
// identity's session and leaves other agents on the connection alone.
let mcpSession: { scope: string; headers: Record<string, string>; sessionId: string } | undefined;
try {
const dispatchRemote = (target: string, init: RequestInit) =>
options.remoteHttpRequest
? options.remoteHttpRequest(target, init)
: guardedRemoteHttpFetch(target, init, {
...remoteHttpFetchOptions(),
// This call site owns a caller-set budget that can exceed the
// transport's default response deadline, so hand it down rather than
// letting the tighter default cut a legitimately slow tool short.
responseTimeoutMs: ms,
});
const requireExplicitRetryAfterOAuthRefresh = async (refreshedResponse: Response): Promise<never> => {
// The provider rejected this dispatch, but automatic replay is unsafe:
// a server can apply a request before returning 401. A caller retry
// starts a fresh credential-scoped MCP session and dispatches once.
if (mcpSession) forgetMcpHttpSession(mcpSession);
await refreshedResponse.body?.cancel().catch(() => undefined);
execution.response = {
httpStatus: refreshedResponse.status,
contentType: refreshedResponse.headers.get("content-type"),
bodySizeBytes: 0,
upstreamRequestId:
refreshedResponse.headers.get("x-request-id") ??
refreshedResponse.headers.get("traceparent"),
};
throw new ToolGatewayHttpError(
409,
"The provider rejected the saved OAuth token, which has now been refreshed. Retry the action explicitly; the original call was not replayed.",
"oauth_refreshed_retry_required",
{ connectionId: connection.id, catalogEntryId: entry.id, execution },
);
};
if (isRailwayEndpoint(connection.config.url) && normalizeRailwayToolName(entry.toolName).startsWith(RAILWAY_TOOL_PREFIX)) {
if (!isRailwayConnection(connection) || connection.config.railwayApiStatus !== "available") {
throw new ToolGatewayHttpError(422, "Railway API access is not verified. Refresh actions or reconnect this Railway connection.", "railway_api_not_verified");
}
const ssh = asRecord(connection.config.railwaySsh);
const sshRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH);
execution.request.endpoint = RAILWAY_API_URL;
execution.request.protocol = entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command` ? "Railway GraphQL + SSH" : "Railway GraphQL";
const client = createRailwayClient({
authorization: credentialHeaders.Authorization ?? "",
signal: controller.signal,
request: dispatchRemote,
runCommand: ssh?.grantId === grant.id && ssh?.enabled === true && sshRef
? async (input) => runRailwaySshCommand({
...input,
privateKey: await resolveGrantSecretValue(session, connection, grant, sshRef),
knownHosts: typeof ssh.knownHosts === "string" ? ssh.knownHosts : "",
})
: undefined,
});
const data = await client.call(entry.toolName, parameters);
const record = asRecord(data);
const failedCommand = entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command` && (record?.exitCode !== 0 || record?.timedOut === true || record?.truncated === true);
return {
result: normalizeMcpToolResult({ content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data, isError: failedCommand }, "mcp_http", entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`, "railway"),
headerSummary,
execution,
};
}
let requestHeaders = headers;
if (connectionRequiresMcpSession(connection)) {
const scope = `${connection.id}:grant:${grant.id}:actor:${session.agentId}:${endpoint}`;
requestHeaders = await getMcpHttpSession({
scope,
send: (init) =>
dispatchRemote(endpoint, {
...init,
redirect: "manual",
signal: controller.signal,
}),
headers,
requestId,
});
const sessionId = new Headers(requestHeaders).get("mcp-session-id");
if (sessionId) mcpSession = { scope, headers, sessionId };
}
// The guard runs inside this call and the connection is pinned to the
// address it approved, so an operator-supplied hostname cannot be rebound
// onto a loopback or metadata address between validation and dispatch
// (PAP-17098).
const requestInit: RequestInit = {
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(requestHeaders),
signal: controller.signal,
body: JSON.stringify({
jsonrpc: "2.0",
id: requestId,
method: "tools/call",
params: {
name: entry.toolName,
arguments: parameters,
},
}),
};
let response = await dispatchRemote(endpoint, requestInit);
if (isInsufficientConnectionScope(response)) {
await response.body?.cancel().catch(() => undefined);
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id });
}
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (
response.status === 401 &&
connection.authKind === "oauth" &&
connection.credentialSource === "paperclip_vault" &&
isPaperclipCloudConnectorStrategy(oauth?.strategy)
) {
credentialHeaders = {
...projectedConnectionHeaders(connection),
...(await resolveCredentialHeaders(session, connection, grant, {
forceRefresh: true,
})),
};
builtHeaders = buildRemoteHeaders({
session,
connection,
credentialHeaders,
callerHeaders,
});
headers = builtHeaders.headers;
headerSummary = builtHeaders.summary;
await requireExplicitRetryAfterOAuthRefresh(response);
}
if (
response.status === 401 &&
connection.authKind === "oauth" &&
connection.credentialSource === "paperclip_vault" &&
!isPaperclipCloudConnectorStrategy(oauth?.strategy) &&
options.oauthGrantRefresher
) {
credentialHeaders = {
...projectedConnectionHeaders(connection),
...(await resolveCredentialHeaders(session, connection, grant, {
forceRefresh: true,
})),
};
builtHeaders = buildRemoteHeaders({
session,
connection,
credentialHeaders,
callerHeaders,
});
headers = builtHeaders.headers;
headerSummary = builtHeaders.summary;
await requireExplicitRetryAfterOAuthRefresh(response);
}
if (
response.status === 401 &&
connection.credentialSource === "vercel_connect"
) {
if (!connection.externalCredential || !vercelConnect) {
throw new ToolGatewayHttpError(
503,
"Vercel Connect is not configured",
"vercel_connect_unavailable",
{
connectionId: connection.id,
},
);
}
const tokenRequest = vercelTokenRequest({
credential: connection.externalCredential,
grant,
connectionId: connection.id,
companyId: connection.companyId,
});
vercelConnect.evict(tokenRequest);
credentialHeaders = {
...projectedConnectionHeaders(connection),
...(await resolveCredentialHeaders(session, connection, grant, {
forceRefresh: true,
})),
};
builtHeaders = buildRemoteHeaders({
session,
connection,
credentialHeaders,
callerHeaders,
});
headers = builtHeaders.headers;
headerSummary = builtHeaders.summary;
await requireExplicitRetryAfterOAuthRefresh(response);
}
const sessionExpired = response.status === 404 && new Headers(requestHeaders).has("mcp-session-id");
if (sessionExpired) {
// The next explicit call initializes again. Never replay a tools/call
// automatically: the failed call may have changed app data.
if (mcpSession) forgetMcpHttpSession(mcpSession);
}
const body = response.ok
? JSON.stringify(await readMcpHttpResponse(response, requestId, {
maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES,
onRequest: async (message) => {
const pending = extractRemoteMcpPending(message);
if (pending) {
await retainUpstreamHandoff(invocationId, pending);
// Defer this interaction to the user. Do not claim that consent
// was granted or repeat a potentially state-changing tool call.
await dispatchRemote(endpoint, {
method: "POST", headers: mcpHttpRequestHeaders(requestHeaders),
body: JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { action: "cancel" } }),
});
throw new ToolGatewayHttpError(409, "This tool needs authorization in the provider.", "provider_interaction_required", { upstreamPending: pending, invocationId });
}
const request = extractMcpElicitationRequest(message);
if (request) await requestElicitationForRecordedToolCall({ session, tool, invocationId, request });
},
}))
: 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 (isInsufficientConnectionScope(response, body)) {
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", {
connectionId: connection.id, catalogEntryId: entry.id,
});
}
if (!response.ok) {
// Session expiration is recoverable on an explicit retry. Marking the
// connection unhealthy here would hide every tool and prevent it.
if (!sessionExpired) {
await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error.");
}
throw new ToolGatewayHttpError(
502,
sessionExpired ? "Remote MCP session expired. Retry the action explicitly to start a new session." : "Remote MCP server returned an HTTP error",
"mcp_remote_status",
{
status: response.status,
...(sessionExpired ? { sessionExpired: true } : {}),
connectionId: connection.id,
catalogEntryId: entry.id,
execution,
},
);
}
let payload: unknown;
try {
payload = JSON.parse(body);
} catch {
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 upstreamPending = extractRemoteMcpPending(payloadRecord, String(connection.config.sourceTemplateKey ?? ""), entry.toolName);
if (upstreamPending) {
await retainUpstreamHandoff(invocationId, upstreamPending);
throw new ToolGatewayHttpError(409, "Complete the provider's authorization or approval before continuing. The original call has not been replayed.", "provider_interaction_required", { upstreamPending, invocationId });
}
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 sourceTemplateKey =
typeof connection.config.sourceTemplateKey === "string"
? connection.config.sourceTemplateKey
: null;
const result = normalizeMcpToolResult(
payloadRecord.result,
"mcp_http",
false,
sourceTemplateKey,
);
await markRemoteConnectionHealth(
connection,
"ok",
"Remote MCP server responded to tools/call.",
);
return { result, headerSummary, execution };
} catch (error) {
if (error instanceof McpHttpResponseError) {
const failure = error.reason === "too_large" ? responseTooLargeError()
: error.reason === "malformed_response" ? malformedRemoteMcpResponse()
: new ToolGatewayHttpError(502, "Remote MCP server returned invalid JSON", "mcp_remote_invalid_json");
// These describe one response body, not the connection: the server
// answered with HTTP 2xx. Marking the connection unhealthy would hide
// every tool, and only a successful call restores health.
// The reader may have cancelled the stream partway, and the server can
// then drop its session. Start a fresh session on the next call.
if (mcpSession) forgetMcpHttpSession(mcpSession);
throw new ToolGatewayHttpError(failure.status, failure.message, failure.reasonCode, {
...failure.details, connectionId: connection.id, catalogEntryId: entry.id, execution,
});
}
if (error instanceof RailwayError) {
throw new ToolGatewayHttpError(error.status, error.message, error.code, { connectionId: connection.id, catalogEntryId: entry.id, execution });
}
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,
useProviderDefaultTimeout = false,
): Promise<RemoteHttpExecutionResult> {
const { entry, connection } = await resolveConnectedLocalStdioTool(
session,
tool,
);
const grant = await resolveConnectionGrant(session, connection);
const template = await resolveLocalStdioRuntimeTemplate(connection);
const env = await localStdioEnvironment(
session,
connection,
template,
grant,
);
const invoke = () => callLocalStdioMcp({ connection, entry, template, env, parameters,
timeoutMs: useProviderDefaultTimeout && template.templateId === "paperclip.cognee-cloud" ? 60_000 : ms });
// Cognee is a bundled HTTP client. Provider failures are tool failures, not
// crashed local processes, and must never consume slots or restart budgets.
const result = template.templateId === "paperclip.cognee-cloud"
? await invoke()
: 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 invoke();
},
);
return {
result: normalizeMcpToolResult(result, template.templateId === "paperclip.cognee-cloud" ? "cognee_cloud" : "local_stdio", template.templateId !== "paperclip.cognee-cloud"),
};
}
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,
});
}
const nativeMetadata = row.gateway.metadata;
const [profile] = await db.select({
profileKey: toolProfiles.profileKey,
status: toolProfiles.status,
source: sql<string | null>`${toolProfiles.metadata}->>'source'`,
agentId: sql<string | null>`${toolProfiles.metadata}->>'agentId'`,
assignmentDigest: sql<string | null>`${toolProfiles.metadata}->>'assignmentDigest'`,
}).from(toolProfiles).where(and(
eq(toolProfiles.id, row.gateway.profileId),
eq(toolProfiles.companyId, row.gateway.companyId),
)).limit(1);
// The immutable native profile also identifies legacy assignments when an
// update has cleared the gateway metadata. Missing metadata must fail closed.
const nativeAssignment = Object.hasOwn(nativeMetadata ?? {}, "nativeRuntimeAssignmentDigest") ||
profile?.profileKey.startsWith("native:") || profile?.source === "paperclip_runner";
const nativeOwner = nativeMetadata?.agentId;
if (nativeAssignment && (
typeof nativeOwner !== "string" || !uuidPattern.test(nativeOwner) ||
typeof nativeMetadata?.nativeRuntimeAssignmentDigest !== "string" ||
!/^[a-f0-9]{64}$/.test(nativeMetadata.nativeRuntimeAssignmentDigest) ||
(row.gateway.agentId && row.gateway.agentId !== nativeOwner) ||
row.token.subjectType !== "heartbeat_run"
)) {
return recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId,
bearerToken, reasonCode: "gateway_token_run_context_invalid", clientMetadata,
});
}
if (nativeAssignment) {
if (profile?.status !== "active" || profile.source !== "paperclip_runner" ||
profile.agentId !== nativeOwner ||
profile.assignmentDigest !== nativeMetadata!.nativeRuntimeAssignmentDigest) {
return recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId,
bearerToken, reasonCode: "gateway_token_run_context_invalid", clientMetadata,
});
}
}
let agentId = row.gateway.agentId ?? (nativeAssignment ? nativeOwner as string : null);
let runId: string | null = null;
let responsibleUserId: 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,
responsibleUserId: heartbeatRuns.responsibleUserId,
})
.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 (agentId && 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;
responsibleUserId = run.responsibleUserId;
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,
gatewayProfileId: row.gateway.profileId,
gatewayTokenId: row.token.id || tokenId,
gatewayTokenAllowedActions: normalizeGatewayTokenActions(
row.token.allowedActions,
),
actorType: runId ? "agent" : "system",
actorId: runId ? agentId : row.token.id,
responsibleUserId,
createdAt: row.token.createdAt,
expiresAt:
row.token.expiresAt ??
new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000),
};
await assertNamedGatewayProtocolLimit(
session,
input.protocolMethod,
clientMetadata,
);
return captureSessionIdentity(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, args.tool);
const connectedMcpExecution =
(args.tool.providerType === "mcp_remote_http" || args.tool.providerType === "provider_rest")
? await executeRemoteHttpTool(
args.session,
args.tool,
args.parameters,
executionTimeoutMs,
args.invocationId,
undefined,
args.timeoutMs === undefined,
)
: args.tool.providerType === "mcp_local_stdio"
? await executeLocalStdioTool(
args.session,
args.tool,
args.parameters,
executionTimeoutMs,
args.timeoutMs === undefined,
)
: 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",
});
const providerResult = asRecord(resultValidation.value);
if (providerResult?.error) {
throw new ToolGatewayHttpError(502, String(providerResult.content || providerResult.error),
"tool_error", { execution: connectedMcpExecution.execution });
}
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,
},
});
// After the bookkeeping above: a throw there is caught below, overwrites
// the row to failed, and emits — an earlier success emit would make one
// execution report both outcomes.
void emitConnectionInvoked(db, args.invocationId);
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));
void emitConnectionInvoked(db, 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 },
...(err instanceof ToolGatewayHttpError && err.reasonCode === "provider_interaction_required"
? { upstreamPending: err.details.upstreamPending as ToolUpstreamPending }
: {}),
};
}
}
/**
* 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,
responsibleUserId: 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));
void emitConnectionInvoked(db, 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));
void emitConnectionInvoked(db, invocation.id);
await reflectToolActionInteractionLifecycle({
actionRequestId,
status: "failed",
errorCode: "tool_execution_failed",
errorMessage: message,
});
}
}
async function waitForActionRequestExecution(actionRequestId: string) {
let deadline = Date.now() + ACTION_REQUEST_EXECUTION_WAIT_MS;
while (true) {
const [match] = await db
.select({
actionRequest: toolActionRequests,
invocationStatus: toolInvocations.status,
invocationStartedAt: toolInvocations.startedAt,
})
.from(toolActionRequests)
.innerJoin(
toolInvocations,
eq(toolInvocations.id, toolActionRequests.invocationId),
)
.where(eq(toolActionRequests.id, actionRequestId))
.limit(1);
const row = match?.actionRequest;
if (!row || row.status !== "executing") return row ?? null;
deadline = extendApprovedExecutionWaitDeadline({
currentDeadlineMs: deadline,
invocationStatus: match.invocationStatus,
invocationStartedAt: match.invocationStartedAt,
preparationStartedAt: row.updatedAt,
preparationWaitMs: ACTION_REQUEST_PREPARATION_WAIT_MS,
executionWaitMs: ACTION_REQUEST_EXECUTION_WAIT_MS,
});
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) break;
await new Promise((resolve) =>
setTimeout(
resolve,
Math.min(ACTION_REQUEST_EXECUTION_POLL_MS, remainingMs),
),
);
}
throw new ToolGatewayHttpError(
409,
"Approved tool action is still executing",
"action_execution_in_progress",
{
actionRequestId,
preparationWaitMs: ACTION_REQUEST_PREPARATION_WAIT_MS,
executionWaitMs: ACTION_REQUEST_EXECUTION_WAIT_MS,
},
);
}
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;
claimUpdatedAt: Date;
expectedInvocationStatus: "awaiting_approval" | "executing";
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();
const settled = await db.transaction(async (tx) => {
// Lock in the same invocation -> request order used by the normal
// execution settlement path. The claim timestamp is the ownership token:
// a consumer that merely read an approved row cannot settle another
// consumer's claim, and a pre-dispatch failure cannot overwrite a call
// that has already entered provider execution or completed successfully.
const [invocation] = await tx
.select({ status: toolInvocations.status })
.from(toolInvocations)
.where(eq(toolInvocations.id, input.invocationId))
.for("update")
.limit(1);
if (invocation?.status !== input.expectedInvocationStatus) return false;
const [actionRequest] = await tx
.select({
status: toolActionRequests.status,
updatedAt: toolActionRequests.updatedAt,
})
.from(toolActionRequests)
.where(eq(toolActionRequests.id, input.actionRequestId))
.for("update")
.limit(1);
if (
actionRequest?.status !== "executing" ||
actionRequest.updatedAt.getTime() !== input.claimUpdatedAt.getTime()
) {
return false;
}
await tx
.update(toolInvocations)
.set({
status: "failed",
idempotencyKey: null,
errorCode: reasonCode,
errorMessage: message,
completedAt: now,
updatedAt: now,
})
.where(
and(
eq(toolInvocations.id, input.invocationId),
eq(toolInvocations.status, input.expectedInvocationStatus),
),
);
await tx
.update(toolActionRequests)
.set({
status: "failed",
resolvedAt: now,
updatedAt: now,
})
.where(
and(
eq(toolActionRequests.id, input.actionRequestId),
eq(toolActionRequests.status, "executing"),
eq(toolActionRequests.updatedAt, input.claimUpdatedAt),
),
);
return true;
});
if (!settled) return { reasonCode, message, settled: false };
void emitConnectionInvoked(db, input.invocationId);
await reflectToolActionInteractionLifecycle({
actionRequestId: input.actionRequestId,
status: "failed",
errorCode: reasonCode,
errorMessage: message,
});
return { reasonCode, message, settled: true };
}
async function expireApprovedActionForManagedArgumentDrift(input: {
actionRequestId: string;
invocationId: string;
toolName: string;
ownsExecutingClaim?: boolean;
}) {
const error = new ToolGatewayHttpError(
409,
"Approved tool action managed arguments changed after review; request a new approval",
"approved_tool_managed_arguments_changed",
{
actionRequestId: input.actionRequestId,
invocationId: input.invocationId,
tool: input.toolName,
},
);
const now = new Date();
await options.beforeManagedArgumentDriftExpiry?.();
const [expired] = await db
.update(toolActionRequests)
.set({ status: "expired", resolvedAt: now, updatedAt: now })
.where(
and(
eq(toolActionRequests.id, input.actionRequestId),
input.ownsExecutingClaim
? inArray(toolActionRequests.status, ["approved", "executing"])
: eq(toolActionRequests.status, "approved"),
),
)
.returning({ id: toolActionRequests.id });
if (!expired) return error;
await db
.update(toolInvocations)
.set({
status: "failed",
approvalState: "expired",
idempotencyKey: null,
errorCode: error.reasonCode,
errorMessage: error.message,
completedAt: now,
updatedAt: now,
})
.where(eq(toolInvocations.id, input.invocationId));
void emitConnectionInvoked(db, input.invocationId);
await reflectToolActionInteractionLifecycle({
actionRequestId: expired.id,
status: "expired",
errorCode: error.reasonCode,
errorMessage: error.message,
});
return error;
}
// 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 restoreApprovedActionIdentity(
session: ToolGatewaySession,
identityContextId: string | undefined,
) {
if (!identityContextId) return;
const [origin] = await db
.select()
.from(runIdentityContexts)
.where(
and(
eq(runIdentityContexts.id, identityContextId),
eq(runIdentityContexts.companyId, session.companyId),
eq(runIdentityContexts.runId, session.runId!),
eq(runIdentityContexts.status, "accepted"),
),
);
if (!origin)
throw new ToolGatewayHttpError(
409,
"Approved action identity is unavailable",
"identity_context_unavailable",
);
session.identityContextId = origin.id;
session.responsibleUserId =
origin.cause === "company_default" ? null : origin.responsibleUserId;
}
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,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "awaiting_approval",
error,
});
throw error;
}
if (signedPayload.executionOnApprove !== true) {
const error = new ToolGatewayHttpError(
409,
"This approval predates execute-on-approve and must remain inert",
"legacy_approved_action_inert",
);
await markApprovedActionFailed({
actionRequestId: claimed.id,
invocationId: invocation.id,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "awaiting_approval",
error,
});
throw error;
}
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 {
await restoreApprovedActionIdentity(
session,
signedPayload.identityContextId,
);
session.approvedInvocationId = invocation.id;
session.approvedSlackInvocationId = invocation.id;
tool = await findToolForSession(session, invocation.toolName);
liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(
session,
tool,
);
const currentAccess = await policyService.decide(
policyInputForTool({
session,
tool,
parameters: signedPayload.arguments,
}),
);
if (
!currentAccess.allowed &&
currentAccess.decision !== "require_approval"
)
throw new ToolGatewayHttpError(
403,
currentAccess.explanation,
currentAccess.reasonCode,
);
} catch (error) {
await markApprovedActionFailed({
actionRequestId: claimed.id,
invocationId: invocation.id,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "awaiting_approval",
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,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "awaiting_approval",
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,
identityContextId: signedPayload.identityContextId,
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,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "awaiting_approval",
error,
});
throw error;
}
let managedArgumentsRemainCurrent: boolean;
try {
managedArgumentsRemainCurrent =
await approvedManagedArgumentsRemainCurrent(session, tool, parameters);
} catch (error) {
await markApprovedActionFailed({
actionRequestId: claimed.id,
invocationId: invocation.id,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "awaiting_approval",
error,
});
throw error;
}
if (!managedArgumentsRemainCurrent) {
throw await expireApprovedActionForManagedArgumentDrift({
actionRequestId: claimed.id,
invocationId: invocation.id,
toolName: invocation.toolName,
ownsExecutingClaim: true,
});
}
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" || tool.providerType === "provider_rest")
? (
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, invocation.id),
executionTimeoutMs,
)
: (() => {
throw new ToolGatewayHttpError(
409,
"Plugin actions cannot execute outside their originating run",
"approved_execution_unsupported",
);
})();
const resultRecord = asRecord(result);
if (resultRecord?.error)
throw new ToolGatewayHttpError(
502,
String(resultRecord.content || resultRecord.error),
"tool_execution_failed",
);
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));
void emitConnectionInvoked(db, 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,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "executing",
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",
"failed",
]),
),
)
.orderBy(desc(toolActionRequests.createdAt))
.limit(1);
if (!match) return null;
if (match.actionRequest.status === "failed" && match.invocation.errorCode !== "provider_interaction_required") 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() >=
TOOL_ACTION_REQUEST_SIGNING_GRACE_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 === "failed") {
throw new ToolGatewayHttpError(409,
"The provider is waiting for authorization or approval. Continue the existing execution; do not repeat the original call.",
"provider_interaction_required", {
invocationId: invocation.id, actionRequestId: actionRequest.id,
upstreamPending: recoverUpstreamHandoff(invocation),
});
}
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 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" || actionRequest.status === "failed"
) {
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 } : {}),
...(recoverUpstreamHandoff(invocation) ? { upstreamPending: recoverUpstreamHandoff(invocation) } : {}),
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",
"resources/list",
"resources/read",
"prompts/list",
"prompts/get",
],
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>;
signal?: AbortSignal;
}): Promise<ToolGatewayDescriptor[]> {
input.signal?.throwIfAborted();
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, input.signal);
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,
visibleToolsHash: createHash("sha256").update(JSON.stringify(tools.map((tool) => tool.name).sort())).digest("hex"),
},
});
return tools;
},
async executeContextForNamedGateway(input: {
gatewayId?: string | null;
gatewayPublicId?: string | null;
bearerToken: string;
method:
"resources/list" | "resources/read" | "prompts/list" | "prompts/get";
params?: Record<string, unknown>;
callerHeaders?: Record<string, string | string[] | undefined>;
}): Promise<Record<string, unknown>> {
return namedGatewayContextProtocol(input);
},
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,
options: { signal?: AbortSignal } = {},
): Promise<ToolGatewayDescriptor[]> {
options.signal?.throwIfAborted();
const session = await getActiveSession(sessionToken);
const tools = await listToolsForContext(session, options.signal);
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,
visibleToolsHash: createHash("sha256").update(JSON.stringify(tools.map((tool) => tool.name).sort())).digest("hex"),
},
});
return tools;
},
async listPluginToolsForAgent(input: {
companyId: string;
agentId: string;
}): Promise<AgentToolDescriptor[]> {
await assertAgentInCompany(input.companyId, input.agentId);
const decisions = await decideToolsForListing(pluginTools(), (tool) =>
policyInputForAgentTool({
companyId: input.companyId,
agentId: input.agentId,
tool,
}),
);
const visibleTools = decisions
.filter(
({ decision }) =>
decision.allowed || decision.decision === "require_approval",
)
.map(({ tool }) => tool);
return deduplicateToolDescriptors(visibleTools)
.map((tool) => {
const {
providerType: _providerType,
risk: _risk,
...descriptor
} = tool;
return descriptor;
});
},
async browserUseSessionAuthorized(binding: { companyId: string; agentId: string; runId: string; issueId: string; connectionId: string; grantId: string; invocationId: string }) {
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, binding.companyId), eq(toolConnections.id, binding.connectionId)));
const [invocation] = await db.select().from(toolInvocations).where(and(eq(toolInvocations.companyId, binding.companyId), eq(toolInvocations.id, binding.invocationId)));
if (!connection || !invocation || !connection.enabled || connection.status !== "active") return false;
const [issue] = await db.select({ projectId: issues.projectId }).from(issues).where(and(eq(issues.companyId, binding.companyId), eq(issues.id, binding.issueId)));
const session: ToolGatewaySession = { ...binding, id: randomUUID(), token: "", projectId: issue?.projectId ?? null, createdAt: new Date(), expiresAt: new Date(Date.now() + 60000) };
try {
const grant = await resolveConnectionGrant(session, connection, false);
if (grant.id !== binding.grantId) return false;
const tool = (await connectedMcpToolsForConnection(binding.companyId, binding.connectionId)).find(t => t.name === invocation.toolName);
if (!tool) return false;
const decision = await policyService.decide(policyInputForTool({ session, tool, consumeRateLimit: false }));
return decision.allowed || decision.decision === "require_approval";
} catch { return false; }
},
/** Standing instructions use exactly the same authorization as tool discovery. */
async resolveConnectionInstructionsForRun(binding: { companyId: string; agentId: string; runId: string }) {
const candidates = await db.select().from(toolConnections).where(and(
eq(toolConnections.companyId, binding.companyId), eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"), sql`${toolConnections.agentInstructions}->>'enabled' = 'true'`,
));
if (!candidates.length) return null;
const runContext = await resolveRunContext(binding);
const session = await captureSessionIdentity({
...binding, ...runContext, id: randomUUID(), token: "", createdAt: new Date(), expiresAt: new Date(Date.now() + 60_000),
});
await assertAgentInCompany(binding.companyId, binding.agentId);
const candidateIds = new Set(candidates.map((connection) => connection.id));
// Prompt assembly needs authorization, not a full discovery listing. Share
// descriptors and policy decisions, but only authorize candidate sources;
// discovery admission pressure must not remove guidance or reset sessions.
const tools = (await connectionToolsForContext(session))
.filter((tool) => tool.connectionId && candidateIds.has(tool.connectionId));
const decisions = await decideToolsForListing(tools, (tool) => policyInputForTool({ session, tool }));
const visible = new Set(decisions.flatMap(({ tool, decision }) =>
(decision.allowed || decision.decision === "require_approval") && tool.connectionId ? [tool.connectionId] : [],
));
const sources: Parameters<typeof composeConnectionInstructions>[0] = [];
for (const connection of candidates) {
if (!visible.has(connection.id)) continue;
try {
const grant = await resolveConnectionGrant(session, connection, false);
sources.push({ connection, grantId: grant.id });
} catch (error) {
// A missing/revoked identity makes this connection unavailable, without
// opening an interaction or blocking unrelated work at prompt assembly.
if (!(error instanceof ToolGatewayHttpError) && !(error instanceof HttpError)) throw error;
if (error.status >= 500) throw error;
}
}
return composeConnectionInstructions(sources);
},
async browserUseResources(binding: { companyId: string; agentId: string; runId?: string; issueId?: string }) {
if (!binding.runId || !binding.issueId) return [];
const [connection] = await db.select({ id: toolConnections.id }).from(toolConnections).where(and(
eq(toolConnections.companyId, binding.companyId), eq(toolConnections.transport, "rest_api"),
eq(toolConnections.enabled, true), eq(toolConnections.status, "active"),
sql`${toolConnections.config}->>'sourceTemplateKey' = 'browser-use-cloud'`,
)).limit(1);
if (!connection) return [];
const session: ToolGatewaySession = { ...binding, runId: binding.runId, issueId: binding.issueId, id: randomUUID(), token: "", projectId: null, createdAt: new Date(), expiresAt: new Date(Date.now() + 60000) };
const tools = (await listToolsForContext(session)).filter(t => t.providerType === "provider_rest");
const resources: Array<{ id: string; label: string; connectionId: string }> = [];
for (const connectionId of new Set(tools.map(t => t.connectionId!))) {
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, binding.companyId), eq(toolConnections.id, connectionId)));
if (!connection || !isBrowserUseConnection(connection)) continue;
try { await resolveConnectionGrant(session, connection, false); resources.push({ id: connectionId, connectionId, label: "Browser Use Cloud" }); } catch { /* Unavailable grants do not contribute a runtime skill. */ }
}
return resources;
},
async summarizeConnectionAccessForAgent(input: {
companyId: string;
connectionId: string;
agentId: string;
heartbeatRunId?: string | null;
issueId?: string | null;
projectId?: string | null;
}) {
await assertAgentInCompany(input.companyId, input.agentId);
const tools = await connectedMcpToolsForConnection(
input.companyId,
input.connectionId,
);
const decisions = (
await decideToolsForListing(tools, (tool) =>
policyInputForAgentTool({
companyId: input.companyId,
agentId: input.agentId,
heartbeatRunId: input.heartbeatRunId,
issueId: input.issueId,
projectId: input.projectId,
tool,
}),
)
).map(({ tool, decision }) => {
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,
responsibleUserId: 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 = await governedToolArguments(
session,
tool,
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,
},
);
let signedArguments: ReturnType<typeof signToolArguments>;
try {
signedArguments = signToolArguments({
invocationId,
toolName: 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, recorded.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, invocationId));
void emitConnectionInvoked(db, invocationId);
throw new ToolGatewayHttpError(
500,
error.message,
"signing_secret_unconfigured",
{
invocationId,
tool: tool.name,
},
);
}
throw error;
}
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) {
// recordInvocation inserted this row already terminal (denied or
// rate_limited), so this is its only completion boundary.
void emitConnectionInvoked(db, invocationId);
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 sweepActionReviews() {
// A process may stop between persisting a provider outcome and updating the
// feed projection. Reconcile from authoritative rows before delivering it.
const unreflected = await db
.select({ request: toolActionRequests, invocation: toolInvocations })
.from(toolActionRequests)
.innerJoin(
issueThreadInteractions,
eq(issueThreadInteractions.id, toolActionRequests.interactionId),
)
.innerJoin(
toolInvocations,
eq(toolInvocations.id, toolActionRequests.invocationId),
)
.where(
and(
inArray(toolActionRequests.status, [
"executed",
"failed",
"expired",
"cancelled",
]),
sql`coalesce(${issueThreadInteractions.result}->'toolAction'->>'status', '') <> case when ${toolActionRequests.status} = 'cancelled' then 'expired' else ${toolActionRequests.status} end`,
),
)
.limit(100);
for (const { request, invocation } of unreflected)
await reflectToolActionInteractionLifecycle({
actionRequestId: request.id,
status: request.status as
"executed" | "failed" | "expired" | "cancelled",
errorCode: invocation.errorCode,
errorMessage: invocation.errorMessage,
resultSummary: invocation.resultSummary?.summary,
});
const now = new Date();
const staleAt = new Date(now.getTime() - 10 * 60_000);
let cursor: string | undefined;
let scanned = 0;
for (;;) {
const rows = await db
.select()
.from(toolActionRequests)
.where(
and(
or(
and(
eq(toolActionRequests.status, "pending"),
lte(toolActionRequests.expiresAt, now),
),
eq(toolActionRequests.status, "approved"),
and(
eq(toolActionRequests.status, "executing"),
lte(toolActionRequests.updatedAt, staleAt),
),
),
cursor ? gt(toolActionRequests.id, cursor) : undefined,
),
)
.orderBy(asc(toolActionRequests.id))
.limit(100);
for (const row of rows) {
if (row.status === "approved") {
await this.approveActionRequest({
companyId: row.companyId,
actionRequestId: row.id,
actor: { userId: row.decidedByUserId ?? row.resolvedByUserId },
}).catch((error) =>
logger.warn(
{ err: error, actionRequestId: row.id },
"Could not recover approved tool action",
),
);
continue;
}
const status = row.status === "pending" ? "expired" : "failed";
const errorCode =
status === "failed"
? "tool_execution_outcome_unknown"
: "action_expired";
const errorMessage =
status === "failed"
? "Execution was interrupted; the external outcome is unknown. Inspect the provider before retrying."
: "The approval request expired before a decision.";
const [changed] = await db
.update(toolActionRequests)
.set({ status, resolvedAt: now, updatedAt: now })
.where(
and(
eq(toolActionRequests.id, row.id),
eq(toolActionRequests.status, row.status),
eq(toolActionRequests.updatedAt, row.updatedAt),
),
)
.returning();
if (!changed) continue;
await db
.update(toolInvocations)
.set({
status: "failed",
errorCode,
errorMessage,
completedAt: now,
updatedAt: now,
})
.where(eq(toolInvocations.id, row.invocationId));
void emitConnectionInvoked(db, row.invocationId);
await reflectToolActionInteractionLifecycle({
actionRequestId: row.id,
status,
errorCode,
errorMessage,
});
}
scanned += rows.length;
if (rows.length < 100) break;
cursor = rows[rows.length - 1].id;
}
return { scanned };
},
async approveActionRequest(input: {
companyId: string;
rememberAction?: boolean;
issueId?: string;
interactionId?: string;
actionRequestId: string;
actor: { agentId?: string | null; userId?: string | null };
}) {
if (input.actor.agentId)
throw new ToolGatewayHttpError(
403,
"Only a human can resolve a tool review",
"human_review_required",
);
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 (
["executing", "executed", "failed"].includes(actionRequest.status) &&
actionRequest.decidedAt
) {
await options.onToolActionSettled?.(actionRequest.id);
return actionRequestResolution(actionRequest);
}
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"),
),
);
}
await reflectToolActionInteractionLifecycle({
actionRequestId: actionRequest.id,
status: "cancelled",
});
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;
}
if (actionRequest.expiresAt && actionRequest.expiresAt <= new Date())
throw new ToolGatewayHttpError(
409,
"Tool review has expired",
"action_expired",
);
if (
!isTestOriginInvocation(invocation) &&
signedPayload.executionOnApprove === true
) {
const [issue] = await db
.select()
.from(issues)
.where(
and(
eq(issues.id, invocation.issueId!),
eq(issues.companyId, input.companyId),
),
)
.limit(1);
if (!issue || issue.status === "done" || issue.status === "cancelled")
throw new ToolGatewayHttpError(
409,
"Task is closed",
"action_task_closed",
);
const session: ToolGatewaySession = {
id: `review:${actionRequest.id}`,
token: "",
companyId: input.companyId,
agentId: invocation.agentId,
runId: invocation.runId,
issueId: issue.id,
projectId: issue.projectId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
await restoreApprovedActionIdentity(
session,
signedPayload.identityContextId,
);
session.approvedSlackInvocationId = invocation.id;
const tool = await findToolForSession(session, invocation.toolName);
if (
!approvalSnapshotsMatch(
signedPayload.approvalSnapshot,
await connectedRemoteApprovalSnapshot(session, tool),
)
)
throw new ToolGatewayHttpError(
409,
"Tool definition or connection changed; request a new review",
"approved_tool_target_changed",
);
const access = await policyService.decide(
policyInputForTool({
session,
tool,
parameters: signedPayload.arguments,
}),
);
if (!access.allowed && access.decision !== "require_approval")
throw new ToolGatewayHttpError(
403,
access.explanation,
access.reasonCode,
);
}
const updated = await commitToolActionReview(db, {
...input,
decision: "approved",
});
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;
issueId?: string;
interactionId?: string;
actionRequestId: string;
reason?: string;
actor: { agentId?: string | null; userId?: string | null };
}) {
const updated = await commitToolActionReview(db, {
...input,
decision: "rejected",
});
await options
.onToolActionSettled?.(updated.id)
.catch((error) =>
logger.warn(
{ err: error, actionRequestId: updated.id },
"Tool review continuation will be retried",
),
);
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();
// A retry carries the signed originating operation, even if steering has
// since accepted instructions from someone else in this same run.
if (input.approvedActionRequestId) {
const [request] = await db
.select()
.from(toolActionRequests)
.where(
and(
eq(toolActionRequests.id, input.approvedActionRequestId),
eq(toolActionRequests.companyId, session.companyId),
),
);
const [invocation] = request
? await db
.select()
.from(toolInvocations)
.where(
and(
eq(toolInvocations.id, request.invocationId),
eq(toolInvocations.companyId, session.companyId),
eq(toolInvocations.runId, session.runId!),
eq(toolInvocations.agentId, session.agentId!),
),
)
: [];
const payload =
request && invocation
? readSignedToolArgumentsPayload({
signedArguments: request.signedArguments,
invocationId: invocation.id,
toolName: invocation.toolName,
signingSecret: options.toolActionSigningSecret,
})
: null;
if (payload?.identityContextId) {
const [origin] = await db
.select()
.from(runIdentityContexts)
.where(
and(
eq(runIdentityContexts.id, payload.identityContextId),
eq(runIdentityContexts.companyId, session.companyId),
eq(runIdentityContexts.runId, session.runId!),
eq(runIdentityContexts.status, "accepted"),
),
);
if (!origin)
throw new ToolGatewayHttpError(
409,
"Approved action identity is unavailable",
"identity_context_unavailable",
);
session.identityContextId = origin.id;
session.responsibleUserId =
origin.cause === "company_default"
? null
: origin.responsibleUserId;
}
}
let tool = await findToolForSession(session, input.tool);
if (tool.providerType === "paperclip_slack_chat") {
const definition = SLACK_TOOLS.find(t => t.name === tool.upstreamToolName);
const args = definition?.schema.parse(input.parameters ?? {}) as Record<string, unknown> | undefined;
if (!args) throw new ToolGatewayHttpError(403, "Unknown Slack operation", "slack_task_required");
if (typeof args.idempotencyKey === "string") {
const key = `slack:${tool.connectionId}:${session.issueId}:${session.responsibleUserId}:${args.idempotencyKey}`;
const [prior] = await db.select().from(toolInvocations).where(and(eq(toolInvocations.companyId, session.companyId), eq(toolInvocations.idempotencyKey, key)));
const currentHash = validateToolContent({ value: args, direction: "arguments", sensitiveMode: "redact", promptInjectionMode: "ignore" }).summary.sha256;
if (prior && (prior.toolName !== tool.name || prior.argumentsHash !== currentHash)) throw new ToolGatewayHttpError(409, "Idempotency key belongs to a different Slack operation", "slack_idempotency_conflict");
input = { ...input, idempotencyKey: key };
}
}
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;
}
// Managed provider arguments are part of the governed call, not a
// transport decoration. Project them before hashing, policy evaluation,
// approval signing, previews, and audit summaries. Approved retries
// re-project only for a compatibility comparison and dispatch the
// already-reviewed signed payload unchanged.
if (!input.approvedActionRequestId) {
requestedParameters = await governedToolArguments(
session,
tool,
requestedParameters,
);
}
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) {
let replayResult: unknown = replay.result;
if (tool.providerType === "paperclip_github_chat" && ["comment", "update_comment", "submit_review"].includes(tool.upstreamToolName ?? "")) {
const access = await policyService.decide(policyInputForTool({ session, tool, parameters: effectiveParameters, consumeRateLimit: false }));
if (!access.allowed && access.decision !== "require_approval")
throw new ToolGatewayHttpError(policyErrorStatus(access), access.explanation, access.reasonCode);
const receipt = await githubChatReviewService(db).readPublicationReceipt(session, tool.upstreamToolName ?? "", effectiveParameters, replay.invocationId);
if (receipt) replayResult = validateToolContent({ value: { content: JSON.stringify(receipt), data: receipt },
direction: "result", sensitiveMode: "redact", promptInjectionMode: "block" }).value;
}
return {
invocationId: replay.invocationId,
status: "replayed" as const,
tool: virtualToolName ?? tool.name,
targetTool: virtualToolName ? tool.name : undefined,
result: replayResult,
};
}
}
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",
);
}
if (signedPayload.executionOnApprove !== true) {
const error = new ToolGatewayHttpError(
409,
"This approval predates execute-on-approve and must remain inert",
"legacy_approved_action_inert",
);
await options.beforeLegacyApprovedActionClaim?.();
const claimedAt = new Date();
const [claimed] = await db
.update(toolActionRequests)
.set({
status: "executing",
resolvedByAgentId: session.agentId,
updatedAt: claimedAt,
})
.where(
and(
eq(toolActionRequests.id, actionRequest.id),
eq(toolActionRequests.status, "approved"),
),
)
.returning();
if (!claimed) {
throw new ToolGatewayHttpError(
409,
"Tool action request was already consumed",
"action_already_consumed",
);
}
await reflectToolActionInteractionLifecycle({
actionRequestId: claimed.id,
status: "executing",
});
await markApprovedActionFailed({
actionRequestId: claimed.id,
invocationId: storedInvocation.id,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "awaiting_approval",
error,
});
throw error;
}
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,
identityContextId: signedPayload.identityContextId,
signingSecret: options.toolActionSigningSecret,
})
) {
throw new ToolGatewayHttpError(
409,
"Approved tool action arguments do not match reviewed hash",
"signed_arguments_mismatch",
);
}
if (
!(await approvedManagedArgumentsRemainCurrent(
session,
tool,
storedParameters,
))
) {
throw await expireApprovedActionForManagedArgumentDrift({
actionRequestId: actionRequest.id,
invocationId: storedInvocation.id,
toolName: storedInvocation.toolName,
});
}
const claimedAt = new Date();
const [claimed] = await db
.update(toolActionRequests)
.set({
status: "executing",
resolvedByAgentId: session.agentId,
updatedAt: claimedAt,
})
.where(
and(
eq(toolActionRequests.id, actionRequest.id),
eq(toolActionRequests.status, "approved"),
),
)
.returning();
if (!claimed) {
throw new ToolGatewayHttpError(
409,
"Tool action request was already consumed",
"action_already_consumed",
);
}
await reflectToolActionInteractionLifecycle({
actionRequestId: claimed.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,
});
let accessDecision = await policyService.decide(decisionInput);
if (accessDecision.allowed && tool.providerType === "paperclip_slack_chat" && SLACK_TOOLS.some(t => t.name === tool.upstreamToolName && t.risk === "approval")) {
accessDecision = { ...accessDecision, allowed: false, decision: "require_approval", reasonCode: "requires_approval_policy", explanation: "Slack destructive actions, channel creation and invitations require approval." };
}
const recorded = await policyService.recordInvocation(
decisionInput,
accessDecision,
);
await policyService.writeAudit(decisionInput, accessDecision);
invocationId = recorded.invocation.id;
const retryingSlackRateLimit = recorded.replayed && accessDecision.allowed && tool.providerType === "paperclip_slack_chat" && session.agentId && session.runId && session.issueId
? await claimSlackRateLimitRetry(db, { companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId, endpointId: String(asRecord(tool.providerMetadata)?.endpointId ?? ""), identityContextId: session.identityContextId }, invocationId)
: false;
const gitHubPublicationReplay = recorded.replayed &&
tool.providerType === "paperclip_github_chat" &&
["comment", "update_comment", "submit_review"].includes(tool.upstreamToolName ?? "") &&
recorded.invocation.approvalState === "not_required";
const gitHubReplayPolicyAllowed = accessDecision.allowed;
const gitHubReplayBindingMatches =
recorded.invocation.agentId === session.agentId && recorded.invocation.runId === session.runId &&
recorded.invocation.issueId === session.issueId && recorded.invocation.connectionId === tool.connectionId &&
recorded.invocation.toolName === tool.name && recorded.invocation.argumentsHash === effectiveArgumentsSummary.sha256 &&
["authorized", "executing", "succeeded", "failed", "timed_out"].includes(recorded.invocation.status);
if (gitHubPublicationReplay && gitHubReplayPolicyAllowed && !gitHubReplayBindingMatches)
throw new ToolGatewayHttpError(409, "Idempotency key belongs to a different GitHub publication", "github_idempotency_conflict");
const retryingGitHubPublication = gitHubPublicationReplay && accessDecision.allowed &&
recorded.invocation.approvalState === "not_required" && gitHubReplayBindingMatches;
if (recorded.replayed && !retryingSlackRateLimit && !retryingGitHubPublication) {
if (gitHubPublicationReplay && !gitHubReplayPolicyAllowed)
throw new ToolGatewayHttpError(policyErrorStatus(accessDecision), accessDecision.explanation, accessDecision.reasonCode);
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) {
// recordInvocation inserted this row already terminal (denied or
// rate_limited), so this is its only completion boundary.
void emitConnectionInvoked(db, invocationId);
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",
...(retryingGitHubPublication ? { errorCode: null, errorMessage: null, completedAt: null } : {}),
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, tool);
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" || tool.providerType === "provider_rest")
? await executeRemoteHttpTool(
session,
tool,
effectiveParameters,
executionTimeoutMs,
invocationId,
input.callerHeaders,
input.timeoutMs === undefined,
)
: tool.providerType === "mcp_local_stdio"
? await executeLocalStdioTool(
session,
tool,
effectiveParameters,
executionTimeoutMs,
input.timeoutMs === undefined,
)
: 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, invocationId),
executionTimeoutMs,
);
const resultValidation = validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block",
});
const completedAt = new Date();
const validatedMcpResult = connectedMcpExecution
? asRecord(resultValidation.value)
: null;
if (validatedMcpResult?.error) {
throw new ToolGatewayHttpError(
502,
String(validatedMcpResult.content || validatedMcpResult.error),
"tool_error",
{ execution: connectedMcpExecution?.execution },
);
}
await db
.update(toolInvocations)
.set({
status: "succeeded",
resultHash: resultValidation.summary.sha256 ?? null,
resultSummary: resultValidation.summary,
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
completedAt,
updatedAt: completedAt,
})
.where(eq(toolInvocations.id, invocationId));
if (input.approvedActionRequestId) {
const [executedRequest] = await db
.update(toolActionRequests)
.set({
status: "executed",
resolvedAt: completedAt,
updatedAt: completedAt,
})
.where(
and(
eq(toolActionRequests.id, input.approvedActionRequestId),
eq(toolActionRequests.status, "executing"),
),
)
.returning({ id: toolActionRequests.id });
if (executedRequest) {
await reflectToolActionInteractionLifecycle({
actionRequestId: executedRequest.id,
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,
},
});
// After the bookkeeping above: a throw there is caught below, overwrites
// the row to failed, and emits — an earlier success emit would make one
// execution report both outcomes.
void emitConnectionInvoked(db, invocationId);
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;
}
const completedAt = new Date();
await db
.update(toolInvocations)
.set({
status:
status === 504
? "timed_out"
: status === 429
? "rate_limited"
: "failed",
errorCode: reasonCode,
errorMessage: message,
completedAt,
updatedAt: completedAt,
})
.where(eq(toolInvocations.id, invocationId));
void emitConnectionInvoked(db, invocationId);
if (input.approvedActionRequestId) {
const [failedRequest] = await db
.update(toolActionRequests)
.set({
status: "failed",
resolvedAt: completedAt,
updatedAt: completedAt,
})
.where(
and(
eq(toolActionRequests.id, input.approvedActionRequestId),
eq(toolActionRequests.status, "executing"),
),
)
.returning({ id: toolActionRequests.id });
if (failedRequest) {
await reflectToolActionInteractionLifecycle({
actionRequestId: failedRequest.id,
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) {
// recordInvocation inserted this row already terminal (denied or
// rate_limited), so this is its only completion boundary.
void emitConnectionInvoked(db, invocationId);
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,
},
});
// After the bookkeeping above: a throw there is caught below, overwrites
// the row to failed, and emits — an earlier success emit would make one
// execution report both outcomes.
void emitConnectionInvoked(db, invocationId);
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));
void emitConnectionInvoked(db, 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 expiredTokens = await db.select({ id: toolMcpGatewayTokens.id })
.from(toolMcpGatewayTokens)
.where(lte(toolMcpGatewayTokens.expiresAt, now))
.orderBy(asc(toolMcpGatewayTokens.expiresAt), asc(toolMcpGatewayTokens.id))
.limit(500);
if (expiredTokens.length > 0) {
await db.delete(toolMcpGatewayTokens)
.where(and(inArray(toolMcpGatewayTokens.id, expiredTokens.map((token) => token.id)), lte(toolMcpGatewayTokens.expiresAt, now)))
.returning({ id: toolMcpGatewayTokens.id });
}
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>;