Files
PaperClipAI/server/src/services/tool-gateway.ts
T
DottaandPaperclip 0ffc091473 feat(connections): add durable GitHub identities and webhooks (#12843)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents need source control access for repository work
> - A shared token cannot preserve the responsible person's identity or
an agent's dedicated identity
> - GitHub App tokens also need durable refresh, repository access
checks, and webhook delivery
> - Paperclip already has managed connections, encrypted grants, run
secret leases, and merge-confirmation behavior
> - This pull request extends those systems with GitHub identities
instead of adding a parallel credential system
> - The benefit is durable GitHub access with explicit identity,
repository, runtime, and webhook boundaries

## Linked Issues or Issue Description

No public GitHub issue describes this connection change. This
description follows the feature request template.

**Subsystem affected**

Connected Apps, connection grants, secret resolution, native Git runtime
setup, webhook processing, and the Apps UI.

**Problem or motivation**

Users need to connect GitHub once and let agents use the correct GitHub
identity. A run should use a dedicated agent account when one exists.
Otherwise, it should use the responsible person's account. The
connection must survive token expiry, repository access changes, and
temporary instance downtime.

**Proposed solution**

Add user-owned and agent-owned GitHub grants to the existing connection
model. Resolve one identity for MCP, Git, `gh`, health checks, and
webhook bindings. Store provider tokens in the existing encrypted secret
system. Refresh expiring token pairs under the existing lease and
compare-and-swap path. Register signed Cloud webhook bindings and
process normalized pull request and installation events through a
durable local inbox.

**Alternatives considered**

An organization-wide GitHub token would lose person and agent
attribution. Environment variables alone would bypass the managed
connection and grant model. A new GitHub-only credential store would
duplicate the existing secret and access systems. GitHub App
installation tokens and private-key custody remain outside this first
version.

**Roadmap alignment**

This change implements the Connected Apps direction. It also extends the
shipped MCP Tool Gateway, per-agent secret access, and
action-attribution systems. It does not add a repository catalog. The
open repository catalog work in
[#11234](https://github.com/paperclipai/paperclip/pull/11234) is related
and complementary.

## What Changed

- Added agent-owned connection grants and a per-agent credential policy
with company and subject constraints.
- Added a managed GitHub App method while keeping the personal access
token method as an advanced fallback.
- Added durable access-token and refresh-token handling with proactive
rotation and one automatic recovery after a provider `401`.
- Added GitHub identity and installation summaries without storing
repository-name lists.
- Added signed Cloud webhook binding, event lease, acknowledgement,
local idempotency, pull request merge processing, and installation
access handling.
- Added one identity resolver for MCP, native Git, `gh`, checkout,
health checks, and webhook bindings.
- Added a class-3 run projection for `GH_TOKEN`, `GITHUB_TOKEN`, a
`github.com`-only credential helper, SSH-to-HTTPS rewrite, and GitHub
noreply commit attribution.
- Added personal and dedicated-agent setup choices plus identity,
repository, continuity, and webhook status in the Apps UI.
- Added schema migrations, tests, and connection documentation.

## Verification

- The current head is fully green in GitHub CI, including build,
typecheck, all serialized/general server shards, all browser shards,
policy, canary dry run, review, and security checks.
- Live staging proof completed with a non-expiring GitHub App user
token, selected-repository installation, repository add/remove refresh,
managed MCP, native `gh`, HTTPS clone/push/delete, GitHub noreply commit
attribution, signed merged-PR webhook acceptance, durable
Cloud-to-instance delivery, and installation-access event processing.
Temporary branches and temporary repository access were removed
afterward.
- `pnpm check:token-gates` passed.
- `pnpm -r typecheck` passed before and after the rebase onto
`origin/master`.
- `pnpm build` passed.
- The focused connector suite passed 285 tests after the rebase.
- The full stable suite passed 5,790 tests and failed 22 tests across 8
general server files. The failures reproduced as shared-runner
environment issues. They included `/tmp` versus `/private/tmp`, closed
database connections, and invalid high ephemeral ports. The focused
connection tests pass in isolation.

## Risks

- Migrations add agent grant subjects and a durable connection-event
inbox. Migration numbering and safety checks pass.
- A raw GitHub user token enters the agent process for Git and `gh`.
Per-tool Ask-first controls cannot limit those shell operations. The UI
warns users about this boundary.
- GitHub App user tokens can be non-expiring. Paperclip performs a
continuity check every 30 days, but provider revocation still requires a
reconnect.
- The webhook path accepts only signed and bounded payloads. It stores a
minimal normalized record and no raw provider payload.
- GitHub repository permissions remain authoritative. Removed access can
make a cached repository count temporarily stale, but runtime access
fails immediately.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code
execution, browser control, and multi-file repository editing. The
context window size was not provided.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-04 18:02:52 -05:00

7811 lines
305 KiB
TypeScript
Raw Blame History

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