Files
PaperClipAI/server/src/services/tool-gateway.ts
T
DottaandPaperclip 5f0da562db fix(server): wake chat lifecycle milestones from committed changes (#17606)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connected chats show safe notices when a run starts, waits,
finishes, or fails.
> - A recurring scan projected these notices even when no eligible work
existed.
> - Eligibility depends on committed run state, conversation lineage,
and current authority.
> - Existing database rows can reconstruct that work after restart.
> - This PR wakes a scoped worker from those writes and removes
lifecycle polling.
> - Native progress events keep their separate existing owner until the
next migration.

## Linked Issues or Issue Description

**What happened?**

The chat maintenance timer repeatedly scanned run lifecycle state. Empty
and settled instances still queried the database. A run-status event
alone cannot replace the scan: prompt receipts, continuation receipts,
identity changes, and restored authority can make a run eligible later.

**What did you expect?**

Committed changes wake projection. Startup reconstructs eligible work. A
completed pass stops until another relevant change arrives. Failed work
retains recovery without scanning settled history forever.

**Steps to reproduce**

Configure an automatic chat endpoint. Finish its work, or leave it
unused. Observe recurring lifecycle projection queries from the chat
maintenance callback.

Stacked on #17592. Related foundation: #15625. This PR owns lifecycle
milestones only.

## What Changed

- Preserve upstream coalesced Cloud membership writes together with
locked membership restoration and its committed chat notification.

- Signal milestone eligibility from the real Slack account-link
transaction.

- Register the lifecycle worker with the existing delivery coordinator.
Remove lifecycle projection from the periodic chat callback. Keep native
progress in a separately named lane and preserve the explicit combined
API.
- Reconstruct company/issue scopes from the existing selector. Drain
bounded batches with company fairness. Retry failures, retain writes
through drain/resume, and disarm settled work.
- Signal from supported run, presentation, interaction, receipt,
lineage, conversation, recovery, and authority writers. Preserve their
transaction ownership, including nested savepoints and uncertain
settlement. No new table is added.
- Preserve provider authorization, authored-final suppression,
responsible-user continuity, deduplication, the 24-hour selection
window, and raw-output restrictions. Keep provider interpretation in its
owner.
- Preserve PostgreSQL timestamp precision in lifecycle pagination.
Rejected submillisecond candidates must not repeat the same page and
starve later notices.
- Document producer coverage and alternate-writer dispositions in
`doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md`.

## Verification

Published head: `1d056f68c5b1005933b9e12de8745bf1c4cbeeca`, targeting
master after #17592 merged at
`7d75ae0569e41786640cf09647ae8849411d2f22`. A normal merge integrates
the actual landed parent. The entire 77-path milestone patch is
binary-identical to the locally verified preparation across that
ancestry reconciliation; both unrelated master paths are preserved. The
four earlier producer/test integration conflicts retain scoped milestone
and voice notices, current onboarding completion admission, and expanded
identity query/read-scope coverage.

- **330 distinct selected cases pass on the verified unchanged patch:**
305 across 14 complete suites (all five milestone worker suites, run
identity, board claim, Cloud provisioning, auth session routes, Cloud
actor authentication, stale-lock recovery, GitHub credentials, project
Git authorization, and app admission wiring), plus 25 selected real chat
producer cases. The remaining 1,590 chat integration cases were skipped,
not passed.
- The real producer cases cover saved milestone discovery, GitHub and
Slack identity changes, account-link rollback/reconfirmation, ordinary
ingress reactivation, post-close action/delivery receipt settlement, all
ten authority-restoration paths including Cloud synchronization, and
changed native review metadata.
- Worker coverage verifies outer commit, rollback, uncertain-write
recovery, restart, bounded batching/fairness, timestamp precision,
retries, drain/resume, and settled SQL quiet. Authorization and existing
publication deduplication remain with their owning services.
- Server production typechecking, build, and module-boundary checks
pass. The tests above were executed separately. Production typechecking
excludes `src/__tests__` and does not replace those runs. Each database
owner uses its own newly created and app-migrated PostgreSQL fixture.
- [Current-parent acceptance and preserved failure
ledger](https://github.com/paperclipai/paperclip/blob/1d056f68c5b1005933b9e12de8745bf1c4cbeeca/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md#current-parent-integration-and-acceptance-october-28)
records commands, counts, producer scope, conflict dispositions, and
limits. Earlier combined-stack counts and failures keep their original
revision scope; local acceptance does not erase old CI failures.
- No new migration, queue table, dependency, workflow, or shared
scheduler contract is introduced. Parent migration 0397, bounded consent
recovery, voice cleanup/retention repairs, and runner checkpoint
restoration remain present.
- **Exact-head Greptile is 5/5 and all 55 current checks are clean.** No
Ponytail review is present, which is an explicitly authorized
nonblocker; no failing review is waived. Older reviews and parent checks
are not current passes. Actual failing Ponytail findings must be fixed;
an absent/stopped/paused turn without a verdict is an authorized
nonblocker. Active unfinished Ponytail uses its original request/start
timer, with a 25-minute monitor cutoff respecting the user's 30-minute
maximum.
- No complete local repository matrix, live provider tenant, external
alarm, physical host/database suspension, or fleet savings claim is
made. Native progress remains a separate following slice.

## Risks

- A future eligibility writer must notify the existing projection
boundary. Independent SQL writers or another active server process are
outside this process-local signal contract.
- Authority and lineage remain sensitive. The worker uses the existing
selectors and publication policy; it does not grant execution or
disclosure authority.
- Native progress and other publication maintenance still have recurring
work. This PR removes one reason, not the entire chat maintenance timer.
- Worker quiet does not prove database suspension, host sleep, or
external alarm coverage. Cloud sleep blockers remain unchanged.
- Merge eligibility requires passing current-head CI and Greptile 5/5
after this actual-master reconciliation. Subsequent stack slices retain
their own gates.

## Model Used

OpenAI Codex, GPT-6 family. The exact serving model ID and
context-window size are not exposed. Used repository inspection,
reasoning, shell execution, PostgreSQL tests, browser E2Es and GitHub
tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details available in this session)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run the documented focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-28 11:07:54 -05:00

11881 lines
439 KiB
TypeScript

import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
import { writeToolConnectionHealthChange } from "./tool-connection-health-notifications.js";
import { writeApprovalChange } from "./approval-work-notifications.js";
import { toolReviewProjectionService } from "./tool-review-projection.js";
import { toolReviewInterruptionService } from "./tool-review-interruption.js";
import { bindVerifiedToolApprovalArguments, claimToolTestApproval, releaseToolApproval, persistToolTestOutcome, startToolInvocation, type ToolApprovalClaim } from "./tool-approval-execution.js";
import { approvedToolReviewCompanies, prepareApprovedToolReview, recoverApprovedToolReviews } from "./tool-review-recovery.js";
import { toolReviewExpiryService } from "./tool-review-expiry.js";
import { notifyToolReviewChange, writeToolReviewChange } from "./tool-review-work-notifications.js";
import { restConnectorFor } from "./connectors/rest-registry.js";
import type { runPromptInjectionProvider } from "./prompt-injection-provider.js";
import { promptInjectionPreventionService } from "./prompt-injection-prevention.js";
import { adapterExecutionControls } from "./adapter-execution-control.js";
import type { BudgetServiceHooks } from "./budgets.js";
import type { PromptInjectionDetectorMetadata } from "@paperclipai/shared";
import { connectionManagementService, type ConnectionManagementContext } from "./connection-management.js";
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js";
import { nativeProviderCatalogFor } from "./native-provider-catalogs.js";
import { composeConnectionInstructions } from "./connection-instructions.js";
import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared";
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 { authorizationService, canActorReadIssuePrivacy, type AuthorizationActor } from "./authorization.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 { boundRunIssueIdSql } from "./run-task-binding.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 { getAppDefinitionForApplicationKey, isConnectorFeatureEnabled } from "@paperclipai/shared";
import { instanceSettingsService } from "./instance-settings.js";
import { spawn } from "node:child_process";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import {
and,
asc,
desc,
eq,
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,
McpHttpInitializationError,
mcpHttpRequestHeaders,
parseMcpHttpResponseBody,
} from "./mcp-http.js";
import {
projectedConnectionHeaders,
projectedConnectionToolArguments,
projectedConnectionToolInputSchema,
} from "./tool-access.js";
import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription } from "./google-chat-tool-policy.js";
import { assertGmailArchiveToolArgumentsSupported, gmailArchiveToolDescription } from "./gmail-archive-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 { unresolvedRecoveryToolInvocation } from "./recovery-effect-receipts.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,
toolRequestIdentityHash,
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;
originatingAgentId?: 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 {
signal?: AbortSignal;
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 {
signal?: AbortSignal;
managementContext?: ConnectionManagementContext;
companyId: string;
connectionId: string;
agentId: string;
userId: string;
toolName: string;
parameters?: unknown;
timeoutMs?: number;
}
interface ExecutePluginToolInput {
signal?: AbortSignal;
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;
budgetHooks?: BudgetServiceHooks;
/** Internal test seam for classification; never accepted from tool inputs. */
promptInjectionProvider?: typeof runPromptInjectionProvider;
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;
} = {},
) {
const injectionPrevention = promptInjectionPreventionService(db, { budgetHooks: options.budgetHooks, provider: options.promptInjectionProvider });
function resultClassifier(session: ToolGatewaySession, tool: ToolGatewayDescriptor, toolInvocationId: string | null,
{ approvedAction = false, signal: callerSignal }: { approvedAction?: boolean; signal?: AbortSignal } = {}) {
// Approved actions retain historical run attribution, not that run's lifetime.
const runSignal = session.runId && !approvedAction
? adapterExecutionControls.get(session.runId)?.controller.signal : undefined;
const signal = callerSignal && runSignal ? AbortSignal.any([callerSignal, runSignal]) : callerSignal ?? runSignal;
return async (content: string) => {
signal?.throwIfAborted();
// Legacy/plugin sessions may not carry identity fields. Resolve them from
// the admitted run, never from parameters. Signed approvals keep their origin.
let identity = session.originatingAgentId ? { ...session, agentId: session.originatingAgentId } : session;
if (session.runId && !approvedAction && !session.identityContextId) {
const [run] = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, session.runId), eq(heartbeatRuns.companyId, session.companyId)));
identity = await captureSessionIdentity({ ...identity, responsibleUserId: run?.responsibleUserId ?? session.responsibleUserId });
}
return injectionPrevention.classify({ companyId: identity.companyId, source: "gateway",
actorType: identity.actorType === "user" ? "user" : identity.agentId ? "agent" : "system",
actorId: identity.actorId ?? identity.agentId ?? identity.gatewayTokenId ?? identity.companyId,
responsibleUserId: identity.responsibleUserId ?? null, agentId: identity.agentId,
issueId: identity.issueId, projectId: identity.projectId, runId: identity.runId, identityContextId: identity.identityContextId,
toolInvocationId, toolName: tool.name, toolType: tool.providerType, approvedAction,
}, content, signal);
};
}
// 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 = (await 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 basePolicyService = toolAccessPolicyService(db, { toolActionSigningSecret: options.toolActionSigningSecret });
const issuerDecisionCaches = new WeakMap<ToolAccessDecisionCache, ToolAccessDecisionCache>();
async function decideGatewayPolicy(
input: ToolAccessDecisionInput,
decisionOptions: NonNullable<Parameters<typeof basePolicyService.decide>[1]> & { requiredGatewayAction?: ToolMcpGatewayTokenAction } = {},
policy: Pick<typeof basePolicyService, "decide" | "writeAudit"> = basePolicyService,
database: Db = db,
): Promise<ToolAccessDecision> {
const issuerId = input.runContext?.originatingAgentId;
if (!issuerId || !input.runContext?.gatewayId || input.actor.actorType !== "agent") return policy.decide(input, decisionOptions);
if (!input.runContext.gatewayTokenId) throw new ToolGatewayHttpError(403, "Delegated gateway token is no longer available", "gateway_token_run_context_invalid");
let issuerCache: ToolAccessDecisionCache | undefined;
if (decisionOptions.cache) {
issuerCache = issuerDecisionCaches.get(decisionOptions.cache);
if (!issuerCache) { issuerCache = createToolAccessDecisionCache(); issuerDecisionCaches.set(decisionOptions.cache, issuerCache); }
}
const requiredAction = decisionOptions.requiredGatewayAction ?? (input.consumeRateLimit ? "tools/call" : undefined);
const authorityKey = `gateway-issuer:${input.companyId}:${input.runContext.gatewayTokenId}:${issuerId}:${requiredAction ?? "inspect"}`;
let authority = issuerCache?.get(authorityKey);
if (!authority) {
authority = assertGatewayIssuerAuthority(input, requiredAction, database);
issuerCache?.set(authorityKey, authority);
}
await authority;
if (issuerId === input.actor.agentId) {
const decision = await policy.decide(input, decisionOptions);
return { ...decision, policyExplanation: { ...decision.policyExplanation, delegatedGatewayIssuer: issuerId } };
}
const issuerInput = { ...input, actor: { ...input.actor, agentId: issuerId } };
const issuer = await policy.decide(issuerInput, { ...decisionOptions, cache: issuerCache });
const target = await policy.decide(input, { ...decisionOptions, sharedRateDecision: issuer });
const chosen = !issuer.allowed && issuer.decision !== "require_approval" ? issuer
: !target.allowed ? target : issuer.decision === "require_approval" ? issuer : target;
if (input.consumeRateLimit) await policy.writeAudit(issuerInput, issuer);
return { ...chosen, matchedPolicyIds: [...new Set([...issuer.matchedPolicyIds, ...target.matchedPolicyIds])], policyExplanation: {
...chosen.policyExplanation,
consumedRateAdmissions: target.policyExplanation?.consumedRateAdmissions,
delegatedCallerConsumedRateAdmissions: issuer.policyExplanation?.consumedRateAdmissions,
delegatedGatewayIssuer: issuerId,
} };
}
const policyService = {
...basePolicyService,
decide: decideGatewayPolicy,
admitInvocation(input: ToolAccessDecisionInput, invocationId: string, claim?: ToolApprovalClaim) {
return basePolicyService.admitInvocation(input, invocationId, claim, (policy, admissionInput, transaction) =>
decideGatewayPolicy(admissionInput, { admittedInvocationId: invocationId, requiredGatewayAction: "tools/call" }, policy, transaction));
},
};
async function assertGatewayIssuerAuthority(input: ToolAccessDecisionInput, requiredAction?: ToolMcpGatewayTokenAction, database: Db = db) {
const [row] = await database.select({ token: toolMcpGatewayTokens, gateway: toolMcpGateways }).from(toolMcpGatewayTokens)
.innerJoin(toolMcpGateways, eq(toolMcpGateways.id, toolMcpGatewayTokens.gatewayId))
.where(and(eq(toolMcpGatewayTokens.id, input.runContext!.gatewayTokenId!), eq(toolMcpGatewayTokens.companyId, input.companyId)));
const issuerId = input.runContext!.originatingAgentId!;
if (!row || row.token.subjectType !== "gateway_client" || row.token.createdByAgentId !== issuerId || !row.token.responsibleUserId
|| input.actor.actorId !== issuerId || row.gateway.id !== input.runContext?.gatewayId || row.gateway.status !== "active"
|| row.token.revokedAt || (row.token.expiresAt && row.token.expiresAt <= new Date())
|| (row.gateway.agentId && row.gateway.agentId !== input.actor.agentId)) {
throw new ToolGatewayHttpError(403, "Delegated gateway authority is no longer current", "gateway_token_run_context_invalid");
}
if (requiredAction && !normalizeGatewayTokenActions(row.token.allowedActions).includes(requiredAction)) throw new ToolGatewayHttpError(403, "The gateway token no longer allows this action", "gateway_token_action_not_allowed");
const actor = { type: "agent" as const, companyId: input.companyId, agentId: issuerId, onBehalfOfUserId: row.token.responsibleUserId };
const resource = { type: "connection_management" as const, companyId: input.companyId, gatewayId: row.gateway.id,
profileId: row.gateway.profileId, purpose: "tool" as const, operation: "gateway_token.use", targetAgentId: row.gateway.agentId,
projectId: row.gateway.projectId, issueId: row.gateway.issueId };
await connectionManagementService(database).authorize(actor, "connections:gateway", resource, { mutation: false });
await connectionManagementService(database).authorize(actor, "connections:test", resource, { mutation: false });
}
async function recordToolInvocation(input: ToolAccessDecisionInput, decision: ToolAccessDecision) {
try {
return await policyService.recordInvocation(input, decision);
} catch (error) {
const details = error instanceof HttpError ? asRecord(error.details) : null;
if (error instanceof HttpError && error.status === 409 && details?.reasonCode === "legacy_tool_idempotency_unverifiable") {
throw new ToolGatewayHttpError(error.status, error.message, details.reasonCode, details);
}
throw error;
}
}
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,
selectedConnectionIds?: string[],
): Promise<ToolGatewayDescriptor[]> {
if (selectedConnectionIds?.length === 0) return [];
// 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),
selectedConnectionIds ? inArray(toolConnections.id, selectedConnectionIds) : undefined,
),
)
.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 featureSettings = connectionRows.some(({ application }) => getAppDefinitionForApplicationKey(application.applicationKey)?.experimentalFeatureKey)
? await instanceSettingsService(db).getExperimental() : undefined;
const eligibleRows = rows.filter(
({ catalogEntry, connection, application }) =>
(!featureSettings || isConnectorFeatureEnabled(getAppDefinitionForApplicationKey(application.applicationKey), featureSettings)) &&
!isRetiredComposioConnection(connection) &&
!(nativeProviderCatalogFor(connection) && !nativeProviderCatalogFor(connection)!.isCurrentEntry(catalogEntry)) &&
!(restConnectorFor(connection) && !restConnectorFor(connection)!.isCurrent(catalogEntry)) &&
!(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) &&
(((isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection) || restConnectorFor(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) && !nativeProviderCatalogFor(connection) && !restConnectorFor(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:
gmailArchiveToolDescription(
connection,
catalogEntry.toolName,
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: boundRunIssueIdSql(),
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,
}, { readScope: "identity" });
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;
detectorMetadata?: PromptInjectionDetectorMetadata;
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.detectorMetadata ||
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 ?? {}),
...(input.detectorMetadata ? { promptInjection: input.detectorMetadata } : {}),
}
: 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 projection = asRecord(currentResult?.toolAction);
if (projection?.status === (input.status === "cancelled" ? "expired" : input.status)
&& projection.errorCode === (input.errorCode ?? null)
&& projection.errorMessage === (input.errorMessage ?? null)
&& projection.resultSummary === (input.resultSummary ?? null)) return false;
const now = new Date();
if (linked.issueId && interaction.status === "pending" && ["expired", "cancelled"].includes(input.status)) {
await notifyChatRunMilestones(tx, { companyId: linked.companyId, issueId: linked.issueId });
}
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 = toolRequestIdentityHash(input.parameters, options.toolActionSigningSecret);
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 writeToolReviewChange(db, actionRequest.companyId, tx => tx
.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 writeApprovalChange(db, input.session.companyId, tx => tx
.insert(approvals)
.values({
companyId: input.session.companyId,
type: "request_board_approval",
requestedByAgentId: input.session.originatingAgentId ?? 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;
const approvalIssueId = input.session.issueId;
await writeApprovalChange(db, input.session.companyId, tx => tx
.insert(issueApprovals)
.values({
companyId: input.session.companyId,
issueId: approvalIssueId,
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 writeToolReviewChange(db, actionRequest.companyId, tx => tx
.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,
originatingAgentId: input.session.originatingAgentId,
gatewayTokenId: input.session.gatewayTokenId,
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;
originatingAgentId?: string | null;
gatewayTokenId?: 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,
originatingAgentId: input.originatingAgentId ?? null,
issueId: input.issueId ?? null,
projectId: input.projectId ?? null,
gatewayId: input.gatewayId ?? null,
gatewayTokenId: input.gatewayTokenId ?? 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 connectionToolsForContext(session)).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 measureToolDiscovery(work => toolDiscoveryScheduler.run(work, signal), () => buildToolsForContext(session, 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 measureRunPhase("connectors.discovery.github", () => githubGuestBotConnectionForSession(db, session));
let connectedTools = (await measureRunPhase("connectors.discovery.connections", () => connectedMcpToolsForCompany(session.companyId)))
.filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat"));
const connectionIds = [...new Set(connectedTools.flatMap((tool) => tool.connectionId ? [tool.connectionId] : []))];
if (connectionIds.length) {
const connections = await db.select().from(toolConnections).where(and(
eq(toolConnections.companyId, session.companyId), inArray(toolConnections.id, connectionIds),
));
for (const connection of connections.filter((item) => item.config.sourceTemplateKey === "google-workspace")) {
let scopes: string[] = [];
try { scopes = googleWorkspaceGrantedScopes(await resolveConnectionGrant(session, connection, false)); }
catch { /* Missing identity or consent never contributes tools. */ }
connectedTools = connectedTools.filter((tool) => tool.connectionId !== connection.id
|| isGoogleWorkspaceToolGranted(tool.upstreamToolName ?? "", scopes));
}
}
return [
...await measureRunPhase("connectors.discovery.github", () => githubBotToolsForSession(db, session)),
...await measureRunPhase("connectors.discovery.slack", () => slackToolsForSession(db, session)),
...await measureRunPhase("connectors.discovery.speko", () => spekoToolsForSession(db, session)),
...connectedTools,
];
}
async function buildToolsForContext(
session: ToolGatewaySession,
signal?: AbortSignal,
): Promise<ToolGatewayDescriptor[]> {
signal?.throwIfAborted();
if (session.agentId) {
await measureRunPhase("connectors.discovery.agent", () => 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 measureRunPhase("connectors.discovery.policy", () => 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 measureRunPhase("connectors.discovery.on_demand_policy", () => 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") {
try {
const data = await githubChatReviewService(db).execute(session, tool.upstreamToolName ?? "", parameters, invocationId);
return { content: JSON.stringify(data), data };
} catch (error) {
// Complete request identities can reach the publication owner through
// fresh execution instead of receipt replay. Preserve the same conflict
// category for changed content without reclassifying stale-head or
// assessment-coverage conflicts from that owner.
if (error instanceof HttpError && error.status === 409
&& asRecord(error.details)?.reasonCode === "github_idempotency_conflict") {
throw new ToolGatewayHttpError(error.status, error.message, "github_idempotency_conflict", asRecord(error.details) ?? {});
}
throw error;
}
}
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 writeToolConnectionHealthChange(db, connection, tx => tx
.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.originatingAgentId ?? 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) && !nativeProviderCatalogFor(connection) && !restConnectorFor(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");
}
const nativeProvider = nativeProviderCatalogFor(connection);
if (nativeProvider && !nativeProvider.isCurrentEntry(entry)) {
throw new ToolGatewayHttpError(409, `${nativeProvider.name}'s action catalog changed. Review the updated actions in Permissions.`, `${nativeProvider.slug}_catalog_changed`);
}
const restConnector = restConnectorFor(connection);
if (restConnector && !restConnector.isCurrent(entry)) {
throw new ToolGatewayHttpError(409, `${restConnector.name}'s action catalog changed. Review the updated actions in Permissions.`, "provider_catalog_changed");
}
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 assertApplicationFeatureAvailable(applicationKey: unknown) {
const app = getAppDefinitionForApplicationKey(applicationKey);
if (app?.experimentalFeatureKey && !isConnectorFeatureEnabled(app, await instanceSettingsService(db).getExperimental())) {
throw new ToolGatewayHttpError(403, "This connector is unavailable on this instance.", "connector_unavailable");
}
}
async function fullyAssignedMcpConnections(session: ToolGatewaySession) {
if (!session.gatewayProfileId) return [];
const rows = await db
.select({ connection: toolConnections, applicationKey: toolApplications.applicationKey })
.from(toolProfileEntries)
.innerJoin(
toolConnections,
eq(toolProfileEntries.connectionId, toolConnections.id),
)
.innerJoin(toolApplications, eq(toolConnections.applicationId, toolApplications.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(toolApplications.companyId, session.companyId),
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
inArray(toolConnections.transport, ["mcp_remote", "local_stdio"]),
),
);
const settings = rows.some(row => getAppDefinitionForApplicationKey(row.applicationKey)?.experimentalFeatureKey)
? await instanceSettingsService(db).getExperimental() : undefined;
return rows.filter(row => !settings || isConnectorFeatureEnabled(getAppDefinitionForApplicationKey(row.applicationKey), settings))
.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> {
const [application] = await db.select({ applicationKey: toolApplications.applicationKey }).from(toolApplications)
.where(and(eq(toolApplications.id, input.connection.applicationId), eq(toolApplications.companyId, input.session.companyId))).limit(1);
if (!application) throw new ToolGatewayHttpError(404, "MCP connection not found", "connection_not_found");
await assertApplicationFeatureAvailable(application.applicationKey);
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,
);
await assertApplicationFeatureAvailable(tool.applicationKey);
// Recheck immediately before dispatch, including previously approved calls
// and connections whose stored grant/catalog predates scope reduction.
assertGoogleChatToolArgumentsSupported(connection, entry.toolName, parameters);
assertGmailArchiveToolArgumentsSupported(connection, entry.toolName, parameters);
if (useDefaultTimeout && isRailwayConnection(connection) && entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`) {
ms = railwayCommandBudgetMs(parameters);
}
const grant = await resolveConnectionGrant(session, connection);
const restConnector = restConnectorFor(connection);
if (restConnector) {
const budgetMs = Math.min(ms, 30_000);
const deadlineAt = Date.now() + budgetMs;
const signal = AbortSignal.timeout(budgetMs);
const client = restConnector.client(connection, await resolveCredentialHeaders(session, connection, grant),
(url, init) => options.remoteHttpRequest ? options.remoteHttpRequest(url, init)
: guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions()), signal, deadlineAt);
const data = await client.invoke(entry.toolName, parameters);
return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } };
}
const nativeProvider = nativeProviderCatalogFor(connection);
if (nativeProvider) {
const budgetMs = Math.min(ms, 30_000);
const deadlineAt = Date.now() + budgetMs;
const signal = AbortSignal.timeout(budgetMs);
const data = await nativeProvider.execute({ binding: session, connection,
headers: await resolveCredentialHeaders(session, connection, grant),
request: (url, init) => options.remoteHttpRequest ? options.remoteHttpRequest(url, init)
: guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions()),
toolName: entry.toolName, parameters, signal, deadlineAt });
return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } };
}
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 workspaceTarget = connection.config.sourceTemplateKey === "google-workspace"
? googleWorkspaceToolTarget(entry.toolName) : null;
// One service's call failure is not evidence that every Google endpoint is
// unhealthy. The invocation still records/returns its error; catalog refresh
// handles service availability, and grant checks remain authoritative.
const markCallHealth = (status: Parameters<typeof markRemoteConnectionHealth>[1], message: string) =>
workspaceTarget && status !== "ok" ? Promise.resolve() : markRemoteConnectionHealth(connection, status, message);
if (connection.config.sourceTemplateKey === "google-workspace"
&& (!workspaceTarget || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(grant)))) {
throw new ToolGatewayHttpError(403, "This Google action needs a permission you have not granted. Reconnect Google Workspace to change access.", "oauth_insufficient_scope");
}
const endpoint = workspaceTarget?.serverUrl ?? await resolvedRemoteEndpoint(session, connection, grant);
const upstreamToolName = workspaceTarget?.upstreamName ?? entry.toolName;
// 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,
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 = async (target: string, init: RequestInit) => {
// Refresh can reduce consent. Recheck the selected grant after refreshing
// credentials and before every dispatch (including an authorization retry).
if (workspaceTarget) {
const [currentGrant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
));
if (!currentGrant || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(currentGrant))) {
throw new ToolGatewayHttpError(403, "This Google action is not covered by the current permission grant.", "oauth_insufficient_scope");
}
}
return 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: upstreamToolName,
arguments: parameters,
},
}),
};
let response = await dispatchRemote(endpoint, requestInit);
if (isInsufficientConnectionScope(response)) {
await response.body?.cancel().catch(() => undefined);
await markCallHealth("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 markCallHealth("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 (response.status === 401 || response.status === 403) {
await markCallHealth("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);
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 markCallHealth(
"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") {
if (mcpSession) forgetMcpHttpSession(mcpSession);
throw new ToolGatewayHttpError(
504,
"Remote MCP tool call timed out",
"tool_timeout",
{
connectionId: connection.id,
catalogEntryId: entry.id,
execution,
},
);
}
if (error instanceof McpHttpInitializationError && (error.status === 401 || error.status === 403)) {
await markCallHealth("error", "Remote MCP session authorization failed.");
}
if (mcpSession) forgetMcpHttpSession(mcpSession);
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,
responsibleUserId: row.responsibleUserId,
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,
});
}
}
if (row.token.subjectType === "gateway_client" && (row.token.createdByAgentId || row.token.responsibleUserId)) {
// Durable clients inherit current issuer/user authority, not the setup run's lifetime.
// The issuer FK is cleared on agent deletion. Retain the responsible-user
// marker so deletion cannot turn delegated credentials into system clients.
if (!row.token.createdByAgentId || !row.token.responsibleUserId) return recordNamedGatewayAuthFailure({
gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId, bearerToken,
reasonCode: "gateway_token_run_context_invalid", clientMetadata,
});
const delegatedActor = { type: "agent" as const, companyId: row.gateway.companyId,
agentId: row.token.createdByAgentId, onBehalfOfUserId: row.token.responsibleUserId };
const resource = { type: "connection_management" as const, companyId: row.gateway.companyId,
gatewayId: row.gateway.id, profileId: row.gateway.profileId, purpose: "tool" as const, operation: "gateway_token.use", targetAgentId: row.gateway.agentId,
projectId: row.gateway.projectId, issueId: row.gateway.issueId };
try {
await connectionManagementService(db).authorize(delegatedActor, "connections:gateway", resource, { mutation: false });
await connectionManagementService(db).authorize(delegatedActor, "connections:test", resource, { mutation: false });
} catch {
return recordNamedGatewayAuthFailure({ gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId,
bearerToken, reasonCode: "gateway_token_run_context_invalid", clientMetadata });
}
agentId ??= row.token.createdByAgentId;
responsibleUserId = row.token.responsibleUserId;
}
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,
originatingAgentId: row.token.subjectType === "gateway_client" && row.token.responsibleUserId ? row.token.createdByAgentId : null,
gatewayTokenAllowedActions: normalizeGatewayTokenActions(
row.token.allowedActions,
),
actorType: runId || responsibleUserId ? "agent" : "system",
actorId: runId ? agentId : responsibleUserId ? row.token.createdByAgentId : 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 {
if (invocation.policyExplanation?.delegatedConnectionTest) return invocation.connectionId !== null && invocation.gatewayId === null;
return (
invocation.actorType === "user" &&
invocation.runId === null &&
invocation.issueId === null &&
invocation.gatewayId === null &&
invocation.connectionId !== null
);
}
function delegatedTestCallerSession(session: ToolGatewaySession): ToolGatewaySession | null {
const callerId = session.originatingAgentId;
return callerId && callerId !== session.agentId ? { ...session, agentId: callerId } : null;
}
async function assertDelegatedTestCredentialAccess(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
const caller = delegatedTestCallerSession(session);
if (!caller || !tool.connectionId) return;
const [connection] = await db.select().from(toolConnections).where(and(
eq(toolConnections.companyId, session.companyId), eq(toolConnections.id, tool.connectionId),
));
if (!connection) throw new ToolGatewayHttpError(404, "Tool connection not found", "connection_not_found");
// Resolve consent metadata before any vault read. Testing another agent
// cannot select that agent's dedicated credential or a different identity.
const callerGrant = await resolveConnectionGrant(caller, connection, false);
const targetGrant = await resolveConnectionGrant(session, connection, false);
if (callerGrant.id !== targetGrant.id) {
throw new ToolGatewayHttpError(403, "Delegated calls cannot use another agent's credential identity", session.gatewayTokenId ? "gateway_credential_identity_mismatch" : "test_credential_identity_mismatch");
}
}
async function assertCurrentDelegatedTestCaller(session: ToolGatewaySession, tool: ToolGatewayDescriptor, parameters: unknown, invocationId: string) {
const caller = delegatedTestCallerSession(session);
if (!caller) return;
const current = await policyService.decide(policyInputForTool({ session: caller, tool, parameters }), { admittedInvocationId: invocationId });
if (!current.allowed && !(session.approvedInvocationId && current.decision === "require_approval")) {
throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode);
}
await assertDelegatedTestCredentialAccess(session, tool);
}
async function assertCurrentGatewayIssuer(session: ToolGatewaySession, tool: ToolGatewayDescriptor, parameters: unknown, invocationId: string, approved: boolean) {
if (!session.gatewayId || !session.originatingAgentId) return;
const current = await policyService.decide(policyInputForTool({ session, tool, parameters }), { admittedInvocationId: invocationId, requiredGatewayAction: "tools/call" });
if (!current.allowed && !(approved && current.decision === "require_approval")) throw new ToolGatewayHttpError(policyErrorStatus(current), current.explanation, current.reasonCode);
await assertDelegatedTestCredentialAccess(session, tool);
}
/**
* 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;
signal?: AbortSignal;
approvedClaim?: ToolApprovalClaim;
managementContext?: ConnectionManagementContext;
}): Promise<
| { decision: "allowed"; invocationId: string; result: unknown }
| {
decision: "allowed";
invocationId: string;
error: { message: string; reasonCode: string };
}
> {
if (!(await startToolInvocation(db, args.invocationId, args.approvedClaim))) {
throw new ToolGatewayHttpError(409, "Tool action execution claim is no longer current", "action_already_consumed");
}
await writeAudit({
session: args.session,
companyId: args.companyId,
agentId: args.agentId,
runId: args.session.runId,
issueId: args.session.issueId,
actorType: args.session.actorType,
actorId: args.session.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();
let connectedMcpExecution: Awaited<ReturnType<typeof executeRemoteHttpTool>> | Awaited<ReturnType<typeof executeLocalStdioTool>> | null;
let resultValidation: Awaited<ReturnType<typeof validateToolContent>>;
try {
await args.managementContext?.reauthorize();
if (args.managementContext?.actor.type === "agent") {
Object.assign(args.session, args.managementContext.workContext);
await assertCurrentDelegatedTestCaller(args.session, args.tool, args.parameters, args.invocationId);
const current = await policyService.decide(policyInputForTool({ session: args.session, tool: args.tool, parameters: args.parameters }), { admittedInvocationId: args.invocationId });
if (!current.allowed && !(args.session.approvedInvocationId && current.decision === "require_approval")) {
throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode);
}
}
const executionTimeoutMs = timeoutMs(args.timeoutMs, args.tool);
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;
resultValidation = await validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block", classifier: resultClassifier(args.session, args.tool, args.invocationId, { signal: args.signal, approvedAction: Boolean(args.session.approvedInvocationId) }),
});
const providerResult = asRecord(resultValidation.value);
if (providerResult?.error) {
throw new ToolGatewayHttpError(502, String(providerResult.content || providerResult.error),
"tool_error", { execution: connectedMcpExecution.execution });
}
} 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 persistToolTestOutcome(db, args.invocationId, {
status: status === 504 ? "timed_out" : status === 429 ? "rate_limited" : "failed",
errorCode: reasonCode, errorMessage: message,
}, args.approvedClaim);
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, promptInjection: err.detectorMetadata }
: {}),
...(executionAuditFromError(err)
? { execution: executionAuditFromError(err) }
: {}),
},
tool: args.tool,
});
await writeAudit({
session: args.session,
companyId: args.companyId,
agentId: args.agentId,
runId: args.session.runId,
issueId: args.session.issueId,
actorType: args.session.actorType,
actorId: args.session.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,
...(err instanceof ToolContentValidationError ? { promptInjection: err.detectorMetadata } : {}),
...(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 }
: {}),
};
}
await persistToolTestOutcome(db, args.invocationId, {
status: "succeeded", result: resultValidation.summary,
}, args.approvedClaim);
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,
detectorMetadata: resultValidation.detectorMetadata,
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: args.session.runId,
issueId: args.session.issueId,
actorType: args.session.actorType,
actorId: args.session.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,
promptInjection: resultValidation.detectorMetadata,
headerSummary: connectedMcpExecution.headerSummary ?? undefined,
execution: connectedMcpExecution.execution,
},
});
void emitConnectionInvoked(db, args.invocationId);
return {
decision: "allowed" as const,
invocationId: args.invocationId,
result: resultValidation.value,
};
}
function isApprovalPreparationRejection(error: unknown) {
return error instanceof ToolContentValidationError ||
(error instanceof ToolGatewayHttpError || error instanceof HttpError) && error.status >= 400 && error.status < 500 &&
![408, 425, 429].includes(error.status);
}
async function bindVerifiedApprovalArguments(invocation: typeof toolInvocations.$inferSelect, claim: ToolApprovalClaim, parameters: unknown) {
const preview = summarizeToolValue(parameters).sha256;
const verified = toolRequestIdentityHash(parameters, options.toolActionSigningSecret);
if (invocation.argumentsHash === preview && invocation.argumentsHash !== verified &&
!await bindVerifiedToolApprovalArguments(db, claim, { preview, verified })) {
throw new ToolGatewayHttpError(409, "Tool action execution claim is no longer current", "action_already_consumed");
}
}
async function runApprovedTestInvocation(
invocation: typeof toolInvocations.$inferSelect,
signedPayload: NonNullable<ReturnType<typeof readSignedToolArgumentsPayload>>,
actionRequestId: string,
): Promise<void> {
const claim = await claimToolTestApproval(db, actionRequestId, invocation.id);
if (!claim) return;
let execution: Parameters<typeof runTestToolInvocation>[0];
try {
const agentId = invocation.agentId;
if (!invocation.connectionId || !agentId) {
throw new ToolGatewayHttpError(409, "Test action context is no longer available", "action_origin_invalid");
}
const delegated = invocation.policyExplanation?.delegatedConnectionTest as {
agentId: string; responsibleUserId: string; runId: string | null; keyId: string | null;
resource: ConnectionManagementContext["resource"];
} | undefined;
const userId = delegated?.responsibleUserId ?? invocation.actorId ?? "board";
const session: ToolGatewaySession = {
id: "test-call", token: "test-call", companyId: invocation.companyId, agentId,
runId: null, issueId: null, projectId: null, actorType: delegated ? "agent" : "user", actorId: delegated?.agentId ?? userId,
responsibleUserId: userId, createdAt: new Date(), expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
let managementContext: ConnectionManagementContext | undefined;
if (delegated) {
managementContext = await connectionManagementService(db).authorize({ type: "agent", companyId: invocation.companyId,
agentId: delegated.agentId, onBehalfOfUserId: delegated.responsibleUserId,
runId: delegated.runId, keyId: delegated.keyId,
}, "connections:test", delegated.resource, { targetAgentIds: [agentId], afterHumanAction: true });
Object.assign(session, managementContext.workContext, { originatingAgentId: delegated.agentId, approvedInvocationId: invocation.id });
}
const tool = (await connectedMcpToolsForConnection(invocation.companyId, invocation.connectionId))
.find(candidate => candidate.name === invocation.toolName || candidate.upstreamToolName === invocation.toolName);
if (!tool) throw new ToolGatewayHttpError(404, `Tool "${invocation.toolName}" is no longer connected`, "tool_not_found");
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");
}
if (!(await approvedManagedArgumentsRemainCurrent(session, tool, signedPayload.arguments))) {
throw new ToolGatewayHttpError(409, "Approved tool action managed arguments changed after review", "approved_tool_managed_arguments_changed");
}
await bindVerifiedApprovalArguments(invocation, claim, signedPayload.arguments);
const access = await basePolicyService.admitInvocation(
policyInputForTool({ session, tool, parameters: signedPayload.arguments, consumeRateLimit: false }),
invocation.id,
claim,
async (policy, admissionInput) => {
const callerSession = delegatedTestCallerSession(session);
const callerInput = callerSession ? policyInputForTool({ session: callerSession, tool,
parameters: signedPayload.arguments, consumeRateLimit: true }) : null;
const options = { admittedInvocationId: invocation.id };
const caller = callerInput ? await policy.decide(callerInput, options) : null;
const target = await policy.decide(admissionInput, { ...options, sharedRateDecision: caller ?? undefined });
const chosen = caller && !caller.allowed && caller.decision !== "require_approval" ? caller
: !target.allowed ? target : caller?.decision === "require_approval" ? caller : target;
if (callerInput && caller) await policy.writeAudit(callerInput, caller);
return { ...chosen, policyExplanation: {
...chosen.policyExplanation,
consumedRateAdmissions: target.policyExplanation?.consumedRateAdmissions,
delegatedCallerConsumedRateAdmissions: caller?.policyExplanation?.consumedRateAdmissions,
} };
},
);
if (!access.allowed && access.decision !== "require_approval") {
throw new ToolGatewayHttpError(403, access.explanation, access.reasonCode);
}
const argumentsSummary = (await validateToolContent({ value: signedPayload.arguments,
direction: "arguments", sensitiveMode: "redact", promptInjectionMode: "ignore" })).summary;
execution = { session, managementContext, tool, parameters: signedPayload.arguments, invocationId: invocation.id,
companyId: invocation.companyId, agentId, userId, argumentsSummary, reasonCode: "approval_granted",
approvedClaim: claim, matchedPolicyIds: invocation.matchedPolicyIds ?? [] };
} catch (err) {
if (!isApprovalPreparationRejection(err)) {
// Preparation has not entered runTestToolInvocation. Preserve approval
// through dependency failures; the claim owner still verifies no dispatch.
await releaseToolApproval(db, claim);
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const errorCode = err instanceof ToolGatewayHttpError || err instanceof ToolContentValidationError
? err.reasonCode : "tool_execution_failed";
const settled = await persistToolTestOutcome(db, invocation.id, { status: "failed", errorCode, errorMessage: message }, claim);
if (settled) void emitConnectionInvoked(db, invocation.id);
return;
}
await runTestToolInvocation(execution);
}
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 instanceof ToolContentValidationError)
? 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({
companyId: toolActionRequests.companyId,
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 notifyToolReviewChange(tx, actionRequest.companyId);
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: {
companyId: string;
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 writeToolReviewChange(db, input.companyId, tx => tx
.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 writeToolReviewChange(db, claimed.companyId, tx => tx
.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 (session.gatewayTokenId && session.gatewayId) {
const [token] = await db.select({ subjectType: toolMcpGatewayTokens.subjectType, issuer: toolMcpGatewayTokens.createdByAgentId, userId: toolMcpGatewayTokens.responsibleUserId })
.from(toolMcpGatewayTokens).where(and(eq(toolMcpGatewayTokens.id, session.gatewayTokenId), eq(toolMcpGatewayTokens.companyId, session.companyId), eq(toolMcpGatewayTokens.gatewayId, session.gatewayId)));
// Durable delegated clients retain issuer authority across approval.
// Board clients and independently run-bound heartbeat clients keep their identity.
if (token?.subjectType === "gateway_client" && (token.issuer || token.userId)) {
session.originatingAgentId = token.issuer ?? session.originatingAgentId ?? session.actorId;
session.responsibleUserId = token.userId;
}
}
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 writeToolReviewChange(db, actionRequest.companyId, tx => tx
.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",
);
}
const claim: ToolApprovalClaim = {
actionRequestId: claimed.id, invocationId: invocation.id, claimedAt: claimed.updatedAt,
};
const agentId = invocation.agentId;
async function prepareExecution() {
// 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",
);
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",
);
throw error;
}
const session: ToolGatewaySession = {
id: `approved-action:${claimed.id}`,
token: "",
companyId: invocation.companyId,
agentId,
runId: invocation.runId,
issueId: invocation.issueId,
projectId: issue?.projectId ?? null,
gatewayId: invocation.gatewayId,
gatewayPublicId: invocation.gatewayPublicId,
gatewayTokenId: invocation.gatewayTokenId,
actorType: invocation.actorType as ToolGatewaySession["actorType"],
actorId: invocation.actorId,
originatingAgentId: typeof invocation.policyExplanation?.delegatedGatewayIssuer === "string" ? invocation.policyExplanation.delegatedGatewayIssuer : invocation.actorType === "agent" && invocation.gatewayId && !invocation.runId && !invocation.gatewayTokenId ? invocation.actorId : null,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
await restoreApprovedActionIdentity(
session,
signedPayload.identityContextId,
);
session.approvedInvocationId = invocation.id;
session.approvedSlackInvocationId = invocation.id;
const tool = await findToolForSession(session, invocation.toolName);
const liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(
session,
tool,
);
if (
!approvalSnapshotsMatch(
signedPayload.approvalSnapshot,
liveApprovalSnapshot,
)
) {
const error = new ToolGatewayHttpError(
409,
"Approved tool action target changed after review",
"approved_tool_target_changed",
);
throw error;
}
const parameters = signedPayload.arguments;
const canonicalArguments = canonicalToolArguments(parameters);
if (
![
toolRequestIdentityHash(parameters, options.toolActionSigningSecret),
// Historical approvals used the redacted preview hash. Their signature
// still binds the complete canonical arguments checked below.
summarizeToolValue(parameters).sha256,
].includes(claimed.canonicalArgumentsHash) ||
!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",
);
throw error;
}
const managedArgumentsRemainCurrent =
await approvedManagedArgumentsRemainCurrent(session, tool, parameters);
if (!managedArgumentsRemainCurrent) {
throw await expireApprovedActionForManagedArgumentDrift({
companyId: claimed.companyId,
actionRequestId: claimed.id,
invocationId: invocation.id,
toolName: invocation.toolName,
ownsExecutingClaim: true,
});
}
const argumentsSummary = (await validateToolContent({
value: parameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
})).summary;
// Validate the signed and managed payload before consuming admission.
// Historical payload drift belongs to the expiry owner above.
await bindVerifiedApprovalArguments(invocation, claim, signedPayload.arguments);
const currentAccess = await policyService.admitInvocation(
policyInputForTool({
session,
tool,
parameters: signedPayload.arguments,
consumeRateLimit: false,
}),
invocation.id,
claim,
);
if (
!currentAccess.allowed &&
currentAccess.decision !== "require_approval"
)
throw new ToolGatewayHttpError(
403,
currentAccess.explanation,
currentAccess.reasonCode,
);
// 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 });
return { session, tool, parameters, argumentsSummary };
}
let prepared: Awaited<ReturnType<typeof prepareExecution>>;
try {
prepared = await prepareExecution();
} catch (error) {
if (isApprovalPreparationRejection(error)) {
await markApprovedActionFailed({
actionRequestId: claimed.id,
invocationId: invocation.id,
claimUpdatedAt: claimed.updatedAt,
expectedInvocationStatus: "awaiting_approval",
error,
});
} else {
await releaseToolApproval(db, claim);
}
throw error;
}
const { session, tool, parameters, argumentsSummary } = prepared;
const startedAt = Date.now();
// Keep dispatch and outcome persistence outside the preparation retry
// boundary. Only the still-current claim may record the provider start.
if (!(await startToolInvocation(db, invocation.id, claim))) {
throw new ToolGatewayHttpError(409, "Tool action execution claim is no longer current", "action_already_consumed");
}
await reflectToolActionInteractionLifecycle({
actionRequestId: claimed.id,
status: "executing",
});
try {
await assertCurrentGatewayIssuer(session, tool, parameters, invocation.id, true);
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 = await validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block", classifier: resultClassifier(session, tool, invocation.id, { approvedAction: true }),
});
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 writeToolReviewChange(db, claimed.companyId, tx => tx
.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,
detectorMetadata: resultValidation.detectorMetadata,
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, ...(error instanceof ToolContentValidationError ? { promptInjection: error.detectorMetadata, findings: error.findings } : {}) },
tool,
});
throw error;
}
}
async function matchingAgentActionRequest(input: {
session: ToolGatewaySession;
toolName: string;
argumentsHash: string;
parameters: unknown;
}) {
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),
inArray(toolActionRequests.canonicalArgumentsHash, [input.argumentsHash, summarizeToolValue(input.parameters).sha256]),
eq(toolInvocations.agentId, input.session.agentId),
eq(toolInvocations.toolName, input.toolName),
inArray(toolActionRequests.status, [
"pending",
"approved",
"executing",
"rejected",
"executed",
"failed",
]),
),
)
.orderBy(
desc(sql`${toolActionRequests.canonicalArgumentsHash} = ${input.argumentsHash}`),
desc(toolActionRequests.createdAt),
)
.limit(1);
if (!match) return null;
if (match.actionRequest.signedArguments) {
const signed = readSignedToolArgumentsPayload({
signedArguments: match.actionRequest.signedArguments,
invocationId: match.invocation.id,
toolName: match.invocation.toolName,
signingSecret: options.toolActionSigningSecret,
});
// Redacted legacy hashes can collide across different secrets. Only
// replay the exact authenticated arguments, including legacy approvals.
if (!signed || canonicalToolArguments(signed.arguments) !== canonicalToolArguments(input.parameters)) return null;
} else if (match.actionRequest.canonicalArgumentsHash !== input.argumentsHash) {
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 writeToolReviewChange(db, pendingRequest.companyId, tx => tx
.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;
parameters: unknown;
}) {
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.
*/
async function buildTestCallStatus(
actionRequest: typeof toolActionRequests.$inferSelect,
invocation: typeof toolInvocations.$inferSelect,
): Promise<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 = (await 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,
};
}
const reviewProjection = toolReviewProjectionService(db, reflectToolActionInteractionLifecycle);
const reviewInterruption = toolReviewInterruptionService(db, async settled => {
void emitConnectionInvoked(db, settled.invocationId);
await reflectToolActionInteractionLifecycle(settled);
});
const reviewExpiry = toolReviewExpiryService(db, async expired => {
void emitConnectionInvoked(db, expired.invocationId);
await reflectToolActionInteractionLifecycle(expired);
});
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 };
managementContext?: ConnectionManagementContext;
}): Promise<ToolMcpGatewayWithTokens> {
await input.managementContext?.reauthorize();
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,
},
});
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
action: "tool_gateway.created", entityType: "tool_mcp_gateway", entityId: gateway.id,
details: { profileId: gateway.profileId } });
return getGatewayWithTokens(input.companyId, gateway.id);
},
async updateNamedGateway(input: {
companyId: string;
gatewayId: string;
body: UpdateToolMcpGateway;
managementContext?: ConnectionManagementContext;
}): Promise<ToolMcpGatewayWithTokens> {
await input.managementContext?.reauthorize();
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();
}
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
action: "tool_gateway.updated", entityType: "tool_mcp_gateway", entityId: updated.id,
details: { profileId: updated.profileId, status: updated.status } });
return getGatewayWithTokens(input.companyId, updated.id);
},
async createNamedGatewayToken(input: {
companyId: string;
gatewayId: string;
body: CreateToolMcpGatewayToken;
actor?: { agentId?: string | null; userId?: string | null };
managementContext?: ConnectionManagementContext;
}): Promise<ToolMcpGatewayTokenCreated> {
await input.managementContext?.reauthorize();
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",
);
if (input.actor?.agentId && !input.managementContext && input.body.subjectType !== "heartbeat_run") {
throw new ToolGatewayHttpError(403, "Agent client tokens require delegated authority", "gateway_token_run_context_invalid");
}
if (input.managementContext?.actor.type === "agent" && input.body.subjectType === "heartbeat_run" &&
(!input.managementContext.actor.runId || input.body.subjectId !== input.managementContext.actor.runId)) {
throw new ToolGatewayHttpError(403, "Heartbeat tokens must bind the authenticated run", "gateway_token_run_context_invalid");
}
const tokenId = randomUUID();
const token = generateNamedGatewayToken(tokenId);
const tokenPrefix = `pcgw_${tokenId.slice(0, 8)}`;
const now = new Date();
const [row] = await db.transaction(async tx => {
if (input.body.expiresAt) await notifyDeliveryWork(tx, DELIVERY_QUEUES.gatewayCredentialExpiry);
return tx
.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,
responsibleUserId: input.managementContext?.actor.type === "agent" ? input.managementContext.responsibleUserId : null,
createdByAgentId: input.actor?.agentId ?? null,
createdByUserId: input.actor?.userId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
});
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
action: "tool_gateway.token_created", entityType: "tool_mcp_gateway", entityId: input.gatewayId,
details: { tokenId: row.id, subjectType: row.subjectType, allowedActions: row.allowedActions } });
return { ...toGatewayToken(row), token };
},
async revokeNamedGatewayToken(input: {
companyId: string;
tokenId: string;
revokedAt?: Date;
managementContext?: ConnectionManagementContext;
}): Promise<ToolMcpGatewayToken> {
await input.managementContext?.reauthorize();
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",
);
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
action: "tool_gateway.token_revoked", entityType: "tool_mcp_gateway", entityId: row.gatewayId,
details: { tokenId: row.id } });
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.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.gatewayCredentialExpiry);
await tx.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 connections = await measureRunPhase("connectors.browser_use.connection", () => 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'`,
)).orderBy(toolConnections.name, toolConnections.id));
if (!connections.length) 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) };
await assertAgentInCompany(binding.companyId, binding.agentId);
if (await githubGuestBotConnectionForSession(db, session)) return [];
const tools = await measureRunPhase("connectors.browser_use.catalog", async () => {
const candidates = await connectedMcpToolsForCompany(binding.companyId, connections.map(connection => connection.id));
const decisions = await decideToolsForListing(candidates, tool => policyInputForTool({ session, tool }));
return decisions.filter(({ decision }) => decision.allowed || decision.decision === "require_approval").map(({ tool }) => tool);
});
const visible = new Set(tools.map(tool => tool.connectionId));
const resources: Array<{ id: string; label: string; connectionId: string }> = [];
for (const candidate of connections) {
if (!visible.has(candidate.id)) continue;
const [connection] = await measureRunPhase("connectors.browser_use.connection", () => db.select().from(toolConnections).where(and(
eq(toolConnections.companyId, binding.companyId), eq(toolConnections.id, candidate.id),
eq(toolConnections.enabled, true), eq(toolConnections.status, "active"),
)));
if (!connection || !isBrowserUseConnection(connection)) continue;
try { await measureRunPhase("connectors.browser_use.grant", () => resolveConnectionGrant(session, connection, false)); resources.push({ id: connection.id, connectionId: connection.id, 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 input.managementContext?.reauthorize();
const actingAgentId = input.managementContext?.actor.type === "agent" ? input.managementContext.actor.agentId : null;
await assertAgentInCompany(input.companyId, input.agentId);
const session: ToolGatewaySession = {
id: "test-call",
token: "test-call",
companyId: input.companyId,
agentId: input.agentId,
runId: input.managementContext?.workContext.runId ?? null,
issueId: input.managementContext?.workContext.issueId ?? null,
projectId: input.managementContext?.workContext.projectId ?? null,
originatingAgentId: actingAgentId,
actorType: actingAgentId ? "agent" : "user",
actorId: actingAgentId ?? 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 = await validateToolContent({
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
const decisionInput = policyInputForTool({
session,
tool,
parameters: requestedParameters,
idempotencyKey: `test-call:${randomUUID()}`,
consumeRateLimit: true,
});
await assertDelegatedTestCredentialAccess(session, tool);
const callerSession = delegatedTestCallerSession(session);
const callerDecisionInput = callerSession ? policyInputForTool({ session: callerSession, tool, parameters: requestedParameters, consumeRateLimit: true }) : null;
const callerDecision = callerDecisionInput ? await policyService.decide(callerDecisionInput) : null;
const targetDecision = await policyService.decide(decisionInput, { sharedRateDecision: callerDecision ?? undefined });
// Both principals' action controls apply. Either denial wins; either
// Ask first decision creates the ordinary human approval request.
const chosenDecision = callerDecision && !callerDecision.allowed && callerDecision.decision !== "require_approval" ? callerDecision
: !targetDecision.allowed ? targetDecision
: callerDecision?.decision === "require_approval" ? callerDecision : targetDecision;
const accessDecision = { ...chosenDecision, policyExplanation: {
...chosenDecision.policyExplanation,
consumedRateAdmissions: targetDecision.policyExplanation?.consumedRateAdmissions,
delegatedCallerConsumedRateAdmissions: callerDecision?.policyExplanation?.consumedRateAdmissions,
} };
if (callerDecisionInput && callerDecision) await policyService.writeAudit(callerDecisionInput, callerDecision);
if (accessDecision.allowed || accessDecision.decision === "require_approval") await assertDelegatedTestCredentialAccess(session, tool);
const recorded = await recordToolInvocation(
decisionInput,
accessDecision,
);
await policyService.writeAudit(decisionInput, accessDecision);
const invocationId = recorded.invocation.id;
if (actingAgentId && input.managementContext) {
await db.update(toolInvocations).set({ policyExplanation: {
...recorded.invocation.policyExplanation,
delegatedConnectionTest: {
agentId: actingAgentId, responsibleUserId: input.managementContext.responsibleUserId,
runId: input.managementContext.actor.runId ?? null, keyId: input.managementContext.actor.keyId ?? null,
resource: input.managementContext.resource,
},
} }).where(eq(toolInvocations.id, invocationId));
}
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 pendingActionRequest = recorded.actionRequest;
const canonicalArguments = canonicalToolArguments(requestedParameters);
const canonicalArgumentsHash = toolRequestIdentityHash(requestedParameters, options.toolActionSigningSecret);
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 writeToolReviewChange(db, pendingActionRequest.companyId, tx => tx
.update(toolActionRequests)
.set({
status: "cancelled",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(toolActionRequests.id, pendingActionRequest.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 writeToolReviewChange(db, pendingActionRequest.companyId, tx => tx
.update(toolActionRequests)
.set({
canonicalArgumentsHash,
canonicalArgumentsSummary: argumentValidation.summary,
signedArguments,
previewMarkdown,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
updatedAt: new Date(),
})
.where(eq(toolActionRequests.id, pendingActionRequest.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: session.runId,
issueId: session.issueId,
actorType: actingAgentId ? "agent" : "user",
actorId: actingAgentId ?? 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: session.runId,
issueId: session.issueId,
actorType: actingAgentId ? "agent" : "user",
actorId: actingAgentId ?? 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,
},
};
}
await input.managementContext?.reauthorize();
return runTestToolInvocation({
session,
managementContext: input.managementContext,
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,
signal: input.signal,
});
},
/**
* 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;
actor?: AuthorizationActor;
}): 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",
);
}
if (input.actor?.type === "agent" && invocation.actorId !== input.actor.agentId) {
throw new ToolGatewayHttpError(403, "Test belongs to another principal", "test_principal_access_denied");
}
if (invocation.issueId) {
if (!input.actor) throw new ToolGatewayHttpError(403, "Task access is required", "test_task_access_denied");
const [issue] = await db.select().from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, invocation.issueId)));
const decision = issue ? await authorizationService(db).decide({ actor: input.actor, action: "issue:read", resource: {
type: "issue", companyId: input.companyId, issueId: issue.id, projectId: issue.projectId,
parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId,
} }) : null;
if (!issue || !decision?.allowed || (input.actor.type === "agent" && (
!(await canActorReadIssuePrivacy(db, input.actor, issue)) || !input.actor.onBehalfOfUserId
|| !(await canActorReadIssuePrivacy(db, { type: "board", userId: input.actor.onBehalfOfUserId }, issue))
))) {
throw new ToolGatewayHttpError(403, "Task is unavailable to this principal", "test_task_access_denied");
}
}
return buildTestCallStatus(actionRequest, invocation);
},
repairActionReviewProjections: reviewProjection.sweep,
actionReviewProjectionCompanies: reviewProjection.companies,
settleInterruptedActionReviews: reviewInterruption.sweep,
nextActionReviewInterruptionAt: reviewInterruption.next,
sweepExpiredActionReviews: reviewExpiry.sweep,
nextActionReviewExpiryAt: reviewExpiry.next,
actionReviewExpiryDeadlines: reviewExpiry.deadlines,
sweepExpiredActionReviewsForCompany: reviewExpiry.sweepForCompany,
approvedActionReviewCompanies: (companyId?: string) => approvedToolReviewCompanies(db, companyId),
async recoverApprovedActionReviews(companyId?: string) {
return recoverApprovedToolReviews(db, async row => {
try {
const prepared = await prepareApprovedToolReview(db, { actionRequestId: row.request.id,
invocationId: row.invocation.id, signingSecret: options.toolActionSigningSecret });
if (prepared.kind === "skip") return false;
if (prepared.kind === "settled") {
await reflectToolActionInteractionLifecycle(prepared.projection);
return false;
}
const { request, invocation, payload } = prepared;
if (!isTestOriginInvocation(invocation) && payload.executionOnApprove !== true) return false;
await this.approveActionRequest({ companyId: request.companyId, actionRequestId: request.id,
actor: { userId: request.decidedByUserId ?? request.resolvedByUserId } });
const [remaining] = await db.select({ status: toolActionRequests.status }).from(toolActionRequests)
.where(eq(toolActionRequests.id, request.id));
return remaining?.status === "approved";
} catch (err) {
logger.warn({ err, actionRequestId: row.request.id }, "Could not recover approved tool action");
return true;
}
}, companyId);
},
async sweepActionReviews(input: { includeExpiry?: boolean; includeApproved?: boolean; includeInterrupted?: boolean } = {}) {
await reviewProjection.sweep();
let scanned = input.includeExpiry === false ? 0 : (await reviewExpiry.sweep()).scanned;
if (input.includeApproved !== false) scanned += (await this.recoverApprovedActionReviews()).scanned;
if (input.includeInterrupted !== false) scanned += (await reviewInterruption.sweep()).scanned;
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 writeToolReviewChange(db, actionRequest.companyId, tx => tx
.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)) {
await runApprovedTestInvocation(invocation, signedPayload, actionRequest.id);
const [settled] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.id, actionRequest.id));
return actionRequestResolution(settled ?? actionRequest);
}
if (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,
actorType: "agent",
actorId: invocation.actorId,
originatingAgentId: typeof invocation.policyExplanation?.delegatedGatewayIssuer === "string" ? invocation.policyExplanation.delegatedGatewayIssuer : invocation.actorType === "agent" && invocation.gatewayId && !invocation.runId && !invocation.gatewayTokenId ? invocation.actorId : null,
gatewayId: invocation.gatewayId,
gatewayPublicId: invocation.gatewayPublicId,
gatewayTokenId: invocation.gatewayTokenId,
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 previewHash = summarizeToolValue(signedPayload.arguments).sha256;
const access = await policyService.decide(
policyInputForTool({
session,
tool,
parameters: signedPayload.arguments,
consumeRateLimit: false,
}),
{ admittedInvocationId: invocation.id, requiredGatewayAction: "tools/call",
admittedPreviewHash: [previewHash, toolRequestIdentityHash(signedPayload.arguments, options.toolActionSigningSecret)]
.includes(actionRequest.canonicalArgumentsHash) ? previewHash : undefined,
},
);
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,
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 = toolRequestIdentityHash(args, options.toolActionSigningSecret);
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 = await validateToolContent({
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
const result = await executeVirtualSearchTools(
session,
requestedParameters,
);
const resultValidation = await validateToolContent({
value: result, direction: "result", sensitiveMode: "redact",
promptInjectionMode: "block", classifier: resultClassifier(session, tool, null, { signal: input.signal }),
}).catch(async (error: unknown) => {
if (error instanceof ToolContentValidationError) {
await writeToolCallEvent({ session, eventType: "call_failed", outcome: "failure", toolName: tool.name,
reasonCode: error.reasonCode, tool, detectorMetadata: error.detectorMetadata, metadata: { findings: error.findings } });
await writeAudit({ session, companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId,
action: "tool_gateway.call_failed", details: { tool: tool.name, reasonCode: error.reasonCode, promptInjection: error.detectorMetadata } });
throw new ToolGatewayHttpError(422, error.message, error.reasonCode, { findings: error.findings });
}
throw error;
});
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();
if (resultValidation.detectorMetadata?.invocationId) await injectionPrevention.linkToolInvocation(
session.companyId, resultValidation.detectorMetadata.invocationId, invocation.id);
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,
detectorMetadata: resultValidation.detectorMetadata,
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,
promptInjection: resultValidation.detectorMetadata,
},
});
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 = await validateToolContent({
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
let recoveredGitHubPublication: { content: string; data: unknown } | undefined;
let effectiveParameters: unknown = requestedParameters;
let effectiveArgumentsSummary = argumentValidation.summary;
if (session.runId && session.issueId && tool.connectionId && tool.risk !== "read" &&
// This owner has an exact publication receipt/reconciliation protocol.
!(tool.providerType === "paperclip_github_chat" &&
["comment", "update_comment", "submit_review"].includes(tool.upstreamToolName ?? ""))) {
const unresolved = await unresolvedRecoveryToolInvocation(db, {
companyId: session.companyId, issueId: session.issueId, runId: session.runId,
connectionId: tool.connectionId, toolName: tool.name, risk: tool.risk,
});
if (unresolved) throw new ToolGatewayHttpError(409,
"A previous operation has an unconfirmed outcome. Reconcile it before repeating this operation; reads and independent work remain available.",
"recovery_effect_outcome_unresolved", { invocationId: unresolved });
}
if (!input.approvedActionRequestId) {
const replay = await replayMatchingAgentAction({
session,
toolName: tool.name,
argumentsHash: toolRequestIdentityHash(requestedParameters, options.toolActionSigningSecret),
parameters: requestedParameters,
});
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) {
// Revalidate the recovered receipt, without mutating the completed
// invocation or executing its external write again.
try {
const validation = await validateToolContent({ value: { content: JSON.stringify(receipt), data: receipt },
direction: "result", sensitiveMode: "redact", promptInjectionMode: "block", classifier: resultClassifier(session, tool, replay.invocationId, { signal: input.signal }) });
replayResult = validation.value;
await writeToolCallEvent({ invocationId: replay.invocationId, session, eventType: "call_completed", outcome: "success",
toolName: tool.name, reasonCode: "receipt_revalidated", tool, detectorMetadata: validation.detectorMetadata, metadata: { replay: true } });
await writeAudit({ session, companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId,
action: "tool_gateway.call_completed", details: { invocationId: replay.invocationId, replay: true, promptInjection: validation.detectorMetadata } });
} catch (error) {
if (error instanceof ToolContentValidationError) {
await writeToolCallEvent({ invocationId: replay.invocationId, session, eventType: "call_failed", outcome: "failure",
toolName: tool.name, reasonCode: error.reasonCode, tool, detectorMetadata: error.detectorMetadata, metadata: { replay: true, findings: error.findings } });
await writeAudit({ session, companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId,
action: "tool_gateway.call_failed", details: { invocationId: replay.invocationId, replay: true, reasonCode: error.reasonCode, promptInjection: error.detectorMetadata } });
throw new ToolGatewayHttpError(422, error.message, error.reasonCode, { findings: error.findings });
}
throw error;
}
}
}
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",
);
}
const requestingActor = policyInputForTool({ session, tool }).actor;
if (
actionRequest.issueId !== session.issueId ||
storedInvocation.issueId !== session.issueId ||
storedInvocation.agentId !== session.agentId ||
storedInvocation.runId !== session.runId ||
actionRequest.requestedByAgentId !== (requestingActor.actorType === "agent" ? requestingActor.actorId : null)
) {
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 writeToolReviewChange(db, actionRequest.companyId, tx => tx
.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 writeToolReviewChange(db, actionRequest.companyId, tx => tx
.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 writeToolReviewChange(db, actionRequest.companyId, tx => tx
.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 = await validateToolContent({
value: storedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
const storedCanonical = canonicalToolArguments(storedParameters);
if (
![
toolRequestIdentityHash(storedParameters, options.toolActionSigningSecret),
summarizeToolValue(storedParameters).sha256,
].includes(actionRequest.canonicalArgumentsHash) ||
!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({
companyId: actionRequest.companyId,
actionRequestId: actionRequest.id,
invocationId: storedInvocation.id,
toolName: storedInvocation.toolName,
});
}
const claimedAt = new Date();
const [claimed] = await writeToolReviewChange(db, actionRequest.companyId, tx => tx
.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 recordToolInvocation(
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" &&
["begin_review", "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 &&
["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 (retryingGitHubPublication) {
// Invocation hashes describe shortened audit previews. The durable
// publication checks the complete saved request and current authority.
let receipt;
try {
receipt = await githubChatReviewService(db).readPublicationReceipt(
session, tool.upstreamToolName!, effectiveParameters, invocationId,
);
} catch (error) {
if (error instanceof HttpError && error.status === 409)
throw new ToolGatewayHttpError(409, error.message, "github_idempotency_conflict");
throw error;
}
if (receipt) {
recoveredGitHubPublication = { content: JSON.stringify(receipt), data: receipt };
} else {
const canRepeatPreflight = tool.upstreamToolName === "begin_review" ||
(recorded.invocation.status !== "succeeded" && !recorded.invocation.resultHash);
const exactRequest = recorded.invocation.argumentsHash ===
toolRequestIdentityHash(effectiveParameters, options.toolActionSigningSecret);
if (!canRepeatPreflight || !exactRequest)
throw new ToolGatewayHttpError(409, "The saved GitHub publication is not available yet", "github_publication_unavailable");
}
}
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 {
await assertCurrentGatewayIssuer(session, tool, effectiveParameters, invocationId, !!input.approvedActionRequestId);
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 = recoveredGitHubPublication ?? (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 = await validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block", classifier: resultClassifier(session, tool, invocationId, { approvedAction: Boolean(input.approvedActionRequestId), signal: input.signal }),
});
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 actionRequestId = input.approvedActionRequestId;
const [executedRequest] = await writeToolReviewChange(db, session.companyId, tx => tx
.update(toolActionRequests)
.set({
status: "executed",
resolvedAt: completedAt,
updatedAt: completedAt,
})
.where(
and(
eq(toolActionRequests.id, actionRequestId),
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,
detectorMetadata: resultValidation.detectorMetadata,
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,
promptInjection: resultValidation.detectorMetadata,
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 actionRequestId = input.approvedActionRequestId;
const [failedRequest] = await writeToolReviewChange(db, session.companyId, tx => tx
.update(toolActionRequests)
.set({
status: "failed",
resolvedAt: completedAt,
updatedAt: completedAt,
})
.where(
and(
eq(toolActionRequests.id, actionRequestId),
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, promptInjection: normalizedError.detectorMetadata }
: {}),
...(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,
...(normalizedError instanceof ToolContentValidationError ? { promptInjection: normalizedError.detectorMetadata } : {}),
...(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 = await 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 recordToolInvocation(
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 = await validateToolContent({
value: result,
direction: "result",
sensitiveMode: "redact",
promptInjectionMode: "block", classifier: resultClassifier(sessionLike, tool, invocationId, { signal: input.signal }),
});
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,
detectorMetadata: resultValidation.detectorMetadata,
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,
promptInjection: resultValidation.detectorMetadata,
},
});
// 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, promptInjection: err.detectorMetadata }
: 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,
...(err instanceof ToolContentValidationError ? { promptInjection: err.detectorMetadata } : {}),
},
});
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 nextCredentialExpiryAt(): Promise<number | null> {
const [row] = await db.execute<{ at: string | Date | null }>(sql`
select min(expires_at) as at from (
(select ${toolMcpGatewayTokens.expiresAt} as expires_at from ${toolMcpGatewayTokens}
where ${toolMcpGatewayTokens.expiresAt} is not null
order by ${toolMcpGatewayTokens.expiresAt} limit 1)
union all
(select ${toolGatewaySessions.expiresAt} as expires_at from ${toolGatewaySessions}
order by ${toolGatewaySessions.expiresAt} limit 1)
) expiries
`);
return row?.at == null ? null : new Date(row.at).getTime();
},
async cleanupExpiredSessions(input: { now?: Date; singleBatch?: boolean } = {}) {
const now = input.now ?? new Date();
let deletedCount = 0;
while (true) {
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 expiredSessions = await db.select({ id: toolGatewaySessions.id })
.from(toolGatewaySessions)
.where(lte(toolGatewaySessions.expiresAt, now))
.orderBy(asc(toolGatewaySessions.expiresAt), asc(toolGatewaySessions.id))
.limit(500);
if (expiredSessions.length > 0) {
const rows = await db.delete(toolGatewaySessions)
.where(and(inArray(toolGatewaySessions.id, expiredSessions.map((session) => session.id)), lte(toolGatewaySessions.expiresAt, now)))
.returning({ id: toolGatewaySessions.id });
deletedCount += rows.length;
}
// The worker schedules the next page; one-shot startup/manual callers
// must finish the backlog without relying on another scheduled pass.
if (input.singleBatch || (expiredTokens.length < 500 && expiredSessions.length < 500)) break;
}
return { deletedCount };
},
async listRuntimeSlots(companyId?: string) {
return runtimeSupervisor.listSlots(companyId);
},
async stopRuntimeSlot(input: {
companyId: string;
slotId: string;
actor?: { agentId?: string | null; runId?: string | null };
managementContext?: ConnectionManagementContext;
}) {
await input.managementContext?.reauthorize();
try {
const result = await runtimeSupervisor.stopSlot({
companyId: input.companyId,
slotId: input.slotId,
agentId: input.actor?.agentId ?? null,
runId: input.actor?.runId ?? null,
});
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
action: "tool_runtime.stopped", entityType: "tool_runtime_slot", entityId: input.slotId });
return result;
} 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 };
managementContext?: ConnectionManagementContext;
}) {
await input.managementContext?.reauthorize();
try {
const result = await runtimeSupervisor.restartSlot({
companyId: input.companyId,
slotId: input.slotId,
agentId: input.actor?.agentId ?? null,
runId: input.actor?.runId ?? null,
});
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
action: "tool_runtime.restarted", entityType: "tool_runtime_slot", entityId: input.slotId });
return result;
} 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>;