mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## 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>
7811 lines
305 KiB
TypeScript
7811 lines
305 KiB
TypeScript
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>;
|