mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents call remote MCP tools through the Paperclip tool gateway > - The gateway keeps a health status for each remote MCP connection, and it hides the tools of a connection that has `healthStatus = "error"` > - One oversized, malformed, or invalid-JSON `tools/call` reply set the whole connection to `error` > - Health goes back to `ok` only after a successful call, but the hidden tools prevent that call, so the connection stayed locked until a person reconnected it > - This pull request makes these reply errors fail only the one call, and it starts a new MCP session for the caller on the next call > - The benefit is that one large or bad reply no longer disconnects a working connection for every agent ## Linked Issues or Issue Description No public issue exists. Related open PRs fix other health downgrades in the same function. They do not overlap with this change: - Refs #11910 (timeouts and JSON-RPC errors) - Refs #15325 (transport errors) **What happened?** An agent called a remote MCP tool that returned more than `MAX_REMOTE_MCP_RESPONSE_BYTES` (1 MB). The gateway returned 502 `mcp_remote_response_too_large`. It also set the connection to `healthStatus = "error"`. Then `connectedMcpConnectionFilter` hid all tools of the connection (except `per_user` connections), and `connections_search` showed the connection as `needs_user_action`. The `malformed_response` and `invalid_json` errors from `readMcpHttpResponse` did the same thing. `readMcpHttpResponse` can also cancel the reply stream before the end. The remote server can then close its MCP session. The gateway kept the cached `mcp-session-id` for up to 30 minutes and cleared it only after a 404. The next calls then failed with "Remote MCP session expired". **Expected behavior** A reply that is too large or not correct fails only that call. The connection stays healthy, and its tools stay visible. The next call uses a new MCP session. **Steps to reproduce** 1. Add a remote MCP connection with `mcpSessionRequired: true`. 2. Call a tool that returns a reply larger than 1 MB. 3. Look at the connection: `healthStatus` is `error`. 4. Start a new gateway session: the tools of the connection are not in the list. **Paperclip version or commit** `master` at `99a9de9940bf5974352d9dbfbb2f21e62e89689f` **Deployment mode** All modes. The fault is in the server tool gateway. ## What Changed - `server/src/services/tool-gateway.ts`: `too_large`, `malformed_response`, and `invalid_json` from `readMcpHttpResponse` now fail only the call. The caller gets the same 502 reason code as before. The gateway does not call `markRemoteConnectionHealth(…, "error")` for these errors. The `invalid_json` branch after `JSON.parse(body)` also does not change health now, so all `invalid_json` paths are the same. - The `mcp_remote_response_too_large` message now tells the caller to request a smaller result, for example a narrower query or a smaller page size. The error details now include `maxBytes`. - `server/src/services/mcp-http.ts`: new `forgetMcpHttpSession()`. It removes only the cached session for one scope and one credential set, and only while that entry still holds the session ID that failed. It does not touch other agents, sessions that are initializing, or a newer session that replaced the failed one. - The gateway calls `forgetMcpHttpSession()` after these reply errors, so the next call from that caller initializes a new session. The existing 404 "session expired" path now uses the same function. Before, both paths cleared all sessions and all pending initializations on the connection. That made a concurrent initialization by another agent fail with "MCP connection changed while initializing", which the gateway reported as a fetch failure and marked as a connection `error`. - `forgetMcpHttpSessions(connectionId)` is not changed. Disconnect and revocation still clear the full connection. Why `malformed_response` and `invalid_json` are also per-call errors: - Each error is about one reply body. The server was reachable, accepted the credentials, and sent HTTP 2xx. A reply can be too large or bad because of the tool and its arguments. That is not a fault of the connection. - The other malformed-reply checks in the same function (payload is not an object, or has no `result`) already throw `remote_mcp_malformed_response` and do not change health. Only the reader-level errors changed health. - Health recovers only after a successful call. An `error` status for one bad reply therefore locks the connection until a person reconnects it. - Other failures (HTTP errors, fetch failures, timeouts, JSON-RPC errors) still change health. This PR does not change them. #11910 and #15325 address some of them. ## Verification - `server/src/__tests__/tool-gateway.test.ts`: the recovery test now runs for three replies: oversized, invalid JSON, and a reply without the requested message ID. Each run uses a fake HTTP MCP server with `mcpSessionRequired: true` that gives `session-N` for each `initialize`. Each run checks that: - the call fails with the correct 502 reason code (and, for the oversized reply, the smaller-page hint and `maxBytes: 1000000`) - `healthStatus` stays `ok` - a new gateway session still lists the tool and can call it - the second `tools/call` uses `session-2`, not `session-1` - New test: agent A gets an oversized reply while agent B initializes a session on the same connection. Agent B's call completes, health stays `ok`, and the two calls use `session-1` and `session-2`. With the old connection-wide reset, this test fails: agent B gets 502 `mcp_remote_fetch_failed`. - `server/src/__tests__/remote-mcp-protocol.test.ts`: new unit test. `forgetMcpHttpSession()` keeps the session of a different identity, and a late failure from an old session does not remove the newer session. - Each regression check fails without its fix. Without the health change, the test fails on `healthStatus: 'error'`. Without the session reset, it fails with `['session-1', 'session-1']`. ``` cd server npx vitest run src/__tests__/tool-gateway.test.ts src/__tests__/tool-gateway-service.test.ts src/__tests__/remote-mcp-protocol.test.ts Test Files 3 passed (3) Tests 132 passed (132) ``` - I did not run the full server `tsc --noEmit` locally because the sandbox does not have sufficient memory. The CI typecheck covers it. ## Risks - Low risk. The change affects only the error path of remote MCP `tools/call`. - A remote server that always sends bad replies now keeps `healthStatus = "ok"`. Each call still fails with a clear 502 reason code and an audit record, so the failure stays visible. Only the connection-wide hiding of tools stops. - After one of these errors, the next call from the same caller sends one more `initialize` request. This adds one round trip. - A 404 "session expired" now clears only the session of the caller that got the 404. Before, it cleared the cached sessions of all agents on the connection. If the remote server restarts, each agent now gets its own "session expired" error one time and then initializes again. A 404 is about one session, and the old connection-wide clear also cancelled other agents' initializations. - #11910 and #15325 change the same catch block. The PR that merges last can have a small merge conflict. > 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 - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, 1M-token context window. - Run as an agent in Claude Code with tool use (shell, file edit, and local test runs). ## 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 - [x] 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 (no user-facing documentation changes are necessary) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge This PR replaces #15418. It keeps the same commits on a branch name without an internal ticket ID, and adds a fix for the Greptile review on #15418. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
11316 lines
384 KiB
TypeScript
11316 lines
384 KiB
TypeScript
import { composeConnectionInstructions } from "./connection-instructions.js";
|
|
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
|
import { boundedMcpToolName } from "./mcp-tool-names.js";
|
|
import { browserUseService } from "./browser-use.js";
|
|
import { isBrowserUseConnection } from "./browser-use-client.js";
|
|
import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "./cognee-connection.js";
|
|
import { HttpError } from "../errors.js";
|
|
import { claimSlackRateLimitRetry } from "./connectors/slack-retry.js";
|
|
import { resolveSlackTaskAuthority } from "./connectors/slack-authority.js";
|
|
import { SLACK_TOOLS } from "@paperclipai/shared";
|
|
import { slackToolsForSession } from "./connectors/slack-catalog.js";
|
|
import { executeSlackTool } from "./connectors/slack.js";
|
|
import { githubGuestBotConnectionForSession, githubBotToolsForSession } from "./chat-github-tools.js";
|
|
import { githubChatReviewService } from "./chat-github-reviews.js";
|
|
import { runIdentityContexts } from "@paperclipai/db";
|
|
import { captureRunIdentity } from "./run-identity.js";
|
|
import { emitConnectionInvoked } from "./connector-telemetry.js";
|
|
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
|
|
import { extractRemoteMcpPending } from "./remote-mcp-pending.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { spawn } from "node:child_process";
|
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import {
|
|
and,
|
|
asc,
|
|
desc,
|
|
eq,
|
|
gt,
|
|
inArray,
|
|
isNull,
|
|
lte,
|
|
ne,
|
|
or,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
approvals,
|
|
companies,
|
|
companyMemberships,
|
|
companySecrets,
|
|
connectionGrantMembers,
|
|
connectionGrantDelegations,
|
|
connectionGrants,
|
|
documents,
|
|
heartbeatRuns,
|
|
issueApprovals,
|
|
issueDocuments,
|
|
issueThreadInteractions,
|
|
issues,
|
|
projects,
|
|
toolActionRequests,
|
|
toolActionDeliveries,
|
|
toolAccessAuditEvents,
|
|
toolApplications,
|
|
toolCallEvents,
|
|
toolCatalogEntries,
|
|
toolConnections,
|
|
toolGatewayRateLimitCounters,
|
|
toolGatewaySessions,
|
|
toolInvocations,
|
|
toolMcpGateways,
|
|
toolMcpGatewayTokens,
|
|
toolPolicies,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
toolStdioCommandTemplates,
|
|
} from "@paperclipai/db";
|
|
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
|
|
import type {
|
|
CreateToolMcpGateway,
|
|
CreateToolMcpGatewayToken,
|
|
DeploymentExposure,
|
|
DeploymentMode,
|
|
McpConnectionCredentialRef,
|
|
SecretVersionSelector,
|
|
ToolAccessDecision,
|
|
ToolAccessDecisionInput,
|
|
ToolConnectionTestCallStatus,
|
|
ToolUpstreamPending,
|
|
ToolConnectionTestCallStatusPhase,
|
|
ToolCredentialSecretRef,
|
|
ToolMcpGateway,
|
|
ToolMcpGatewayClientSnippet,
|
|
ToolMcpGatewayToken,
|
|
ToolMcpGatewayTokenAction,
|
|
ToolMcpGatewayTokenCreated,
|
|
ToolMcpGatewayWithTokens,
|
|
UpdateToolMcpGateway,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
isGitHubConnectorProfileId,
|
|
isGoogleWorkspaceConnectorProfileId,
|
|
type GitHubConnectorProfileId,
|
|
type GoogleWorkspaceConnectorProfileId,
|
|
} from "@paperclipai/shared";
|
|
import type {
|
|
AgentToolDescriptor,
|
|
PluginToolDispatcher,
|
|
} from "./plugin-tool-dispatcher.js";
|
|
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
|
import { secretService } from "./secrets.js";
|
|
import { connectionGrantCredentialRef, resolveConnectionGrantSecret } from "./connection-credentials.js";
|
|
import { railwayCommandBudgetMs, createRailwayClient, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_API_URL, RAILWAY_TOOL_PREFIX, RailwayError } from "./railway.js";
|
|
import { RAILWAY_SSH_SECRET_PATH, runRailwaySshCommand } from "./railway-ssh.js";
|
|
import {
|
|
initializeMcpHttpSession,
|
|
getMcpHttpSession,
|
|
forgetMcpHttpSession,
|
|
readMcpHttpResponse,
|
|
McpHttpResponseError,
|
|
mcpHttpRequestHeaders,
|
|
parseMcpHttpResponseBody,
|
|
} from "./mcp-http.js";
|
|
import {
|
|
projectedConnectionHeaders,
|
|
projectedConnectionToolArguments,
|
|
projectedConnectionToolInputSchema,
|
|
} from "./tool-access.js";
|
|
import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription } from "./google-chat-tool-policy.js";
|
|
import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
|
|
import {
|
|
guardedRemoteHttpFetch,
|
|
type GuardedRemoteHttpFetchOptions,
|
|
} from "./remote-http-fetch.js";
|
|
import {
|
|
REMOTE_URL_SECRET_CONFIG_PATH,
|
|
remoteUrlCredentialMatchesPublicUrl,
|
|
} from "./remote-url-credentials.js";
|
|
import {
|
|
createToolAccessDecisionCache,
|
|
runContextSnapshotString,
|
|
toolAccessPolicyService,
|
|
type ToolAccessDecisionCache,
|
|
} from "./tool-access-policy.js";
|
|
import { toolDiscoveryScheduler, ToolDiscoveryBusyError } from "./tool-discovery-scheduler.js";
|
|
import { commitToolActionReview } from "./tool-action-review.js";
|
|
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
|
import {
|
|
createToolRuntimeSupervisor,
|
|
ToolRuntimeSupervisorError,
|
|
type ToolRuntimeSupervisorOptions,
|
|
type ToolRuntimeSlotView,
|
|
} from "./tool-runtime-supervisor.js";
|
|
import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js";
|
|
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared";
|
|
import {
|
|
createPaperclipCloudConnector,
|
|
isPaperclipCloudConnectorStrategy,
|
|
paperclipCloudConnectorConfigFromEnv,
|
|
PaperclipCloudConnectorError,
|
|
type PaperclipCloudConnector,
|
|
} from "./paperclip-cloud-connector.js";
|
|
import {
|
|
createVercelConnectClient,
|
|
vercelGrantReference,
|
|
vercelTokenRequest,
|
|
VercelConnectClientError,
|
|
type VercelConnectClient,
|
|
} from "./vercel-connect.js";
|
|
import {
|
|
canonicalToolArguments,
|
|
readSignedToolArgumentsPayload,
|
|
signToolArguments,
|
|
summarizeToolValue,
|
|
TOOL_ACTION_REQUEST_SIGNING_GRACE_MS,
|
|
ToolActionSigningSecretMissingError,
|
|
ToolContentValidationError,
|
|
validateToolContent,
|
|
verifyToolArgumentsSignature,
|
|
} from "./tool-content-guards.js";
|
|
import { extendApprovedExecutionWaitDeadline } from "./approved-execution-wait.js";
|
|
|
|
const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000;
|
|
const MAX_SESSION_TTL_MS = 60 * 60 * 1000;
|
|
const DEFAULT_TOOL_TIMEOUT_MS = 10_000;
|
|
|
|
export function resolveCredentialGrantKind(
|
|
policy: "shared" | "per_user" | "per_user_with_fallback" | "per_agent",
|
|
actingUserId: string | null,
|
|
hasUserGrant: boolean,
|
|
): "organization" | "user" | "user_authorization_required" {
|
|
if (policy === "per_agent") return "user_authorization_required";
|
|
if (policy === "shared") return "organization";
|
|
if (actingUserId && hasUserGrant) return "user";
|
|
return policy === "per_user" ? "user_authorization_required" : "organization";
|
|
}
|
|
|
|
export function isConnectionGrantAudienceAllowed(
|
|
memberUserIds: string[],
|
|
actingUserId: string | null,
|
|
actingUserIsActiveMember: boolean,
|
|
): boolean {
|
|
if (actingUserId !== null && !actingUserIsActiveMember) return false;
|
|
return (
|
|
memberUserIds.length === 0 ||
|
|
(actingUserId !== null && memberUserIds.includes(actingUserId))
|
|
);
|
|
}
|
|
// When a human approves a parked write, the server carries it out on their
|
|
// behalf with no interactive caller left to raise `timeoutMs`. Remote write
|
|
// providers (e.g. Zapier Google Sheets `add_row`) routinely take longer than
|
|
// the 10s interactive default, so an approved action would otherwise abort with
|
|
// `tool_timeout` even though the approval succeeded. Give approved executions
|
|
// the full permitted headroom instead.
|
|
const APPROVED_EXECUTION_TIMEOUT_MS = 60_000;
|
|
const ACTION_REQUEST_EXECUTION_POLL_MS = 25;
|
|
// Approval execution performs live target, signature, managed-argument, and
|
|
// issue-state checks before provider dispatch. Give that preparation a
|
|
// separate bounded window so it cannot consume the provider's execution
|
|
// budget for concurrent consumers.
|
|
const ACTION_REQUEST_PREPARATION_WAIT_MS = 2 * 60 * 1000;
|
|
// Concurrent consumers must wait at least as long as the provider execution
|
|
// they are joining. The extra grace lets the owner persist the terminal request
|
|
// state after the provider timeout/result settles.
|
|
const ACTION_REQUEST_EXECUTION_WAIT_MS = APPROVED_EXECUTION_TIMEOUT_MS + 5_000;
|
|
// The gateway creates an ask-first request in two steps: it inserts the row
|
|
// with a null signature, then it signs the row and sets the expiry. A concurrent
|
|
// matching call can observe the row in this window. A null signature alone does
|
|
// not prove the create stopped; the create can still run in another request. Only
|
|
// treat an unsigned row as abandoned after this grace time from createdAt. This
|
|
// grace must exceed the normal sign path (approval-snapshot fetch + interaction
|
|
// create) so a live create keeps its own row.
|
|
const MAX_REMOTE_MCP_RESPONSE_BYTES = 1_000_000;
|
|
const ACTIVE_GATEWAY_RUN_STATUSES = new Set(["running"]);
|
|
// A tool listing decides access for every tool in the company. The decisions
|
|
// of one listing share a read cache, so this cap only limits how many of them
|
|
// (and their uncached reads, such as rate-limit counters) run at the same time.
|
|
const LISTING_DECISION_CONCURRENCY = 16;
|
|
const uuidPattern =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
type McpGatewayProtocolMethod =
|
|
| "initialize"
|
|
| "tools/list"
|
|
| "tools/call"
|
|
| "resources/list"
|
|
| "resources/read"
|
|
| "prompts/list"
|
|
| "prompts/get";
|
|
type McpGatewayRateLimitConfig = { windowMs: number; max: number };
|
|
type McpGatewayRateLimitState = {
|
|
limited: boolean;
|
|
count: number;
|
|
retryAfterMs: number;
|
|
};
|
|
type McpGatewayProtocolLimitOptions = {
|
|
authFailures: McpGatewayRateLimitConfig;
|
|
gatewayRequests: McpGatewayRateLimitConfig;
|
|
tokenRequests: McpGatewayRateLimitConfig;
|
|
sessionSetup: McpGatewayRateLimitConfig;
|
|
};
|
|
|
|
const DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS: McpGatewayProtocolLimitOptions = {
|
|
authFailures: { windowMs: 5 * 60 * 1000, max: 20 },
|
|
gatewayRequests: { windowMs: 60 * 1000, max: 300 },
|
|
tokenRequests: { windowMs: 60 * 1000, max: 120 },
|
|
sessionSetup: { windowMs: 60 * 1000, max: 30 },
|
|
};
|
|
const TOOL_APPROVAL_DESCRIPTION_SUFFIX =
|
|
"Requires human approval: calling it posts an approval card on your task and you will be woken with the result once decided.";
|
|
|
|
export type ToolGatewayProviderType =
|
|
| "mcp_http_fixture"
|
|
| "mcp_stdio_fixture"
|
|
| "provider_rest"
|
|
| "mcp_remote_http"
|
|
| "mcp_local_stdio"
|
|
| "paperclip_self"
|
|
| "paperclip_plugin"
|
|
| "paperclip_virtual"
|
|
| "paperclip_github_chat"
|
|
| "paperclip_slack_chat";
|
|
|
|
export interface ConnectedMcpGatewayMetadata {
|
|
applicationId: string;
|
|
applicationKey: string | null;
|
|
applicationDisplayName: string;
|
|
connectionId: string;
|
|
catalogEntryId: string;
|
|
transport: "mcp_remote" | "local_stdio" | "rest_api";
|
|
gatewayToolName: string;
|
|
upstreamToolName: string;
|
|
catalogName: string;
|
|
inputSchema: Record<string, unknown>;
|
|
outputSchema: Record<string, unknown> | null;
|
|
annotations: Record<string, unknown>;
|
|
risk: {
|
|
level: string;
|
|
isReadOnly: boolean;
|
|
isWrite: boolean;
|
|
isDestructive: boolean;
|
|
};
|
|
onDemandTools?: boolean;
|
|
}
|
|
|
|
export interface ToolGatewayDescriptor extends AgentToolDescriptor {
|
|
providerType: ToolGatewayProviderType;
|
|
risk: "read" | "write" | "destructive";
|
|
applicationId?: string | null;
|
|
applicationKey?: string | null;
|
|
applicationDisplayName?: string | null;
|
|
connectionId?: string | null;
|
|
catalogEntryId?: string | null;
|
|
upstreamToolName?: string | null;
|
|
providerMetadata?: ConnectedMcpGatewayMetadata | Record<string, unknown>;
|
|
}
|
|
|
|
export interface ToolGatewaySession {
|
|
id: string;
|
|
token: string;
|
|
companyId: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
issueId: string | null;
|
|
projectId: string | null;
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
gatewayName?: string | null;
|
|
gatewayProfileId?: string | null;
|
|
gatewayTokenId?: string | null;
|
|
gatewayTokenAllowedActions?: ToolMcpGatewayTokenAction[];
|
|
actorType?: "agent" | "user" | "system" | "plugin";
|
|
actorId?: string | null;
|
|
/** Human whose personal connection grant applies to this execution. */
|
|
responsibleUserId?: string | null;
|
|
/** Captured by the controller for this request, never accepted from tool arguments. */
|
|
identityContextId?: string | null;
|
|
/** Set only after verifying the signed approved action. */
|
|
approvedSlackInvocationId?: string;
|
|
/** Set only after the signed review is verified. */
|
|
approvedInvocationId?: string;
|
|
createdAt: Date;
|
|
expiresAt: Date;
|
|
}
|
|
|
|
export type ToolGatewayRuntimeSlot = ToolRuntimeSlotView;
|
|
|
|
export class ToolGatewayHttpError extends Error {
|
|
constructor(
|
|
public readonly status: number,
|
|
message: string,
|
|
public readonly reasonCode: string,
|
|
public readonly details: Record<string, unknown> = {},
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
interface ExecuteGatewayToolInput {
|
|
sessionToken: string;
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
tool: string;
|
|
parameters?: unknown;
|
|
timeoutMs?: number;
|
|
approvedActionRequestId?: string | null;
|
|
idempotencyKey?: string | null;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
}
|
|
|
|
interface ExecuteTestCallInput {
|
|
companyId: string;
|
|
connectionId: string;
|
|
agentId: string;
|
|
userId: string;
|
|
toolName: string;
|
|
parameters?: unknown;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
interface ExecutePluginToolInput {
|
|
actor: {
|
|
type: "agent" | "board";
|
|
agentId?: string | null;
|
|
companyId?: string | null;
|
|
userId?: string | null;
|
|
runId?: string | null;
|
|
};
|
|
tool: string;
|
|
parameters: unknown;
|
|
runContext: ToolRunContext;
|
|
}
|
|
|
|
type HeaderPolicyConfig = {
|
|
staticHeaders: Array<{ name: string; value: string }>;
|
|
passthroughAllowlist: string[];
|
|
metadataHeaders: Array<
|
|
| "company_id"
|
|
| "agent_id"
|
|
| "issue_id"
|
|
| "project_id"
|
|
| "run_id"
|
|
| "gateway_session_id"
|
|
| "correlation_id"
|
|
>;
|
|
};
|
|
|
|
type HeaderPolicySummary = {
|
|
staticHeaderNames: string[];
|
|
credentialHeaderNames: string[];
|
|
passthroughHeaderNames: string[];
|
|
droppedPassthroughHeaderNames: string[];
|
|
metadataHeaderNames: string[];
|
|
collisionRules: Array<{ header: string; source: string; action: string }>;
|
|
};
|
|
|
|
type RemoteHttpExecutionResult = {
|
|
result: unknown;
|
|
headerSummary?: HeaderPolicySummary;
|
|
execution?: RemoteHttpExecutionAudit;
|
|
};
|
|
|
|
type RemoteHttpExecutionAudit = {
|
|
transport: "mcp_remote";
|
|
request: {
|
|
protocol: "MCP JSON-RPC 2.0" | "Railway GraphQL" | "Railway GraphQL + SSH";
|
|
httpMethod: "POST";
|
|
endpoint: string;
|
|
mcpMethod: "tools/call";
|
|
requestId: string;
|
|
upstreamToolName: string;
|
|
dispatched: true;
|
|
};
|
|
response?: {
|
|
httpStatus: number;
|
|
contentType: string | null;
|
|
bodySizeBytes: number;
|
|
upstreamRequestId: string | null;
|
|
};
|
|
};
|
|
|
|
type LocalStdioRuntimeTemplate = {
|
|
templateId: string;
|
|
command: string | null;
|
|
args: string[];
|
|
envKeys: string[];
|
|
};
|
|
|
|
const BUILTIN_LOCAL_STDIO_RUNTIME_TEMPLATES: Record<
|
|
string,
|
|
Omit<LocalStdioRuntimeTemplate, "templateId">
|
|
> = {
|
|
"paperclip.cognee-cloud": COGNEE_STDIO_TEMPLATE,
|
|
"paperclip.google-sheets": {
|
|
command: "paperclip-google-sheets-mcp-server",
|
|
args: [],
|
|
envKeys: [
|
|
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON",
|
|
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON_PATH",
|
|
"GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS",
|
|
],
|
|
},
|
|
"paperclip.echo-calculator-time": {
|
|
command: null,
|
|
args: [],
|
|
envKeys: [],
|
|
},
|
|
"paperclip.synthetic-todo-kv": {
|
|
command: null,
|
|
args: [],
|
|
envKeys: [],
|
|
},
|
|
};
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
async function mapWithConcurrency<T, R>(
|
|
items: readonly T[],
|
|
concurrency: number,
|
|
mapper: (item: T) => Promise<R>,
|
|
signal?: AbortSignal,
|
|
): Promise<R[]> {
|
|
const results = new Array<R>(items.length);
|
|
let nextIndex = 0;
|
|
let failed = false;
|
|
|
|
async function worker() {
|
|
while (!failed && nextIndex < items.length) {
|
|
const index = nextIndex;
|
|
nextIndex += 1;
|
|
try {
|
|
signal?.throwIfAborted();
|
|
results[index] = await mapper(items[index]!);
|
|
} catch (error) {
|
|
// The batch fails with this error, so the other workers stop.
|
|
failed = true;
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
const workers = await Promise.allSettled(
|
|
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
|
|
);
|
|
const failure = workers.find((result) => result.status === "rejected");
|
|
if (failure?.status === "rejected") throw failure.reason;
|
|
signal?.throwIfAborted();
|
|
return results;
|
|
}
|
|
|
|
const sensitivePassthroughHeaderPattern =
|
|
/(^|[-_])(auth|authorization|cookie|secret|session|token)([-_]|$)|(^|[-_])api[-_]?key([-_]|$)/i;
|
|
const sensitivePassthroughHeaderNames = new Set([
|
|
"authorization",
|
|
"proxy-authorization",
|
|
"cookie",
|
|
"set-cookie",
|
|
"x-paperclip-tool-gateway-token",
|
|
]);
|
|
|
|
function isSensitivePassthroughHeader(name: string) {
|
|
return (
|
|
name.startsWith("x-paperclip-") ||
|
|
sensitivePassthroughHeaderNames.has(name) ||
|
|
sensitivePassthroughHeaderPattern.test(name)
|
|
);
|
|
}
|
|
|
|
function stringValue(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function auditSafeEndpoint(endpoint: string): string {
|
|
try {
|
|
const url = new URL(endpoint);
|
|
return `${url.origin}${url.pathname}`;
|
|
} catch {
|
|
return "configured remote MCP endpoint";
|
|
}
|
|
}
|
|
|
|
function executionAuditFromError(
|
|
error: unknown,
|
|
): RemoteHttpExecutionAudit | undefined {
|
|
if (!(error instanceof ToolGatewayHttpError)) return undefined;
|
|
const execution = error.details.execution;
|
|
if (!execution || typeof execution !== "object" || Array.isArray(execution))
|
|
return undefined;
|
|
return execution as RemoteHttpExecutionAudit;
|
|
}
|
|
|
|
function generateGatewayToken(sessionId: string) {
|
|
return `pcgt_${sessionId}.${randomBytes(32).toString("base64url")}`;
|
|
}
|
|
|
|
function generateNamedGatewayToken(tokenId: string) {
|
|
return `pcgw_${tokenId}.${randomBytes(32).toString("base64url")}`;
|
|
}
|
|
|
|
function hashGatewayToken(token: string) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
function sessionIdFromGatewayToken(token: string) {
|
|
const match = token.match(/^pcgt_([0-9a-fA-F-]{36})\.[A-Za-z0-9_-]+$/);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function namedGatewayTokenId(token: string) {
|
|
const match = token.match(/^pcgw_([0-9a-fA-F-]{36})\.[A-Za-z0-9_-]+$/);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function positiveInt(value: string | undefined, fallback: number) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function mergeLimitConfig(
|
|
defaults: McpGatewayRateLimitConfig,
|
|
overrides: Partial<McpGatewayRateLimitConfig> | undefined,
|
|
): McpGatewayRateLimitConfig {
|
|
return {
|
|
windowMs:
|
|
overrides?.windowMs && overrides.windowMs > 0
|
|
? overrides.windowMs
|
|
: defaults.windowMs,
|
|
max: overrides?.max && overrides.max > 0 ? overrides.max : defaults.max,
|
|
};
|
|
}
|
|
|
|
function mcpGatewayProtocolLimits(
|
|
overrides:
|
|
| Partial<{
|
|
authFailures: Partial<McpGatewayRateLimitConfig>;
|
|
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
|
|
tokenRequests: Partial<McpGatewayRateLimitConfig>;
|
|
sessionSetup: Partial<McpGatewayRateLimitConfig>;
|
|
}>
|
|
| undefined,
|
|
): McpGatewayProtocolLimitOptions {
|
|
const envDefaults: McpGatewayProtocolLimitOptions = {
|
|
authFailures: {
|
|
windowMs: positiveInt(
|
|
process.env.PAPERCLIP_MCP_GATEWAY_AUTH_FAILURE_WINDOW_MS,
|
|
DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.authFailures.windowMs,
|
|
),
|
|
max: positiveInt(
|
|
process.env.PAPERCLIP_MCP_GATEWAY_AUTH_FAILURE_LIMIT,
|
|
DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.authFailures.max,
|
|
),
|
|
},
|
|
gatewayRequests: {
|
|
windowMs: positiveInt(
|
|
process.env.PAPERCLIP_MCP_GATEWAY_REQUEST_WINDOW_MS,
|
|
DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.gatewayRequests.windowMs,
|
|
),
|
|
max: positiveInt(
|
|
process.env.PAPERCLIP_MCP_GATEWAY_REQUEST_LIMIT,
|
|
DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.gatewayRequests.max,
|
|
),
|
|
},
|
|
tokenRequests: {
|
|
windowMs: positiveInt(
|
|
process.env.PAPERCLIP_MCP_GATEWAY_TOKEN_REQUEST_WINDOW_MS,
|
|
DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.tokenRequests.windowMs,
|
|
),
|
|
max: positiveInt(
|
|
process.env.PAPERCLIP_MCP_GATEWAY_TOKEN_REQUEST_LIMIT,
|
|
DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.tokenRequests.max,
|
|
),
|
|
},
|
|
sessionSetup: {
|
|
windowMs: positiveInt(
|
|
process.env.PAPERCLIP_MCP_GATEWAY_SESSION_SETUP_WINDOW_MS,
|
|
DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.sessionSetup.windowMs,
|
|
),
|
|
max: positiveInt(
|
|
process.env.PAPERCLIP_MCP_GATEWAY_SESSION_SETUP_LIMIT,
|
|
DEFAULT_MCP_GATEWAY_PROTOCOL_LIMITS.sessionSetup.max,
|
|
),
|
|
},
|
|
};
|
|
return {
|
|
authFailures: mergeLimitConfig(
|
|
envDefaults.authFailures,
|
|
overrides?.authFailures,
|
|
),
|
|
gatewayRequests: mergeLimitConfig(
|
|
envDefaults.gatewayRequests,
|
|
overrides?.gatewayRequests,
|
|
),
|
|
tokenRequests: mergeLimitConfig(
|
|
envDefaults.tokenRequests,
|
|
overrides?.tokenRequests,
|
|
),
|
|
sessionSetup: mergeLimitConfig(
|
|
envDefaults.sessionSetup,
|
|
overrides?.sessionSetup,
|
|
),
|
|
};
|
|
}
|
|
|
|
function tokenPrefixFromNamedBearer(token: string) {
|
|
const tokenId = namedGatewayTokenId(token);
|
|
if (tokenId) return `pcgw_${tokenId.slice(0, 8)}`;
|
|
return token.startsWith("pcgw_") ? "pcgw_malformed" : "unknown";
|
|
}
|
|
|
|
function safeHeaderValue(
|
|
headers: Record<string, string | string[] | undefined> | undefined,
|
|
name: string,
|
|
maxLength = 160,
|
|
) {
|
|
const value = headers?.[name] ?? headers?.[name.toLowerCase()];
|
|
const raw = Array.isArray(value) ? value[0] : value;
|
|
if (!raw) return null;
|
|
const sanitized = raw.replace(/[\r\n\t]/g, " ").trim();
|
|
return sanitized ? sanitized.slice(0, maxLength) : null;
|
|
}
|
|
|
|
function safeClientMetadata(
|
|
headers: Record<string, string | string[] | undefined> | undefined,
|
|
) {
|
|
const clientName =
|
|
safeHeaderValue(headers, "x-paperclip-client-name", 120) ??
|
|
safeHeaderValue(headers, "mcp-client-name", 120) ??
|
|
null;
|
|
const correlationId =
|
|
safeHeaderValue(headers, "x-request-id", 120) ??
|
|
safeHeaderValue(headers, "x-correlation-id", 120) ??
|
|
null;
|
|
return {
|
|
clientName,
|
|
correlationId,
|
|
userAgent: safeHeaderValue(headers, "user-agent", 200),
|
|
};
|
|
}
|
|
|
|
function rateLimitWindowStart(current: number, windowMs: number) {
|
|
return new Date(Math.floor(current / windowMs) * windowMs);
|
|
}
|
|
|
|
function gatewaySessionFromRow(
|
|
row: typeof toolGatewaySessions.$inferSelect,
|
|
): ToolGatewaySession {
|
|
return {
|
|
id: row.id,
|
|
token: "",
|
|
companyId: row.companyId,
|
|
agentId: row.agentId,
|
|
runId: row.runId,
|
|
issueId: row.issueId,
|
|
projectId: row.projectId,
|
|
createdAt: row.createdAt,
|
|
expiresAt: row.expiresAt,
|
|
};
|
|
}
|
|
|
|
function timeoutMs(value: number | undefined) {
|
|
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 actionName = input.tool.displayName?.trim() || input.tool.name;
|
|
const trustLine =
|
|
input.tool.risk === "destructive"
|
|
? `${actionName}. This can permanently change or remove data.`
|
|
: actionName;
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(input.argumentsSummary.summary);
|
|
} catch {
|
|
return trustLine;
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
return trustLine;
|
|
|
|
const fieldLines: string[] = [];
|
|
for (const [key, value] of Object.entries(
|
|
parsed as Record<string, unknown>,
|
|
)) {
|
|
if (fieldLines.length >= 6) break;
|
|
if (isIdentifierArgumentKey(key)) continue;
|
|
const rendered = humanizeArgumentValue(value);
|
|
if (rendered === null) continue;
|
|
fieldLines.push(`**${humanizeArgumentKey(key)}:** ${rendered}`);
|
|
}
|
|
|
|
if (fieldLines.length === 0) return trustLine;
|
|
return [trustLine, ...fieldLines].join(" · ");
|
|
}
|
|
|
|
const BUILTIN_TOOLS: ToolGatewayDescriptor[] = [
|
|
{
|
|
name: "mcp-remote-fixture:echo",
|
|
displayName: "Remote fixture echo",
|
|
description:
|
|
"Remote HTTP MCP fixture that echoes a message without spawning a local process.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "mcp-remote-fixture",
|
|
providerType: "mcp_http_fixture",
|
|
risk: "read",
|
|
},
|
|
{
|
|
name: "mcp-remote-fixture:add",
|
|
displayName: "Remote fixture add",
|
|
description:
|
|
"Remote HTTP MCP fixture that adds two numbers without spawning a local process.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: { a: { type: "number" }, b: { type: "number" } },
|
|
required: ["a", "b"],
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "mcp-remote-fixture",
|
|
providerType: "mcp_http_fixture",
|
|
risk: "read",
|
|
},
|
|
{
|
|
name: "mcp-remote-fixture:update_note",
|
|
displayName: "Remote fixture update note",
|
|
description:
|
|
"Remote HTTP MCP fixture that simulates a side-effecting write.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: { noteId: { type: "string" }, body: { type: "string" } },
|
|
required: ["noteId", "body"],
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "mcp-remote-fixture",
|
|
providerType: "mcp_http_fixture",
|
|
risk: "write",
|
|
},
|
|
{
|
|
name: "paperclip-self:list_my_issues",
|
|
displayName: "List my Paperclip issues",
|
|
description:
|
|
"Paperclip self-MCP read fixture that lists the authenticated agent's current issues.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: { limit: { type: "number" } },
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "paperclip-self",
|
|
providerType: "paperclip_self",
|
|
risk: "read",
|
|
},
|
|
{
|
|
name: "paperclip-self:get_issue_context",
|
|
displayName: "Get issue context",
|
|
description:
|
|
"Paperclip self-MCP read fixture that returns scoped issue context and plan document metadata.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: { issueId: { type: "string" } },
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "paperclip-self",
|
|
providerType: "paperclip_self",
|
|
risk: "read",
|
|
},
|
|
{
|
|
name: "mcp-stdio-fixture:increment_counter",
|
|
displayName: "Stdio runtime counter",
|
|
description:
|
|
"Local stdio MCP fixture that lazy-starts a supervised runtime slot and increments slot-local state.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: {},
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "mcp-stdio-fixture",
|
|
providerType: "mcp_stdio_fixture",
|
|
risk: "read",
|
|
},
|
|
{
|
|
name: "mcp-stdio-fixture:runtime_status",
|
|
displayName: "Stdio runtime status",
|
|
description:
|
|
"Local stdio MCP fixture that reports the reused runtime slot state.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: {},
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "mcp-stdio-fixture",
|
|
providerType: "mcp_stdio_fixture",
|
|
risk: "read",
|
|
},
|
|
];
|
|
|
|
const VIRTUAL_SEARCH_TOOLS: ToolGatewayDescriptor = {
|
|
name: "search_tools",
|
|
displayName: "Search available tools",
|
|
description:
|
|
"Search the tools available through this Paperclip gateway without loading every target tool into the tool list.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string" },
|
|
limit: { type: "number" },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "paperclip-gateway",
|
|
providerType: "paperclip_virtual",
|
|
risk: "read",
|
|
};
|
|
|
|
const VIRTUAL_RUN_TOOL: ToolGatewayDescriptor = {
|
|
name: "run_tool",
|
|
displayName: "Run a selected tool",
|
|
description:
|
|
"Run a target tool by name after Paperclip applies the target tool's profile, policy, approval, and rate-limit checks.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: {
|
|
tool: { type: "string" },
|
|
arguments: { type: "object" },
|
|
},
|
|
required: ["tool"],
|
|
additionalProperties: false,
|
|
},
|
|
pluginId: "paperclip-gateway",
|
|
providerType: "paperclip_virtual",
|
|
risk: "write",
|
|
};
|
|
|
|
const VIRTUAL_TOOLS = [VIRTUAL_SEARCH_TOOLS, VIRTUAL_RUN_TOOL];
|
|
|
|
export function createToolGatewayService(
|
|
db: Db,
|
|
options: {
|
|
pluginToolDispatcher?: PluginToolDispatcher;
|
|
deploymentMode?: DeploymentMode;
|
|
deploymentExposure?: DeploymentExposure;
|
|
trustedLocalStdioRuntimeHost?: string | null;
|
|
runtimeSupervisor?: ToolRuntimeSupervisorOptions;
|
|
toolActionSigningSecret?: string;
|
|
onToolActionSettled?: (actionRequestId: string) => Promise<unknown>;
|
|
/** Test seam for deterministic remote MCP protocol fixtures. */
|
|
remoteHttpRequest?: (url: string, init: RequestInit) => Promise<Response>;
|
|
/** Test seam for refreshing personal Gmail grants. */
|
|
paperclipCloudConnector?: PaperclipCloudConnector | null;
|
|
/** @deprecated Use paperclipCloudConnector. */
|
|
paperclipIdGmailConnector?: PaperclipCloudConnector | null;
|
|
/** Refreshes customer-owned/DCR OAuth grants before remote MCP execution. */
|
|
oauthGrantRefresher?: (input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
grantId: string;
|
|
forceRefresh?: boolean;
|
|
actor?: {
|
|
actorType?: "agent" | "user" | "system" | "plugin";
|
|
actorId?: string | null;
|
|
};
|
|
issueId?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
}) => Promise<typeof connectionGrants.$inferSelect>;
|
|
/** Test seam for resolving Vercel Connect credentials. */
|
|
vercelConnectClient?: VercelConnectClient | null;
|
|
/** Test seam for reproducing the managed-argument drift expiry race. */
|
|
beforeManagedArgumentDriftExpiry?: () => Promise<void>;
|
|
/** Test seam for pausing a legacy approved request before its execution claim. */
|
|
beforeLegacyApprovedActionClaim?: () => Promise<void>;
|
|
mcpGatewayProtocolLimits?: Partial<{
|
|
authFailures: Partial<McpGatewayRateLimitConfig>;
|
|
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
|
|
tokenRequests: Partial<McpGatewayRateLimitConfig>;
|
|
sessionSetup: Partial<McpGatewayRateLimitConfig>;
|
|
}>;
|
|
now?: () => number;
|
|
} = {},
|
|
) {
|
|
// Authorization links can contain one-time codes. Keep them briefly in memory;
|
|
// persist only the redacted request and execution identifiers for recovery.
|
|
const upstreamHandoffs = new Map<string, { pending: ToolUpstreamPending; expires: number }>();
|
|
async function retainUpstreamHandoff(invocationId: string, pending: ToolUpstreamPending) {
|
|
for (const [id, value] of upstreamHandoffs) if (value.expires <= Date.now()) upstreamHandoffs.delete(id);
|
|
while (upstreamHandoffs.size >= 256) upstreamHandoffs.delete(upstreamHandoffs.keys().next().value!);
|
|
upstreamHandoffs.set(invocationId, { pending, expires: Date.now() + 15 * 60_000 });
|
|
const summary = validateToolContent({
|
|
value: { upstreamPending: { ...pending, links: [] } }, direction: "result", sensitiveMode: "redact", promptInjectionMode: "ignore",
|
|
}).summary;
|
|
await db.update(toolInvocations).set({ resultSummary: summary }).where(eq(toolInvocations.id, invocationId));
|
|
}
|
|
function recoverUpstreamHandoff(invocation: typeof toolInvocations.$inferSelect): ToolUpstreamPending | undefined {
|
|
if (invocation.errorCode !== "provider_interaction_required") return undefined;
|
|
const cached = upstreamHandoffs.get(invocation.id);
|
|
if (cached && cached.expires > Date.now()) return cached.pending;
|
|
upstreamHandoffs.delete(invocation.id);
|
|
const stored = asRecord(storedInvocationResult(invocation));
|
|
const pending = asRecord(stored?.upstreamPending);
|
|
if (!pending || !["approval", "authorization"].includes(String(pending.kind))) return undefined;
|
|
return { ...pending, links: [] } as unknown as ToolUpstreamPending;
|
|
}
|
|
const runtimeSupervisor = createToolRuntimeSupervisor(db, {
|
|
deploymentMode: options.deploymentMode,
|
|
deploymentExposure: options.deploymentExposure,
|
|
trustedLocalStdioRuntimeHost: options.trustedLocalStdioRuntimeHost,
|
|
...options.runtimeSupervisor,
|
|
});
|
|
const pluginToolDispatcher = options.pluginToolDispatcher;
|
|
const interactions = issueThreadInteractionService(db);
|
|
const policyService = toolAccessPolicyService(db);
|
|
const secrets = secretService(db);
|
|
// Authentication produces a new session object for every operation. Keep
|
|
// credential acquisition scoped to that object and out of persisted inputs.
|
|
const githubOperationCredentials = new WeakMap<
|
|
ToolGatewaySession,
|
|
{
|
|
grant: typeof connectionGrants.$inferSelect;
|
|
headers: Record<string, string>;
|
|
}
|
|
>();
|
|
const configuredCloudConnector =
|
|
options.paperclipCloudConnector ?? options.paperclipIdGmailConnector;
|
|
const connectorWasProvided =
|
|
options.paperclipCloudConnector !== undefined ||
|
|
options.paperclipIdGmailConnector !== undefined;
|
|
let cachedCloudConnector = configuredCloudConnector ?? null;
|
|
const currentCloudConnector = (): PaperclipCloudConnector | null => {
|
|
if (cachedCloudConnector || connectorWasProvided)
|
|
return cachedCloudConnector;
|
|
const config = paperclipCloudConnectorConfigFromEnv();
|
|
cachedCloudConnector = config
|
|
? createPaperclipCloudConnector({ config, now: options.now })
|
|
: null;
|
|
return cachedCloudConnector;
|
|
};
|
|
const gmailRefreshFlights = new Map<
|
|
string,
|
|
Promise<typeof connectionGrants.$inferSelect>
|
|
>();
|
|
const vercelConnect =
|
|
options.vercelConnectClient === undefined
|
|
? createVercelConnectClient()
|
|
: options.vercelConnectClient;
|
|
const protocolLimits = mcpGatewayProtocolLimits(
|
|
options.mcpGatewayProtocolLimits,
|
|
);
|
|
let nextProtocolRateLimitPruneAt = 0;
|
|
|
|
async function pruneExpiredProtocolRateLimitCounters(current: number) {
|
|
if (current < nextProtocolRateLimitPruneAt) return;
|
|
nextProtocolRateLimitPruneAt = current + 60_000;
|
|
await db
|
|
.delete(toolGatewayRateLimitCounters)
|
|
.where(lte(toolGatewayRateLimitCounters.resetAt, new Date(current)));
|
|
}
|
|
|
|
async function consumeProtocolRateLimit(input: {
|
|
companyId: string;
|
|
counterKey: string;
|
|
config: McpGatewayRateLimitConfig;
|
|
}): Promise<McpGatewayRateLimitState> {
|
|
const current = options.now?.() ?? Date.now();
|
|
const windowStartAt = rateLimitWindowStart(current, input.config.windowMs);
|
|
const resetAt = new Date(windowStartAt.getTime() + input.config.windowMs);
|
|
const nowDate = new Date(current);
|
|
const windowStartIso = windowStartAt.toISOString();
|
|
const resetIso = resetAt.toISOString();
|
|
const nowIso = nowDate.toISOString();
|
|
await pruneExpiredProtocolRateLimitCounters(current);
|
|
const rows = Array.from(
|
|
await db.execute(sql<{ count: number | string }>`
|
|
INSERT INTO "tool_gateway_rate_limit_counters" (
|
|
"company_id",
|
|
"counter_key",
|
|
"window_start_at",
|
|
"window_ms",
|
|
"limit",
|
|
"count",
|
|
"reset_at",
|
|
"created_at",
|
|
"updated_at"
|
|
)
|
|
VALUES (
|
|
${input.companyId},
|
|
${input.counterKey},
|
|
${windowStartIso}::timestamptz,
|
|
${input.config.windowMs},
|
|
${input.config.max},
|
|
1,
|
|
${resetIso}::timestamptz,
|
|
${nowIso}::timestamptz,
|
|
${nowIso}::timestamptz
|
|
)
|
|
ON CONFLICT ("company_id", "counter_key", "window_start_at")
|
|
DO UPDATE SET
|
|
"count" = "tool_gateway_rate_limit_counters"."count" + 1,
|
|
"window_ms" = ${input.config.windowMs},
|
|
"limit" = ${input.config.max},
|
|
"reset_at" = ${resetIso}::timestamptz,
|
|
"updated_at" = ${nowIso}::timestamptz
|
|
RETURNING "count"
|
|
`),
|
|
);
|
|
const count = Number(rows[0]?.count ?? 1);
|
|
return {
|
|
limited: count > input.config.max,
|
|
count,
|
|
retryAfterMs: Math.max(0, resetAt.getTime() - current),
|
|
};
|
|
}
|
|
|
|
function pluginTools(): ToolGatewayDescriptor[] {
|
|
return (pluginToolDispatcher?.listToolsForAgent() ?? []).map((tool) => ({
|
|
...tool,
|
|
providerType: "paperclip_plugin" as const,
|
|
risk: inferToolRisk(tool.name),
|
|
}));
|
|
}
|
|
|
|
function allTools(): ToolGatewayDescriptor[] {
|
|
return [...BUILTIN_TOOLS, ...pluginTools()];
|
|
}
|
|
|
|
function connectedMcpConnectionFilter(companyId: string) {
|
|
return and(
|
|
eq(toolConnections.companyId, companyId),
|
|
inArray(toolConnections.transport, ["mcp_remote", "local_stdio", "rest_api"]),
|
|
eq(toolConnections.status, "active"),
|
|
eq(toolConnections.enabled, true),
|
|
// A personal connection has no company-level credential to probe. A
|
|
// credential-less health sweep can therefore mark it as errored even
|
|
// while the responsible user's grant is valid. Keep its cached active
|
|
// catalog discoverable; execution resolves and validates that user's
|
|
// grant, and a successful call restores the shared health indicator.
|
|
or(
|
|
inArray(toolConnections.healthStatus, ["ok", "healthy"]),
|
|
eq(toolConnections.credentialPolicy, "per_user"),
|
|
),
|
|
eq(toolApplications.companyId, companyId),
|
|
inArray(toolApplications.type, ["mcp_http", "mcp_stdio", "rest_api"]),
|
|
eq(toolApplications.status, "active"),
|
|
);
|
|
}
|
|
|
|
async function connectedMcpToolsForCompany(
|
|
companyId: string,
|
|
): Promise<ToolGatewayDescriptor[]> {
|
|
// Read each connection and application row once. Selecting them in the
|
|
// catalog join repeats the full connection row (config included) for
|
|
// every tool, and one connection can expose hundreds of tools. Both reads
|
|
// share one read-only snapshot, so they agree like a single join, and the
|
|
// second read gets only the connections that have listed tools.
|
|
const { connectionRows, catalogRows } = await db.transaction(
|
|
async (tx) => {
|
|
const catalogRows = await tx
|
|
.select({ catalogEntry: toolCatalogEntries })
|
|
.from(toolCatalogEntries)
|
|
.innerJoin(
|
|
toolConnections,
|
|
eq(toolCatalogEntries.connectionId, toolConnections.id),
|
|
)
|
|
.innerJoin(
|
|
toolApplications,
|
|
eq(toolConnections.applicationId, toolApplications.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.entryKind, "tool"),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
isNull(toolCatalogEntries.quarantinedAt),
|
|
connectedMcpConnectionFilter(companyId),
|
|
),
|
|
)
|
|
.orderBy(toolConnections.name, toolCatalogEntries.name);
|
|
const connectionIds = [
|
|
...new Set(
|
|
catalogRows.map(({ catalogEntry }) => catalogEntry.connectionId),
|
|
),
|
|
];
|
|
if (connectionIds.length === 0)
|
|
return { connectionRows: [], catalogRows };
|
|
const connectionRows = await tx
|
|
.select({
|
|
connection: toolConnections,
|
|
application: toolApplications,
|
|
})
|
|
.from(toolConnections)
|
|
.innerJoin(
|
|
toolApplications,
|
|
eq(toolConnections.applicationId, toolApplications.id),
|
|
)
|
|
.where(
|
|
and(
|
|
inArray(toolConnections.id, connectionIds),
|
|
connectedMcpConnectionFilter(companyId),
|
|
),
|
|
);
|
|
return { connectionRows, catalogRows };
|
|
},
|
|
{ isolationLevel: "repeatable read", accessMode: "read only" },
|
|
);
|
|
const connectionRowsById = new Map(
|
|
connectionRows.map((row) => [row.connection.id, row]),
|
|
);
|
|
const rows = catalogRows.flatMap(({ catalogEntry }) => {
|
|
const connectionRow = connectionRowsById.get(catalogEntry.connectionId);
|
|
return connectionRow ? [{ catalogEntry, ...connectionRow }] : [];
|
|
});
|
|
|
|
const eligibleRows = rows.filter(
|
|
({ catalogEntry, connection, application }) =>
|
|
!isRetiredComposioConnection(connection) &&
|
|
!(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) &&
|
|
((isBrowserUseConnection(connection) && application.type === "rest_api") ||
|
|
(connection.transport === "mcp_remote" &&
|
|
application.type === "mcp_http") ||
|
|
(connection.transport === "local_stdio" &&
|
|
application.type === "mcp_stdio")),
|
|
);
|
|
const baseNames = eligibleRows.map(
|
|
({ catalogEntry, connection, application }) => {
|
|
const applicationKey = application.applicationKey ?? null;
|
|
const connectionNamespace = `${slugSegment(applicationKey ?? connection.name ?? application.name, "mcp")}-${shortStableId(connection.id)}`;
|
|
const toolSlug = slugSegment(catalogEntry.toolName, "tool");
|
|
return `${connection.transport === "rest_api" ? "app" : "mcp"}.${connectionNamespace}:${toolSlug}`;
|
|
},
|
|
);
|
|
const baseNameCounts = baseNames.reduce<Map<string, number>>(
|
|
(counts, name) => {
|
|
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
return counts;
|
|
},
|
|
new Map(),
|
|
);
|
|
|
|
return eligibleRows.map(
|
|
({ catalogEntry, connection, application }, index) => {
|
|
if (
|
|
connection.transport !== "mcp_remote" &&
|
|
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection)
|
|
) {
|
|
throw new Error(
|
|
`Non-MCP connection ${connection.id} cannot be exposed through the MCP gateway`,
|
|
);
|
|
}
|
|
const baseName = baseNames[index]!;
|
|
const unboundedGatewayToolName =
|
|
baseNameCounts.get(baseName)! > 1
|
|
? `${baseName}-${shortStableId(catalogEntry.id)}`
|
|
: baseName;
|
|
const gatewayToolName = boundedMcpToolName(unboundedGatewayToolName, [
|
|
connection.id, application.id, catalogEntry.toolName,
|
|
...(baseNameCounts.get(baseName)! > 1 ? [catalogEntry.id] : []),
|
|
]);
|
|
const applicationKey = application.applicationKey ?? null;
|
|
const inputSchema = projectedConnectionToolInputSchema(
|
|
connection,
|
|
catalogEntry.inputSchema ?? {},
|
|
catalogEntry.toolName,
|
|
);
|
|
const outputSchema = catalogEntry.outputSchema ?? null;
|
|
const annotations = catalogEntry.annotations ?? {};
|
|
const risk = riskFromCatalogEntry(catalogEntry);
|
|
const onDemandTools = readOnDemandToolsEnabled(connection);
|
|
const providerMetadata: ConnectedMcpGatewayMetadata = {
|
|
applicationId: application.id,
|
|
applicationKey,
|
|
applicationDisplayName: application.name,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
transport: connection.transport as ConnectedMcpGatewayMetadata["transport"],
|
|
gatewayToolName,
|
|
upstreamToolName: catalogEntry.toolName,
|
|
catalogName: catalogEntry.name,
|
|
inputSchema,
|
|
outputSchema,
|
|
annotations,
|
|
risk: {
|
|
level: catalogEntry.riskLevel,
|
|
isReadOnly: catalogEntry.isReadOnly,
|
|
isWrite: catalogEntry.isWrite,
|
|
isDestructive: catalogEntry.isDestructive,
|
|
},
|
|
onDemandTools,
|
|
};
|
|
return {
|
|
name: gatewayToolName,
|
|
displayName: catalogEntry.title ?? catalogEntry.toolName,
|
|
description:
|
|
googleChatToolDescription(connection, catalogEntry.toolName, catalogEntry.description) ??
|
|
`Connected MCP tool ${catalogEntry.toolName} from ${connection.name}.`,
|
|
parametersSchema: inputSchema,
|
|
pluginId: `mcp:${applicationKey ?? application.id}`,
|
|
providerType:
|
|
connection.transport === "rest_api" ? "provider_rest" : connection.transport === "local_stdio"
|
|
? "mcp_local_stdio"
|
|
: "mcp_remote_http",
|
|
risk,
|
|
applicationId: application.id,
|
|
applicationKey,
|
|
applicationDisplayName: application.name,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
upstreamToolName: catalogEntry.toolName,
|
|
providerMetadata,
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
async function connectedMcpToolsForConnection(
|
|
companyId: string,
|
|
connectionId: string,
|
|
): Promise<ToolGatewayDescriptor[]> {
|
|
return (await connectedMcpToolsForCompany(companyId)).filter(
|
|
(tool) => tool.connectionId === connectionId,
|
|
);
|
|
}
|
|
|
|
async function assertAgentInCompany(
|
|
companyId: string,
|
|
agentId: string,
|
|
): Promise<void> {
|
|
const [agent] = await db
|
|
.select({
|
|
companyId: agents.companyId,
|
|
})
|
|
.from(agents)
|
|
.where(eq(agents.id, agentId))
|
|
.limit(1);
|
|
|
|
if (!agent || agent.companyId !== companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"Agent not found for company",
|
|
"agent_not_found",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* "Last changed by {Actor} · {relativeTime}" audit hint for the Test tab Off
|
|
* side panel. Looks across the configuration that governs this agent's access
|
|
* to the connection — the policies that matched, the profiles in effect, the
|
|
* entries that scope them to this connection, and the bindings that assigned
|
|
* those profiles to the agent — and reports the most recent edit. Only
|
|
* policies and bindings carry an actor, so the attributed agent name is best
|
|
* effort: when the latest edit was a profile/entry toggle (no actor column),
|
|
* the timestamp is still returned but the actor is null.
|
|
*/
|
|
async function summarizeAccessLastChange(input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
agentId: string;
|
|
policyIds: string[];
|
|
profileIds: string[];
|
|
}): Promise<{
|
|
lastChangedAt: string | null;
|
|
lastChangedByAgentId: string | null;
|
|
lastChangedByName: string | null;
|
|
}> {
|
|
const empty = {
|
|
lastChangedAt: null,
|
|
lastChangedByAgentId: null,
|
|
lastChangedByName: null,
|
|
};
|
|
const candidates: Array<{ updatedAt: Date; agentId: string | null }> = [];
|
|
|
|
if (input.policyIds.length > 0) {
|
|
const policies = await db
|
|
.select({
|
|
updatedAt: toolPolicies.updatedAt,
|
|
agentId: toolPolicies.createdByAgentId,
|
|
})
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, input.companyId),
|
|
inArray(toolPolicies.id, input.policyIds),
|
|
),
|
|
);
|
|
candidates.push(
|
|
...policies.map((row) => ({
|
|
updatedAt: row.updatedAt,
|
|
agentId: row.agentId,
|
|
})),
|
|
);
|
|
}
|
|
|
|
if (input.profileIds.length > 0) {
|
|
const [profiles, entries, bindings] = await Promise.all([
|
|
db
|
|
.select({ updatedAt: toolProfiles.updatedAt })
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, input.companyId),
|
|
inArray(toolProfiles.id, input.profileIds),
|
|
),
|
|
),
|
|
db
|
|
.select({ updatedAt: toolProfileEntries.updatedAt })
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, input.companyId),
|
|
inArray(toolProfileEntries.profileId, input.profileIds),
|
|
eq(toolProfileEntries.connectionId, input.connectionId),
|
|
),
|
|
),
|
|
db
|
|
.select({
|
|
updatedAt: toolProfileBindings.updatedAt,
|
|
agentId: toolProfileBindings.createdByAgentId,
|
|
})
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, input.companyId),
|
|
inArray(toolProfileBindings.profileId, input.profileIds),
|
|
eq(toolProfileBindings.targetType, "agent"),
|
|
eq(toolProfileBindings.targetId, input.agentId),
|
|
),
|
|
),
|
|
]);
|
|
candidates.push(
|
|
...profiles.map((row) => ({ updatedAt: row.updatedAt, agentId: null })),
|
|
);
|
|
candidates.push(
|
|
...entries.map((row) => ({ updatedAt: row.updatedAt, agentId: null })),
|
|
);
|
|
candidates.push(
|
|
...bindings.map((row) => ({
|
|
updatedAt: row.updatedAt,
|
|
agentId: row.agentId,
|
|
})),
|
|
);
|
|
}
|
|
|
|
if (candidates.length === 0) return empty;
|
|
const latest = candidates.reduce((a, b) =>
|
|
b.updatedAt.getTime() > a.updatedAt.getTime() ? b : a,
|
|
);
|
|
|
|
let lastChangedByName: string | null = null;
|
|
if (latest.agentId) {
|
|
const [actor] = await db
|
|
.select({ name: agents.name })
|
|
.from(agents)
|
|
.where(eq(agents.id, latest.agentId))
|
|
.limit(1);
|
|
lastChangedByName = actor?.name ?? null;
|
|
}
|
|
return {
|
|
lastChangedAt: latest.updatedAt.toISOString(),
|
|
lastChangedByAgentId: latest.agentId,
|
|
lastChangedByName,
|
|
};
|
|
}
|
|
|
|
async function resolveRunContext(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
issueId?: string | null;
|
|
projectId?: string | null;
|
|
}): Promise<{ issueId: string | null; projectId: string | null }> {
|
|
const [run] = await db
|
|
.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
issueId: runContextSnapshotString("issueId"),
|
|
projectId: runContextSnapshotString("projectId"),
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, input.runId))
|
|
.limit(1);
|
|
|
|
if (!run || run.companyId !== input.companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Run does not belong to company",
|
|
"run_company_mismatch",
|
|
);
|
|
}
|
|
if (run.agentId !== input.agentId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Run does not belong to agent",
|
|
"run_agent_mismatch",
|
|
);
|
|
}
|
|
if (!ACTIVE_GATEWAY_RUN_STATUSES.has(run.status)) {
|
|
throw new ToolGatewayHttpError(403, "Run is not active", "run_inactive");
|
|
}
|
|
|
|
const snapshotIssueId = stringValue(run.issueId);
|
|
const snapshotProjectId = stringValue(run.projectId);
|
|
if (
|
|
(input.issueId && snapshotIssueId && input.issueId !== snapshotIssueId) ||
|
|
(input.projectId &&
|
|
snapshotProjectId &&
|
|
input.projectId !== snapshotProjectId)
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Supplied run context does not match stored heartbeat context",
|
|
"run_context_mismatch",
|
|
);
|
|
}
|
|
const issueId = snapshotIssueId ?? input.issueId ?? null;
|
|
let projectId = snapshotProjectId ?? input.projectId ?? null;
|
|
if (issueId) {
|
|
const [issue] = await db
|
|
.select({ companyId: issues.companyId, projectId: issues.projectId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.limit(1);
|
|
if (!issue || issue.companyId !== input.companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Issue context is outside the run company",
|
|
"run_context_mismatch",
|
|
);
|
|
}
|
|
if (projectId && issue.projectId && projectId !== issue.projectId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Project context does not match issue context",
|
|
"run_context_mismatch",
|
|
);
|
|
}
|
|
projectId = projectId ?? issue.projectId;
|
|
}
|
|
if (projectId) {
|
|
const [project] = await db
|
|
.select({ companyId: projects.companyId })
|
|
.from(projects)
|
|
.where(eq(projects.id, projectId))
|
|
.limit(1);
|
|
if (!project || project.companyId !== input.companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Project context is outside the run company",
|
|
"run_context_mismatch",
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
issueId,
|
|
projectId,
|
|
};
|
|
}
|
|
|
|
async function writeAudit(input: {
|
|
session?: ToolGatewaySession | null;
|
|
companyId: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
issueId: string | null;
|
|
actorType?: LogActivityInput["actorType"];
|
|
actorId?: string;
|
|
action: string;
|
|
details: Record<string, unknown>;
|
|
}) {
|
|
const dedicatedAuditAction =
|
|
input.action === "tool_gateway.discovery"
|
|
? "discovery"
|
|
: input.action === "tool_gateway.session_revoked"
|
|
? "session_revoked"
|
|
: input.action === "tool_gateway.call_allowed" ||
|
|
input.action === "tool_gateway.session_created"
|
|
? "policy_decision"
|
|
: input.action === "tool_gateway.call_completed"
|
|
? "call_completed"
|
|
: input.action === "tool_gateway.call_denied" ||
|
|
input.action === "tool_gateway.session_rejected"
|
|
? "call_denied"
|
|
: input.action === "tool_gateway.call_deferred"
|
|
? "call_failed"
|
|
: "call_failed";
|
|
const dedicatedOutcome =
|
|
input.action === "tool_gateway.session_revoked"
|
|
? "success"
|
|
: input.action === "tool_gateway.call_denied" ||
|
|
input.action === "tool_gateway.session_rejected"
|
|
? "denied"
|
|
: input.action === "tool_gateway.call_deferred"
|
|
? "timeout"
|
|
: input.action === "tool_gateway.call_failed"
|
|
? "failure"
|
|
: "success";
|
|
try {
|
|
const tokenId = input.session?.gatewayTokenId && uuidPattern.test(input.session.gatewayTokenId)
|
|
? input.session.gatewayTokenId
|
|
: typeof input.details.gatewayTokenId === "string" && uuidPattern.test(input.details.gatewayTokenId)
|
|
? input.details.gatewayTokenId : null;
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: input.companyId,
|
|
gatewayId:
|
|
input.session?.gatewayId ??
|
|
(typeof input.details.gatewayId === "string" &&
|
|
uuidPattern.test(input.details.gatewayId)
|
|
? input.details.gatewayId
|
|
: null),
|
|
// Cleanup can remove a token after request admission. Resolve the FK
|
|
// inside this INSERT and lock a surviving row until the statement ends.
|
|
// A plain existence read before insertion still races with deletion.
|
|
gatewayTokenId: tokenId ? sql`(select ${toolMcpGatewayTokens.id} from ${toolMcpGatewayTokens}
|
|
where ${toolMcpGatewayTokens.id} = ${tokenId} and ${toolMcpGatewayTokens.companyId} = ${input.companyId}
|
|
for key share)` : null,
|
|
gatewayPublicId:
|
|
typeof input.details.gatewayPublicId === "string"
|
|
? input.details.gatewayPublicId
|
|
: null,
|
|
clientName:
|
|
typeof input.details.clientName === "string"
|
|
? input.details.clientName
|
|
: null,
|
|
correlationId:
|
|
typeof input.details.correlationId === "string"
|
|
? input.details.correlationId
|
|
: null,
|
|
connectionId:
|
|
typeof input.details.connectionId === "string"
|
|
? input.details.connectionId
|
|
: null,
|
|
catalogEntryId:
|
|
typeof input.details.catalogEntryId === "string"
|
|
? input.details.catalogEntryId
|
|
: null,
|
|
actorType:
|
|
input.actorType ??
|
|
input.session?.actorType ??
|
|
(input.agentId ? "agent" : "system"),
|
|
actorId:
|
|
input.actorId ??
|
|
input.session?.actorId ??
|
|
input.agentId ??
|
|
input.session?.gatewayTokenId ??
|
|
input.companyId,
|
|
action: dedicatedAuditAction,
|
|
outcome: dedicatedOutcome,
|
|
reasonCode:
|
|
typeof input.details.reasonCode === "string"
|
|
? input.details.reasonCode
|
|
: null,
|
|
details: {
|
|
source: input.action,
|
|
agentId: input.agentId,
|
|
issueId: input.issueId,
|
|
projectId: input.session?.projectId ?? null,
|
|
runId: input.runId,
|
|
gatewaySessionId: input.session?.id ?? null,
|
|
identityContextId: input.session?.identityContextId ?? null,
|
|
gatewayId: input.session?.gatewayId ?? null,
|
|
gatewayPublicId: input.session?.gatewayPublicId ?? null,
|
|
gatewayName: input.session?.gatewayName ?? null,
|
|
gatewayTokenId: input.session?.gatewayTokenId ?? null,
|
|
...input.details,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
await recordToolRuntimeAuditWriteFailure(db, input.companyId);
|
|
throw error;
|
|
}
|
|
|
|
const entityType = input.issueId
|
|
? "issue"
|
|
: input.session?.gatewayId
|
|
? "tool_mcp_gateway"
|
|
: "agent";
|
|
const entityId =
|
|
input.issueId ??
|
|
input.session?.gatewayId ??
|
|
input.agentId ??
|
|
input.companyId;
|
|
await logActivity(db, {
|
|
companyId: input.companyId,
|
|
actorType:
|
|
input.actorType ??
|
|
input.session?.actorType ??
|
|
(input.agentId ? "agent" : "system"),
|
|
actorId:
|
|
input.actorId ??
|
|
input.session?.actorId ??
|
|
input.agentId ??
|
|
input.session?.gatewayTokenId ??
|
|
input.companyId,
|
|
action: input.action,
|
|
entityType,
|
|
entityId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: input.issueId,
|
|
details: {
|
|
gatewaySessionId: input.session?.id ?? null,
|
|
gatewayId: input.session?.gatewayId ?? null,
|
|
gatewayPublicId: input.session?.gatewayPublicId ?? null,
|
|
issueId: input.issueId,
|
|
projectId: input.session?.projectId ?? null,
|
|
runId: input.runId,
|
|
...input.details,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function writeSessionAuthFailure(
|
|
row: typeof toolGatewaySessions.$inferSelect,
|
|
reasonCode: string,
|
|
details: Record<string, unknown> = {},
|
|
) {
|
|
const session = gatewaySessionFromRow(row);
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: "tool_gateway.session_rejected",
|
|
details: {
|
|
decision: "deny",
|
|
reasonCode,
|
|
expiresAt: session.expiresAt.toISOString(),
|
|
revokedAt: row.revokedAt?.toISOString() ?? null,
|
|
...details,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function assertSessionRunIsActive(
|
|
row: typeof toolGatewaySessions.$inferSelect,
|
|
) {
|
|
const [run] = await db
|
|
.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, row.runId))
|
|
.limit(1);
|
|
|
|
if (
|
|
!run ||
|
|
run.companyId !== row.companyId ||
|
|
run.agentId !== row.agentId ||
|
|
!ACTIVE_GATEWAY_RUN_STATUSES.has(run.status)
|
|
) {
|
|
await writeSessionAuthFailure(row, "session_run_inactive", {
|
|
runStatus: run?.status ?? null,
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
401,
|
|
"Tool gateway session is expired or invalid",
|
|
"session_run_inactive",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function getActiveSession(
|
|
sessionToken: string,
|
|
namedGatewayProtocol?: {
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
protocolMethod: McpGatewayProtocolMethod;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
},
|
|
): Promise<ToolGatewaySession> {
|
|
const token = sessionToken.trim();
|
|
if (!token) {
|
|
throw new ToolGatewayHttpError(
|
|
401,
|
|
"Tool gateway session is expired or invalid",
|
|
"session_invalid",
|
|
);
|
|
}
|
|
if (namedGatewayTokenId(token)) {
|
|
return namedGatewaySessionFromBearer({
|
|
gatewayId: namedGatewayProtocol?.gatewayId ?? null,
|
|
gatewayPublicId: namedGatewayProtocol?.gatewayPublicId ?? null,
|
|
bearerToken: token,
|
|
protocolMethod: namedGatewayProtocol?.protocolMethod ?? "tools/call",
|
|
callerHeaders: namedGatewayProtocol?.callerHeaders,
|
|
});
|
|
}
|
|
|
|
const tokenHash = hashGatewayToken(token);
|
|
const [row] = await db
|
|
.select()
|
|
.from(toolGatewaySessions)
|
|
.where(eq(toolGatewaySessions.tokenHash, tokenHash))
|
|
.limit(1);
|
|
|
|
if (!row) {
|
|
const sessionId = sessionIdFromGatewayToken(token);
|
|
if (sessionId) {
|
|
const [candidate] = await db
|
|
.select()
|
|
.from(toolGatewaySessions)
|
|
.where(eq(toolGatewaySessions.id, sessionId))
|
|
.limit(1);
|
|
if (candidate) {
|
|
await writeSessionAuthFailure(candidate, "session_invalid");
|
|
}
|
|
}
|
|
throw new ToolGatewayHttpError(
|
|
401,
|
|
"Tool gateway session is expired or invalid",
|
|
"session_invalid",
|
|
);
|
|
}
|
|
|
|
if (row.revokedAt) {
|
|
await writeSessionAuthFailure(row, "session_revoked");
|
|
throw new ToolGatewayHttpError(
|
|
401,
|
|
"Tool gateway session is expired or invalid",
|
|
"session_revoked",
|
|
);
|
|
}
|
|
|
|
if (row.expiresAt.getTime() <= Date.now()) {
|
|
await writeSessionAuthFailure(row, "session_expired");
|
|
throw new ToolGatewayHttpError(
|
|
401,
|
|
"Tool gateway session is expired or invalid",
|
|
"session_expired",
|
|
);
|
|
}
|
|
|
|
await assertSessionRunIsActive(row);
|
|
|
|
const now = new Date();
|
|
await db
|
|
.update(toolGatewaySessions)
|
|
.set({ lastUsedAt: now, updatedAt: now })
|
|
.where(eq(toolGatewaySessions.id, row.id));
|
|
|
|
const session = gatewaySessionFromRow({
|
|
...row,
|
|
lastUsedAt: now,
|
|
updatedAt: now,
|
|
});
|
|
return captureSessionIdentity(session);
|
|
}
|
|
|
|
async function captureSessionIdentity(
|
|
session: ToolGatewaySession,
|
|
): Promise<ToolGatewaySession> {
|
|
// Authentication creates a fresh operation snapshot on every invocation.
|
|
// Never trust a previously attached context on a reusable transport session.
|
|
// Approved operations restore their signed origin after authentication.
|
|
if (!session.runId || !session.agentId) return session;
|
|
const [run] = await db
|
|
.select({
|
|
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, session.runId),
|
|
eq(heartbeatRuns.companyId, session.companyId),
|
|
),
|
|
);
|
|
if (!run?.activeIdentityContextId) return session;
|
|
const captured = await captureRunIdentity(db, {
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
});
|
|
return {
|
|
...session,
|
|
identityContextId: captured.context?.id,
|
|
responsibleUserId:
|
|
captured.context?.cause === "company_default"
|
|
? null
|
|
: captured.context?.responsibleUserId,
|
|
};
|
|
}
|
|
|
|
function normalizeGatewayTokenActions(
|
|
value: unknown,
|
|
): ToolMcpGatewayTokenAction[] {
|
|
const known = new Set<ToolMcpGatewayTokenAction>([
|
|
"tools/list",
|
|
"tools/call",
|
|
"resources/list",
|
|
"resources/read",
|
|
"prompts/list",
|
|
"prompts/get",
|
|
]);
|
|
const actions = Array.isArray(value)
|
|
? value.filter((action): action is ToolMcpGatewayTokenAction =>
|
|
known.has(action as ToolMcpGatewayTokenAction),
|
|
)
|
|
: [];
|
|
return actions.length > 0 ? actions : [...known];
|
|
}
|
|
|
|
async function assertGatewayTokenAction(
|
|
session: ToolGatewaySession,
|
|
action: ToolMcpGatewayTokenAction,
|
|
) {
|
|
const allowedActions = session.gatewayTokenAllowedActions;
|
|
if (!allowedActions || allowedActions.includes(action)) return;
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: action.endsWith("/list")
|
|
? "tool_gateway.discovery"
|
|
: "tool_gateway.call_denied",
|
|
details: {
|
|
decision: "deny",
|
|
reasonCode: "gateway_token_action_denied",
|
|
requestedAction: action,
|
|
allowedActions,
|
|
},
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Gateway bearer token is not allowed to perform this MCP action",
|
|
"gateway_token_action_denied",
|
|
{
|
|
requestedAction: action,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function writeToolCallEvent(input: {
|
|
invocationId?: string | null;
|
|
actionRequestId?: string | null;
|
|
session: ToolGatewaySession;
|
|
eventType:
|
|
| "policy_decision"
|
|
| "invocation_created"
|
|
| "approval_requested"
|
|
| "approval_resolved"
|
|
| "call_started"
|
|
| "call_completed"
|
|
| "call_failed"
|
|
| "call_denied";
|
|
outcome:
|
|
"pending" | "success" | "failure" | "denied" | "timeout" | "cancelled";
|
|
toolName: string;
|
|
policyDecision?:
|
|
"allow" | "deny" | "require_approval" | "defer_runtime" | null;
|
|
reasonCode?: string | null;
|
|
argumentsSummary?: ReturnType<typeof summarizeToolValue> | null;
|
|
resultSummary?: ReturnType<typeof summarizeToolValue> | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
tool?: ToolGatewayDescriptor | null;
|
|
}) {
|
|
const metadata = input.tool ? toolAuditMetadata(input.tool) : {};
|
|
await db.insert(toolCallEvents).values({
|
|
companyId: input.session.companyId,
|
|
invocationId: input.invocationId ?? null,
|
|
actionRequestId: input.actionRequestId ?? null,
|
|
eventType: input.eventType,
|
|
outcome: input.outcome,
|
|
actorType:
|
|
input.session.actorType ?? (input.session.agentId ? "agent" : "system"),
|
|
actorId:
|
|
input.session.actorId ??
|
|
input.session.agentId ??
|
|
input.session.gatewayTokenId ??
|
|
input.session.companyId,
|
|
agentId: input.session.agentId,
|
|
issueId: input.session.issueId,
|
|
runId: input.session.runId,
|
|
applicationId: input.tool?.applicationId ?? null,
|
|
connectionId: input.tool?.connectionId ?? null,
|
|
catalogEntryId: input.tool?.catalogEntryId ?? null,
|
|
toolName: input.toolName,
|
|
decision: input.policyDecision ?? null,
|
|
reasonCode: input.reasonCode ?? null,
|
|
matchedPolicyIds: [],
|
|
requestHash: input.argumentsSummary?.sha256 ?? null,
|
|
requestSummary: input.argumentsSummary ?? null,
|
|
resultHash: input.resultSummary?.sha256 ?? null,
|
|
resultSummary: input.resultSummary ?? null,
|
|
resultSizeBytes: input.resultSummary?.sizeBytes ?? null,
|
|
metadata:
|
|
Object.keys(metadata).length > 0 ||
|
|
input.metadata ||
|
|
input.session.projectId ||
|
|
input.session.identityContextId
|
|
? {
|
|
...metadata,
|
|
identityContextId: input.session.identityContextId ?? null,
|
|
gatewayId: input.session.gatewayId ?? null,
|
|
gatewayName: input.session.gatewayName ?? null,
|
|
projectId: input.session.projectId ?? null,
|
|
...(input.metadata ?? {}),
|
|
}
|
|
: null,
|
|
});
|
|
}
|
|
|
|
async function reflectToolActionInteractionLifecycle(input: {
|
|
actionRequestId: string;
|
|
status:
|
|
| "approved"
|
|
| "executing"
|
|
| "executed"
|
|
| "failed"
|
|
| "expired"
|
|
| "cancelled";
|
|
errorCode?: string | null;
|
|
errorMessage?: string | null;
|
|
resultSummary?: string | null;
|
|
}): Promise<void> {
|
|
const [linked] = await db
|
|
.select({
|
|
companyId: toolActionRequests.companyId,
|
|
interactionId: toolActionRequests.interactionId,
|
|
issueId: toolActionRequests.issueId,
|
|
})
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, input.actionRequestId))
|
|
.limit(1);
|
|
if (!linked?.interactionId) return;
|
|
|
|
const interactionId = linked.interactionId;
|
|
const changed = await db.transaction(async (tx) => {
|
|
const [interaction] = await tx
|
|
.select({
|
|
status: issueThreadInteractions.status,
|
|
result: issueThreadInteractions.result,
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, linked.companyId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.limit(1);
|
|
const [currentRequest] = await tx
|
|
.select({ status: toolActionRequests.status })
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, input.actionRequestId))
|
|
.for("update");
|
|
if (currentRequest?.status !== input.status) return false;
|
|
if (!interaction) return;
|
|
|
|
const currentResult =
|
|
interaction.result && typeof interaction.result === "object"
|
|
? (interaction.result as unknown as Record<string, unknown>)
|
|
: null;
|
|
const outcome =
|
|
typeof currentResult?.outcome === "string"
|
|
? currentResult.outcome
|
|
: interaction.status === "accepted"
|
|
? "accepted"
|
|
: interaction.status === "rejected"
|
|
? "rejected"
|
|
: interaction.status === "expired" ||
|
|
input.status === "expired" ||
|
|
input.status === "cancelled"
|
|
? "stale_target"
|
|
: null;
|
|
if (!outcome) return;
|
|
|
|
const now = new Date();
|
|
await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
...(["expired", "cancelled"].includes(input.status) &&
|
|
interaction.status === "pending"
|
|
? { status: input.status, resolvedAt: now }
|
|
: {}),
|
|
result: {
|
|
...(currentResult ?? { version: 1, outcome }),
|
|
toolAction: {
|
|
...asRecord(currentResult?.toolAction),
|
|
version: 1,
|
|
status: input.status === "cancelled" ? "expired" : input.status,
|
|
errorCode: input.errorCode ?? null,
|
|
errorMessage: input.errorMessage ?? null,
|
|
resultSummary: input.resultSummary ?? null,
|
|
updatedAt: now.toISOString(),
|
|
},
|
|
} as unknown as NonNullable<
|
|
typeof issueThreadInteractions.$inferInsert.result
|
|
>,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(issueThreadInteractions.id, interactionId));
|
|
return true;
|
|
});
|
|
if (!changed) return;
|
|
await logActivity(db, {
|
|
companyId: linked.companyId,
|
|
actorType: "system",
|
|
actorId: "tool-gateway",
|
|
action: "issue.thread_interaction_updated",
|
|
entityType: "issue",
|
|
entityId: linked.issueId!,
|
|
details: {
|
|
interactionId: linked.interactionId,
|
|
actionRequestId: input.actionRequestId,
|
|
executionStatus: input.status,
|
|
},
|
|
});
|
|
if (["executed", "failed", "expired", "cancelled"].includes(input.status))
|
|
await options
|
|
.onToolActionSettled?.(input.actionRequestId)
|
|
.catch((error) =>
|
|
logger.warn(
|
|
{ err: error, actionRequestId: input.actionRequestId },
|
|
"Tool review continuation will be retried",
|
|
),
|
|
);
|
|
}
|
|
|
|
async function approvalRequiredInstructions(
|
|
issueId: string,
|
|
): Promise<string> {
|
|
const [issue] = await db
|
|
.select({ identifier: issues.identifier })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.limit(1);
|
|
const task = issue?.identifier ?? issueId;
|
|
return `A human approval card was posted on task ${task}. Do not retry this call now. Wrap up other work and end your run noting you are waiting on tool approval (status in_review). You will be woken when it is decided; if approved, the action runs automatically and your wake includes the result.`;
|
|
}
|
|
|
|
async function throwApprovalRequired(input: {
|
|
invocationId: string;
|
|
actionRequestId: string;
|
|
interactionId?: string | null;
|
|
issueId: string;
|
|
toolName: string;
|
|
argumentsHash: string;
|
|
}): Promise<never> {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action requires approval",
|
|
"approval_required",
|
|
{
|
|
invocationId: input.invocationId,
|
|
actionRequestId: input.actionRequestId,
|
|
interactionId: input.interactionId ?? null,
|
|
issueId: input.issueId,
|
|
tool: input.toolName,
|
|
argumentsHash: input.argumentsHash,
|
|
instructions: await approvalRequiredInstructions(input.issueId),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function requestApprovalForRecordedToolCall(input: {
|
|
invocation: typeof toolInvocations.$inferSelect;
|
|
actionRequest: typeof toolActionRequests.$inferSelect | null;
|
|
session: ToolGatewaySession;
|
|
tool: ToolGatewayDescriptor;
|
|
parameters: unknown;
|
|
argumentsSummary: ReturnType<typeof summarizeToolValue>;
|
|
policyDecision: ToolAccessDecision;
|
|
}): Promise<never> {
|
|
const canonicalArguments = canonicalToolArguments(input.parameters);
|
|
const canonicalArgumentsHash = input.argumentsSummary.sha256 ?? "";
|
|
const approvalSnapshot = await connectedRemoteApprovalSnapshot(
|
|
input.session,
|
|
input.tool,
|
|
{
|
|
requireResolvedCredentials: true,
|
|
},
|
|
);
|
|
|
|
if (!input.session.issueId) {
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "denied",
|
|
approvalState: "required",
|
|
errorCode: "approval_path_missing",
|
|
errorMessage:
|
|
"Approval-required tool calls need an issue-scoped gateway session",
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, input.invocation.id));
|
|
void emitConnectionInvoked(db, input.invocation.id);
|
|
await writeToolCallEvent({
|
|
invocationId: input.invocation.id,
|
|
actionRequestId: input.actionRequest?.id ?? null,
|
|
session: input.session,
|
|
eventType: "call_denied",
|
|
outcome: "denied",
|
|
toolName: input.tool.name,
|
|
policyDecision: "deny",
|
|
reasonCode: "approval_path_missing",
|
|
argumentsSummary: input.argumentsSummary,
|
|
tool: input.tool,
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action requires approval, but this gateway session is not attached to an issue",
|
|
"approval_path_missing",
|
|
{
|
|
invocationId: input.invocation.id,
|
|
tool: input.tool.name,
|
|
instructions:
|
|
"This session is not attached to a task, so an approval card cannot be posted. Re-run this action from a run that has the task checked out.",
|
|
},
|
|
);
|
|
}
|
|
|
|
if (!input.actionRequest) {
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "denied",
|
|
errorCode: "approval_request_missing",
|
|
errorMessage:
|
|
"Approval-required policy decision did not create an action request",
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, input.invocation.id));
|
|
void emitConnectionInvoked(db, input.invocation.id);
|
|
throw new ToolGatewayHttpError(
|
|
500,
|
|
"Approval request was not created",
|
|
"approval_request_missing",
|
|
{
|
|
invocationId: input.invocation.id,
|
|
tool: input.tool.name,
|
|
},
|
|
);
|
|
}
|
|
const actionRequest = input.actionRequest;
|
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
|
|
|
let signedArguments: ReturnType<typeof signToolArguments>;
|
|
try {
|
|
signedArguments = signToolArguments({
|
|
invocationId: input.invocation.id,
|
|
toolName: input.tool.name,
|
|
canonicalArguments,
|
|
approvalSnapshot: approvalSnapshot ?? undefined,
|
|
executionOnApprove: true,
|
|
identityContextId: input.session.identityContextId ?? undefined,
|
|
signingSecret: options.toolActionSigningSecret,
|
|
});
|
|
} catch (error) {
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: "cancelled",
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, actionRequest.id),
|
|
eq(toolActionRequests.status, "pending"),
|
|
),
|
|
);
|
|
if (error instanceof ToolActionSigningSecretMissingError) {
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "failed",
|
|
errorCode: "signing_secret_unconfigured",
|
|
errorMessage: error.message,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, input.invocation.id));
|
|
void emitConnectionInvoked(db, input.invocation.id);
|
|
throw new ToolGatewayHttpError(
|
|
500,
|
|
error.message,
|
|
"signing_secret_unconfigured",
|
|
{
|
|
invocationId: input.invocation.id,
|
|
tool: input.tool.name,
|
|
},
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
// Board-only technical detail for the formal-approval interaction (target=custom).
|
|
const detailsMarkdown = [
|
|
`Tool: \`${input.tool.name}\``,
|
|
`Risk: \`${input.tool.risk}\``,
|
|
"",
|
|
"Arguments reviewed for execution:",
|
|
"",
|
|
"```json",
|
|
input.argumentsSummary.summary,
|
|
"```",
|
|
].join("\n");
|
|
|
|
// Prosumer-facing card preview (M5/M7/M9). Respect an already-set custom preview
|
|
// (e.g. OpenClaw-supplied), otherwise emit plain language with no technical vocab.
|
|
const previewMarkdown =
|
|
actionRequest.previewMarkdown?.trim() ||
|
|
buildHumanizedActionPreview({
|
|
tool: input.tool,
|
|
argumentsSummary: input.argumentsSummary,
|
|
});
|
|
|
|
let formalApprovalId: string | null = null;
|
|
if (toolRequiresFormalApproval(input.tool)) {
|
|
const [approval] = await db
|
|
.insert(approvals)
|
|
.values({
|
|
companyId: input.session.companyId,
|
|
type: "request_board_approval",
|
|
requestedByAgentId: input.session.agentId,
|
|
payload: {
|
|
title: `Approve high-risk tool action: ${input.tool.name}`,
|
|
summary: `${input.tool.name} is classified as ${input.tool.risk} and requires formal board approval before execution.`,
|
|
recommendedAction:
|
|
"Approve only if the reviewed arguments match the intended operation.",
|
|
risks: [
|
|
"The tool may perform irreversible or externally visible side effects.",
|
|
"Execution will use the stored reviewed arguments exactly once.",
|
|
],
|
|
source: "tool_gateway",
|
|
invocationId: input.invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
tool: input.tool.name,
|
|
risk: input.tool.risk,
|
|
argumentsHash: canonicalArgumentsHash,
|
|
},
|
|
})
|
|
.returning();
|
|
formalApprovalId = approval.id;
|
|
await db
|
|
.insert(issueApprovals)
|
|
.values({
|
|
companyId: input.session.companyId,
|
|
issueId: input.session.issueId,
|
|
approvalId: approval.id,
|
|
linkedByAgentId: input.session.agentId,
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
|
|
const interaction = await interactions.create(
|
|
{ id: input.session.issueId, companyId: input.session.companyId },
|
|
{
|
|
kind: "request_confirmation",
|
|
idempotencyKey: `tool-action:${actionRequest.id}`,
|
|
title: "Approve tool action",
|
|
resolverPolicy: "human_only",
|
|
sourceRunId: input.session.runId ?? undefined,
|
|
summary: "This action needs your approval before it runs.",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: `Approve ${input.tool.displayName?.trim() || input.tool.name}?`,
|
|
acceptLabel: "Approve action",
|
|
rejectLabel: "Reject action",
|
|
rejectRequiresReason: false,
|
|
allowDeclineReason: true,
|
|
supersedeOnUserComment: false,
|
|
detailsMarkdown,
|
|
target: {
|
|
type: "custom",
|
|
key: `tool-action:${actionRequest.id}`,
|
|
revisionId: canonicalArgumentsHash,
|
|
label: input.tool.name,
|
|
},
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: actionRequest.id,
|
|
invocationId: input.invocation.id,
|
|
toolName: input.tool.name,
|
|
toolDisplayName: input.tool.displayName?.trim() || input.tool.name,
|
|
connectionId: input.tool.connectionId ?? null,
|
|
applicationId: input.tool.applicationId ?? null,
|
|
appDisplayName: input.tool.applicationDisplayName?.trim() || null,
|
|
risk:
|
|
input.tool.risk === "read"
|
|
? "read"
|
|
: input.tool.risk === "destructive"
|
|
? "destructive"
|
|
: "write",
|
|
...(!formalApprovalId &&
|
|
input.session.agentId &&
|
|
input.tool.connectionId
|
|
? {
|
|
rememberActionScope: `This agent may use this action with different arguments on this connection${input.session.projectId ? " within this project" : ""}.`,
|
|
}
|
|
: {}),
|
|
previewMarkdown,
|
|
argumentsSummaryJson: input.argumentsSummary.summary,
|
|
argumentsHash: canonicalArgumentsHash,
|
|
expiresAt: expiresAt.toISOString(),
|
|
},
|
|
},
|
|
},
|
|
{ agentId: input.session.agentId },
|
|
);
|
|
|
|
// Sign the row only while it is still pending. A concurrent matching call can
|
|
// expire this row when the create runs longer than the abandon grace time.
|
|
// Guard the update with the pending status so the sign step never resurrects
|
|
// an expired row. When the guard matches no row, the row is already resolved.
|
|
// Do not emit an approval card for a dead row; throw a retriable conflict so
|
|
// the retry finds the live request or creates a fresh, signable one.
|
|
const signedRows = await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
interactionId: interaction.id,
|
|
canonicalArgumentsHash,
|
|
canonicalArgumentsSummary: input.argumentsSummary,
|
|
signedArguments,
|
|
previewMarkdown,
|
|
approvalId: formalApprovalId,
|
|
expiresAt,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, actionRequest.id),
|
|
eq(toolActionRequests.status, "pending"),
|
|
),
|
|
)
|
|
.returning({ id: toolActionRequests.id });
|
|
if (signedRows.length === 0) {
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "failed",
|
|
approvalState: "expired",
|
|
errorCode: "approval_request_superseded",
|
|
errorMessage:
|
|
"The approval request was resolved before it could be signed",
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, input.invocation.id));
|
|
void emitConnectionInvoked(db, input.invocation.id);
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"The approval request was resolved before it could be signed",
|
|
"approval_request_superseded",
|
|
{
|
|
invocationId: input.invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
tool: input.tool.name,
|
|
instructions:
|
|
"A parallel call already handled this approval. Retry the same call now to reach the live approval request.",
|
|
},
|
|
);
|
|
}
|
|
|
|
await db
|
|
.insert(toolActionDeliveries)
|
|
.values({
|
|
companyId: input.session.companyId,
|
|
actionRequestId: actionRequest.id,
|
|
issueId: input.session.issueId,
|
|
interactionId: interaction.id,
|
|
})
|
|
.onConflictDoNothing();
|
|
|
|
await writeToolCallEvent({
|
|
invocationId: input.invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
session: input.session,
|
|
eventType: "approval_requested",
|
|
outcome: "pending",
|
|
toolName: input.tool.name,
|
|
policyDecision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
argumentsSummary: input.argumentsSummary,
|
|
metadata: {
|
|
actionRequestId: actionRequest.id,
|
|
interactionId: interaction.id,
|
|
approvalId: formalApprovalId,
|
|
},
|
|
tool: input.tool,
|
|
});
|
|
|
|
await writeAudit({
|
|
session: input.session,
|
|
companyId: input.session.companyId,
|
|
agentId: input.session.agentId,
|
|
runId: input.session.runId,
|
|
issueId: input.session.issueId,
|
|
action: "tool_gateway.approval_requested",
|
|
details: {
|
|
invocationId: input.invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
interactionId: interaction.id,
|
|
approvalId: formalApprovalId,
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
matchedPolicyIds: input.policyDecision.matchedPolicyIds,
|
|
tool: input.tool.name,
|
|
...toolAuditMetadata(input.tool),
|
|
argumentsSummary: input.argumentsSummary,
|
|
},
|
|
});
|
|
|
|
return throwApprovalRequired({
|
|
invocationId: input.invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
interactionId: interaction.id,
|
|
issueId: input.session.issueId,
|
|
toolName: input.tool.name,
|
|
argumentsHash: canonicalArgumentsHash,
|
|
});
|
|
}
|
|
|
|
function policyInputForTool(input: {
|
|
session: ToolGatewaySession;
|
|
tool: ToolGatewayDescriptor;
|
|
parameters?: unknown;
|
|
idempotencyKey?: string | null;
|
|
consumeRateLimit?: boolean;
|
|
}): ToolAccessDecisionInput {
|
|
return policyInputForAgentTool({
|
|
companyId: input.session.companyId,
|
|
agentId: input.session.agentId,
|
|
actorType: input.session.actorType,
|
|
actorId: input.session.actorId ?? input.session.gatewayTokenId ?? null,
|
|
tool: input.tool,
|
|
parameters: input.parameters,
|
|
idempotencyKey: input.idempotencyKey,
|
|
consumeRateLimit: input.consumeRateLimit,
|
|
heartbeatRunId: input.session.runId,
|
|
issueId: input.session.issueId,
|
|
projectId: input.session.projectId,
|
|
gatewayId: input.session.gatewayId ?? null,
|
|
});
|
|
}
|
|
|
|
function policyInputForAgentTool(input: {
|
|
companyId: string;
|
|
agentId: string | null;
|
|
actorType?: "agent" | "user" | "system" | "plugin";
|
|
actorId?: string | null;
|
|
tool: ToolGatewayDescriptor;
|
|
parameters?: unknown;
|
|
idempotencyKey?: string | null;
|
|
consumeRateLimit?: boolean;
|
|
heartbeatRunId?: string | null;
|
|
issueId?: string | null;
|
|
projectId?: string | null;
|
|
gatewayId?: string | null;
|
|
}): ToolAccessDecisionInput {
|
|
const actorType = input.actorType ?? (input.agentId ? "agent" : "system");
|
|
const actorId =
|
|
input.actorId ?? input.agentId ?? input.gatewayId ?? input.companyId;
|
|
return {
|
|
companyId: input.companyId,
|
|
actor: {
|
|
actorType,
|
|
actorId,
|
|
agentId: input.agentId,
|
|
},
|
|
runContext: {
|
|
heartbeatRunId: input.heartbeatRunId ?? null,
|
|
issueId: input.issueId ?? null,
|
|
projectId: input.projectId ?? null,
|
|
gatewayId: input.gatewayId ?? null,
|
|
},
|
|
request: {
|
|
toolName: input.tool.name,
|
|
applicationId: input.tool.applicationId ?? null,
|
|
applicationKey: input.tool.applicationKey ?? null,
|
|
connectionId: input.tool.connectionId ?? null,
|
|
catalogEntryId: input.tool.catalogEntryId ?? null,
|
|
providerType: input.tool.providerType,
|
|
upstreamToolName: input.tool.upstreamToolName ?? input.tool.name,
|
|
riskLevel: input.tool.risk,
|
|
arguments: input.parameters ?? {},
|
|
idempotencyKey: input.idempotencyKey ?? null,
|
|
sideEffecting: input.tool.risk !== "read",
|
|
},
|
|
consumeRateLimit: input.consumeRateLimit === true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decides access for each tool of one listing. All decisions of the listing
|
|
* share one actor and run context, so they share one read cache: a listing
|
|
* reads the agent, run, profiles and policies once, not once per tool.
|
|
*/
|
|
async function decideToolsForListing<T extends ToolGatewayDescriptor>(
|
|
tools: readonly T[],
|
|
inputForTool: (tool: T) => ToolAccessDecisionInput,
|
|
cache: ToolAccessDecisionCache = createToolAccessDecisionCache(),
|
|
signal?: AbortSignal,
|
|
): Promise<Array<{ tool: T; decision: ToolAccessDecision }>> {
|
|
return mapWithConcurrency(tools, LISTING_DECISION_CONCURRENCY, async (tool) => ({
|
|
tool,
|
|
decision: await policyService.decide(inputForTool(tool), { cache }),
|
|
}), signal);
|
|
}
|
|
|
|
function policyErrorStatus(decision: ToolAccessDecision) {
|
|
if (decision.decision === "rate_limited") return 429;
|
|
return 403;
|
|
}
|
|
|
|
function findStaticTool(toolName: string): ToolGatewayDescriptor {
|
|
const tool = allTools().find((candidate) => candidate.name === toolName);
|
|
if (!tool) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${toolName}" not found`,
|
|
"tool_not_found",
|
|
{ tool: toolName },
|
|
);
|
|
}
|
|
return tool;
|
|
}
|
|
|
|
async function findToolForSession(
|
|
session: ToolGatewaySession,
|
|
toolName: string,
|
|
): Promise<ToolGatewayDescriptor> {
|
|
const connectedTools = await connectedMcpToolsForCompany(session.companyId);
|
|
const hasOnDemandTargets = connectedTools.some(isOnDemandRemoteTool);
|
|
const virtualTools = hasOnDemandTargets ? VIRTUAL_TOOLS : [];
|
|
const githubBotTools = await githubBotToolsForSession(db, session);
|
|
const tool = [...allTools(), ...connectedTools, ...virtualTools, ...githubBotTools, ...await slackToolsForSession(db, session)]
|
|
.filter(
|
|
(candidate) =>
|
|
session.agentId ||
|
|
(candidate.providerType !== "paperclip_self" &&
|
|
candidate.providerType !== "paperclip_plugin"),
|
|
)
|
|
.find((candidate) => candidate.name === toolName);
|
|
if (!tool) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${toolName}" not found`,
|
|
"tool_not_found",
|
|
{ tool: toolName },
|
|
);
|
|
}
|
|
const guestBotConnection = await githubGuestBotConnectionForSession(db, session);
|
|
if (guestBotConnection && tool.connectionId && (tool.connectionId !== guestBotConnection || tool.providerType !== "paperclip_github_chat"))
|
|
throw new ToolGatewayHttpError(403, "Sponsored GitHub runs can only use their bot's governed connection; sponsorship does not grant personal credentials", "guest_connection_denied");
|
|
if (session.identityContextId && session.agentId && tool.connectionId) {
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, tool.connectionId),
|
|
eq(toolConnections.companyId, session.companyId),
|
|
),
|
|
);
|
|
if (
|
|
connection?.config.sourceTemplateKey === "github" ||
|
|
connection?.transportConfig?.sourceTemplateKey === "github"
|
|
) {
|
|
let selected = await resolveManagedGitHubIdentitySelection(
|
|
db,
|
|
session.companyId,
|
|
{
|
|
agentId: session.agentId,
|
|
responsibleUserId: session.responsibleUserId,
|
|
allowStandingDelegation: false,
|
|
},
|
|
);
|
|
if (!selected.grant)
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
selected.error ?? "No GitHub identity connected",
|
|
"github_identity_unavailable",
|
|
);
|
|
const original = selected.grant;
|
|
// Acquire before policy evaluation or dispatch. An alternate connection
|
|
// gets its own catalog descriptor and policy checks; never replay a call.
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
const grant = selected.grant!;
|
|
const target = connectedTools.find(
|
|
(candidate) =>
|
|
candidate.connectionId === grant.connectionId &&
|
|
candidate.upstreamToolName === tool.upstreamToolName &&
|
|
candidate.providerType === tool.providerType,
|
|
);
|
|
if (!target)
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"This GitHub tool is unavailable for the responsible person",
|
|
"github_tool_unavailable",
|
|
);
|
|
const [selectedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, grant.connectionId),
|
|
eq(toolConnections.companyId, session.companyId),
|
|
),
|
|
);
|
|
if (!selectedConnection)
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"GitHub connection is unavailable",
|
|
"github_identity_unavailable",
|
|
);
|
|
if (attempt === 0)
|
|
await resolveConnectionGrant(session, selectedConnection);
|
|
try {
|
|
const headers = await resolveCredentialHeaders(
|
|
session,
|
|
selectedConnection,
|
|
grant,
|
|
);
|
|
githubOperationCredentials.set(session, { grant, headers });
|
|
return target;
|
|
} catch (error) {
|
|
if (attempt !== 0) throw error;
|
|
const alternate = await resolveManagedGitHubIdentitySelection(
|
|
db,
|
|
session.companyId,
|
|
{
|
|
agentId: session.agentId,
|
|
responsibleUserId: session.responsibleUserId,
|
|
allowStandingDelegation: false,
|
|
excludeGrantId: original.id,
|
|
},
|
|
);
|
|
const accountId = original.providerTenant?.github?.userId;
|
|
if (
|
|
!accountId ||
|
|
!alternate.grant ||
|
|
alternate.grant.providerTenant?.github?.userId !== accountId ||
|
|
alternate.grant.subjectUserId !== original.subjectUserId ||
|
|
alternate.grant.subjectAgentId !== original.subjectAgentId
|
|
)
|
|
throw error;
|
|
selected = alternate;
|
|
}
|
|
}
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"GitHub credentials are unavailable",
|
|
"github_identity_unavailable",
|
|
);
|
|
}
|
|
}
|
|
return tool;
|
|
}
|
|
|
|
function virtualRunToolInput(parameters: unknown): {
|
|
targetToolName: string;
|
|
targetParameters: unknown;
|
|
} {
|
|
const params = asRecord(parameters) ?? {};
|
|
const targetToolName =
|
|
typeof params.tool === "string" ? params.tool.trim() : "";
|
|
if (!targetToolName) {
|
|
throw new ToolGatewayHttpError(
|
|
400,
|
|
"run_tool requires a target tool name",
|
|
"invalid_parameters",
|
|
);
|
|
}
|
|
return {
|
|
targetToolName,
|
|
targetParameters: params.arguments ?? {},
|
|
};
|
|
}
|
|
|
|
async function searchableOnDemandTools(
|
|
session: ToolGatewaySession,
|
|
): Promise<ToolGatewayDescriptor[]> {
|
|
const tools = (await connectedMcpToolsForCompany(session.companyId)).filter(
|
|
isOnDemandRemoteTool,
|
|
);
|
|
const decisions = await decideToolsForListing(tools, (tool) =>
|
|
policyInputForTool({ session, tool }),
|
|
);
|
|
return decisions
|
|
.filter(
|
|
({ decision }) =>
|
|
decision.allowed || decision.decision === "require_approval",
|
|
)
|
|
.map(({ tool }) => tool);
|
|
}
|
|
|
|
async function executeVirtualSearchTools(
|
|
session: ToolGatewaySession,
|
|
parameters: unknown,
|
|
) {
|
|
const params = asRecord(parameters) ?? {};
|
|
const query =
|
|
typeof params.query === "string" ? params.query.trim().toLowerCase() : "";
|
|
const limit = Math.max(1, Math.min(50, Number(params.limit ?? 10) || 10));
|
|
const tools = (await searchableOnDemandTools(session))
|
|
.filter((tool) => {
|
|
if (!query) return true;
|
|
return [
|
|
tool.name,
|
|
tool.displayName,
|
|
tool.description,
|
|
tool.applicationKey,
|
|
tool.upstreamToolName,
|
|
]
|
|
.filter((value): value is string => typeof value === "string")
|
|
.some((value) => value.toLowerCase().includes(query));
|
|
})
|
|
.slice(0, limit)
|
|
.map((tool) => ({
|
|
name: tool.name,
|
|
displayName: tool.displayName ?? tool.name,
|
|
description: tool.description ?? null,
|
|
parametersSchema: tool.parametersSchema,
|
|
applicationId: tool.applicationId ?? null,
|
|
connectionId: tool.connectionId ?? null,
|
|
catalogEntryId: tool.catalogEntryId ?? null,
|
|
upstreamToolName: tool.upstreamToolName ?? tool.name,
|
|
risk: tool.risk,
|
|
}));
|
|
|
|
return {
|
|
content: JSON.stringify({ tools }),
|
|
data: { tools },
|
|
};
|
|
}
|
|
|
|
async function listToolsForContext(
|
|
session: ToolGatewaySession,
|
|
signal?: AbortSignal,
|
|
): Promise<ToolGatewayDescriptor[]> {
|
|
try {
|
|
return await toolDiscoveryScheduler.run(() => buildToolsForContext(session, signal), signal);
|
|
} catch (error) {
|
|
if (error instanceof ToolDiscoveryBusyError) {
|
|
throw new ToolGatewayHttpError(503, error.message, "tool_discovery_busy");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Shared connection descriptors and task restrictions; no provider calls. */
|
|
async function connectionToolsForContext(session: ToolGatewaySession): Promise<ToolGatewayDescriptor[]> {
|
|
const guestBotConnection = await githubGuestBotConnectionForSession(db, session);
|
|
const connectedTools = (await connectedMcpToolsForCompany(session.companyId))
|
|
.filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat"));
|
|
return [
|
|
...await githubBotToolsForSession(db, session),
|
|
...await slackToolsForSession(db, session),
|
|
...connectedTools,
|
|
];
|
|
}
|
|
|
|
async function buildToolsForContext(
|
|
session: ToolGatewaySession,
|
|
signal?: AbortSignal,
|
|
): Promise<ToolGatewayDescriptor[]> {
|
|
signal?.throwIfAborted();
|
|
if (session.agentId) {
|
|
await assertAgentInCompany(session.companyId, session.agentId);
|
|
}
|
|
const connectionTools = await connectionToolsForContext(session);
|
|
const onDemandTargets = connectionTools.filter(isOnDemandRemoteTool);
|
|
const tools = [
|
|
...allTools(),
|
|
...connectionTools.filter((tool) => !isOnDemandRemoteTool(tool)),
|
|
].filter(
|
|
(tool) =>
|
|
session.agentId ||
|
|
(tool.providerType !== "paperclip_self" &&
|
|
tool.providerType !== "paperclip_plugin"),
|
|
);
|
|
const decisionCache = createToolAccessDecisionCache();
|
|
const decisions = await decideToolsForListing(
|
|
tools,
|
|
(tool) => policyInputForTool({ session, tool }),
|
|
decisionCache,
|
|
signal,
|
|
);
|
|
const visibleTools = decisions
|
|
.filter(
|
|
({ decision }) =>
|
|
decision.allowed || decision.decision === "require_approval",
|
|
)
|
|
.map(({ tool, decision }) =>
|
|
decision.decision === "require_approval"
|
|
? {
|
|
...tool,
|
|
description: [
|
|
tool.description?.trim(),
|
|
TOOL_APPROVAL_DESCRIPTION_SUFFIX,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
}
|
|
: tool,
|
|
);
|
|
if (onDemandTargets.length > 0) {
|
|
const targetDecisions = await decideToolsForListing(
|
|
onDemandTargets,
|
|
(tool) => policyInputForTool({ session, tool }),
|
|
decisionCache,
|
|
signal,
|
|
);
|
|
if (
|
|
targetDecisions.some(
|
|
({ decision }) =>
|
|
decision.allowed || decision.decision === "require_approval",
|
|
)
|
|
) {
|
|
visibleTools.push(...VIRTUAL_TOOLS);
|
|
}
|
|
}
|
|
return visibleTools;
|
|
}
|
|
|
|
async function executeBuiltinTool(
|
|
session: ToolGatewaySession,
|
|
tool: ToolGatewayDescriptor,
|
|
parameters: unknown,
|
|
invocationId?: string,
|
|
) {
|
|
const params = asRecord(parameters) ?? {};
|
|
|
|
if (tool.providerType === "paperclip_slack_chat") {
|
|
if (!session.agentId || !session.runId || !session.issueId) throw new ToolGatewayHttpError(403, "Slack task binding required", "slack_task_required");
|
|
try {
|
|
const data = await executeSlackTool(db, { companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId, endpointId: String(asRecord(tool.providerMetadata)?.endpointId ?? ""), identityContextId: session.identityContextId, approvedInvocationId: session.approvedSlackInvocationId }, tool.upstreamToolName ?? "", parameters, fetch, invocationId);
|
|
return { content: JSON.stringify(data), data };
|
|
} catch (error) {
|
|
if (error instanceof HttpError) {
|
|
const details = asRecord(error.details) ?? {};
|
|
throw new ToolGatewayHttpError(error.status, error.message, typeof details.code === "string" ? details.code : "slack_operation_rejected", details);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
if (tool.providerType === "paperclip_github_chat") {
|
|
const data = await githubChatReviewService(db).execute(session, tool.upstreamToolName ?? "", parameters, invocationId);
|
|
return { content: JSON.stringify(data), data };
|
|
}
|
|
if (tool.name === "mcp-remote-fixture:echo") {
|
|
return {
|
|
content: String(params.message ?? ""),
|
|
data: {
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (tool.name === "mcp-remote-fixture:add") {
|
|
const a = Number(params.a);
|
|
const b = Number(params.b);
|
|
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
throw new ToolGatewayHttpError(
|
|
400,
|
|
"Parameters a and b must be finite numbers",
|
|
"invalid_parameters",
|
|
);
|
|
}
|
|
return {
|
|
content: String(a + b),
|
|
data: {
|
|
result: a + b,
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (tool.name === "mcp-remote-fixture:update_note") {
|
|
const noteId =
|
|
typeof params.noteId === "string" ? params.noteId.trim() : "";
|
|
const body = typeof params.body === "string" ? params.body : "";
|
|
if (!noteId || !body) {
|
|
throw new ToolGatewayHttpError(
|
|
400,
|
|
"Parameters noteId and body are required",
|
|
"invalid_parameters",
|
|
);
|
|
}
|
|
return {
|
|
content: JSON.stringify({ noteId, updated: true }),
|
|
data: {
|
|
noteId,
|
|
bodyLength: body.length,
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (tool.name === "paperclip-self:list_my_issues") {
|
|
if (!session.agentId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Paperclip self tools require an agent-scoped gateway session",
|
|
"agent_context_required",
|
|
);
|
|
}
|
|
const limit = Math.max(1, Math.min(50, Number(params.limit ?? 10) || 10));
|
|
const rows = await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, session.companyId),
|
|
eq(issues.assigneeAgentId, session.agentId),
|
|
),
|
|
)
|
|
.orderBy(desc(issues.updatedAt))
|
|
.limit(limit);
|
|
|
|
return {
|
|
content: JSON.stringify(rows),
|
|
data: { issues: rows },
|
|
};
|
|
}
|
|
|
|
if (tool.name === "paperclip-self:get_issue_context") {
|
|
if (!session.agentId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Paperclip self tools require an agent-scoped gateway session",
|
|
"agent_context_required",
|
|
);
|
|
}
|
|
const issueId =
|
|
typeof params.issueId === "string" ? params.issueId : session.issueId;
|
|
if (!issueId) {
|
|
throw new ToolGatewayHttpError(
|
|
400,
|
|
"issueId is required when the session is not issue-scoped",
|
|
"missing_issue_id",
|
|
);
|
|
}
|
|
const [issue] = await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
description: issues.description,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(eq(issues.companyId, session.companyId), eq(issues.id, issueId)),
|
|
)
|
|
.limit(1);
|
|
if (!issue) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"Issue not found",
|
|
"issue_not_found",
|
|
);
|
|
}
|
|
|
|
const [planDocument] = await db
|
|
.select({
|
|
documentId: documents.id,
|
|
title: documents.title,
|
|
latestRevisionId: documents.latestRevisionId,
|
|
latestRevisionNumber: documents.latestRevisionNumber,
|
|
})
|
|
.from(issueDocuments)
|
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
|
.where(
|
|
and(
|
|
eq(issueDocuments.issueId, issue.id),
|
|
eq(issueDocuments.key, "plan"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
return {
|
|
content: JSON.stringify({ issue, planDocument: planDocument ?? null }),
|
|
data: { issue, planDocument: planDocument ?? null },
|
|
};
|
|
}
|
|
|
|
if (tool.providerType === "mcp_stdio_fixture") {
|
|
return runtimeSupervisor.useFixtureSlot(
|
|
{
|
|
companyId: session.companyId,
|
|
connectionKey: `${session.companyId}:mcp-stdio-fixture:default`,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
agentId: session.agentId,
|
|
},
|
|
async (handle) => {
|
|
const priorUseCount = Number(handle.metadata.useCount ?? 0) || 0;
|
|
let counter = Number(handle.metadata.counter ?? 0) || 0;
|
|
if (tool.name === "mcp-stdio-fixture:increment_counter") {
|
|
counter += 1;
|
|
handle.metadata.counter = counter;
|
|
handle.appendLog("stdout", `increment_counter counter=${counter}`);
|
|
} else {
|
|
handle.appendLog("stdout", `runtime_status counter=${counter}`);
|
|
}
|
|
const nextUseCount = priorUseCount + 1;
|
|
return {
|
|
content: JSON.stringify({
|
|
slotId: handle.slot.id,
|
|
status: handle.slot.status,
|
|
counter,
|
|
useCount: nextUseCount,
|
|
}),
|
|
data: {
|
|
slotId: handle.slot.id,
|
|
status: handle.slot.status,
|
|
counter,
|
|
useCount: nextUseCount,
|
|
lazyStarted: priorUseCount === 0,
|
|
reusedRuntimeSlot: priorUseCount > 0,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${tool.name}" not found`,
|
|
"tool_not_found",
|
|
);
|
|
}
|
|
|
|
function remoteEndpoint(config: Record<string, unknown>): string {
|
|
const value = config.url ?? config.endpoint ?? config.remoteUrl;
|
|
const parsed = parseRemoteHttpEndpoint(
|
|
value,
|
|
(message, code) => new ToolGatewayHttpError(422, message, code),
|
|
);
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function resolvedRemoteEndpoint(
|
|
session: ToolGatewaySession,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
): Promise<string> {
|
|
const publicEndpoint = remoteEndpoint(connection.config ?? {});
|
|
const ref = (connection.credentialRefs ?? []).find(
|
|
(candidate) => candidate.placement === "url",
|
|
);
|
|
if (!ref) return publicEndpoint;
|
|
const grantRef = grantRefForCredential(grant, ref);
|
|
if (!grantRef) {
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"A configured credential secret could not be resolved.",
|
|
"mcp_remote_missing_secret",
|
|
{
|
|
connectionId: connection.id,
|
|
credential: REMOTE_URL_SECRET_CONFIG_PATH,
|
|
},
|
|
);
|
|
}
|
|
const value = await resolveGrantSecretValue(
|
|
session,
|
|
connection,
|
|
grant,
|
|
grantRef,
|
|
);
|
|
if (!remoteUrlCredentialMatchesPublicUrl(publicEndpoint, value)) {
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"The stored MCP URL credential no longer matches this connection.",
|
|
"mcp_remote_url_credential_mismatch",
|
|
{ connectionId: connection.id },
|
|
);
|
|
}
|
|
return parseRemoteHttpEndpoint(
|
|
value,
|
|
(message, code) => new ToolGatewayHttpError(422, message, code),
|
|
).toString();
|
|
}
|
|
|
|
function allowPrivateRemoteEndpoints() {
|
|
return (
|
|
options.deploymentMode !== "authenticated" ||
|
|
options.deploymentExposure !== "public"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Guard options for every outbound call to an operator-supplied MCP endpoint.
|
|
*
|
|
* The private-network check no longer runs as a standalone pre-flight: it is
|
|
* part of `guardedRemoteHttpFetch`, which keeps the approved address and dials
|
|
* it directly. Splitting validation from dispatch is what created the
|
|
* DNS-rebinding TOCTOU in PAP-17098.
|
|
*/
|
|
function remoteHttpFetchOptions(): GuardedRemoteHttpFetchOptions {
|
|
return {
|
|
allowPrivateNetwork: allowPrivateRemoteEndpoints(),
|
|
error: (message, code) => new ToolGatewayHttpError(422, message, code),
|
|
};
|
|
}
|
|
|
|
function headerName(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(trimmed)) return null;
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
function headerValue(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
if (/[\r\n]/.test(value)) return null;
|
|
return value;
|
|
}
|
|
|
|
function stringArray(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter((entry): entry is string => typeof entry === "string");
|
|
}
|
|
|
|
function readHeaderPolicy(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): HeaderPolicyConfig {
|
|
const config = asRecord(connection.config) ?? {};
|
|
const transportConfig = asRecord(connection.transportConfig) ?? {};
|
|
const rawPolicy =
|
|
asRecord(config.headerPolicy) ??
|
|
asRecord(transportConfig.headerPolicy) ??
|
|
{};
|
|
const passthrough = asRecord(rawPolicy.passthrough) ?? {};
|
|
const staticHeaders = rawPolicy.staticHeaders;
|
|
const parsedStaticHeaders: Array<{ name: string; value: string }> = [];
|
|
|
|
if (Array.isArray(staticHeaders)) {
|
|
for (const entry of staticHeaders) {
|
|
const record = asRecord(entry);
|
|
const name = headerName(record?.name);
|
|
const value = headerValue(record?.value);
|
|
if (name && value !== null) parsedStaticHeaders.push({ name, value });
|
|
}
|
|
} else {
|
|
const record = asRecord(staticHeaders);
|
|
if (record) {
|
|
for (const [rawName, rawValue] of Object.entries(record)) {
|
|
const name = headerName(rawName);
|
|
const value = headerValue(rawValue);
|
|
if (name && value !== null) parsedStaticHeaders.push({ name, value });
|
|
}
|
|
}
|
|
}
|
|
|
|
const passthroughAllowlist = [
|
|
...stringArray(passthrough.allow),
|
|
...stringArray(passthrough.allowedHeaders),
|
|
...stringArray(rawPolicy.allowedPassthroughHeaders),
|
|
]
|
|
.map(headerName)
|
|
.filter((name): name is string => Boolean(name))
|
|
.filter((name) => !isSensitivePassthroughHeader(name));
|
|
|
|
const metadata = asRecord(rawPolicy.metadata) ?? {};
|
|
const metadataHeaders = [
|
|
...stringArray(metadata.forward),
|
|
...stringArray(metadata.headers),
|
|
...stringArray(rawPolicy.forwardContextHeaders),
|
|
].filter(
|
|
(value): value is HeaderPolicyConfig["metadataHeaders"][number] =>
|
|
value === "company_id" ||
|
|
value === "agent_id" ||
|
|
value === "issue_id" ||
|
|
value === "project_id" ||
|
|
value === "run_id" ||
|
|
value === "gateway_session_id" ||
|
|
value === "correlation_id",
|
|
);
|
|
|
|
return {
|
|
staticHeaders: parsedStaticHeaders,
|
|
passthroughAllowlist: [...new Set(passthroughAllowlist)],
|
|
metadataHeaders: [...new Set(metadataHeaders)],
|
|
};
|
|
}
|
|
|
|
function readOnDemandToolsEnabled(
|
|
connectionOrConfig:
|
|
typeof toolConnections.$inferSelect | Record<string, unknown>,
|
|
): boolean {
|
|
const config =
|
|
"config" in connectionOrConfig
|
|
? (asRecord(connectionOrConfig.config) ?? {})
|
|
: connectionOrConfig;
|
|
const raw =
|
|
asRecord(config.onDemandTools) ?? asRecord(config.loadToolsOnDemand);
|
|
return (
|
|
config.onDemandTools === true ||
|
|
config.loadToolsOnDemand === true ||
|
|
raw?.enabled === true
|
|
);
|
|
}
|
|
|
|
function isOnDemandRemoteTool(tool: ToolGatewayDescriptor): boolean {
|
|
const metadata = asRecord(tool.providerMetadata);
|
|
return (
|
|
tool.providerType === "mcp_remote_http" &&
|
|
metadata?.onDemandTools === true
|
|
);
|
|
}
|
|
|
|
function normalizeCallerHeaders(
|
|
input: ExecuteGatewayToolInput["callerHeaders"],
|
|
): Record<string, string> {
|
|
const headers: Record<string, string> = {};
|
|
for (const [rawName, rawValue] of Object.entries(input ?? {})) {
|
|
const name = headerName(rawName);
|
|
if (!name) continue;
|
|
const value = Array.isArray(rawValue) ? rawValue.join(", ") : rawValue;
|
|
const normalizedValue = headerValue(value);
|
|
if (normalizedValue !== null) headers[name] = normalizedValue;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function metadataHeadersForSession(
|
|
session: ToolGatewaySession,
|
|
policy: HeaderPolicyConfig,
|
|
): Record<string, string> {
|
|
const headers: Record<string, string> = {};
|
|
const values: Record<
|
|
HeaderPolicyConfig["metadataHeaders"][number],
|
|
string | null
|
|
> = {
|
|
company_id: session.companyId,
|
|
agent_id: session.agentId,
|
|
issue_id: session.issueId,
|
|
project_id: session.projectId,
|
|
run_id: session.runId,
|
|
gateway_session_id: session.id,
|
|
correlation_id: randomUUID(),
|
|
};
|
|
for (const key of policy.metadataHeaders) {
|
|
const value = values[key];
|
|
if (value) headers[`x-paperclip-${key.replace(/_/g, "-")}`] = value;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function buildRemoteHeaders(input: {
|
|
session: ToolGatewaySession;
|
|
connection: typeof toolConnections.$inferSelect;
|
|
credentialHeaders: Record<string, string>;
|
|
callerHeaders?: ExecuteGatewayToolInput["callerHeaders"];
|
|
}): { headers: Record<string, string>; summary: HeaderPolicySummary } {
|
|
const policy = readHeaderPolicy(input.connection);
|
|
const caller = normalizeCallerHeaders(input.callerHeaders);
|
|
const credentialHeaders: Record<string, string> = {};
|
|
for (const [name, value] of Object.entries(input.credentialHeaders)) {
|
|
const normalized = headerName(name);
|
|
if (normalized) credentialHeaders[normalized] = value;
|
|
}
|
|
const reservedHeaders = new Set([
|
|
"accept",
|
|
"content-type",
|
|
"content-length",
|
|
"host",
|
|
"connection",
|
|
]);
|
|
const managedCredentialHeaders = new Set(Object.keys(credentialHeaders));
|
|
const headers: Record<string, string> = {};
|
|
const summary: HeaderPolicySummary = {
|
|
staticHeaderNames: [],
|
|
credentialHeaderNames: Object.keys(credentialHeaders).sort(),
|
|
passthroughHeaderNames: [],
|
|
droppedPassthroughHeaderNames: [],
|
|
metadataHeaderNames: [],
|
|
collisionRules: [],
|
|
};
|
|
|
|
for (const [name, value] of Object.entries(caller)) {
|
|
if (reservedHeaders.has(name)) {
|
|
summary.droppedPassthroughHeaderNames.push(name);
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "caller",
|
|
action: "dropped_reserved_header",
|
|
});
|
|
continue;
|
|
}
|
|
if (managedCredentialHeaders.has(name)) {
|
|
summary.droppedPassthroughHeaderNames.push(name);
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "caller",
|
|
action: "kept_managed_credential",
|
|
});
|
|
continue;
|
|
}
|
|
if (isSensitivePassthroughHeader(name)) {
|
|
summary.droppedPassthroughHeaderNames.push(name);
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "caller",
|
|
action: "dropped_sensitive_header",
|
|
});
|
|
continue;
|
|
}
|
|
if (!policy.passthroughAllowlist.includes(name)) {
|
|
summary.droppedPassthroughHeaderNames.push(name);
|
|
continue;
|
|
}
|
|
headers[name] = value;
|
|
summary.passthroughHeaderNames.push(name);
|
|
}
|
|
|
|
for (const { name, value } of policy.staticHeaders) {
|
|
if (reservedHeaders.has(name)) {
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "static",
|
|
action: "dropped_reserved_header",
|
|
});
|
|
continue;
|
|
}
|
|
if (managedCredentialHeaders.has(name)) {
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "static",
|
|
action: "kept_managed_credential",
|
|
});
|
|
continue;
|
|
}
|
|
if (headers[name] !== undefined) {
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "static",
|
|
action: "overrode_passthrough",
|
|
});
|
|
}
|
|
headers[name] = value;
|
|
summary.staticHeaderNames.push(name);
|
|
}
|
|
|
|
const metadataHeaders = metadataHeadersForSession(input.session, policy);
|
|
for (const [name, value] of Object.entries(metadataHeaders)) {
|
|
if (reservedHeaders.has(name)) continue;
|
|
if (managedCredentialHeaders.has(name)) {
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "metadata",
|
|
action: "kept_managed_credential",
|
|
});
|
|
continue;
|
|
}
|
|
if (headers[name] !== undefined) {
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "metadata",
|
|
action: "overrode_previous_header",
|
|
});
|
|
}
|
|
headers[name] = value;
|
|
summary.metadataHeaderNames.push(name);
|
|
}
|
|
|
|
for (const [name, value] of Object.entries(credentialHeaders)) {
|
|
if (headers[name] !== undefined) {
|
|
summary.collisionRules.push({
|
|
header: name,
|
|
source: "credential",
|
|
action: "overrode_previous_header",
|
|
});
|
|
}
|
|
headers[name] = value;
|
|
}
|
|
|
|
summary.staticHeaderNames.sort();
|
|
summary.passthroughHeaderNames.sort();
|
|
summary.droppedPassthroughHeaderNames = [
|
|
...new Set(summary.droppedPassthroughHeaderNames),
|
|
].sort();
|
|
summary.metadataHeaderNames.sort();
|
|
return { headers, summary };
|
|
}
|
|
|
|
async function markRemoteConnectionHealth(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
status: "ok" | "error" | "missing_secret" | "degraded",
|
|
message: string | null,
|
|
) {
|
|
const now = new Date();
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
healthStatus: status,
|
|
healthMessage: message,
|
|
healthCheckedAt: now,
|
|
lastHealthAt: now,
|
|
lastError: status === "ok" ? null : message,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
}
|
|
|
|
function grantRefForCredential(
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
ref: McpConnectionCredentialRef,
|
|
): ToolCredentialSecretRef | undefined {
|
|
return connectionGrantCredentialRef(grant, ref);
|
|
}
|
|
|
|
async function resolveGrantSecretValue(
|
|
session: ToolGatewaySession,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
ref: ToolCredentialSecretRef,
|
|
): Promise<string> {
|
|
try {
|
|
return (await resolveConnectionGrantSecret(db, connection, grant, ref, {
|
|
consumerType: "tool_connection", consumerId: connection.id,
|
|
actorType: "system", actorId: session.agentId,
|
|
responsibleUserId: grant.subjectUserId, issueId: session.issueId, heartbeatRunId: session.runId,
|
|
})).value;
|
|
} catch (error) {
|
|
if (error instanceof HttpError) throw new ToolGatewayHttpError(error.status, error.message,
|
|
String(asRecord(error.details)?.code ?? "mcp_remote_missing_secret"), asRecord(error.details) ?? {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function maybeRefreshPaperclipCloudGrant(
|
|
session: ToolGatewaySession,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
forceRefresh = false,
|
|
): Promise<typeof connectionGrants.$inferSelect> {
|
|
const oauth = asRecord(asRecord(connection.config)?.oauth);
|
|
if (!oauth || !isPaperclipCloudConnectorStrategy(oauth.strategy))
|
|
return grant;
|
|
const configuredProfile = oauth.connectorProfile;
|
|
const connectorProfile:
|
|
GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId =
|
|
configuredProfile === undefined
|
|
? "gmail.draft"
|
|
: typeof configuredProfile === "string" &&
|
|
(isGoogleWorkspaceConnectorProfileId(configuredProfile) ||
|
|
isGitHubConnectorProfileId(configuredProfile))
|
|
? configuredProfile
|
|
: (() => {
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"Managed authorization has an invalid connector profile",
|
|
"connector_profile_invalid",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
})();
|
|
const connectorSubject =
|
|
typeof oauth.connectorSubjectAgentId === "string"
|
|
? `agent:${oauth.connectorSubjectAgentId}`
|
|
: typeof oauth.connectorSubjectUserId === "string"
|
|
? oauth.connectorSubjectUserId
|
|
: grant.kind === "agent" && grant.subjectAgentId
|
|
? `agent:${grant.subjectAgentId}`
|
|
: grant.subjectUserId;
|
|
const grantOauth = asRecord(asRecord(grant.providerTenant)?.oauth);
|
|
const expiresAt =
|
|
typeof grantOauth?.accessTokenExpiresAt === "string"
|
|
? Date.parse(grantOauth.accessTokenExpiresAt)
|
|
: Number.NaN;
|
|
const currentTime = options.now?.() ?? Date.now();
|
|
// The preferred GitHub App policy yields a non-expiring ghu_ token and no
|
|
// refresh token. Absence of an expiry is deliberate, not an invitation to
|
|
// enter the rotation path.
|
|
if (
|
|
grantOauth?.accessTokenExpiresAt === null ||
|
|
grantOauth?.accessTokenExpiresAt === undefined
|
|
)
|
|
return grant;
|
|
const refreshedAt =
|
|
typeof grantOauth.refreshedAt === "string"
|
|
? Date.parse(grantOauth.refreshedAt)
|
|
: Number.NaN;
|
|
const rotationDue =
|
|
!Number.isFinite(refreshedAt) ||
|
|
refreshedAt <= currentTime - 30 * 24 * 60 * 60_000;
|
|
if (
|
|
!forceRefresh &&
|
|
Number.isFinite(expiresAt) &&
|
|
expiresAt > currentTime + 60 * 60_000 &&
|
|
!rotationDue
|
|
)
|
|
return grant;
|
|
if (oauth.strategy === "paperclip_id_connector") {
|
|
// Paperclip ID used different endpoints, signing metadata, envelope
|
|
// purposes, and a different Google client. Its refresh token cannot be
|
|
// exchanged through Paperclip Cloud. Let an unexpired access token finish
|
|
// its useful life, then require an explicit managed-connector enrollment
|
|
// and provider reconnect instead of sending it to the wrong client.
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "needs_reauthorization",
|
|
updatedAt: new Date(currentTime),
|
|
})
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Legacy authorization must be reconnected through Paperclip Cloud",
|
|
"connector_reauthorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
}
|
|
const existingFlight = gmailRefreshFlights.get(grant.id);
|
|
if (existingFlight) return existingFlight;
|
|
const refresh = (async () => {
|
|
const cloudConnector = currentCloudConnector();
|
|
if (!cloudConnector || !connectorSubject) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "needs_reauthorization",
|
|
updatedAt: new Date(currentTime),
|
|
})
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Managed authorization must be reconnected",
|
|
"connector_reauthorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
}
|
|
const accessRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.access_token",
|
|
);
|
|
const refreshRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
);
|
|
if (!accessRef || !refreshRef) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "needs_reauthorization",
|
|
updatedAt: new Date(currentTime),
|
|
})
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Managed authorization must be reconnected",
|
|
"connector_reauthorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
}
|
|
const refreshToken = await resolveGrantSecretValue(
|
|
session,
|
|
connection,
|
|
grant,
|
|
refreshRef,
|
|
);
|
|
try {
|
|
const credentials = await cloudConnector.refresh({
|
|
subject: connectorSubject,
|
|
companyId: connection.companyId,
|
|
profile: connectorProfile,
|
|
refreshToken,
|
|
});
|
|
await secrets.rotate(accessRef.secretId, {
|
|
value: credentials.accessToken,
|
|
});
|
|
if (credentials.refreshToken) {
|
|
await secrets.rotate(refreshRef.secretId, {
|
|
value: credentials.refreshToken,
|
|
});
|
|
}
|
|
const providerTenant = {
|
|
...(grant.providerTenant ?? {}),
|
|
oauth: {
|
|
...(grant.providerTenant?.oauth ?? {}),
|
|
strategy: "paperclip_cloud_connector",
|
|
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
|
|
scopes: credentials.scopes,
|
|
tokenType: credentials.tokenType,
|
|
refreshedAt: new Date(options.now?.() ?? Date.now()).toISOString(),
|
|
...(credentials.refreshTokenExpiresAt
|
|
? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt }
|
|
: {}),
|
|
},
|
|
};
|
|
const [updated] = await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant,
|
|
updatedAt: new Date(options.now?.() ?? Date.now()),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updated) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Managed authorization is no longer active",
|
|
"connector_reauthorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
}
|
|
return updated;
|
|
} catch (error) {
|
|
if (error instanceof ToolGatewayHttpError) throw error;
|
|
if (
|
|
error instanceof PaperclipCloudConnectorError &&
|
|
error.code === "REAUTHORIZATION_REQUIRED"
|
|
) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "needs_reauthorization",
|
|
updatedAt: new Date(options.now?.() ?? Date.now()),
|
|
})
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Managed authorization must be reconnected",
|
|
"connector_reauthorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
}
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
"Managed authorization could not be refreshed",
|
|
"connector_refresh_failed",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
}
|
|
})();
|
|
gmailRefreshFlights.set(grant.id, refresh);
|
|
try {
|
|
return await refresh;
|
|
} finally {
|
|
if (gmailRefreshFlights.get(grant.id) === refresh)
|
|
gmailRefreshFlights.delete(grant.id);
|
|
}
|
|
}
|
|
|
|
async function resolveCredentialHeaders(
|
|
session: ToolGatewaySession,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
resolveOptions: { forceRefresh?: boolean } = {},
|
|
): Promise<Record<string, string>> {
|
|
const tracked =
|
|
session.identityContextId &&
|
|
(connection.config.sourceTemplateKey === "github" ||
|
|
connection.transportConfig?.sourceTemplateKey === "github");
|
|
try {
|
|
const captured = githubOperationCredentials.get(session);
|
|
const headers =
|
|
!resolveOptions.forceRefresh && captured?.grant.id === grant.id
|
|
? captured.headers
|
|
: await resolveCredentialHeadersUnrecorded(
|
|
session,
|
|
connection,
|
|
grant,
|
|
resolveOptions,
|
|
);
|
|
if (tracked)
|
|
await db
|
|
.update(runIdentityContexts)
|
|
.set({
|
|
github: {
|
|
status: "available",
|
|
login: grant.providerTenant?.github?.login,
|
|
source: grant.kind === "agent" ? "dedicated" : "personal",
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
authenticationMode: "managed",
|
|
},
|
|
})
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.id, session.identityContextId!),
|
|
eq(runIdentityContexts.companyId, session.companyId),
|
|
),
|
|
);
|
|
return headers;
|
|
} catch (error) {
|
|
if (tracked)
|
|
await db
|
|
.update(runIdentityContexts)
|
|
.set({
|
|
github: {
|
|
status: "unavailable",
|
|
reason: "GitHub authorization is unavailable",
|
|
source: grant.kind === "agent" ? "dedicated" : "personal",
|
|
},
|
|
})
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.id, session.identityContextId!),
|
|
eq(runIdentityContexts.companyId, session.companyId),
|
|
),
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function resolveCredentialHeadersUnrecorded(
|
|
session: ToolGatewaySession,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
resolveOptions: { forceRefresh?: boolean } = {},
|
|
): Promise<Record<string, string>> {
|
|
if (connection.credentialSource === "vercel_connect") {
|
|
if (!connection.externalCredential || !vercelConnect) {
|
|
throw new ToolGatewayHttpError(
|
|
503,
|
|
"Vercel Connect is not configured",
|
|
"vercel_connect_unavailable",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
}
|
|
const request = vercelTokenRequest({
|
|
credential: connection.externalCredential,
|
|
grant,
|
|
connectionId: connection.id,
|
|
companyId: connection.companyId,
|
|
});
|
|
try {
|
|
const token = await vercelConnect.getToken(request, resolveOptions);
|
|
if (
|
|
token.connector.id !== connection.externalCredential.connectorId &&
|
|
token.connector.uid !== connection.externalCredential.connectorUid
|
|
) {
|
|
throw new VercelConnectClientError(
|
|
"vercel_connect_request_failed",
|
|
502,
|
|
);
|
|
}
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
externalCredential: vercelGrantReference({
|
|
credential: connection.externalCredential,
|
|
token,
|
|
subjectId: grant.externalCredential?.subjectId,
|
|
verifiedAt: new Date(options.now?.() ?? Date.now()),
|
|
}),
|
|
status: "active",
|
|
revokedAt: null,
|
|
updatedAt: new Date(options.now?.() ?? Date.now()),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
);
|
|
return {
|
|
[connection.externalCredential.headerName]:
|
|
`${connection.externalCredential.headerPrefix ?? ""}${token.token}`,
|
|
};
|
|
} catch (error) {
|
|
if (
|
|
error instanceof VercelConnectClientError &&
|
|
error.code === "vercel_connect_authorization_required"
|
|
) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "needs_reauthorization",
|
|
updatedAt: new Date(options.now?.() ?? Date.now()),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
),
|
|
);
|
|
const responsibleUserId =
|
|
grant.kind === "user"
|
|
? grant.subjectUserId
|
|
: session.responsibleUserId;
|
|
if (responsibleUserId) {
|
|
await createUserAuthorizationInteraction(
|
|
session,
|
|
connection,
|
|
responsibleUserId,
|
|
grant.kind === "user" ? "user" : "organization",
|
|
);
|
|
}
|
|
}
|
|
const code =
|
|
error instanceof VercelConnectClientError
|
|
? error.code
|
|
: "vercel_connect_request_failed";
|
|
const status =
|
|
error instanceof VercelConnectClientError ? error.status : 502;
|
|
const message =
|
|
error instanceof VercelConnectClientError
|
|
? error.message
|
|
: "Vercel Connect could not resolve this credential.";
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
code === "vercel_connect_unavailable" ||
|
|
code === "vercel_connect_auth_failed" ||
|
|
code === "vercel_connect_installation_required"
|
|
? "degraded"
|
|
: "error",
|
|
message,
|
|
);
|
|
throw new ToolGatewayHttpError(status, message, code, {
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
});
|
|
}
|
|
}
|
|
const oauth = asRecord(asRecord(connection.config)?.oauth);
|
|
if (
|
|
isPaperclipCloudConnectorStrategy(oauth?.strategy) &&
|
|
!options.oauthGrantRefresher
|
|
) {
|
|
// Compatibility fallback for isolated service consumers. The production
|
|
// app supplies tool-access's lease/CAS refresher below.
|
|
grant = await maybeRefreshPaperclipCloudGrant(
|
|
session,
|
|
connection,
|
|
grant,
|
|
resolveOptions.forceRefresh === true,
|
|
);
|
|
}
|
|
if (
|
|
connection.authKind === "oauth" &&
|
|
connection.credentialSource === "paperclip_vault" &&
|
|
options.oauthGrantRefresher
|
|
) {
|
|
try {
|
|
grant = await options.oauthGrantRefresher({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
forceRefresh: resolveOptions.forceRefresh,
|
|
actor: {
|
|
actorType:
|
|
session.actorType ?? (session.agentId ? "agent" : "system"),
|
|
actorId: session.actorId ?? session.agentId,
|
|
},
|
|
issueId: session.issueId,
|
|
heartbeatRunId: session.runId,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof ToolGatewayHttpError) throw error;
|
|
const record = asRecord(error);
|
|
const details = asRecord(record?.details) ?? {};
|
|
const status = typeof record?.status === "number" ? record.status : 502;
|
|
const reasonCode =
|
|
typeof details.code === "string"
|
|
? details.code
|
|
: "oauth_refresh_failed";
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "OAuth authorization could not be refreshed";
|
|
throw new ToolGatewayHttpError(status, message, reasonCode, {
|
|
...details,
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
});
|
|
}
|
|
}
|
|
const headers: Record<string, string> = {};
|
|
for (const ref of connection.credentialRefs ?? []) {
|
|
if (ref.placement !== "header") continue;
|
|
const grantRef = grantRefForCredential(grant, ref);
|
|
if (!grantRef) continue;
|
|
try {
|
|
const value = await resolveGrantSecretValue(
|
|
session,
|
|
connection,
|
|
grant,
|
|
grantRef,
|
|
);
|
|
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
|
|
} catch (error) {
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
"missing_secret",
|
|
"A configured credential secret could not be resolved.",
|
|
);
|
|
if (error instanceof ToolGatewayHttpError && error.reasonCode === "grant_credential_invalid") throw error;
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"A configured credential secret could not be resolved.",
|
|
"mcp_remote_missing_secret",
|
|
{ connectionId: connection.id, credential: ref.name },
|
|
);
|
|
}
|
|
}
|
|
const oauthAccessRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.access_token",
|
|
);
|
|
if (oauthAccessRef && headers.Authorization === undefined) {
|
|
try {
|
|
const value = await resolveGrantSecretValue(
|
|
session,
|
|
connection,
|
|
grant,
|
|
oauthAccessRef,
|
|
);
|
|
headers.Authorization = `Bearer ${value}`;
|
|
} catch (error) {
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
"missing_secret",
|
|
"A configured credential secret could not be resolved.",
|
|
);
|
|
if (error instanceof ToolGatewayHttpError && error.reasonCode === "grant_credential_invalid") throw error;
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"A configured credential secret could not be resolved.",
|
|
"mcp_remote_missing_secret",
|
|
{
|
|
connectionId: connection.id,
|
|
credential: oauthAccessRef.configPath,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function credentialVersionRefHash(value: Record<string, unknown>): string {
|
|
return stableHash(value);
|
|
}
|
|
|
|
async function resolveConnectedCredentialVersion(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
input: {
|
|
secretId: string;
|
|
versionSelector: SecretVersionSelector | undefined;
|
|
configPath: string;
|
|
refHash: string;
|
|
requireResolved: boolean;
|
|
},
|
|
): Promise<ConnectedCredentialVersionSnapshot> {
|
|
const versionSelector = input.versionSelector ?? "latest";
|
|
try {
|
|
const resolvedVersion = await secrets.resolveSecretVersion(
|
|
connection.companyId,
|
|
input.secretId,
|
|
versionSelector,
|
|
);
|
|
return {
|
|
refHash: input.refHash,
|
|
versionSelector: String(versionSelector),
|
|
resolvedVersion,
|
|
};
|
|
} catch {
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
"missing_secret",
|
|
"A configured credential secret could not be resolved.",
|
|
);
|
|
if (input.requireResolved) {
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"A configured credential secret could not be resolved.",
|
|
"mcp_remote_missing_secret",
|
|
{ connectionId: connection.id, credential: input.configPath },
|
|
);
|
|
}
|
|
return {
|
|
refHash: input.refHash,
|
|
versionSelector: String(versionSelector),
|
|
resolvedVersion: -1,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function connectedCredentialVersionSnapshots(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
options: { requireResolved: boolean },
|
|
): Promise<{
|
|
headerCredentialVersions: ConnectedCredentialVersionSnapshot[];
|
|
credentialSecretVersions: ConnectedCredentialVersionSnapshot[];
|
|
}> {
|
|
const headerCredentialVersions: ConnectedCredentialVersionSnapshot[] = [];
|
|
const credentialSecretVersions: ConnectedCredentialVersionSnapshot[] = [];
|
|
|
|
for (const ref of connection.credentialRefs ?? []) {
|
|
if (ref.placement !== "header" && ref.placement !== "url") continue;
|
|
const typedRef = ref as McpConnectionCredentialRef;
|
|
const grantRef = grantRefForCredential(grant, typedRef);
|
|
if (!grantRef) continue;
|
|
const configPath =
|
|
typedRef.placement === "url"
|
|
? REMOTE_URL_SECRET_CONFIG_PATH
|
|
: grantRef.configPath;
|
|
headerCredentialVersions.push(
|
|
await resolveConnectedCredentialVersion(connection, {
|
|
secretId: grantRef.secretId,
|
|
versionSelector: grantRef.versionSelector,
|
|
configPath,
|
|
refHash: credentialVersionRefHash({
|
|
kind: typedRef.placement === "url" ? "url" : "header",
|
|
name: typedRef.name,
|
|
secretId: grantRef.secretId,
|
|
placement: typedRef.placement,
|
|
key: typedRef.key,
|
|
prefix: typedRef.prefix ?? null,
|
|
configPath,
|
|
}),
|
|
requireResolved: options.requireResolved,
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const ref of grant.credentialSecretRefs ?? []) {
|
|
const typedRef = ref as ToolCredentialSecretRef;
|
|
credentialSecretVersions.push(
|
|
await resolveConnectedCredentialVersion(connection, {
|
|
secretId: typedRef.secretId,
|
|
versionSelector: typedRef.versionSelector,
|
|
configPath: typedRef.configPath,
|
|
refHash: credentialVersionRefHash({
|
|
kind: "secret_ref",
|
|
secretId: typedRef.secretId,
|
|
configPath: typedRef.configPath,
|
|
required: typedRef.required ?? true,
|
|
label: typedRef.label ?? null,
|
|
}),
|
|
requireResolved: options.requireResolved,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return { headerCredentialVersions, credentialSecretVersions };
|
|
}
|
|
|
|
async function createUserAuthorizationInteraction(
|
|
session: ToolGatewaySession,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
userId: string,
|
|
grantKind: "organization" | "user" = "user",
|
|
) {
|
|
if (!session.issueId || !session.agentId || !session.runId) return;
|
|
const [company] = await db
|
|
.select({ issuePrefix: companies.issuePrefix })
|
|
.from(companies)
|
|
.where(eq(companies.id, session.companyId))
|
|
.limit(1);
|
|
const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/permissions`;
|
|
const idempotencyKey = `connection-authorization:${connection.id}:${userId}`;
|
|
const payload = {
|
|
version: 1 as const,
|
|
prompt:
|
|
grantKind === "organization"
|
|
? `Reconnect the ${connection.name} organization identity to continue`
|
|
: `Connect your ${connection.name} account to continue`,
|
|
acceptLabel:
|
|
grantKind === "organization"
|
|
? "Reconnect organization"
|
|
: "Connect account",
|
|
rejectLabel: "Not now",
|
|
detailsMarkdown:
|
|
grantKind === "organization"
|
|
? "Vercel Connect reports that the shared organization identity needs authorization."
|
|
: "This run needs your personal authorization. Paperclip will not use another user's identity.",
|
|
target: {
|
|
type: "custom" as const,
|
|
key: `connection:${connection.uid}:user:${userId}`,
|
|
revisionId: connection.updatedAt.toISOString(),
|
|
label: `Connect ${connection.name}`,
|
|
href,
|
|
},
|
|
};
|
|
const [existing] = await db
|
|
.select({ id: issueThreadInteractions.id })
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, session.companyId),
|
|
eq(issueThreadInteractions.issueId, session.issueId),
|
|
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (existing) {
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "requested",
|
|
addresseeUserId: userId,
|
|
payload,
|
|
result: null,
|
|
resolvedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, existing.id));
|
|
return;
|
|
}
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId: session.companyId,
|
|
issueId: session.issueId,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "requested",
|
|
idempotencyKey,
|
|
sourceRunId: session.runId,
|
|
title:
|
|
grantKind === "organization"
|
|
? `Reconnect ${connection.name}`
|
|
: `Connect your ${connection.name}`,
|
|
summary:
|
|
grantKind === "organization"
|
|
? "Organization authorization is required before this run can continue."
|
|
: "Personal authorization is required before this run can continue.",
|
|
createdByAgentId: session.agentId,
|
|
addresseeUserId: userId,
|
|
payload,
|
|
});
|
|
}
|
|
|
|
async function resolveConnectionGrant(
|
|
session: ToolGatewaySession,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
allowInteraction = true,
|
|
): Promise<typeof connectionGrants.$inferSelect> {
|
|
const [run] =
|
|
session.runId && !session.identityContextId
|
|
? await db
|
|
.select({
|
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
invocationSource: heartbeatRuns.invocationSource,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, session.runId),
|
|
eq(heartbeatRuns.companyId, session.companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
: [];
|
|
const actingUserId = session.identityContextId
|
|
? (session.responsibleUserId ?? null)
|
|
: (run?.responsibleUserId ?? session.responsibleUserId ?? null);
|
|
if (
|
|
session.identityContextId &&
|
|
session.agentId &&
|
|
(connection.config.sourceTemplateKey === "github" ||
|
|
connection.transportConfig?.sourceTemplateKey === "github")
|
|
) {
|
|
const captured = githubOperationCredentials.get(session);
|
|
const selected = captured
|
|
? { grant: captured.grant, error: undefined }
|
|
: await resolveManagedGitHubIdentitySelection(db, session.companyId, {
|
|
agentId: session.agentId,
|
|
responsibleUserId: session.responsibleUserId,
|
|
allowStandingDelegation: false,
|
|
});
|
|
if (!selected.grant || selected.grant.connectionId !== connection.id) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
selected.error ??
|
|
"GitHub identity changed; retry through the managed tool",
|
|
"github_identity_unavailable",
|
|
);
|
|
}
|
|
if (selected.grant.kind === "user") {
|
|
const [member] = await db
|
|
.select({ role: companyMemberships.membershipRole })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, session.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, selected.grant.subjectUserId!),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!member || member.role === "viewer") {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"The personal grant owner is not an authorized company member",
|
|
"grant_owner_membership_inactive",
|
|
);
|
|
}
|
|
}
|
|
// Managed GitHub selection is final: a legacy shared policy cannot replace
|
|
// the captured person's grant with an organization or teammate's account.
|
|
return selected.grant;
|
|
}
|
|
const autonomous =
|
|
!session.identityContextId &&
|
|
(run?.invocationSource === "automation" ||
|
|
run?.invocationSource === "timer");
|
|
const findUserGrant = async () => {
|
|
if (!actingUserId) return undefined;
|
|
const [membership] = await db
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, actingUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!membership) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"The personal grant owner is not an active company member",
|
|
"grant_owner_membership_inactive",
|
|
{
|
|
connectionId: connection.id,
|
|
actingUserId,
|
|
remediation: { action: "restore_membership_or_reconnect" },
|
|
},
|
|
);
|
|
}
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, actingUserId),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
return grant;
|
|
};
|
|
const findOrganizationGrant = async () => {
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
eq(connectionGrants.isDefault, true),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!grant) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Organization authorization is required",
|
|
"organization_authorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
},
|
|
);
|
|
}
|
|
const members = await db
|
|
.select({ subjectId: connectionGrantMembers.subjectId })
|
|
.from(connectionGrantMembers)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantMembers.companyId, connection.companyId),
|
|
eq(connectionGrantMembers.grantId, grant.id),
|
|
eq(connectionGrantMembers.subjectType, "user"),
|
|
),
|
|
);
|
|
const activeAudienceMember = actingUserId
|
|
? await db
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, actingUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
if (
|
|
!isConnectionGrantAudienceAllowed(
|
|
members.map((member) => member.subjectId),
|
|
actingUserId,
|
|
Boolean(activeAudienceMember),
|
|
)
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"The acting user is not in this grant's audience",
|
|
"grant_audience_denied",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
actingUserId,
|
|
},
|
|
);
|
|
}
|
|
return grant;
|
|
};
|
|
|
|
if (connection.credentialPolicy === "per_agent") {
|
|
if (!session.agentId) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"A dedicated agent authorization is required",
|
|
"agent_authorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
},
|
|
);
|
|
}
|
|
const [agentGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "agent"),
|
|
eq(connectionGrants.subjectAgentId, session.agentId),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!agentGrant) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"This agent's dedicated authorization is not connected",
|
|
"agent_authorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
agentId: session.agentId,
|
|
},
|
|
);
|
|
}
|
|
return agentGrant;
|
|
}
|
|
|
|
// The owner-selected connection install is the consent boundary for agent use.
|
|
// `responsibleUserId` is resolved and persisted by the control plane, never
|
|
// accepted from agent input, so a run carrying it uses that owner's grant
|
|
// directly. Delegation is reserved for genuinely ownerless unattended runs.
|
|
let userGrant =
|
|
connection.credentialPolicy === "shared"
|
|
? undefined
|
|
: await findUserGrant();
|
|
if (
|
|
!userGrant &&
|
|
!actingUserId &&
|
|
autonomous &&
|
|
session.agentId &&
|
|
connection.credentialPolicy !== "shared"
|
|
) {
|
|
const delegated = await db
|
|
.select({ grant: connectionGrants })
|
|
.from(connectionGrantDelegations)
|
|
.innerJoin(
|
|
connectionGrants,
|
|
and(
|
|
eq(connectionGrants.id, connectionGrantDelegations.grantId),
|
|
eq(
|
|
connectionGrants.companyId,
|
|
connectionGrantDelegations.companyId,
|
|
),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantDelegations.companyId, connection.companyId),
|
|
eq(connectionGrantDelegations.agentId, session.agentId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
);
|
|
if (delegated.length > 1) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"More than one delegated personal authorization matches this autonomous run",
|
|
"ambiguous_personal_grant",
|
|
{
|
|
connectionId: connection.id,
|
|
agentId: session.agentId,
|
|
},
|
|
);
|
|
}
|
|
userGrant = delegated[0]?.grant;
|
|
if (userGrant?.subjectUserId) {
|
|
const [membership] = await db
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userGrant.subjectUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!membership) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"The delegated personal grant owner is not an active company member",
|
|
"grant_owner_membership_inactive",
|
|
{
|
|
connectionId: connection.id,
|
|
grantId: userGrant.id,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const resolution = userGrant
|
|
? "user"
|
|
: resolveCredentialGrantKind(
|
|
connection.credentialPolicy,
|
|
actingUserId,
|
|
false,
|
|
);
|
|
if (resolution === "user" && userGrant) return userGrant;
|
|
if (resolution === "user_authorization_required") {
|
|
if (actingUserId && allowInteraction)
|
|
await createUserAuthorizationInteraction(
|
|
session,
|
|
connection,
|
|
actingUserId,
|
|
);
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"User authorization is required",
|
|
"user_authorization_required",
|
|
{
|
|
connectionId: connection.id,
|
|
actingUserId,
|
|
},
|
|
);
|
|
}
|
|
return findOrganizationGrant();
|
|
}
|
|
|
|
async function resolveConnectedRemoteTool(
|
|
session: ToolGatewaySession,
|
|
tool: ToolGatewayDescriptor,
|
|
) {
|
|
if (
|
|
(tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") ||
|
|
!tool.connectionId ||
|
|
!tool.catalogEntryId
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${tool.name}" not found`,
|
|
"tool_not_found",
|
|
);
|
|
}
|
|
const [entry] = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.id, tool.catalogEntryId),
|
|
eq(toolCatalogEntries.companyId, session.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!entry || entry.status !== "active" || entry.entryKind !== "tool") {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${tool.name}" not found`,
|
|
"tool_not_found",
|
|
);
|
|
}
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, entry.connectionId),
|
|
eq(toolConnections.companyId, session.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection))) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${tool.name}" not found`,
|
|
"tool_not_found",
|
|
);
|
|
}
|
|
if (isRetiredComposioConnection(connection)) {
|
|
throw new ToolGatewayHttpError(422, RETIRED_COMPOSIO_MESSAGE, "composio_broker_retired");
|
|
}
|
|
if (!connection.enabled || connection.status !== "active") {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Connection is disabled.",
|
|
"mcp_remote_connection_disabled",
|
|
{
|
|
connectionId: connection.id,
|
|
},
|
|
);
|
|
}
|
|
if (isRailwayEndpoint(connection.config.url) && isRailwayToolBlocked(entry.toolName)) {
|
|
throw new ToolGatewayHttpError(403, "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", "railway_action_blocked");
|
|
}
|
|
return { entry, connection };
|
|
}
|
|
|
|
async function governedToolArguments(
|
|
session: ToolGatewaySession,
|
|
tool: ToolGatewayDescriptor,
|
|
parameters: unknown,
|
|
): Promise<unknown> {
|
|
if (tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") return parameters;
|
|
const { connection, entry } = await resolveConnectedRemoteTool(session, tool);
|
|
return projectedConnectionToolArguments(connection, parameters, entry.toolName, entry.inputSchema ?? {});
|
|
}
|
|
|
|
async function approvedManagedArgumentsRemainCurrent(
|
|
session: ToolGatewaySession,
|
|
tool: ToolGatewayDescriptor,
|
|
reviewedParameters: unknown,
|
|
): Promise<boolean> {
|
|
const currentParameters = await governedToolArguments(
|
|
session,
|
|
tool,
|
|
reviewedParameters,
|
|
);
|
|
return (
|
|
stableSerialize(currentParameters) === stableSerialize(reviewedParameters)
|
|
);
|
|
}
|
|
|
|
async function resolveConnectedLocalStdioTool(
|
|
session: ToolGatewaySession,
|
|
tool: ToolGatewayDescriptor,
|
|
) {
|
|
if (
|
|
tool.providerType !== "mcp_local_stdio" ||
|
|
!tool.connectionId ||
|
|
!tool.catalogEntryId
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${tool.name}" not found`,
|
|
"tool_not_found",
|
|
);
|
|
}
|
|
const [entry] = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.id, tool.catalogEntryId),
|
|
eq(toolCatalogEntries.companyId, session.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!entry || entry.status !== "active" || entry.entryKind !== "tool") {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${tool.name}" not found`,
|
|
"tool_not_found",
|
|
);
|
|
}
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, entry.connectionId),
|
|
eq(toolConnections.companyId, session.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!connection || connection.transport !== "local_stdio") {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${tool.name}" not found`,
|
|
"tool_not_found",
|
|
);
|
|
}
|
|
if (isRetiredComposioConnection(connection)) {
|
|
throw new ToolGatewayHttpError(422, RETIRED_COMPOSIO_MESSAGE, "composio_broker_retired");
|
|
}
|
|
if (!connection.enabled || connection.status !== "active") {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Connection is disabled.",
|
|
"local_stdio_connection_disabled",
|
|
{
|
|
connectionId: connection.id,
|
|
},
|
|
);
|
|
}
|
|
return { entry, connection };
|
|
}
|
|
|
|
function localStdioTemplateId(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): string {
|
|
const config = asRecord(connection.config) ?? {};
|
|
const templateId = config.templateId;
|
|
if (typeof templateId !== "string" || templateId.trim().length === 0) {
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"Local stdio MCP connection requires an approved templateId",
|
|
"local_stdio_template_missing",
|
|
{
|
|
connectionId: connection.id,
|
|
},
|
|
);
|
|
}
|
|
return templateId.trim();
|
|
}
|
|
|
|
async function resolveLocalStdioRuntimeTemplate(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): Promise<LocalStdioRuntimeTemplate> {
|
|
const templateId = localStdioTemplateId(connection);
|
|
const builtIn = BUILTIN_LOCAL_STDIO_RUNTIME_TEMPLATES[templateId];
|
|
if (builtIn) return { templateId, ...builtIn };
|
|
const [template] = await db
|
|
.select()
|
|
.from(toolStdioCommandTemplates)
|
|
.where(
|
|
and(
|
|
eq(toolStdioCommandTemplates.companyId, connection.companyId),
|
|
eq(toolStdioCommandTemplates.templateKey, templateId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!template || template.status !== "active") {
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"Local stdio MCP connection requires an active approved template",
|
|
"local_stdio_template_invalid",
|
|
{
|
|
connectionId: connection.id,
|
|
templateId,
|
|
},
|
|
);
|
|
}
|
|
return {
|
|
templateId,
|
|
command: template.command,
|
|
args: template.args ?? [],
|
|
envKeys: template.envKeys ?? [],
|
|
};
|
|
}
|
|
|
|
async function localStdioEnvironment(
|
|
session: ToolGatewaySession,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
template: LocalStdioRuntimeTemplate,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
): Promise<NodeJS.ProcessEnv> {
|
|
const env: NodeJS.ProcessEnv = {};
|
|
for (const key of [
|
|
"PATH",
|
|
"Path",
|
|
"SystemRoot",
|
|
"WINDIR",
|
|
"COMSPEC",
|
|
"PATHEXT",
|
|
]) {
|
|
const value = process.env[key];
|
|
if (typeof value === "string" && value.length > 0) {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
for (const key of template.envKeys) {
|
|
const grantRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === `env.${key}`,
|
|
);
|
|
if (!grantRef) continue;
|
|
try {
|
|
env[key] = await resolveGrantSecretValue(
|
|
session,
|
|
connection,
|
|
grant,
|
|
grantRef,
|
|
);
|
|
} catch {
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
"missing_secret",
|
|
"A configured local stdio credential could not be resolved.",
|
|
);
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"A configured local stdio credential could not be resolved.",
|
|
"local_stdio_missing_secret",
|
|
{ connectionId: connection.id, credential: grantRef.configPath },
|
|
);
|
|
}
|
|
}
|
|
if (template.templateId === "paperclip.cognee-cloud") {
|
|
try { cogneeCloudUrl(env.COGNEE_BASE_URL ?? ""); }
|
|
catch {
|
|
throw new ToolGatewayHttpError(422, "Reconnect Cognee with the tenant API Base URL from its API Keys page.", "cognee_cloud_url_invalid");
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function stdioProtocolError(
|
|
message: string,
|
|
details: Record<string, unknown> = {},
|
|
) {
|
|
return new ToolGatewayHttpError(
|
|
502,
|
|
message,
|
|
"local_stdio_protocol_error",
|
|
details,
|
|
);
|
|
}
|
|
|
|
async function callLocalStdioMcp(input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
entry?: typeof toolCatalogEntries.$inferSelect;
|
|
template: LocalStdioRuntimeTemplate;
|
|
env: NodeJS.ProcessEnv;
|
|
parameters?: unknown;
|
|
protocolMethod?: string;
|
|
protocolParams?: Record<string, unknown>;
|
|
timeoutMs: number;
|
|
}): Promise<unknown> {
|
|
if (input.template.templateId === "paperclip.cognee-cloud") {
|
|
if (input.protocolMethod === "resources/list") return { resources: [] };
|
|
if (input.protocolMethod === "prompts/list") return { prompts: [] };
|
|
if (input.protocolMethod && input.protocolMethod !== "tools/call") {
|
|
throw stdioProtocolError("Cognee does not expose this context operation");
|
|
}
|
|
return callCogneeCloud({
|
|
baseUrl: input.env.COGNEE_BASE_URL ?? "", apiKey: input.env.COGNEE_API_KEY ?? "",
|
|
tool: input.entry?.toolName ?? "", parameters: asRecord(input.parameters) ?? {},
|
|
signal: AbortSignal.timeout(input.timeoutMs),
|
|
request: async (url, init) => {
|
|
const response = await guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions());
|
|
const body = await readBoundedRemoteResponse(response);
|
|
if (!response.ok) throw new ToolGatewayHttpError(502,
|
|
`Cognee Cloud request failed (HTTP ${response.status}).`, "cognee_api_error");
|
|
if (!body) return { status: "success" };
|
|
try { return JSON.parse(body); }
|
|
catch { throw new ToolGatewayHttpError(502, "Cognee Cloud returned invalid JSON.", "cognee_api_error"); }
|
|
},
|
|
});
|
|
}
|
|
if (!input.template.command) {
|
|
throw new ToolGatewayHttpError(
|
|
501,
|
|
"Local stdio template does not define an executable command",
|
|
"local_stdio_command_unavailable",
|
|
{
|
|
connectionId: input.connection.id,
|
|
templateId: input.template.templateId,
|
|
},
|
|
);
|
|
}
|
|
const child = spawn(input.template.command, input.template.args, {
|
|
env: input.env,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let nextId = 1;
|
|
const pending = new Map<
|
|
number,
|
|
{
|
|
resolve: (value: unknown) => void;
|
|
reject: (error: Error) => void;
|
|
}
|
|
>();
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGTERM");
|
|
for (const { reject } of pending.values()) {
|
|
reject(
|
|
new ToolGatewayHttpError(
|
|
504,
|
|
"Local stdio MCP tool call timed out",
|
|
"tool_timeout",
|
|
{
|
|
connectionId: input.connection.id,
|
|
catalogEntryId: input.entry?.id ?? null,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
pending.clear();
|
|
}, input.timeoutMs);
|
|
timer.unref?.();
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdout += chunk;
|
|
let newline = stdout.indexOf("\n");
|
|
while (newline >= 0) {
|
|
const line = stdout.slice(0, newline).trim();
|
|
stdout = stdout.slice(newline + 1);
|
|
if (line) {
|
|
try {
|
|
const message = JSON.parse(line) as Record<string, unknown>;
|
|
const id = typeof message.id === "number" ? message.id : null;
|
|
if (id !== null && pending.has(id)) {
|
|
const waiter = pending.get(id)!;
|
|
pending.delete(id);
|
|
if (message.error !== undefined) {
|
|
waiter.reject(
|
|
stdioProtocolError(
|
|
"Local stdio MCP server returned a JSON-RPC error",
|
|
{
|
|
connectionId: input.connection.id,
|
|
catalogEntryId: input.entry?.id ?? null,
|
|
error: message.error,
|
|
},
|
|
),
|
|
);
|
|
} else {
|
|
waiter.resolve(message.result);
|
|
}
|
|
}
|
|
} catch {
|
|
for (const { reject } of pending.values()) {
|
|
reject(
|
|
stdioProtocolError(
|
|
"Local stdio MCP server returned invalid JSON",
|
|
{
|
|
connectionId: input.connection.id,
|
|
catalogEntryId: input.entry?.id ?? null,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
pending.clear();
|
|
}
|
|
}
|
|
newline = stdout.indexOf("\n");
|
|
}
|
|
});
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr = `${stderr}${chunk}`.slice(-4_000);
|
|
});
|
|
const exitPromise = new Promise<void>((resolve, reject) => {
|
|
child.on("error", (error) => {
|
|
const gatewayError = new ToolGatewayHttpError(
|
|
502,
|
|
"Local stdio MCP command failed to start",
|
|
"local_stdio_spawn_failed",
|
|
{
|
|
connectionId: input.connection.id,
|
|
templateId: input.template.templateId,
|
|
message: error.message,
|
|
},
|
|
);
|
|
for (const { reject: rejectPending } of pending.values()) {
|
|
rejectPending(gatewayError);
|
|
}
|
|
pending.clear();
|
|
reject(gatewayError);
|
|
});
|
|
child.on("exit", (code, signal) => {
|
|
if (pending.size === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
for (const { reject: rejectPending } of pending.values()) {
|
|
rejectPending(
|
|
new ToolGatewayHttpError(
|
|
502,
|
|
"Local stdio MCP command exited before responding",
|
|
"local_stdio_process_exited",
|
|
{
|
|
connectionId: input.connection.id,
|
|
catalogEntryId: input.entry?.id ?? null,
|
|
code,
|
|
signal,
|
|
stderr,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
pending.clear();
|
|
resolve();
|
|
});
|
|
});
|
|
const request = (method: string, params: Record<string, unknown>) => {
|
|
const id = nextId;
|
|
nextId += 1;
|
|
const promise = new Promise<unknown>((resolve, reject) => {
|
|
pending.set(id, { resolve, reject });
|
|
});
|
|
child.stdin.write(
|
|
`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`,
|
|
);
|
|
return promise;
|
|
};
|
|
try {
|
|
await request("initialize", {
|
|
protocolVersion: "2024-11-05",
|
|
capabilities: {},
|
|
clientInfo: { name: "paperclip-tool-gateway", version: "0.3.1" },
|
|
});
|
|
child.stdin.write(
|
|
`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`,
|
|
);
|
|
return await request(
|
|
input.protocolMethod ?? "tools/call",
|
|
input.protocolParams ?? {
|
|
name: input.entry?.toolName,
|
|
arguments: input.parameters ?? {},
|
|
},
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
child.stdin.end();
|
|
child.kill("SIGTERM");
|
|
await exitPromise.catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
async function fullyAssignedMcpConnections(session: ToolGatewaySession) {
|
|
if (!session.gatewayProfileId) return [];
|
|
return db
|
|
.select({ connection: toolConnections })
|
|
.from(toolProfileEntries)
|
|
.innerJoin(
|
|
toolConnections,
|
|
eq(toolProfileEntries.connectionId, toolConnections.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, session.companyId),
|
|
eq(toolProfileEntries.profileId, session.gatewayProfileId),
|
|
eq(toolProfileEntries.selectorType, "connection"),
|
|
eq(toolProfileEntries.effect, "include"),
|
|
eq(toolConnections.companyId, session.companyId),
|
|
eq(toolConnections.enabled, true),
|
|
eq(toolConnections.status, "active"),
|
|
inArray(toolConnections.transport, ["mcp_remote", "local_stdio"]),
|
|
),
|
|
)
|
|
.then((rows) => rows.map((row) => row.connection));
|
|
}
|
|
|
|
async function callRemoteConnectionProtocol(input: {
|
|
session: ToolGatewaySession;
|
|
connection: typeof toolConnections.$inferSelect;
|
|
method: string;
|
|
params: Record<string, unknown>;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
}): Promise<unknown> {
|
|
const grant = await resolveConnectionGrant(input.session, input.connection);
|
|
const endpoint = await resolvedRemoteEndpoint(
|
|
input.session,
|
|
input.connection,
|
|
grant,
|
|
);
|
|
const credentialHeaders = {
|
|
...projectedConnectionHeaders(input.connection),
|
|
...(await resolveCredentialHeaders(
|
|
input.session,
|
|
input.connection,
|
|
grant,
|
|
)),
|
|
};
|
|
const { headers } = buildRemoteHeaders({
|
|
session: input.session,
|
|
connection: input.connection,
|
|
credentialHeaders,
|
|
callerHeaders: input.callerHeaders,
|
|
});
|
|
const response = await guardedRemoteHttpFetch(
|
|
endpoint,
|
|
{
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: mcpHttpRequestHeaders(headers),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: `paperclip-context-${randomUUID()}`,
|
|
method: input.method,
|
|
params: input.params,
|
|
}),
|
|
},
|
|
remoteHttpFetchOptions(),
|
|
);
|
|
const body = await readBoundedRemoteResponse(response);
|
|
if (!response.ok) {
|
|
await markRemoteConnectionHealth(
|
|
input.connection,
|
|
"error",
|
|
`Remote MCP server failed ${input.method}.`,
|
|
);
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
"Remote MCP context request failed",
|
|
"mcp_remote_status",
|
|
{
|
|
status: response.status,
|
|
connectionId: input.connection.id,
|
|
method: input.method,
|
|
},
|
|
);
|
|
}
|
|
let payload: unknown;
|
|
try {
|
|
payload = parseMcpHttpResponseBody(
|
|
body,
|
|
response.headers.get("content-type"),
|
|
);
|
|
} catch {
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
"Remote MCP context response was invalid",
|
|
"mcp_remote_invalid_json",
|
|
{
|
|
connectionId: input.connection.id,
|
|
method: input.method,
|
|
},
|
|
);
|
|
}
|
|
const record = asRecord(payload);
|
|
if (
|
|
!record ||
|
|
record.error !== undefined ||
|
|
!Object.prototype.hasOwnProperty.call(record, "result")
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
"Remote MCP context request returned an error",
|
|
"remote_mcp_error",
|
|
{
|
|
connectionId: input.connection.id,
|
|
method: input.method,
|
|
},
|
|
);
|
|
}
|
|
await markRemoteConnectionHealth(
|
|
input.connection,
|
|
"ok",
|
|
`Remote MCP server responded to ${input.method}.`,
|
|
);
|
|
return record.result;
|
|
}
|
|
|
|
async function callAssignedConnectionProtocol(input: {
|
|
session: ToolGatewaySession;
|
|
connection: typeof toolConnections.$inferSelect;
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
}): Promise<unknown> {
|
|
if (input.connection.transport === "mcp_remote") {
|
|
return callRemoteConnectionProtocol({
|
|
...input,
|
|
params: input.params ?? {},
|
|
});
|
|
}
|
|
if (input.connection.transport !== "local_stdio") {
|
|
throw new ToolGatewayHttpError(
|
|
501,
|
|
"Assigned MCP connection transport is unsupported",
|
|
"mcp_transport_unsupported",
|
|
);
|
|
}
|
|
const template = await resolveLocalStdioRuntimeTemplate(input.connection);
|
|
const grant = await resolveConnectionGrant(input.session, input.connection);
|
|
const env = await localStdioEnvironment(
|
|
input.session,
|
|
input.connection,
|
|
template,
|
|
grant,
|
|
);
|
|
if (template.templateId === "paperclip.cognee-cloud") {
|
|
return callLocalStdioMcp({ connection: input.connection, template, env,
|
|
protocolMethod: input.method, protocolParams: input.params ?? {}, timeoutMs: DEFAULT_TOOL_TIMEOUT_MS });
|
|
}
|
|
return runtimeSupervisor.useConnectionSlot(
|
|
{
|
|
companyId: input.session.companyId,
|
|
applicationId: input.connection.applicationId,
|
|
connectionId: input.connection.id,
|
|
connectionKey: `mcp:${input.session.companyId}:${input.connection.id}`,
|
|
runId: input.session.runId,
|
|
issueId: input.session.issueId,
|
|
agentId: input.session.agentId,
|
|
commandTemplateKey: template.templateId,
|
|
metadata: {
|
|
source: "native-runtime-context",
|
|
protocolMethod: input.method,
|
|
},
|
|
},
|
|
async () =>
|
|
callLocalStdioMcp({
|
|
connection: input.connection,
|
|
template,
|
|
env,
|
|
protocolMethod: input.method,
|
|
protocolParams: input.params ?? {},
|
|
timeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function contextHandle(
|
|
kind: "resource" | "prompt",
|
|
connectionId: string,
|
|
value: string,
|
|
) {
|
|
return `paperclip-${kind}://${connectionId}/${Buffer.from(value, "utf8").toString("base64url")}`;
|
|
}
|
|
|
|
function parseContextHandle(kind: "resource" | "prompt", value: unknown) {
|
|
if (typeof value !== "string") return null;
|
|
const match = value.match(
|
|
new RegExp(`^paperclip-${kind}://([0-9a-f-]{36})/([A-Za-z0-9_-]+)$`, "i"),
|
|
);
|
|
if (!match) return null;
|
|
try {
|
|
return {
|
|
connectionId: match[1]!,
|
|
value: Buffer.from(match[2]!, "base64url").toString("utf8"),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function namedGatewayContextProtocol(input: {
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
bearerToken: string;
|
|
method:
|
|
"resources/list" | "resources/read" | "prompts/list" | "prompts/get";
|
|
params?: Record<string, unknown>;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
}) {
|
|
const session = await namedGatewaySessionFromBearer({
|
|
gatewayId: input.gatewayId ?? null,
|
|
gatewayPublicId: input.gatewayPublicId ?? null,
|
|
bearerToken: input.bearerToken,
|
|
protocolMethod: input.method,
|
|
callerHeaders: input.callerHeaders,
|
|
});
|
|
await assertGatewayTokenAction(session, input.method);
|
|
const connections = await fullyAssignedMcpConnections(session);
|
|
if (input.method === "resources/list") {
|
|
const resources = [] as Array<Record<string, unknown>>;
|
|
for (const connection of connections) {
|
|
const result = asRecord(
|
|
await callAssignedConnectionProtocol({
|
|
...input,
|
|
session,
|
|
connection,
|
|
method: input.method,
|
|
}),
|
|
);
|
|
for (const resource of Array.isArray(result?.resources)
|
|
? result.resources
|
|
: []) {
|
|
const record = asRecord(resource);
|
|
if (!record || typeof record.uri !== "string") continue;
|
|
resources.push({
|
|
...record,
|
|
uri: contextHandle("resource", connection.id, record.uri),
|
|
name: `${connection.name}: ${typeof record.name === "string" ? record.name : record.uri}`,
|
|
});
|
|
}
|
|
}
|
|
return { resources };
|
|
}
|
|
if (input.method === "prompts/list") {
|
|
const prompts = [] as Array<Record<string, unknown>>;
|
|
for (const connection of connections) {
|
|
const result = asRecord(
|
|
await callAssignedConnectionProtocol({
|
|
...input,
|
|
session,
|
|
connection,
|
|
method: input.method,
|
|
}),
|
|
);
|
|
for (const prompt of Array.isArray(result?.prompts)
|
|
? result.prompts
|
|
: []) {
|
|
const record = asRecord(prompt);
|
|
if (!record || typeof record.name !== "string") continue;
|
|
prompts.push({
|
|
...record,
|
|
name: contextHandle("prompt", connection.id, record.name),
|
|
title: `${connection.name}: ${typeof record.title === "string" ? record.title : record.name}`,
|
|
});
|
|
}
|
|
}
|
|
return { prompts };
|
|
}
|
|
const kind = input.method === "resources/read" ? "resource" : "prompt";
|
|
const handle = parseContextHandle(
|
|
kind,
|
|
input.params?.[kind === "resource" ? "uri" : "name"],
|
|
);
|
|
const connection = handle
|
|
? connections.find((candidate) => candidate.id === handle.connectionId)
|
|
: null;
|
|
if (!handle || !connection) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Assigned MCP ${kind} was not found`,
|
|
`mcp_${kind}_not_found`,
|
|
);
|
|
}
|
|
const params =
|
|
kind === "resource"
|
|
? { uri: handle.value }
|
|
: { name: handle.value, arguments: input.params?.arguments ?? {} };
|
|
const result = asRecord(
|
|
await callAssignedConnectionProtocol({
|
|
...input,
|
|
session,
|
|
connection,
|
|
method: input.method,
|
|
params,
|
|
}),
|
|
);
|
|
if (kind === "resource" && Array.isArray(result?.contents)) {
|
|
return {
|
|
...result,
|
|
contents: result.contents.map((content) => {
|
|
const record = asRecord(content);
|
|
return record && typeof record.uri === "string"
|
|
? {
|
|
...record,
|
|
uri: contextHandle("resource", connection.id, record.uri),
|
|
}
|
|
: content;
|
|
}),
|
|
};
|
|
}
|
|
return result ?? {};
|
|
}
|
|
|
|
async function connectedRemoteApprovalSnapshot(
|
|
session: ToolGatewaySession,
|
|
tool: ToolGatewayDescriptor,
|
|
options: { requireResolvedCredentials?: boolean } = {},
|
|
): Promise<Record<string, unknown> | null> {
|
|
if (tool.providerType === "paperclip_slack_chat") {
|
|
if (!session.agentId || !session.runId || !session.issueId) throw new ToolGatewayHttpError(403, "Slack task binding required", "slack_task_required");
|
|
const authority = await resolveSlackTaskAuthority(db, { companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId, endpointId: String(asRecord(tool.providerMetadata)?.endpointId ?? ""), identityContextId: session.identityContextId, approvedInvocationId: session.approvedSlackInvocationId });
|
|
return { endpointId: authority.endpoint.id, userId: authority.userId, revision: authority.revision, identityContextId: authority.identityContextId };
|
|
}
|
|
if (
|
|
(tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") ||
|
|
!tool.connectionId ||
|
|
!tool.catalogEntryId
|
|
) {
|
|
return null;
|
|
}
|
|
const [row] = await db
|
|
.select({
|
|
entry: toolCatalogEntries,
|
|
connection: toolConnections,
|
|
application: toolApplications,
|
|
})
|
|
.from(toolCatalogEntries)
|
|
.innerJoin(
|
|
toolConnections,
|
|
eq(toolCatalogEntries.connectionId, toolConnections.id),
|
|
)
|
|
.innerJoin(
|
|
toolApplications,
|
|
eq(toolConnections.applicationId, toolApplications.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.id, tool.catalogEntryId),
|
|
eq(toolCatalogEntries.companyId, session.companyId),
|
|
eq(toolConnections.id, tool.connectionId),
|
|
eq(toolConnections.companyId, session.companyId),
|
|
eq(toolApplications.companyId, session.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!row) return null;
|
|
const grant = await resolveConnectionGrant(session, row.connection);
|
|
const credentialVersions = await connectedCredentialVersionSnapshots(
|
|
row.connection,
|
|
grant,
|
|
{
|
|
requireResolved: options.requireResolvedCredentials === true,
|
|
},
|
|
);
|
|
return {
|
|
applicationId: row.application.id,
|
|
applicationKey: row.application.applicationKey ?? null,
|
|
applicationStatus: row.application.status,
|
|
applicationType: row.application.type,
|
|
connectionId: row.connection.id,
|
|
connectionStatus: row.connection.status,
|
|
connectionEnabled: row.connection.enabled,
|
|
connectionTransport: row.connection.transport,
|
|
connectionConfigHash: stableHash(row.connection.config ?? {}),
|
|
connectionTransportConfigHash: stableHash(
|
|
row.connection.transportConfig ?? {},
|
|
),
|
|
credentialRefsHash: stableHash(row.connection.credentialRefs ?? []),
|
|
credentialSecretRefsHash: stableHash(
|
|
row.connection.credentialSecretRefs ?? [],
|
|
),
|
|
...(isBrowserUseConnection(row.connection) ? { browserSettingsHash: stableHash(await browserUseService(db).getSettings(session.companyId, grant.id)) } : {}),
|
|
credentialGrantId: grant.id,
|
|
credentialGrantRefsHash: stableHash(grant.credentialSecretRefs ?? []),
|
|
headerCredentialVersions: credentialVersions.headerCredentialVersions,
|
|
credentialSecretVersions: credentialVersions.credentialSecretVersions,
|
|
catalogEntryId: row.entry.id,
|
|
catalogStatus: row.entry.status,
|
|
catalogEntryKind: row.entry.entryKind,
|
|
catalogVersionHash: row.entry.versionHash,
|
|
catalogSchemaHash: row.entry.schemaHash ?? null,
|
|
upstreamToolName: row.entry.toolName,
|
|
providerType: tool.providerType,
|
|
gatewayToolName: tool.name,
|
|
riskLevel: tool.risk,
|
|
};
|
|
}
|
|
|
|
function responseTooLargeError() {
|
|
return new ToolGatewayHttpError(
|
|
502,
|
|
"Remote MCP response exceeded the gateway size limit. Request a smaller result, for example a narrower query or a smaller page size.",
|
|
"mcp_remote_response_too_large",
|
|
{ maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES },
|
|
);
|
|
}
|
|
|
|
async function readBoundedRemoteResponse(
|
|
response: Response,
|
|
): Promise<string> {
|
|
const contentLength = response.headers.get("content-length");
|
|
if (
|
|
contentLength &&
|
|
Number(contentLength) > MAX_REMOTE_MCP_RESPONSE_BYTES
|
|
) {
|
|
throw responseTooLargeError();
|
|
}
|
|
const body = await response.text();
|
|
if (Buffer.byteLength(body, "utf8") > MAX_REMOTE_MCP_RESPONSE_BYTES) {
|
|
throw responseTooLargeError();
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function malformedRemoteMcpResponse(): ToolGatewayHttpError {
|
|
return new ToolGatewayHttpError(
|
|
502,
|
|
"Remote MCP server returned a malformed tools/call response",
|
|
"remote_mcp_malformed_response",
|
|
);
|
|
}
|
|
|
|
type McpElicitationRequest = {
|
|
message: string;
|
|
requestedSchema: Record<string, unknown> | null;
|
|
raw: Record<string, unknown>;
|
|
};
|
|
|
|
function extractMcpElicitationRequest(
|
|
value: unknown,
|
|
): McpElicitationRequest | null {
|
|
const record = asRecord(value);
|
|
if (!record) return null;
|
|
const meta = asRecord(record._meta);
|
|
const candidate =
|
|
(record.method === "elicitation/create"
|
|
? asRecord(record.params)
|
|
: null) ??
|
|
asRecord(record.elicitation) ??
|
|
asRecord(record.elicitationRequest) ??
|
|
asRecord(meta?.elicitation) ??
|
|
asRecord(meta?.elicitationRequest);
|
|
if (!candidate) return null;
|
|
const message =
|
|
stringValue(candidate.message) ??
|
|
stringValue(candidate.prompt) ??
|
|
stringValue(candidate.title) ??
|
|
"The MCP tool needs more information before it can continue.";
|
|
const requestedSchema = asRecord(
|
|
candidate.requestedSchema ?? candidate.schema ?? candidate.inputSchema,
|
|
);
|
|
return { message, requestedSchema, raw: candidate };
|
|
}
|
|
|
|
function enumOptions(
|
|
values: unknown[],
|
|
): Array<{ id: string; label: string }> {
|
|
return values.slice(0, 10).map((value, index) => {
|
|
const label =
|
|
typeof value === "string" ||
|
|
typeof value === "number" ||
|
|
typeof value === "boolean"
|
|
? String(value)
|
|
: `Option ${index + 1}`;
|
|
return {
|
|
id: slugSegment(label, `option-${index + 1}`).slice(0, 120),
|
|
label: label.slice(0, 120),
|
|
};
|
|
});
|
|
}
|
|
|
|
function elicitationQuestions(request: McpElicitationRequest) {
|
|
const schema = request.requestedSchema;
|
|
const properties = asRecord(schema?.properties);
|
|
const required = Array.isArray(schema?.required)
|
|
? new Set(
|
|
schema.required.filter(
|
|
(item): item is string => typeof item === "string",
|
|
),
|
|
)
|
|
: new Set<string>();
|
|
const questions: Array<{
|
|
id: string;
|
|
prompt: string;
|
|
helpText?: string | null;
|
|
selectionMode: "single" | "multi";
|
|
required?: boolean;
|
|
options: Array<{
|
|
id: string;
|
|
label: string;
|
|
description?: string | null;
|
|
}>;
|
|
}> = [];
|
|
if (properties) {
|
|
for (const [key, rawProperty] of Object.entries(properties)) {
|
|
if (questions.length >= 10) break;
|
|
const property = asRecord(rawProperty) ?? {};
|
|
const enumValues = Array.isArray(property.enum) ? property.enum : [];
|
|
const options =
|
|
enumValues.length > 0
|
|
? enumOptions(enumValues)
|
|
: [{ id: "answer", label: "Provide answer" }];
|
|
questions.push({
|
|
id: key.slice(0, 120),
|
|
prompt: (
|
|
stringValue(property.title) ??
|
|
stringValue(property.description) ??
|
|
key
|
|
).slice(0, 500),
|
|
helpText:
|
|
enumValues.length > 0
|
|
? null
|
|
: "Use Other to enter the requested value.",
|
|
selectionMode: "single",
|
|
required: required.has(key),
|
|
options,
|
|
});
|
|
}
|
|
}
|
|
if (questions.length > 0) return questions;
|
|
return [
|
|
{
|
|
id: "response",
|
|
prompt: request.message.slice(0, 500),
|
|
helpText: "Use Other to enter the requested response.",
|
|
selectionMode: "single" as const,
|
|
required: true,
|
|
options: [{ id: "answer", label: "Provide response" }],
|
|
},
|
|
];
|
|
}
|
|
|
|
async function requestElicitationForRecordedToolCall(input: {
|
|
session: ToolGatewaySession;
|
|
tool: ToolGatewayDescriptor;
|
|
invocationId: string;
|
|
request: McpElicitationRequest;
|
|
}): Promise<never> {
|
|
if (!input.session.issueId) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"MCP elicitation is not supported for non-interactive gateway clients",
|
|
"elicitation_not_supported",
|
|
{ invocationId: input.invocationId, tool: input.tool.name },
|
|
);
|
|
}
|
|
const interaction = await interactions.create(
|
|
{ id: input.session.issueId, companyId: input.session.companyId },
|
|
{
|
|
kind: "ask_user_questions",
|
|
idempotencyKey: `mcp-elicitation:${input.invocationId}`,
|
|
title: "Tool needs input",
|
|
summary: `${input.tool.name} asked for more information before it can continue.`,
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
title: input.request.message.slice(0, 240),
|
|
submitLabel: "Send response",
|
|
questions: elicitationQuestions(input.request),
|
|
},
|
|
},
|
|
{ agentId: input.session.agentId },
|
|
);
|
|
const now = new Date();
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "awaiting_approval",
|
|
errorCode: "elicitation_required",
|
|
errorMessage:
|
|
"Remote MCP tool requested elicitation; Paperclip created an issue interaction for the response.",
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolInvocations.id, input.invocationId));
|
|
await writeToolCallEvent({
|
|
invocationId: input.invocationId,
|
|
session: input.session,
|
|
eventType: "call_failed",
|
|
outcome: "pending",
|
|
toolName: input.tool.name,
|
|
policyDecision: "defer_runtime",
|
|
reasonCode: "elicitation_required",
|
|
metadata: {
|
|
interactionId: interaction.id,
|
|
elicitation: {
|
|
message: input.request.message,
|
|
requestedSchema: input.request.requestedSchema,
|
|
},
|
|
},
|
|
tool: input.tool,
|
|
});
|
|
await writeAudit({
|
|
session: input.session,
|
|
companyId: input.session.companyId,
|
|
agentId: input.session.agentId,
|
|
runId: input.session.runId,
|
|
issueId: input.session.issueId,
|
|
action: "tool_gateway.elicitation_requested",
|
|
details: {
|
|
invocationId: input.invocationId,
|
|
interactionId: interaction.id,
|
|
decision: "defer_runtime",
|
|
reasonCode: "elicitation_required",
|
|
tool: input.tool.name,
|
|
...toolAuditMetadata(input.tool),
|
|
},
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"MCP tool requested additional input",
|
|
"elicitation_required",
|
|
{
|
|
invocationId: input.invocationId,
|
|
interactionId: interaction.id,
|
|
tool: input.tool.name,
|
|
},
|
|
);
|
|
}
|
|
|
|
function normalizeMcpContent(content: unknown): string {
|
|
if (!Array.isArray(content)) throw malformedRemoteMcpResponse();
|
|
return content
|
|
.map((item) => {
|
|
const record = asRecord(item);
|
|
if (!record || typeof record.type !== "string")
|
|
throw malformedRemoteMcpResponse();
|
|
if (record.type === "text") {
|
|
if (typeof record.text !== "string")
|
|
throw malformedRemoteMcpResponse();
|
|
return record.text;
|
|
}
|
|
return JSON.stringify(record);
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
function normalizeMcpToolResult(
|
|
result: unknown,
|
|
transport: "mcp_http" | "local_stdio" | "cognee_cloud" = "mcp_http",
|
|
spawnedLocalProcess = false,
|
|
sourceTemplateKey?: string | null,
|
|
) {
|
|
const record = asRecord(result);
|
|
if (!record) throw malformedRemoteMcpResponse();
|
|
const providerContent = normalizeMcpContent(record.content);
|
|
const googleWorkspacePermissionDenied =
|
|
record.isError === true &&
|
|
(sourceTemplateKey === "gmail" ||
|
|
sourceTemplateKey?.startsWith("google-") === true) &&
|
|
/caller does not have permission/i.test(providerContent);
|
|
const content = googleWorkspacePermissionDenied
|
|
? "Google rejected this call. Google Workspace MCP is a Developer Preview: enroll the signed-in Workspace account and this OAuth client's Google Cloud project in Google's Developer Preview Program, wait for Google's registration confirmation, then reconnect and try again."
|
|
: providerContent;
|
|
return {
|
|
content,
|
|
data: {
|
|
content: record.content,
|
|
structuredContent: record.structuredContent ?? null,
|
|
isError: record.isError === true,
|
|
transport,
|
|
spawnedLocalProcess,
|
|
},
|
|
...(record.isError === true
|
|
? { error: "MCP tool returned an error result" }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async function executeRemoteHttpTool(
|
|
session: ToolGatewaySession,
|
|
tool: ToolGatewayDescriptor,
|
|
parameters: unknown,
|
|
ms: number,
|
|
invocationId: string,
|
|
callerHeaders?: ExecuteGatewayToolInput["callerHeaders"],
|
|
useDefaultTimeout = false,
|
|
): Promise<RemoteHttpExecutionResult> {
|
|
const { entry, connection } = await resolveConnectedRemoteTool(
|
|
session,
|
|
tool,
|
|
);
|
|
// Recheck immediately before dispatch, including previously approved calls
|
|
// and connections whose stored grant/catalog predates scope reduction.
|
|
assertGoogleChatToolArgumentsSupported(connection, entry.toolName, parameters);
|
|
if (useDefaultTimeout && isRailwayConnection(connection) && entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`) {
|
|
ms = railwayCommandBudgetMs(parameters);
|
|
}
|
|
const grant = await resolveConnectionGrant(session, connection);
|
|
if (isBrowserUseConnection(connection)) {
|
|
const data = await browserUseService(db, options.remoteHttpRequest).execute(session, grant, invocationId!, entry.toolName, parameters);
|
|
return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } };
|
|
}
|
|
const endpoint = await resolvedRemoteEndpoint(session, connection, grant);
|
|
// Method-defined headers are trusted catalog configuration. Treat them as
|
|
// managed headers so callers cannot override the scope that was reviewed
|
|
// during tools/list. Credentials remain authoritative on collisions.
|
|
let credentialHeaders = {
|
|
...projectedConnectionHeaders(connection),
|
|
...(await resolveCredentialHeaders(session, connection, grant)),
|
|
};
|
|
let builtHeaders = buildRemoteHeaders({
|
|
session,
|
|
connection,
|
|
credentialHeaders,
|
|
callerHeaders,
|
|
});
|
|
let headers = builtHeaders.headers;
|
|
let headerSummary = builtHeaders.summary;
|
|
const requestId = `paperclip-tool-${randomUUID()}`;
|
|
const execution: RemoteHttpExecutionAudit = {
|
|
transport: "mcp_remote",
|
|
request: {
|
|
protocol: "MCP JSON-RPC 2.0",
|
|
httpMethod: "POST",
|
|
endpoint: auditSafeEndpoint(endpoint),
|
|
mcpMethod: "tools/call",
|
|
requestId,
|
|
upstreamToolName: entry.toolName,
|
|
dispatched: true,
|
|
},
|
|
};
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), ms);
|
|
timer.unref?.();
|
|
// The cached MCP session this call used, so a failure resets only this
|
|
// identity's session and leaves other agents on the connection alone.
|
|
let mcpSession: { scope: string; headers: Record<string, string>; sessionId: string } | undefined;
|
|
try {
|
|
const dispatchRemote = (target: string, init: RequestInit) =>
|
|
options.remoteHttpRequest
|
|
? options.remoteHttpRequest(target, init)
|
|
: guardedRemoteHttpFetch(target, init, {
|
|
...remoteHttpFetchOptions(),
|
|
// This call site owns a caller-set budget that can exceed the
|
|
// transport's default response deadline, so hand it down rather than
|
|
// letting the tighter default cut a legitimately slow tool short.
|
|
responseTimeoutMs: ms,
|
|
});
|
|
if (isRailwayEndpoint(connection.config.url) && normalizeRailwayToolName(entry.toolName).startsWith(RAILWAY_TOOL_PREFIX)) {
|
|
if (!isRailwayConnection(connection) || connection.config.railwayApiStatus !== "available") {
|
|
throw new ToolGatewayHttpError(422, "Railway API access is not verified. Refresh actions or reconnect this Railway connection.", "railway_api_not_verified");
|
|
}
|
|
const ssh = asRecord(connection.config.railwaySsh);
|
|
const sshRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH);
|
|
execution.request.endpoint = RAILWAY_API_URL;
|
|
execution.request.protocol = entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command` ? "Railway GraphQL + SSH" : "Railway GraphQL";
|
|
const client = createRailwayClient({
|
|
authorization: credentialHeaders.Authorization ?? "",
|
|
signal: controller.signal,
|
|
request: dispatchRemote,
|
|
runCommand: ssh?.grantId === grant.id && ssh?.enabled === true && sshRef
|
|
? async (input) => runRailwaySshCommand({
|
|
...input,
|
|
privateKey: await resolveGrantSecretValue(session, connection, grant, sshRef),
|
|
knownHosts: typeof ssh.knownHosts === "string" ? ssh.knownHosts : "",
|
|
})
|
|
: undefined,
|
|
});
|
|
const data = await client.call(entry.toolName, parameters);
|
|
const record = asRecord(data);
|
|
const failedCommand = entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command` && (record?.exitCode !== 0 || record?.timedOut === true || record?.truncated === true);
|
|
return {
|
|
result: normalizeMcpToolResult({ content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data, isError: failedCommand }, "mcp_http", entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`, "railway"),
|
|
headerSummary,
|
|
execution,
|
|
};
|
|
}
|
|
let requestHeaders = headers;
|
|
if (connection.config.mcpSessionRequired === true) {
|
|
const scope = `${connection.id}:grant:${grant.id}:actor:${session.agentId}:${endpoint}`;
|
|
requestHeaders = await getMcpHttpSession({
|
|
scope,
|
|
send: (init) =>
|
|
dispatchRemote(endpoint, {
|
|
...init,
|
|
redirect: "manual",
|
|
signal: controller.signal,
|
|
}),
|
|
headers,
|
|
requestId,
|
|
});
|
|
const sessionId = new Headers(requestHeaders).get("mcp-session-id");
|
|
if (sessionId) mcpSession = { scope, headers, sessionId };
|
|
}
|
|
// The guard runs inside this call and the connection is pinned to the
|
|
// address it approved, so an operator-supplied hostname cannot be rebound
|
|
// onto a loopback or metadata address between validation and dispatch
|
|
// (PAP-17098).
|
|
const requestInit: RequestInit = {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
// MCP Streamable HTTP requires the Accept header advertising both a JSON
|
|
// body and an SSE stream; spec-compliant servers 406 without it.
|
|
headers: mcpHttpRequestHeaders(requestHeaders),
|
|
signal: controller.signal,
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: requestId,
|
|
method: "tools/call",
|
|
params: {
|
|
name: entry.toolName,
|
|
arguments: parameters,
|
|
},
|
|
}),
|
|
};
|
|
let response = await dispatchRemote(endpoint, requestInit);
|
|
if (isInsufficientConnectionScope(response)) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
|
|
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id });
|
|
}
|
|
const oauth = asRecord(asRecord(connection.config)?.oauth);
|
|
if (
|
|
response.status === 401 &&
|
|
connection.authKind === "oauth" &&
|
|
connection.credentialSource === "paperclip_vault" &&
|
|
isPaperclipCloudConnectorStrategy(oauth?.strategy)
|
|
) {
|
|
credentialHeaders = {
|
|
...projectedConnectionHeaders(connection),
|
|
...(await resolveCredentialHeaders(session, connection, grant, {
|
|
forceRefresh: true,
|
|
})),
|
|
};
|
|
builtHeaders = buildRemoteHeaders({
|
|
session,
|
|
connection,
|
|
credentialHeaders,
|
|
callerHeaders,
|
|
});
|
|
headers = builtHeaders.headers;
|
|
headerSummary = builtHeaders.summary;
|
|
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 sessionExpired = response.status === 404 && new Headers(requestHeaders).has("mcp-session-id");
|
|
if (sessionExpired) {
|
|
// The next explicit call initializes again. Never replay a tools/call
|
|
// automatically: the failed call may have changed app data.
|
|
if (mcpSession) forgetMcpHttpSession(mcpSession);
|
|
}
|
|
const body = response.ok
|
|
? JSON.stringify(await readMcpHttpResponse(response, requestId, {
|
|
maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES,
|
|
onRequest: async (message) => {
|
|
const pending = extractRemoteMcpPending(message);
|
|
if (pending) {
|
|
await retainUpstreamHandoff(invocationId, pending);
|
|
// Defer this interaction to the user. Do not claim that consent
|
|
// was granted or repeat a potentially state-changing tool call.
|
|
await dispatchRemote(endpoint, {
|
|
method: "POST", headers: mcpHttpRequestHeaders(requestHeaders),
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { action: "cancel" } }),
|
|
});
|
|
throw new ToolGatewayHttpError(409, "This tool needs authorization in the provider.", "provider_interaction_required", { upstreamPending: pending, invocationId });
|
|
}
|
|
const request = extractMcpElicitationRequest(message);
|
|
if (request) await requestElicitationForRecordedToolCall({ session, tool, invocationId, request });
|
|
},
|
|
}))
|
|
: await readBoundedRemoteResponse(response);
|
|
execution.response = {
|
|
httpStatus: response.status,
|
|
contentType: response.headers.get("content-type"),
|
|
bodySizeBytes: Buffer.byteLength(body, "utf8"),
|
|
upstreamRequestId:
|
|
response.headers.get("x-request-id") ??
|
|
response.headers.get("x-zapier-request-id") ??
|
|
response.headers.get("traceparent"),
|
|
};
|
|
if (isInsufficientConnectionScope(response, body)) {
|
|
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
|
|
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", {
|
|
connectionId: connection.id, catalogEntryId: entry.id,
|
|
});
|
|
}
|
|
if (!response.ok) {
|
|
// Session expiration is recoverable on an explicit retry. Marking the
|
|
// connection unhealthy here would hide every tool and prevent it.
|
|
if (!sessionExpired) {
|
|
await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error.");
|
|
}
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
sessionExpired ? "Remote MCP session expired. Retry the action explicitly to start a new session." : "Remote MCP server returned an HTTP error",
|
|
"mcp_remote_status",
|
|
{
|
|
status: response.status,
|
|
...(sessionExpired ? { sessionExpired: true } : {}),
|
|
connectionId: connection.id,
|
|
catalogEntryId: entry.id,
|
|
execution,
|
|
},
|
|
);
|
|
}
|
|
let payload: unknown;
|
|
try {
|
|
payload = JSON.parse(body);
|
|
} catch {
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
"Remote MCP server returned invalid JSON",
|
|
"mcp_remote_invalid_json",
|
|
{
|
|
connectionId: connection.id,
|
|
catalogEntryId: entry.id,
|
|
execution,
|
|
},
|
|
);
|
|
}
|
|
const payloadRecord = asRecord(payload);
|
|
if (!payloadRecord) throw malformedRemoteMcpResponse();
|
|
const upstreamPending = extractRemoteMcpPending(payloadRecord, String(connection.config.sourceTemplateKey ?? ""), entry.toolName);
|
|
if (upstreamPending) {
|
|
await retainUpstreamHandoff(invocationId, upstreamPending);
|
|
throw new ToolGatewayHttpError(409, "Complete the provider's authorization or approval before continuing. The original call has not been replayed.", "provider_interaction_required", { upstreamPending, invocationId });
|
|
}
|
|
const topLevelElicitation = extractMcpElicitationRequest(payloadRecord);
|
|
if (topLevelElicitation) {
|
|
await requestElicitationForRecordedToolCall({
|
|
session,
|
|
tool,
|
|
invocationId,
|
|
request: topLevelElicitation,
|
|
});
|
|
}
|
|
if (payloadRecord.error !== undefined) {
|
|
const errorRecord = asRecord(payloadRecord.error);
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
"error",
|
|
"Remote MCP server returned a JSON-RPC error.",
|
|
);
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
"Remote MCP server returned an error",
|
|
"remote_mcp_error",
|
|
{
|
|
code:
|
|
typeof errorRecord?.code === "number" ? errorRecord.code : null,
|
|
connectionId: connection.id,
|
|
catalogEntryId: entry.id,
|
|
execution,
|
|
},
|
|
);
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(payloadRecord, "result")) {
|
|
throw malformedRemoteMcpResponse();
|
|
}
|
|
const resultElicitation = extractMcpElicitationRequest(
|
|
payloadRecord.result,
|
|
);
|
|
if (resultElicitation) {
|
|
await requestElicitationForRecordedToolCall({
|
|
session,
|
|
tool,
|
|
invocationId,
|
|
request: resultElicitation,
|
|
});
|
|
}
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const result = normalizeMcpToolResult(
|
|
payloadRecord.result,
|
|
"mcp_http",
|
|
false,
|
|
sourceTemplateKey,
|
|
);
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
"ok",
|
|
"Remote MCP server responded to tools/call.",
|
|
);
|
|
return { result, headerSummary, execution };
|
|
} catch (error) {
|
|
if (error instanceof McpHttpResponseError) {
|
|
const failure = error.reason === "too_large" ? responseTooLargeError()
|
|
: error.reason === "malformed_response" ? malformedRemoteMcpResponse()
|
|
: new ToolGatewayHttpError(502, "Remote MCP server returned invalid JSON", "mcp_remote_invalid_json");
|
|
// These describe one response body, not the connection: the server
|
|
// answered with HTTP 2xx. Marking the connection unhealthy would hide
|
|
// every tool, and only a successful call restores health.
|
|
// The reader may have cancelled the stream partway, and the server can
|
|
// then drop its session. Start a fresh session on the next call.
|
|
if (mcpSession) forgetMcpHttpSession(mcpSession);
|
|
throw new ToolGatewayHttpError(failure.status, failure.message, failure.reasonCode, {
|
|
...failure.details, connectionId: connection.id, catalogEntryId: entry.id, execution,
|
|
});
|
|
}
|
|
if (error instanceof RailwayError) {
|
|
throw new ToolGatewayHttpError(error.status, error.message, error.code, { connectionId: connection.id, catalogEntryId: entry.id, execution });
|
|
}
|
|
if (error instanceof ToolGatewayHttpError) {
|
|
throw new ToolGatewayHttpError(
|
|
error.status,
|
|
error.message,
|
|
error.reasonCode,
|
|
{
|
|
...error.details,
|
|
execution: error.details.execution ?? execution,
|
|
},
|
|
);
|
|
}
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
"error",
|
|
"Remote MCP tool call timed out.",
|
|
);
|
|
throw new ToolGatewayHttpError(
|
|
504,
|
|
"Remote MCP tool call timed out",
|
|
"tool_timeout",
|
|
{
|
|
connectionId: connection.id,
|
|
catalogEntryId: entry.id,
|
|
execution,
|
|
},
|
|
);
|
|
}
|
|
await markRemoteConnectionHealth(
|
|
connection,
|
|
"error",
|
|
"Remote MCP tool call failed.",
|
|
);
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
"Remote MCP tool call failed",
|
|
"mcp_remote_fetch_failed",
|
|
{
|
|
connectionId: connection.id,
|
|
catalogEntryId: entry.id,
|
|
execution,
|
|
},
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function executeLocalStdioTool(
|
|
session: ToolGatewaySession,
|
|
tool: ToolGatewayDescriptor,
|
|
parameters: unknown,
|
|
ms: number,
|
|
useProviderDefaultTimeout = false,
|
|
): Promise<RemoteHttpExecutionResult> {
|
|
const { entry, connection } = await resolveConnectedLocalStdioTool(
|
|
session,
|
|
tool,
|
|
);
|
|
const grant = await resolveConnectionGrant(session, connection);
|
|
const template = await resolveLocalStdioRuntimeTemplate(connection);
|
|
const env = await localStdioEnvironment(
|
|
session,
|
|
connection,
|
|
template,
|
|
grant,
|
|
);
|
|
const invoke = () => callLocalStdioMcp({ connection, entry, template, env, parameters,
|
|
timeoutMs: useProviderDefaultTimeout && template.templateId === "paperclip.cognee-cloud" ? 60_000 : ms });
|
|
// Cognee is a bundled HTTP client. Provider failures are tool failures, not
|
|
// crashed local processes, and must never consume slots or restart budgets.
|
|
const result = template.templateId === "paperclip.cognee-cloud"
|
|
? await invoke()
|
|
: await runtimeSupervisor.useConnectionSlot(
|
|
{
|
|
companyId: session.companyId,
|
|
applicationId: tool.applicationId ?? null,
|
|
connectionId: connection.id,
|
|
connectionKey: `mcp:${session.companyId}:${connection.id}`,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
agentId: session.agentId,
|
|
commandTemplateKey: template.templateId,
|
|
metadata: {
|
|
fixture: "connected-local-stdio",
|
|
applicationId: tool.applicationId ?? null,
|
|
connectionId: connection.id,
|
|
catalogEntryId: entry.id,
|
|
},
|
|
},
|
|
async (handle) => {
|
|
handle.appendLog("stdout", `calling ${entry.toolName}`);
|
|
return invoke();
|
|
},
|
|
);
|
|
return {
|
|
result: normalizeMcpToolResult(result, template.templateId === "paperclip.cognee-cloud" ? "cognee_cloud" : "local_stdio", template.templateId !== "paperclip.cognee-cloud"),
|
|
};
|
|
}
|
|
|
|
async function runWithTimeout<T>(
|
|
promise: Promise<T>,
|
|
ms: number,
|
|
): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<T>((_resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(
|
|
new ToolGatewayHttpError(
|
|
504,
|
|
"Tool execution timed out",
|
|
"tool_timeout",
|
|
),
|
|
);
|
|
}, ms);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function gatewayEndpointPath(gatewayPublicId: string) {
|
|
return `/mcp/gateways/${gatewayPublicId}`;
|
|
}
|
|
|
|
function gatewayClientSnippets(
|
|
gateway: Pick<
|
|
typeof toolMcpGateways.$inferSelect,
|
|
"gatewayPublicId" | "name"
|
|
>,
|
|
): ToolMcpGatewayClientSnippet[] {
|
|
const endpoint = gatewayEndpointPath(gateway.gatewayPublicId);
|
|
const bearerPlaceholder = "pcgw_...";
|
|
return [
|
|
{
|
|
client: "cursor",
|
|
label: "Cursor",
|
|
config: {
|
|
mcpServers: {
|
|
[gateway.name]: {
|
|
url: endpoint,
|
|
headers: { Authorization: `Bearer ${bearerPlaceholder}` },
|
|
},
|
|
},
|
|
},
|
|
notes: ["Use the full Paperclip origin before the endpoint path."],
|
|
},
|
|
{
|
|
client: "claude_desktop",
|
|
label: "Claude Desktop",
|
|
config: {
|
|
mcpServers: {
|
|
[gateway.name]: {
|
|
url: endpoint,
|
|
headers: { Authorization: `Bearer ${bearerPlaceholder}` },
|
|
},
|
|
},
|
|
},
|
|
notes: [
|
|
"Recent Claude Desktop builds support remote HTTP MCP servers.",
|
|
],
|
|
},
|
|
{
|
|
client: "vscode",
|
|
label: "VS Code",
|
|
config: {
|
|
servers: {
|
|
[gateway.name]: {
|
|
type: "http",
|
|
url: endpoint,
|
|
headers: { Authorization: `Bearer ${bearerPlaceholder}` },
|
|
},
|
|
},
|
|
},
|
|
notes: ["Place this under your MCP extension or editor MCP settings."],
|
|
},
|
|
{
|
|
client: "claude_code",
|
|
label: "Claude Code",
|
|
config: {
|
|
command: "claude",
|
|
args: [
|
|
"mcp",
|
|
"add",
|
|
gateway.name,
|
|
endpoint,
|
|
"--header",
|
|
`Authorization: Bearer ${bearerPlaceholder}`,
|
|
],
|
|
},
|
|
notes: [
|
|
"Use the equivalent remote HTTP MCP add command for your installed version.",
|
|
],
|
|
},
|
|
{
|
|
client: "opencode",
|
|
label: "OpenCode",
|
|
config: {
|
|
mcp: {
|
|
[gateway.name]: {
|
|
url: endpoint,
|
|
headers: { Authorization: `Bearer ${bearerPlaceholder}` },
|
|
},
|
|
},
|
|
},
|
|
notes: ["Use the full Paperclip origin before the endpoint path."],
|
|
},
|
|
];
|
|
}
|
|
|
|
function toGateway(row: typeof toolMcpGateways.$inferSelect): ToolMcpGateway {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
gatewayPublicId: row.gatewayPublicId,
|
|
name: row.name,
|
|
displaySlug: row.displaySlug || row.slug,
|
|
slug: row.slug,
|
|
description: row.description,
|
|
status: row.status,
|
|
profileId: row.profileId,
|
|
defaultProfileMode: row.defaultProfileMode,
|
|
contextScopeType: row.contextScopeType,
|
|
contextScopeId: row.contextScopeId,
|
|
agentId: row.agentId,
|
|
projectId: row.projectId,
|
|
issueId: row.issueId,
|
|
approvalIssueId: row.approvalIssueId,
|
|
endpointPath: gatewayEndpointPath(row.gatewayPublicId),
|
|
authConfig: row.authConfig,
|
|
headerPolicy: row.headerPolicy,
|
|
metadataPolicy: row.metadataPolicy,
|
|
onDemandToolsConfig: row.onDemandToolsConfig,
|
|
metadata: row.metadata ?? {},
|
|
createdByAgentId: row.createdByAgentId,
|
|
createdByUserId: row.createdByUserId,
|
|
archivedAt: row.archivedAt,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function toGatewayToken(
|
|
row: typeof toolMcpGatewayTokens.$inferSelect,
|
|
): ToolMcpGatewayToken {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
gatewayId: row.gatewayId,
|
|
name: row.name,
|
|
tokenPrefix: row.tokenPrefix,
|
|
subjectType: row.subjectType,
|
|
subjectId: row.subjectId,
|
|
clientLabel: row.clientLabel,
|
|
ownerNote: row.ownerNote,
|
|
allowedActions: row.allowedActions,
|
|
expiresAt: row.expiresAt,
|
|
expiryOverrideReason: row.expiryOverrideReason,
|
|
expiryOverrideByUserId: row.expiryOverrideByUserId,
|
|
expiryOverrideByAgentId: row.expiryOverrideByAgentId,
|
|
expiryOverrideAt: row.expiryOverrideAt,
|
|
lastUsedAt: row.lastUsedAt,
|
|
revokedAt: row.revokedAt,
|
|
createdByAgentId: row.createdByAgentId,
|
|
createdByUserId: row.createdByUserId,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
async function getGatewayWithTokens(
|
|
companyId: string,
|
|
gatewayId: string,
|
|
): Promise<ToolMcpGatewayWithTokens> {
|
|
const [gateway] = await db
|
|
.select()
|
|
.from(toolMcpGateways)
|
|
.where(
|
|
and(
|
|
eq(toolMcpGateways.companyId, companyId),
|
|
eq(toolMcpGateways.id, gatewayId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!gateway) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"MCP gateway not found",
|
|
"gateway_not_found",
|
|
);
|
|
}
|
|
const tokens = await db
|
|
.select()
|
|
.from(toolMcpGatewayTokens)
|
|
.where(
|
|
and(
|
|
eq(toolMcpGatewayTokens.companyId, companyId),
|
|
eq(toolMcpGatewayTokens.gatewayId, gatewayId),
|
|
),
|
|
)
|
|
.orderBy(desc(toolMcpGatewayTokens.createdAt));
|
|
return {
|
|
...toGateway(gateway),
|
|
tokens: tokens.map(toGatewayToken),
|
|
clientSnippets: gatewayClientSnippets(gateway),
|
|
};
|
|
}
|
|
|
|
async function assertGatewayContext(input: {
|
|
companyId: string;
|
|
profileId?: string | null;
|
|
agentId?: string | null;
|
|
projectId?: string | null;
|
|
issueId?: string | null;
|
|
}) {
|
|
if (input.profileId) {
|
|
const [profile] = await db
|
|
.select({ id: toolProfiles.id })
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, input.companyId),
|
|
eq(toolProfiles.id, input.profileId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!profile)
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"Gateway profile must belong to the company",
|
|
"gateway_profile_invalid",
|
|
);
|
|
}
|
|
if (input.agentId)
|
|
await assertAgentInCompany(input.companyId, input.agentId);
|
|
if (input.projectId) {
|
|
const [project] = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(
|
|
and(
|
|
eq(projects.companyId, input.companyId),
|
|
eq(projects.id, input.projectId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!project)
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"Gateway project must belong to the company",
|
|
"gateway_project_invalid",
|
|
);
|
|
}
|
|
if (input.issueId) {
|
|
const [issue] = await db
|
|
.select({ id: issues.id, projectId: issues.projectId })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, input.companyId),
|
|
eq(issues.id, input.issueId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!issue)
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"Gateway issue must belong to the company",
|
|
"gateway_issue_invalid",
|
|
);
|
|
if (
|
|
input.projectId &&
|
|
issue.projectId &&
|
|
issue.projectId !== input.projectId
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
422,
|
|
"Gateway issue must belong to the selected project",
|
|
"gateway_issue_project_mismatch",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function findGatewayForProtocolLocator(input: {
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
}) {
|
|
if (input.gatewayId) {
|
|
const [gateway] = await db
|
|
.select()
|
|
.from(toolMcpGateways)
|
|
.where(eq(toolMcpGateways.id, input.gatewayId))
|
|
.limit(1);
|
|
return gateway ?? null;
|
|
}
|
|
if (input.gatewayPublicId) {
|
|
const [gateway] = await db
|
|
.select()
|
|
.from(toolMcpGateways)
|
|
.where(eq(toolMcpGateways.gatewayPublicId, input.gatewayPublicId))
|
|
.limit(1);
|
|
return gateway ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function protocolLimiterKeyClass(method: McpGatewayProtocolMethod) {
|
|
return method === "initialize" ? "session_setup" : method;
|
|
}
|
|
|
|
async function writeProtocolRateLimitAudit(input: {
|
|
session: ToolGatewaySession;
|
|
method: McpGatewayProtocolMethod;
|
|
limiterKeyClass: "token" | "gateway";
|
|
count: number;
|
|
limit: McpGatewayRateLimitConfig;
|
|
retryAfterMs: number;
|
|
clientMetadata: ReturnType<typeof safeClientMetadata>;
|
|
}) {
|
|
await writeAudit({
|
|
session: input.session,
|
|
companyId: input.session.companyId,
|
|
agentId: input.session.agentId,
|
|
runId: input.session.runId,
|
|
issueId: input.session.issueId,
|
|
action: "tool_gateway.call_denied",
|
|
details: {
|
|
decision: "rate_limited",
|
|
reasonCode: "gateway_rate_limited",
|
|
reasonText:
|
|
"The MCP gateway request was rate limited before the protocol action ran.",
|
|
limiterKeyClass: input.limiterKeyClass,
|
|
protocolMethod: input.method,
|
|
protocolAction: protocolLimiterKeyClass(input.method),
|
|
requestCount: input.count,
|
|
limit: input.limit.max,
|
|
windowMs: input.limit.windowMs,
|
|
retryAfterMs: input.retryAfterMs,
|
|
gatewayId: input.session.gatewayId ?? null,
|
|
gatewayPublicId: input.session.gatewayPublicId ?? null,
|
|
gatewayTokenId: input.session.gatewayTokenId ?? null,
|
|
tokenPrefix:
|
|
typeof input.session.gatewayTokenId === "string"
|
|
? `pcgw_${input.session.gatewayTokenId.slice(0, 8)}`
|
|
: null,
|
|
...input.clientMetadata,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function assertNamedGatewayProtocolLimit(
|
|
session: ToolGatewaySession,
|
|
method: McpGatewayProtocolMethod,
|
|
clientMetadata: ReturnType<typeof safeClientMetadata>,
|
|
) {
|
|
const action = protocolLimiterKeyClass(method);
|
|
const tokenLimit =
|
|
method === "initialize"
|
|
? protocolLimits.sessionSetup
|
|
: protocolLimits.tokenRequests;
|
|
const tokenKey = `mcp_gateway_protocol:token:${session.gatewayTokenId ?? session.actorId ?? "unknown"}:${action}`;
|
|
const tokenState = await consumeProtocolRateLimit({
|
|
companyId: session.companyId,
|
|
counterKey: tokenKey,
|
|
config: tokenLimit,
|
|
});
|
|
if (tokenState.limited) {
|
|
await writeProtocolRateLimitAudit({
|
|
session,
|
|
method,
|
|
limiterKeyClass: "token",
|
|
count: tokenState.count,
|
|
limit: tokenLimit,
|
|
retryAfterMs: tokenState.retryAfterMs,
|
|
clientMetadata,
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
429,
|
|
"MCP gateway request was rate limited",
|
|
"gateway_rate_limited",
|
|
{
|
|
reasonText:
|
|
"The MCP gateway request was rate limited before the protocol action ran.",
|
|
limiterKeyClass: "token",
|
|
protocolMethod: method,
|
|
retryAfterMs: tokenState.retryAfterMs,
|
|
},
|
|
);
|
|
}
|
|
|
|
const gatewayLimit =
|
|
method === "initialize"
|
|
? protocolLimits.sessionSetup
|
|
: protocolLimits.gatewayRequests;
|
|
const gatewayKey = `mcp_gateway_protocol:gateway:${session.gatewayId ?? session.gatewayPublicId ?? "unknown"}:${action}`;
|
|
const gatewayState = await consumeProtocolRateLimit({
|
|
companyId: session.companyId,
|
|
counterKey: gatewayKey,
|
|
config: gatewayLimit,
|
|
});
|
|
if (gatewayState.limited) {
|
|
await writeProtocolRateLimitAudit({
|
|
session,
|
|
method,
|
|
limiterKeyClass: "gateway",
|
|
count: gatewayState.count,
|
|
limit: gatewayLimit,
|
|
retryAfterMs: gatewayState.retryAfterMs,
|
|
clientMetadata,
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
429,
|
|
"MCP gateway request was rate limited",
|
|
"gateway_rate_limited",
|
|
{
|
|
reasonText:
|
|
"The MCP gateway request was rate limited before the protocol action ran.",
|
|
limiterKeyClass: "gateway",
|
|
protocolMethod: method,
|
|
retryAfterMs: gatewayState.retryAfterMs,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async function recordNamedGatewayAuthFailure(input: {
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
bearerToken: string;
|
|
reasonCode: string;
|
|
clientMetadata: ReturnType<typeof safeClientMetadata>;
|
|
}): Promise<never> {
|
|
const token = input.bearerToken.trim();
|
|
const tokenId = namedGatewayTokenId(token);
|
|
const gatewayKey = input.gatewayId
|
|
? `id:${input.gatewayId}`
|
|
: `public:${input.gatewayPublicId ?? "unknown"}`;
|
|
const tokenKey = tokenId
|
|
? `id:${tokenId}`
|
|
: `hash:${hashGatewayToken(token).slice(0, 24)}`;
|
|
const gateway = await findGatewayForProtocolLocator(input);
|
|
if (!gateway) {
|
|
throw new ToolGatewayHttpError(
|
|
401,
|
|
"Gateway bearer token is expired or invalid",
|
|
input.reasonCode,
|
|
);
|
|
}
|
|
const gatewayState = await consumeProtocolRateLimit({
|
|
companyId: gateway.companyId,
|
|
counterKey: `mcp_gateway_auth_failure:gateway:${gatewayKey}`,
|
|
config: protocolLimits.authFailures,
|
|
});
|
|
const tokenState = await consumeProtocolRateLimit({
|
|
companyId: gateway.companyId,
|
|
counterKey: `mcp_gateway_auth_failure:token:${gatewayKey}:${tokenKey}`,
|
|
config: protocolLimits.authFailures,
|
|
});
|
|
const limited = gatewayState.limited || tokenState.limited;
|
|
if (limited) {
|
|
const limiterKeyClass = gatewayState.limited
|
|
? "gateway_auth"
|
|
: "token_auth";
|
|
const count = gatewayState.limited
|
|
? gatewayState.count
|
|
: tokenState.count;
|
|
const retryAfterMs = gatewayState.limited
|
|
? gatewayState.retryAfterMs
|
|
: tokenState.retryAfterMs;
|
|
await writeAudit({
|
|
session: {
|
|
id: `gateway:${gateway.id}`,
|
|
token: "",
|
|
companyId: gateway.companyId,
|
|
agentId: gateway.agentId,
|
|
runId: null,
|
|
issueId: gateway.issueId,
|
|
projectId: gateway.projectId,
|
|
gatewayId: gateway.id,
|
|
gatewayPublicId: gateway.gatewayPublicId,
|
|
gatewayName: gateway.name,
|
|
gatewayTokenId: null,
|
|
actorType: "system",
|
|
actorId: gateway.id,
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(),
|
|
},
|
|
companyId: gateway.companyId,
|
|
agentId: gateway.agentId,
|
|
runId: null,
|
|
issueId: gateway.issueId,
|
|
action: "tool_gateway.session_rejected",
|
|
details: {
|
|
decision: "deny",
|
|
reasonCode: "gateway_auth_throttled",
|
|
reasonText:
|
|
"The MCP gateway authentication attempt was throttled after repeated failures.",
|
|
limiterKeyClass,
|
|
failedReasonCode: input.reasonCode,
|
|
requestCount: count,
|
|
limit: protocolLimits.authFailures.max,
|
|
windowMs: protocolLimits.authFailures.windowMs,
|
|
retryAfterMs,
|
|
gatewayId: gateway.id,
|
|
gatewayPublicId: gateway.gatewayPublicId,
|
|
tokenPrefix: tokenPrefixFromNamedBearer(token),
|
|
...input.clientMetadata,
|
|
},
|
|
});
|
|
}
|
|
if (limited) {
|
|
throw new ToolGatewayHttpError(
|
|
429,
|
|
"MCP gateway authentication was throttled",
|
|
"gateway_auth_throttled",
|
|
{
|
|
reasonText:
|
|
"The MCP gateway authentication attempt was throttled after repeated failures.",
|
|
retryAfterMs: Math.max(
|
|
gatewayState.retryAfterMs,
|
|
tokenState.retryAfterMs,
|
|
),
|
|
},
|
|
);
|
|
}
|
|
throw new ToolGatewayHttpError(
|
|
401,
|
|
"Gateway bearer token is expired or invalid",
|
|
input.reasonCode,
|
|
);
|
|
}
|
|
|
|
async function namedGatewaySessionFromBearer(input: {
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
bearerToken: string;
|
|
protocolMethod: McpGatewayProtocolMethod;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
}): Promise<ToolGatewaySession> {
|
|
const clientMetadata = safeClientMetadata(input.callerHeaders);
|
|
const bearerToken = input.bearerToken.trim();
|
|
const tokenId = namedGatewayTokenId(bearerToken.trim());
|
|
const tokenHash = hashGatewayToken(bearerToken.trim());
|
|
const conditions = [eq(toolMcpGatewayTokens.tokenHash, tokenHash)];
|
|
if (input.gatewayId)
|
|
conditions.push(eq(toolMcpGatewayTokens.gatewayId, input.gatewayId));
|
|
if (input.gatewayPublicId)
|
|
conditions.push(
|
|
eq(toolMcpGateways.gatewayPublicId, input.gatewayPublicId),
|
|
);
|
|
const [row] = await db
|
|
.select({ gateway: toolMcpGateways, token: toolMcpGatewayTokens })
|
|
.from(toolMcpGatewayTokens)
|
|
.innerJoin(
|
|
toolMcpGateways,
|
|
eq(toolMcpGatewayTokens.gatewayId, toolMcpGateways.id),
|
|
)
|
|
.where(and(...conditions))
|
|
.limit(1);
|
|
if (!row) {
|
|
await recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_token_invalid",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
if (row.gateway.status !== "active") {
|
|
await recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_disabled",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
if (row.token.revokedAt) {
|
|
await recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_token_revoked",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
if (row.token.expiresAt && row.token.expiresAt.getTime() <= Date.now()) {
|
|
await recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_token_expired",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
const nativeMetadata = row.gateway.metadata;
|
|
const [profile] = await db.select({
|
|
profileKey: toolProfiles.profileKey,
|
|
status: toolProfiles.status,
|
|
source: sql<string | null>`${toolProfiles.metadata}->>'source'`,
|
|
agentId: sql<string | null>`${toolProfiles.metadata}->>'agentId'`,
|
|
assignmentDigest: sql<string | null>`${toolProfiles.metadata}->>'assignmentDigest'`,
|
|
}).from(toolProfiles).where(and(
|
|
eq(toolProfiles.id, row.gateway.profileId),
|
|
eq(toolProfiles.companyId, row.gateway.companyId),
|
|
)).limit(1);
|
|
// The immutable native profile also identifies legacy assignments when an
|
|
// update has cleared the gateway metadata. Missing metadata must fail closed.
|
|
const nativeAssignment = Object.hasOwn(nativeMetadata ?? {}, "nativeRuntimeAssignmentDigest") ||
|
|
profile?.profileKey.startsWith("native:") || profile?.source === "paperclip_runner";
|
|
const nativeOwner = nativeMetadata?.agentId;
|
|
if (nativeAssignment && (
|
|
typeof nativeOwner !== "string" || !uuidPattern.test(nativeOwner) ||
|
|
typeof nativeMetadata?.nativeRuntimeAssignmentDigest !== "string" ||
|
|
!/^[a-f0-9]{64}$/.test(nativeMetadata.nativeRuntimeAssignmentDigest) ||
|
|
(row.gateway.agentId && row.gateway.agentId !== nativeOwner) ||
|
|
row.token.subjectType !== "heartbeat_run"
|
|
)) {
|
|
return recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken, reasonCode: "gateway_token_run_context_invalid", clientMetadata,
|
|
});
|
|
}
|
|
if (nativeAssignment) {
|
|
if (profile?.status !== "active" || profile.source !== "paperclip_runner" ||
|
|
profile.agentId !== nativeOwner ||
|
|
profile.assignmentDigest !== nativeMetadata!.nativeRuntimeAssignmentDigest) {
|
|
return recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken, reasonCode: "gateway_token_run_context_invalid", clientMetadata,
|
|
});
|
|
}
|
|
}
|
|
let agentId = row.gateway.agentId ?? (nativeAssignment ? nativeOwner as string : null);
|
|
let runId: string | null = null;
|
|
let responsibleUserId: string | null = null;
|
|
let issueId = row.gateway.issueId;
|
|
let projectId = row.gateway.projectId;
|
|
if (row.token.subjectType === "heartbeat_run") {
|
|
const tokenRunId = row.token.subjectId;
|
|
if (!tokenRunId || !uuidPattern.test(tokenRunId)) {
|
|
return recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_token_run_invalid",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
const [run] = await db
|
|
.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, tokenRunId))
|
|
.limit(1);
|
|
if (!run || run.companyId !== row.gateway.companyId) {
|
|
return recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_token_run_invalid",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
if (!ACTIVE_GATEWAY_RUN_STATUSES.has(run.status)) {
|
|
return recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_token_run_inactive",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
if (agentId && agentId !== run.agentId) {
|
|
return recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_token_run_context_invalid",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
try {
|
|
const runContext = await resolveRunContext({
|
|
companyId: row.gateway.companyId,
|
|
agentId: run.agentId,
|
|
runId: tokenRunId,
|
|
issueId: row.gateway.issueId,
|
|
projectId: row.gateway.projectId,
|
|
});
|
|
agentId = run.agentId;
|
|
runId = tokenRunId;
|
|
responsibleUserId = run.responsibleUserId;
|
|
issueId = runContext.issueId;
|
|
projectId = runContext.projectId;
|
|
} catch {
|
|
return recordNamedGatewayAuthFailure({
|
|
gatewayId: input.gatewayId,
|
|
gatewayPublicId: input.gatewayPublicId,
|
|
bearerToken,
|
|
reasonCode: "gateway_token_run_context_invalid",
|
|
clientMetadata,
|
|
});
|
|
}
|
|
}
|
|
const now = new Date();
|
|
await db
|
|
.update(toolMcpGatewayTokens)
|
|
.set({ lastUsedAt: now, updatedAt: now })
|
|
.where(eq(toolMcpGatewayTokens.id, row.token.id));
|
|
const session: ToolGatewaySession = {
|
|
id: `gateway:${row.gateway.id}`,
|
|
token: "",
|
|
companyId: row.gateway.companyId,
|
|
agentId,
|
|
runId,
|
|
issueId,
|
|
projectId,
|
|
gatewayId: row.gateway.id,
|
|
gatewayPublicId: row.gateway.gatewayPublicId,
|
|
gatewayName: row.gateway.name,
|
|
gatewayProfileId: row.gateway.profileId,
|
|
gatewayTokenId: row.token.id || tokenId,
|
|
gatewayTokenAllowedActions: normalizeGatewayTokenActions(
|
|
row.token.allowedActions,
|
|
),
|
|
actorType: runId ? "agent" : "system",
|
|
actorId: runId ? agentId : row.token.id,
|
|
responsibleUserId,
|
|
createdAt: row.token.createdAt,
|
|
expiresAt:
|
|
row.token.expiresAt ??
|
|
new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000),
|
|
};
|
|
await assertNamedGatewayProtocolLimit(
|
|
session,
|
|
input.protocolMethod,
|
|
clientMetadata,
|
|
);
|
|
return captureSessionIdentity(session);
|
|
}
|
|
|
|
/**
|
|
* A "test-origin" invocation is one created by the Apps → Test tab's
|
|
* impersonated test call: an out-of-band `actorType: "user"` invocation with
|
|
* no heartbeat run, issue, or gateway behind it. We key off the durable
|
|
* invocation columns rather than audit metadata so the signal survives a
|
|
* reload — and so the live test panel can drive an approved test call to
|
|
* completion without a real agent run re-invoking it.
|
|
*/
|
|
function isTestOriginInvocation(
|
|
invocation: typeof toolInvocations.$inferSelect,
|
|
): boolean {
|
|
return (
|
|
invocation.actorType === "user" &&
|
|
invocation.runId === null &&
|
|
invocation.issueId === null &&
|
|
invocation.gatewayId === null &&
|
|
invocation.connectionId !== null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Execute an already-authorized test-tab tool call against the connected MCP
|
|
* server and record the result/error onto the invocation. Shared by
|
|
* {@link executeTestCall} (the allow path) and the approval-driven execution
|
|
* of a parked ask-first request, so both produce identical persistence,
|
|
* events, and audit entries.
|
|
*/
|
|
async function runTestToolInvocation(args: {
|
|
session: ToolGatewaySession;
|
|
tool: ToolGatewayDescriptor;
|
|
parameters: unknown;
|
|
invocationId: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
userId: string;
|
|
argumentsSummary: ReturnType<typeof summarizeToolValue>;
|
|
reasonCode: string;
|
|
matchedPolicyIds: string[];
|
|
timeoutMs?: number;
|
|
}): Promise<
|
|
| { decision: "allowed"; invocationId: string; result: unknown }
|
|
| {
|
|
decision: "allowed";
|
|
invocationId: string;
|
|
error: { message: string; reasonCode: string };
|
|
}
|
|
> {
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "executing",
|
|
startedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, args.invocationId));
|
|
await writeAudit({
|
|
session: args.session,
|
|
companyId: args.companyId,
|
|
agentId: args.agentId,
|
|
runId: null,
|
|
issueId: null,
|
|
actorType: "user",
|
|
actorId: args.userId,
|
|
action: "tool_gateway.call_allowed",
|
|
details: {
|
|
source: "test",
|
|
invocationId: args.invocationId,
|
|
decision: "allow",
|
|
reasonCode: args.reasonCode,
|
|
matchedPolicyIds: args.matchedPolicyIds,
|
|
tool: args.tool.name,
|
|
...toolAuditMetadata(args.tool),
|
|
argumentsSummary: args.argumentsSummary,
|
|
},
|
|
});
|
|
|
|
const startedAt = Date.now();
|
|
try {
|
|
const executionTimeoutMs = timeoutMs(args.timeoutMs);
|
|
const connectedMcpExecution =
|
|
(args.tool.providerType === "mcp_remote_http" || args.tool.providerType === "provider_rest")
|
|
? await executeRemoteHttpTool(
|
|
args.session,
|
|
args.tool,
|
|
args.parameters,
|
|
executionTimeoutMs,
|
|
args.invocationId,
|
|
undefined,
|
|
args.timeoutMs === undefined,
|
|
)
|
|
: args.tool.providerType === "mcp_local_stdio"
|
|
? await executeLocalStdioTool(
|
|
args.session,
|
|
args.tool,
|
|
args.parameters,
|
|
executionTimeoutMs,
|
|
args.timeoutMs === undefined,
|
|
)
|
|
: null;
|
|
if (!connectedMcpExecution) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${args.tool.name}" not found`,
|
|
"tool_not_found",
|
|
{
|
|
tool: args.tool.name,
|
|
},
|
|
);
|
|
}
|
|
const result = connectedMcpExecution.result;
|
|
const resultValidation = validateToolContent({
|
|
value: result,
|
|
direction: "result",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "block",
|
|
});
|
|
const providerResult = asRecord(resultValidation.value);
|
|
if (providerResult?.error) {
|
|
throw new ToolGatewayHttpError(502, String(providerResult.content || providerResult.error),
|
|
"tool_error", { execution: connectedMcpExecution.execution });
|
|
}
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "succeeded",
|
|
resultHash: resultValidation.summary.sha256 ?? null,
|
|
resultSummary: resultValidation.summary,
|
|
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, args.invocationId));
|
|
await writeToolCallEvent({
|
|
invocationId: args.invocationId,
|
|
session: args.session,
|
|
eventType: "call_completed",
|
|
outcome: "success",
|
|
toolName: args.tool.name,
|
|
policyDecision: "allow",
|
|
reasonCode: "tool_completed",
|
|
argumentsSummary: args.argumentsSummary,
|
|
resultSummary: resultValidation.summary,
|
|
metadata: {
|
|
source: "test",
|
|
headerSummary: connectedMcpExecution.headerSummary ?? undefined,
|
|
execution: connectedMcpExecution.execution,
|
|
},
|
|
tool: args.tool,
|
|
});
|
|
await writeAudit({
|
|
session: args.session,
|
|
companyId: args.companyId,
|
|
agentId: args.agentId,
|
|
runId: null,
|
|
issueId: null,
|
|
actorType: "user",
|
|
actorId: args.userId,
|
|
action: "tool_gateway.call_completed",
|
|
details: {
|
|
source: "test",
|
|
invocationId: args.invocationId,
|
|
decision: "allow",
|
|
reasonCode: "tool_completed",
|
|
tool: args.tool.name,
|
|
...toolAuditMetadata(args.tool),
|
|
durationMs: Date.now() - startedAt,
|
|
argumentsSummary: args.argumentsSummary,
|
|
result: summarizeResult(resultValidation.value),
|
|
resultSummary: resultValidation.summary,
|
|
headerSummary: connectedMcpExecution.headerSummary ?? undefined,
|
|
execution: connectedMcpExecution.execution,
|
|
},
|
|
});
|
|
// After the bookkeeping above: a throw there is caught below, overwrites
|
|
// the row to failed, and emits — an earlier success emit would make one
|
|
// execution report both outcomes.
|
|
void emitConnectionInvoked(db, args.invocationId);
|
|
return {
|
|
decision: "allowed" as const,
|
|
invocationId: args.invocationId,
|
|
result: resultValidation.value,
|
|
};
|
|
} catch (err) {
|
|
const status = err instanceof ToolGatewayHttpError ? err.status : 502;
|
|
const reasonCode =
|
|
err instanceof ToolContentValidationError
|
|
? err.reasonCode
|
|
: err instanceof ToolGatewayHttpError
|
|
? err.reasonCode
|
|
: "tool_execution_failed";
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status:
|
|
status === 504
|
|
? "timed_out"
|
|
: status === 429
|
|
? "rate_limited"
|
|
: "failed",
|
|
errorCode: reasonCode,
|
|
errorMessage: message,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, args.invocationId));
|
|
void emitConnectionInvoked(db, args.invocationId);
|
|
await writeToolCallEvent({
|
|
invocationId: args.invocationId,
|
|
session: args.session,
|
|
eventType: "call_failed",
|
|
outcome: status === 504 ? "timeout" : "failure",
|
|
toolName: args.tool.name,
|
|
policyDecision: status === 504 ? "defer_runtime" : "deny",
|
|
reasonCode,
|
|
argumentsSummary: args.argumentsSummary,
|
|
metadata: {
|
|
source: "test",
|
|
...(err instanceof ToolContentValidationError
|
|
? { findings: err.findings }
|
|
: {}),
|
|
...(executionAuditFromError(err)
|
|
? { execution: executionAuditFromError(err) }
|
|
: {}),
|
|
},
|
|
tool: args.tool,
|
|
});
|
|
await writeAudit({
|
|
session: args.session,
|
|
companyId: args.companyId,
|
|
agentId: args.agentId,
|
|
runId: null,
|
|
issueId: null,
|
|
actorType: "user",
|
|
actorId: args.userId,
|
|
action:
|
|
status === 504
|
|
? "tool_gateway.call_deferred"
|
|
: "tool_gateway.call_failed",
|
|
details: {
|
|
source: "test",
|
|
invocationId: args.invocationId,
|
|
decision: status === 504 ? "defer_runtime" : "deny",
|
|
reasonCode,
|
|
tool: args.tool.name,
|
|
...toolAuditMetadata(args.tool),
|
|
argumentsSummary: args.argumentsSummary,
|
|
durationMs: Date.now() - startedAt,
|
|
error: message,
|
|
...(executionAuditFromError(err)
|
|
? { execution: executionAuditFromError(err) }
|
|
: {}),
|
|
},
|
|
});
|
|
return {
|
|
decision: "allowed" as const,
|
|
invocationId: args.invocationId,
|
|
error: { message, reasonCode },
|
|
...(err instanceof ToolGatewayHttpError && err.reasonCode === "provider_interaction_required"
|
|
? { upstreamPending: err.details.upstreamPending as ToolUpstreamPending }
|
|
: {}),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drive a freshly-approved test-origin ask-first request to completion. The
|
|
* Test tab has no agent run to re-invoke the parked call, so approving it in
|
|
* the Review tab is what executes it — the live status panel then surfaces the
|
|
* real result. Reconstructs the impersonated test session and the signed
|
|
* arguments, and never throws: any failure is recorded on the invocation so it
|
|
* shows up as an error in the panel rather than rolling back the approval.
|
|
*/
|
|
async function runApprovedTestInvocation(
|
|
invocation: typeof toolInvocations.$inferSelect,
|
|
parameters: unknown,
|
|
actionRequestId: string,
|
|
): Promise<void> {
|
|
const agentId = invocation.agentId;
|
|
if (!invocation.connectionId || !agentId) return;
|
|
const userId = invocation.actorId ?? "board";
|
|
const session: ToolGatewaySession = {
|
|
id: "test-call",
|
|
token: "test-call",
|
|
companyId: invocation.companyId,
|
|
agentId,
|
|
runId: null,
|
|
issueId: null,
|
|
projectId: null,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
responsibleUserId: userId,
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
|
|
};
|
|
let tool: ToolGatewayDescriptor | undefined;
|
|
try {
|
|
tool = (
|
|
await connectedMcpToolsForConnection(
|
|
invocation.companyId,
|
|
invocation.connectionId,
|
|
)
|
|
).find(
|
|
(candidate) =>
|
|
candidate.name === invocation.toolName ||
|
|
candidate.upstreamToolName === invocation.toolName,
|
|
);
|
|
} catch {
|
|
tool = undefined;
|
|
}
|
|
if (!tool) {
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "failed",
|
|
errorCode: "tool_not_found",
|
|
errorMessage: `Tool "${invocation.toolName}" is no longer connected`,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, invocation.id));
|
|
void emitConnectionInvoked(db, invocation.id);
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId,
|
|
status: "failed",
|
|
errorCode: "tool_not_found",
|
|
errorMessage: `Tool "${invocation.toolName}" is no longer connected`,
|
|
});
|
|
return;
|
|
}
|
|
const argumentsSummary = validateToolContent({
|
|
value: parameters,
|
|
direction: "arguments",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "ignore",
|
|
}).summary;
|
|
try {
|
|
await runTestToolInvocation({
|
|
session,
|
|
tool,
|
|
parameters,
|
|
invocationId: invocation.id,
|
|
companyId: invocation.companyId,
|
|
agentId,
|
|
userId,
|
|
argumentsSummary,
|
|
reasonCode: "approval_granted",
|
|
matchedPolicyIds: invocation.matchedPolicyIds ?? [],
|
|
});
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId,
|
|
status: "executed",
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "failed",
|
|
errorCode: "tool_execution_failed",
|
|
errorMessage: message,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, invocation.id));
|
|
void emitConnectionInvoked(db, invocation.id);
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId,
|
|
status: "failed",
|
|
errorCode: "tool_execution_failed",
|
|
errorMessage: message,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function waitForActionRequestExecution(actionRequestId: string) {
|
|
let deadline = Date.now() + ACTION_REQUEST_EXECUTION_WAIT_MS;
|
|
while (true) {
|
|
const [match] = await db
|
|
.select({
|
|
actionRequest: toolActionRequests,
|
|
invocationStatus: toolInvocations.status,
|
|
invocationStartedAt: toolInvocations.startedAt,
|
|
})
|
|
.from(toolActionRequests)
|
|
.innerJoin(
|
|
toolInvocations,
|
|
eq(toolInvocations.id, toolActionRequests.invocationId),
|
|
)
|
|
.where(eq(toolActionRequests.id, actionRequestId))
|
|
.limit(1);
|
|
const row = match?.actionRequest;
|
|
if (!row || row.status !== "executing") return row ?? null;
|
|
deadline = extendApprovedExecutionWaitDeadline({
|
|
currentDeadlineMs: deadline,
|
|
invocationStatus: match.invocationStatus,
|
|
invocationStartedAt: match.invocationStartedAt,
|
|
preparationStartedAt: row.updatedAt,
|
|
preparationWaitMs: ACTION_REQUEST_PREPARATION_WAIT_MS,
|
|
executionWaitMs: ACTION_REQUEST_EXECUTION_WAIT_MS,
|
|
});
|
|
const remainingMs = deadline - Date.now();
|
|
if (remainingMs <= 0) break;
|
|
await new Promise((resolve) =>
|
|
setTimeout(
|
|
resolve,
|
|
Math.min(ACTION_REQUEST_EXECUTION_POLL_MS, remainingMs),
|
|
),
|
|
);
|
|
}
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Approved tool action is still executing",
|
|
"action_execution_in_progress",
|
|
{
|
|
actionRequestId,
|
|
preparationWaitMs: ACTION_REQUEST_PREPARATION_WAIT_MS,
|
|
executionWaitMs: ACTION_REQUEST_EXECUTION_WAIT_MS,
|
|
},
|
|
);
|
|
}
|
|
|
|
function storedInvocationResult(
|
|
invocation: typeof toolInvocations.$inferSelect,
|
|
): unknown {
|
|
const summary = invocation.resultSummary?.summary;
|
|
if (typeof summary !== "string") return null;
|
|
try {
|
|
return JSON.parse(summary);
|
|
} catch {
|
|
return summary;
|
|
}
|
|
}
|
|
|
|
async function actionRequestResolution(
|
|
actionRequest: typeof toolActionRequests.$inferSelect,
|
|
) {
|
|
if (
|
|
actionRequest.status !== "executed" &&
|
|
actionRequest.status !== "failed"
|
|
)
|
|
return actionRequest;
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId))
|
|
.limit(1);
|
|
return {
|
|
...actionRequest,
|
|
resultSummary: invocation?.resultSummary?.summary ?? null,
|
|
error: invocation?.errorMessage ?? null,
|
|
};
|
|
}
|
|
|
|
async function markApprovedActionFailed(input: {
|
|
actionRequestId: string;
|
|
invocationId: string;
|
|
claimUpdatedAt: Date;
|
|
expectedInvocationStatus: "awaiting_approval" | "executing";
|
|
error: unknown;
|
|
}) {
|
|
const reasonCode =
|
|
input.error instanceof ToolGatewayHttpError
|
|
? input.error.reasonCode
|
|
: "tool_execution_failed";
|
|
const message =
|
|
input.error instanceof Error ? input.error.message : String(input.error);
|
|
const now = new Date();
|
|
const settled = await db.transaction(async (tx) => {
|
|
// Lock in the same invocation -> request order used by the normal
|
|
// execution settlement path. The claim timestamp is the ownership token:
|
|
// a consumer that merely read an approved row cannot settle another
|
|
// consumer's claim, and a pre-dispatch failure cannot overwrite a call
|
|
// that has already entered provider execution or completed successfully.
|
|
const [invocation] = await tx
|
|
.select({ status: toolInvocations.status })
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, input.invocationId))
|
|
.for("update")
|
|
.limit(1);
|
|
if (invocation?.status !== input.expectedInvocationStatus) return false;
|
|
|
|
const [actionRequest] = await tx
|
|
.select({
|
|
status: toolActionRequests.status,
|
|
updatedAt: toolActionRequests.updatedAt,
|
|
})
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, input.actionRequestId))
|
|
.for("update")
|
|
.limit(1);
|
|
if (
|
|
actionRequest?.status !== "executing" ||
|
|
actionRequest.updatedAt.getTime() !== input.claimUpdatedAt.getTime()
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
await tx
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "failed",
|
|
idempotencyKey: null,
|
|
errorCode: reasonCode,
|
|
errorMessage: message,
|
|
completedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolInvocations.id, input.invocationId),
|
|
eq(toolInvocations.status, input.expectedInvocationStatus),
|
|
),
|
|
);
|
|
await tx
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: "failed",
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, input.actionRequestId),
|
|
eq(toolActionRequests.status, "executing"),
|
|
eq(toolActionRequests.updatedAt, input.claimUpdatedAt),
|
|
),
|
|
);
|
|
return true;
|
|
});
|
|
if (!settled) return { reasonCode, message, settled: false };
|
|
void emitConnectionInvoked(db, input.invocationId);
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: input.actionRequestId,
|
|
status: "failed",
|
|
errorCode: reasonCode,
|
|
errorMessage: message,
|
|
});
|
|
return { reasonCode, message, settled: true };
|
|
}
|
|
|
|
async function expireApprovedActionForManagedArgumentDrift(input: {
|
|
actionRequestId: string;
|
|
invocationId: string;
|
|
toolName: string;
|
|
ownsExecutingClaim?: boolean;
|
|
}) {
|
|
const error = new ToolGatewayHttpError(
|
|
409,
|
|
"Approved tool action managed arguments changed after review; request a new approval",
|
|
"approved_tool_managed_arguments_changed",
|
|
{
|
|
actionRequestId: input.actionRequestId,
|
|
invocationId: input.invocationId,
|
|
tool: input.toolName,
|
|
},
|
|
);
|
|
const now = new Date();
|
|
await options.beforeManagedArgumentDriftExpiry?.();
|
|
const [expired] = await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "expired", resolvedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, input.actionRequestId),
|
|
input.ownsExecutingClaim
|
|
? inArray(toolActionRequests.status, ["approved", "executing"])
|
|
: eq(toolActionRequests.status, "approved"),
|
|
),
|
|
)
|
|
.returning({ id: toolActionRequests.id });
|
|
if (!expired) return error;
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "failed",
|
|
approvalState: "expired",
|
|
idempotencyKey: null,
|
|
errorCode: error.reasonCode,
|
|
errorMessage: error.message,
|
|
completedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolInvocations.id, input.invocationId));
|
|
void emitConnectionInvoked(db, input.invocationId);
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: expired.id,
|
|
status: "expired",
|
|
errorCode: error.reasonCode,
|
|
errorMessage: error.message,
|
|
});
|
|
return error;
|
|
}
|
|
|
|
// Guard for approved-action execution: the issue must still be open. Expires
|
|
// the claimed request and reflects the interaction lifecycle when it is not.
|
|
// Called twice — after winning the executing claim, and again immediately
|
|
// before provider dispatch, because tool/snapshot resolution between the two
|
|
// involves network calls and leaves a seconds-wide window for the issue to
|
|
// close.
|
|
async function assertIssueOpenForApprovedAction(input: {
|
|
claimed: typeof toolActionRequests.$inferSelect;
|
|
invocation: typeof toolInvocations.$inferSelect;
|
|
}): Promise<{ projectId: string | null }> {
|
|
const { claimed, invocation } = input;
|
|
const [issue] = await db
|
|
.select({ status: issues.status, projectId: issues.projectId })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, invocation.issueId!),
|
|
eq(issues.companyId, invocation.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (issue && issue.status !== "done" && issue.status !== "cancelled") {
|
|
return { projectId: issue.projectId };
|
|
}
|
|
const expiredAt = new Date();
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "expired", resolvedAt: expiredAt, updatedAt: expiredAt })
|
|
.where(eq(toolActionRequests.id, claimed.id));
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: claimed.id,
|
|
status: "expired",
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"The issue for this tool action is closed; the approval has expired",
|
|
"action_issue_closed",
|
|
{ actionRequestId: claimed.id, invocationId: invocation.id },
|
|
);
|
|
}
|
|
|
|
async function restoreApprovedActionIdentity(
|
|
session: ToolGatewaySession,
|
|
identityContextId: string | undefined,
|
|
) {
|
|
if (!identityContextId) return;
|
|
const [origin] = await db
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.id, identityContextId),
|
|
eq(runIdentityContexts.companyId, session.companyId),
|
|
eq(runIdentityContexts.runId, session.runId!),
|
|
eq(runIdentityContexts.status, "accepted"),
|
|
),
|
|
);
|
|
if (!origin)
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Approved action identity is unavailable",
|
|
"identity_context_unavailable",
|
|
);
|
|
session.identityContextId = origin.id;
|
|
session.responsibleUserId =
|
|
origin.cause === "company_default" ? null : origin.responsibleUserId;
|
|
}
|
|
|
|
async function executeApprovedAgentInvocation(input: {
|
|
actionRequest: typeof toolActionRequests.$inferSelect;
|
|
invocation: typeof toolInvocations.$inferSelect;
|
|
}) {
|
|
const { actionRequest, invocation } = input;
|
|
if (
|
|
!invocation.agentId ||
|
|
!invocation.issueId ||
|
|
isTestOriginInvocation(invocation)
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action request is not an agent-origin action",
|
|
"action_origin_invalid",
|
|
);
|
|
}
|
|
|
|
const [claimed] = await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "executing", updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, actionRequest.id),
|
|
eq(toolActionRequests.status, "approved"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!claimed) {
|
|
const settled = await waitForActionRequestExecution(actionRequest.id);
|
|
const [settledInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, invocation.id))
|
|
.limit(1);
|
|
if (settled?.status === "executed" && settledInvocation) {
|
|
return storedInvocationResult(settledInvocation);
|
|
}
|
|
if (settled?.status === "failed") {
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
settledInvocation?.errorMessage ?? "Approved tool action failed",
|
|
settledInvocation?.errorCode ?? "tool_execution_failed",
|
|
{ actionRequestId: actionRequest.id, invocationId: invocation.id },
|
|
);
|
|
}
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action request was already consumed",
|
|
"action_already_consumed",
|
|
);
|
|
}
|
|
|
|
// Terminal-issue expiry revokes pending/approved requests, but a claim that
|
|
// committed just before the issue closed slips past that revocation. Recheck
|
|
// the issue after winning the claim so a governed action never runs external
|
|
// side effects for an issue that is already done or cancelled.
|
|
const issue = await assertIssueOpenForApprovedAction({
|
|
claimed,
|
|
invocation,
|
|
});
|
|
|
|
const signedPayload = readSignedToolArgumentsPayload({
|
|
signedArguments: claimed.signedArguments,
|
|
invocationId: invocation.id,
|
|
toolName: invocation.toolName,
|
|
signingSecret: options.toolActionSigningSecret,
|
|
});
|
|
if (!signedPayload) {
|
|
const error = new ToolGatewayHttpError(
|
|
409,
|
|
"Approved tool action arguments signature is invalid",
|
|
"signed_arguments_invalid",
|
|
);
|
|
await markApprovedActionFailed({
|
|
actionRequestId: claimed.id,
|
|
invocationId: invocation.id,
|
|
claimUpdatedAt: claimed.updatedAt,
|
|
expectedInvocationStatus: "awaiting_approval",
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
if (signedPayload.executionOnApprove !== true) {
|
|
const error = new ToolGatewayHttpError(
|
|
409,
|
|
"This approval predates execute-on-approve and must remain inert",
|
|
"legacy_approved_action_inert",
|
|
);
|
|
await markApprovedActionFailed({
|
|
actionRequestId: claimed.id,
|
|
invocationId: invocation.id,
|
|
claimUpdatedAt: claimed.updatedAt,
|
|
expectedInvocationStatus: "awaiting_approval",
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
const session: ToolGatewaySession = {
|
|
id: `approved-action:${claimed.id}`,
|
|
token: "",
|
|
companyId: invocation.companyId,
|
|
agentId: invocation.agentId,
|
|
runId: invocation.runId,
|
|
issueId: invocation.issueId,
|
|
projectId: issue?.projectId ?? null,
|
|
gatewayId: invocation.gatewayId,
|
|
gatewayPublicId: invocation.gatewayPublicId,
|
|
gatewayTokenId: invocation.gatewayTokenId,
|
|
actorType: "agent",
|
|
actorId: invocation.agentId,
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
|
|
};
|
|
let tool: ToolGatewayDescriptor;
|
|
let liveApprovalSnapshot: Awaited<
|
|
ReturnType<typeof connectedRemoteApprovalSnapshot>
|
|
>;
|
|
try {
|
|
await restoreApprovedActionIdentity(
|
|
session,
|
|
signedPayload.identityContextId,
|
|
);
|
|
session.approvedInvocationId = invocation.id;
|
|
session.approvedSlackInvocationId = invocation.id;
|
|
tool = await findToolForSession(session, invocation.toolName);
|
|
liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(
|
|
session,
|
|
tool,
|
|
);
|
|
const currentAccess = await policyService.decide(
|
|
policyInputForTool({
|
|
session,
|
|
tool,
|
|
parameters: signedPayload.arguments,
|
|
}),
|
|
);
|
|
if (
|
|
!currentAccess.allowed &&
|
|
currentAccess.decision !== "require_approval"
|
|
)
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
currentAccess.explanation,
|
|
currentAccess.reasonCode,
|
|
);
|
|
} catch (error) {
|
|
await markApprovedActionFailed({
|
|
actionRequestId: claimed.id,
|
|
invocationId: invocation.id,
|
|
claimUpdatedAt: claimed.updatedAt,
|
|
expectedInvocationStatus: "awaiting_approval",
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
if (
|
|
!approvalSnapshotsMatch(
|
|
signedPayload.approvalSnapshot,
|
|
liveApprovalSnapshot,
|
|
)
|
|
) {
|
|
const error = new ToolGatewayHttpError(
|
|
409,
|
|
"Approved tool action target changed after review",
|
|
"approved_tool_target_changed",
|
|
);
|
|
await markApprovedActionFailed({
|
|
actionRequestId: claimed.id,
|
|
invocationId: invocation.id,
|
|
claimUpdatedAt: claimed.updatedAt,
|
|
expectedInvocationStatus: "awaiting_approval",
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
const parameters = signedPayload.arguments;
|
|
const canonicalArguments = canonicalToolArguments(parameters);
|
|
if (
|
|
claimed.canonicalArgumentsHash !==
|
|
summarizeToolValue(parameters).sha256 ||
|
|
!verifyToolArgumentsSignature({
|
|
signedArguments: claimed.signedArguments,
|
|
invocationId: invocation.id,
|
|
toolName: invocation.toolName,
|
|
canonicalArguments,
|
|
approvalSnapshot: signedPayload.approvalSnapshot,
|
|
executionOnApprove: true,
|
|
identityContextId: signedPayload.identityContextId,
|
|
signingSecret: options.toolActionSigningSecret,
|
|
})
|
|
) {
|
|
const error = new ToolGatewayHttpError(
|
|
409,
|
|
"Approved tool action arguments do not match reviewed hash",
|
|
"signed_arguments_mismatch",
|
|
);
|
|
await markApprovedActionFailed({
|
|
actionRequestId: claimed.id,
|
|
invocationId: invocation.id,
|
|
claimUpdatedAt: claimed.updatedAt,
|
|
expectedInvocationStatus: "awaiting_approval",
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
let managedArgumentsRemainCurrent: boolean;
|
|
try {
|
|
managedArgumentsRemainCurrent =
|
|
await approvedManagedArgumentsRemainCurrent(session, tool, parameters);
|
|
} catch (error) {
|
|
await markApprovedActionFailed({
|
|
actionRequestId: claimed.id,
|
|
invocationId: invocation.id,
|
|
claimUpdatedAt: claimed.updatedAt,
|
|
expectedInvocationStatus: "awaiting_approval",
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
if (!managedArgumentsRemainCurrent) {
|
|
throw await expireApprovedActionForManagedArgumentDrift({
|
|
actionRequestId: claimed.id,
|
|
invocationId: invocation.id,
|
|
toolName: invocation.toolName,
|
|
ownsExecutingClaim: true,
|
|
});
|
|
}
|
|
|
|
const argumentsSummary = validateToolContent({
|
|
value: parameters,
|
|
direction: "arguments",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "ignore",
|
|
}).summary;
|
|
// Final recheck at the last DB write before dispatch: tool and snapshot
|
|
// resolution above involve network calls, so re-verify the issue is still
|
|
// open now that only the provider call remains. A close that commits after
|
|
// this read has raced an execution that was approved, claimed, and verified
|
|
// while the issue was open; that instant is irreducible for an external
|
|
// side effect gated by DB state (holding a DB lock across a remote provider
|
|
// call is not an option), and the accepted linearization is that the
|
|
// execution wins — its result still lands on the expired card via
|
|
// reflectToolActionInteractionLifecycle.
|
|
await assertIssueOpenForApprovedAction({ claimed, invocation });
|
|
|
|
const startedAt = Date.now();
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "executing",
|
|
approvalState: "approved",
|
|
startedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, invocation.id));
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: claimed.id,
|
|
status: "executing",
|
|
});
|
|
|
|
try {
|
|
const executionTimeoutMs = timeoutMs(APPROVED_EXECUTION_TIMEOUT_MS);
|
|
const result =
|
|
(tool.providerType === "mcp_remote_http" || tool.providerType === "provider_rest")
|
|
? (
|
|
await executeRemoteHttpTool(
|
|
session,
|
|
tool,
|
|
parameters,
|
|
executionTimeoutMs,
|
|
invocation.id,
|
|
)
|
|
).result
|
|
: tool.providerType === "mcp_local_stdio"
|
|
? (
|
|
await executeLocalStdioTool(
|
|
session,
|
|
tool,
|
|
parameters,
|
|
executionTimeoutMs,
|
|
)
|
|
).result
|
|
: tool.providerType !== "paperclip_plugin"
|
|
? await runWithTimeout(
|
|
executeBuiltinTool(session, tool, parameters, invocation.id),
|
|
executionTimeoutMs,
|
|
)
|
|
: (() => {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Plugin actions cannot execute outside their originating run",
|
|
"approved_execution_unsupported",
|
|
);
|
|
})();
|
|
const resultRecord = asRecord(result);
|
|
if (resultRecord?.error)
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
String(resultRecord.content || resultRecord.error),
|
|
"tool_execution_failed",
|
|
);
|
|
const resultValidation = validateToolContent({
|
|
value: result,
|
|
direction: "result",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "block",
|
|
});
|
|
const now = new Date();
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "succeeded",
|
|
resultHash: resultValidation.summary.sha256 ?? null,
|
|
resultSummary: resultValidation.summary,
|
|
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
|
|
completedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolInvocations.id, invocation.id));
|
|
void emitConnectionInvoked(db, invocation.id);
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "executed", resolvedAt: now, updatedAt: now })
|
|
.where(eq(toolActionRequests.id, claimed.id));
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: claimed.id,
|
|
status: "executed",
|
|
resultSummary: resultValidation.summary.summary,
|
|
});
|
|
await writeToolCallEvent({
|
|
invocationId: invocation.id,
|
|
actionRequestId: claimed.id,
|
|
session,
|
|
eventType: "call_completed",
|
|
outcome: "success",
|
|
toolName: tool.name,
|
|
policyDecision: "allow",
|
|
reasonCode: "approved_action_executed",
|
|
argumentsSummary,
|
|
resultSummary: resultValidation.summary,
|
|
metadata: {
|
|
durationMs: Date.now() - startedAt,
|
|
timeoutMs: executionTimeoutMs,
|
|
},
|
|
tool,
|
|
});
|
|
return resultValidation.value;
|
|
} catch (error) {
|
|
const { reasonCode } = await markApprovedActionFailed({
|
|
actionRequestId: claimed.id,
|
|
invocationId: invocation.id,
|
|
claimUpdatedAt: claimed.updatedAt,
|
|
expectedInvocationStatus: "executing",
|
|
error,
|
|
});
|
|
await writeToolCallEvent({
|
|
invocationId: invocation.id,
|
|
actionRequestId: claimed.id,
|
|
session,
|
|
eventType: "call_failed",
|
|
outcome: "failure",
|
|
toolName: tool.name,
|
|
policyDecision: "deny",
|
|
reasonCode,
|
|
argumentsSummary,
|
|
metadata: { durationMs: Date.now() - startedAt },
|
|
tool,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function matchingAgentActionRequest(input: {
|
|
session: ToolGatewaySession;
|
|
toolName: string;
|
|
argumentsHash: string;
|
|
}) {
|
|
if (!input.session.issueId || !input.session.agentId) return null;
|
|
const [match] = await db
|
|
.select({
|
|
actionRequest: toolActionRequests,
|
|
invocation: toolInvocations,
|
|
})
|
|
.from(toolActionRequests)
|
|
.innerJoin(
|
|
toolInvocations,
|
|
eq(toolInvocations.id, toolActionRequests.invocationId),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, input.session.companyId),
|
|
eq(toolActionRequests.issueId, input.session.issueId),
|
|
eq(toolActionRequests.canonicalArgumentsHash, input.argumentsHash),
|
|
eq(toolInvocations.agentId, input.session.agentId),
|
|
eq(toolInvocations.toolName, input.toolName),
|
|
inArray(toolActionRequests.status, [
|
|
"pending",
|
|
"approved",
|
|
"executing",
|
|
"rejected",
|
|
"executed",
|
|
"failed",
|
|
]),
|
|
),
|
|
)
|
|
.orderBy(desc(toolActionRequests.createdAt))
|
|
.limit(1);
|
|
if (!match) return null;
|
|
if (match.actionRequest.status === "failed" && match.invocation.errorCode !== "provider_interaction_required") return null;
|
|
// The gateway builds an ask-first request in two steps inside one call: it
|
|
// inserts the row with a null signature and a null expiry, then signs the
|
|
// row and sets the expiry. A concurrent matching call can observe the row in
|
|
// this window. A null signature does not prove the create stopped, because a
|
|
// parallel create can still be signing the same row right now. Only treat an
|
|
// unsigned row as abandoned after the grace time from createdAt has passed;
|
|
// before that, return the match so the retry replays approval_required and
|
|
// does not create a duplicate request or expire a live row. After the grace
|
|
// time an unsigned row stays pending forever and the review queue hides it,
|
|
// so it can never be approved. Do not replay it as a live approval. Expire
|
|
// the row and let the retry create a fresh, signable request. A null expiry
|
|
// alone (without this guard) also makes the getTime() check below unsafe.
|
|
const pendingRequest = match.actionRequest;
|
|
const pendingUnsigned =
|
|
pendingRequest.status === "pending" &&
|
|
pendingRequest.signedArguments === null &&
|
|
Date.now() - pendingRequest.createdAt.getTime() >=
|
|
TOOL_ACTION_REQUEST_SIGNING_GRACE_MS;
|
|
const pendingExpired =
|
|
pendingRequest.status === "pending" &&
|
|
pendingRequest.expiresAt !== null &&
|
|
pendingRequest.expiresAt.getTime() <= Date.now();
|
|
if (pendingUnsigned || pendingExpired) {
|
|
const now = new Date();
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "expired", resolvedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, match.actionRequest.id),
|
|
eq(toolActionRequests.status, "pending"),
|
|
),
|
|
);
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
approvalState: "expired",
|
|
idempotencyKey: null,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolInvocations.id, match.invocation.id));
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: match.actionRequest.id,
|
|
status: "expired",
|
|
});
|
|
return null;
|
|
}
|
|
return match;
|
|
}
|
|
|
|
async function replayMatchingAgentAction(input: {
|
|
session: ToolGatewaySession;
|
|
toolName: string;
|
|
argumentsHash: string;
|
|
}) {
|
|
const match = await matchingAgentActionRequest(input);
|
|
if (!match) return null;
|
|
const { actionRequest, invocation } = match;
|
|
if (actionRequest.status === "failed") {
|
|
throw new ToolGatewayHttpError(409,
|
|
"The provider is waiting for authorization or approval. Continue the existing execution; do not repeat the original call.",
|
|
"provider_interaction_required", {
|
|
invocationId: invocation.id, actionRequestId: actionRequest.id,
|
|
upstreamPending: recoverUpstreamHandoff(invocation),
|
|
});
|
|
}
|
|
if (actionRequest.status === "pending") {
|
|
await throwApprovalRequired({
|
|
invocationId: invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
interactionId: actionRequest.interactionId,
|
|
issueId: input.session.issueId!,
|
|
toolName: input.toolName,
|
|
argumentsHash: input.argumentsHash,
|
|
});
|
|
}
|
|
if (actionRequest.status === "rejected") {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"This tool action was declined; do not retry the same call",
|
|
"action_declined",
|
|
{
|
|
invocationId: invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
instructions:
|
|
"The action was declined. Do not retry the same call; adjust your approach or report the decline on the task.",
|
|
},
|
|
);
|
|
}
|
|
if (actionRequest.status === "executed") {
|
|
return {
|
|
matched: true as const,
|
|
result: storedInvocationResult(invocation),
|
|
invocationId: invocation.id,
|
|
};
|
|
}
|
|
if (actionRequest.status === "executing") {
|
|
const settled = await waitForActionRequestExecution(actionRequest.id);
|
|
const [settledInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, invocation.id))
|
|
.limit(1);
|
|
if (settled?.status === "executed" && settledInvocation) {
|
|
return {
|
|
matched: true as const,
|
|
result: storedInvocationResult(settledInvocation),
|
|
invocationId: invocation.id,
|
|
};
|
|
}
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
settledInvocation?.errorMessage ?? "Approved tool action failed",
|
|
settledInvocation?.errorCode ?? "tool_execution_failed",
|
|
);
|
|
}
|
|
if (actionRequest.status === "approved" && actionRequest.decidedAt) {
|
|
const result = await executeApprovedAgentInvocation({
|
|
actionRequest,
|
|
invocation,
|
|
});
|
|
return { matched: true as const, result, invocationId: invocation.id };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Project an ask-first test request + its invocation onto the lifecycle the
|
|
* Test tab panel renders. Recovers the redacted parameter snapshot (the
|
|
* "Where" row) and, once the call has run, the structured result or error.
|
|
*/
|
|
function buildTestCallStatus(
|
|
actionRequest: typeof toolActionRequests.$inferSelect,
|
|
invocation: typeof toolInvocations.$inferSelect,
|
|
): ToolConnectionTestCallStatus {
|
|
const invocationDone =
|
|
invocation.status === "succeeded" ||
|
|
invocation.status === "failed" ||
|
|
invocation.status === "timed_out" ||
|
|
invocation.status === "rate_limited" ||
|
|
invocation.status === "denied";
|
|
|
|
let phase: ToolConnectionTestCallStatusPhase;
|
|
if (actionRequest.status === "rejected") {
|
|
phase = "denied";
|
|
} else if (actionRequest.status === "cancelled") {
|
|
phase = "cancelled";
|
|
} else if (actionRequest.status === "expired") {
|
|
phase = "expired";
|
|
} else if (
|
|
actionRequest.status === "approved" ||
|
|
actionRequest.status === "executed" || actionRequest.status === "failed"
|
|
) {
|
|
phase = invocationDone ? "done" : "running";
|
|
} else {
|
|
phase = "waiting";
|
|
}
|
|
|
|
// Recover a redacted, structured snapshot of the parameters for the
|
|
// "Where" row — the test-call response never echoes them back.
|
|
let parameters: Record<string, unknown> | null = null;
|
|
const signed = readSignedToolArgumentsPayload({
|
|
signedArguments: actionRequest.signedArguments,
|
|
invocationId: invocation.id,
|
|
toolName: invocation.toolName,
|
|
signingSecret: options.toolActionSigningSecret,
|
|
});
|
|
if (
|
|
signed &&
|
|
signed.arguments &&
|
|
typeof signed.arguments === "object" &&
|
|
!Array.isArray(signed.arguments)
|
|
) {
|
|
const redacted = validateToolContent({
|
|
value: signed.arguments,
|
|
direction: "arguments",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "ignore",
|
|
}).value;
|
|
if (
|
|
redacted &&
|
|
typeof redacted === "object" &&
|
|
!Array.isArray(redacted)
|
|
) {
|
|
parameters = redacted as Record<string, unknown>;
|
|
}
|
|
}
|
|
|
|
let result: unknown;
|
|
let error: ToolConnectionTestCallStatus["error"];
|
|
if (phase === "done") {
|
|
if (invocation.status === "succeeded") {
|
|
const summary = invocation.resultSummary?.summary;
|
|
if (typeof summary === "string") {
|
|
try {
|
|
result = JSON.parse(summary);
|
|
} catch {
|
|
result = summary;
|
|
}
|
|
} else {
|
|
result = null;
|
|
}
|
|
} else {
|
|
error = {
|
|
message: invocation.errorMessage ?? "The call didn't complete.",
|
|
reasonCode: invocation.errorCode ?? null,
|
|
};
|
|
}
|
|
}
|
|
|
|
const durationMs =
|
|
invocation.startedAt && invocation.completedAt
|
|
? Math.max(
|
|
0,
|
|
invocation.completedAt.getTime() - invocation.startedAt.getTime(),
|
|
)
|
|
: null;
|
|
|
|
return {
|
|
actionRequestId: actionRequest.id,
|
|
invocationId: invocation.id,
|
|
phase,
|
|
parameters,
|
|
...(result !== undefined ? { result } : {}),
|
|
...(error ? { error } : {}),
|
|
...(recoverUpstreamHandoff(invocation) ? { upstreamPending: recoverUpstreamHandoff(invocation) } : {}),
|
|
durationMs,
|
|
requestedAt: actionRequest.createdAt.toISOString(),
|
|
resolvedAt: actionRequest.resolvedAt
|
|
? actionRequest.resolvedAt.toISOString()
|
|
: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
async recordRuntimeMcpDeliveryDiagnostic(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
permittedNotInstalledConnections: Array<{ id: string; name: string }>;
|
|
}) {
|
|
if (input.permittedNotInstalledConnections.length === 0) return;
|
|
const [run] = await db
|
|
.select({
|
|
issueId: sql<
|
|
string | null
|
|
>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.agentId, input.agentId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
await writeAudit({
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: run?.issueId ?? null,
|
|
action: "tool_gateway.runtime_mcp_delivery",
|
|
details: {
|
|
decision: "diagnostic",
|
|
reasonCode: "permitted_connections_not_installed",
|
|
deliveredServerCount: 0,
|
|
permittedNotInstalledCount:
|
|
input.permittedNotInstalledConnections.length,
|
|
permittedNotInstalledConnections:
|
|
input.permittedNotInstalledConnections,
|
|
},
|
|
});
|
|
},
|
|
|
|
async listNamedGateways(
|
|
companyId: string,
|
|
): Promise<ToolMcpGatewayWithTokens[]> {
|
|
// Archived gateways are retired — they must not appear in the list UI.
|
|
const gateways = await db
|
|
.select()
|
|
.from(toolMcpGateways)
|
|
.where(
|
|
and(
|
|
eq(toolMcpGateways.companyId, companyId),
|
|
ne(toolMcpGateways.status, "archived"),
|
|
),
|
|
)
|
|
.orderBy(desc(toolMcpGateways.createdAt));
|
|
const rows = await Promise.all(
|
|
gateways.map((gateway) => getGatewayWithTokens(companyId, gateway.id)),
|
|
);
|
|
return rows;
|
|
},
|
|
|
|
async createNamedGateway(input: {
|
|
companyId: string;
|
|
body: CreateToolMcpGateway;
|
|
actor?: { agentId?: string | null; userId?: string | null };
|
|
}): Promise<ToolMcpGatewayWithTokens> {
|
|
await assertGatewayContext({
|
|
companyId: input.companyId,
|
|
profileId: input.body.profileId,
|
|
agentId: input.body.agentId ?? null,
|
|
projectId: input.body.projectId ?? null,
|
|
issueId: input.body.issueId ?? null,
|
|
});
|
|
const now = new Date();
|
|
const slug =
|
|
input.body.displaySlug ??
|
|
input.body.slug ??
|
|
slugSegment(input.body.name, "gateway");
|
|
const [gateway] = await db
|
|
.insert(toolMcpGateways)
|
|
.values({
|
|
companyId: input.companyId,
|
|
name: input.body.name,
|
|
slug,
|
|
displaySlug: slug,
|
|
description: input.body.description ?? null,
|
|
defaultProfileMode: input.body.defaultProfileMode ?? "gateway_only",
|
|
contextScopeType: input.body.contextScopeType ?? "none",
|
|
contextScopeId: input.body.contextScopeId ?? null,
|
|
profileId: input.body.profileId,
|
|
agentId: input.body.agentId ?? null,
|
|
projectId: input.body.projectId ?? null,
|
|
issueId: input.body.issueId ?? null,
|
|
approvalIssueId: input.body.approvalIssueId ?? null,
|
|
...(input.body.authConfig !== undefined
|
|
? { authConfig: input.body.authConfig }
|
|
: {}),
|
|
...(input.body.headerPolicy !== undefined
|
|
? { headerPolicy: input.body.headerPolicy }
|
|
: {}),
|
|
...(input.body.metadataPolicy !== undefined
|
|
? { metadataPolicy: input.body.metadataPolicy }
|
|
: {}),
|
|
...(input.body.onDemandToolsConfig !== undefined
|
|
? { onDemandToolsConfig: input.body.onDemandToolsConfig }
|
|
: {}),
|
|
metadata: input.body.metadata ?? {},
|
|
createdByAgentId: input.actor?.agentId ?? null,
|
|
createdByUserId: input.actor?.userId ?? null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.returning();
|
|
await db
|
|
.insert(toolProfileBindings)
|
|
.values({
|
|
companyId: input.companyId,
|
|
profileId: input.body.profileId,
|
|
targetType: "gateway",
|
|
targetId: gateway.id,
|
|
priority: 10,
|
|
metadata: { source: "named_mcp_gateway" },
|
|
createdByAgentId: input.actor?.agentId ?? null,
|
|
createdByUserId: input.actor?.userId ?? null,
|
|
})
|
|
.onConflictDoNothing();
|
|
await writeAudit({
|
|
session: {
|
|
id: `gateway:${gateway.id}`,
|
|
token: "",
|
|
companyId: gateway.companyId,
|
|
agentId: gateway.agentId,
|
|
runId: null,
|
|
issueId: gateway.issueId,
|
|
projectId: gateway.projectId,
|
|
gatewayId: gateway.id,
|
|
gatewayName: gateway.name,
|
|
actorType: input.actor?.agentId
|
|
? "agent"
|
|
: input.actor?.userId
|
|
? "user"
|
|
: "system",
|
|
actorId: input.actor?.agentId ?? input.actor?.userId ?? gateway.id,
|
|
createdAt: now,
|
|
expiresAt: now,
|
|
},
|
|
companyId: input.companyId,
|
|
agentId: input.actor?.agentId ?? gateway.agentId,
|
|
runId: null,
|
|
issueId: gateway.issueId,
|
|
actorType: input.actor?.agentId
|
|
? "agent"
|
|
: input.actor?.userId
|
|
? "user"
|
|
: "system",
|
|
actorId: input.actor?.agentId ?? input.actor?.userId ?? gateway.id,
|
|
action: "tool_gateway.session_created",
|
|
details: {
|
|
decision: "allow",
|
|
reasonCode: "named_gateway_created",
|
|
gatewayId: gateway.id,
|
|
gatewayName: gateway.name,
|
|
profileId: gateway.profileId,
|
|
},
|
|
});
|
|
return getGatewayWithTokens(input.companyId, gateway.id);
|
|
},
|
|
|
|
async updateNamedGateway(input: {
|
|
companyId: string;
|
|
gatewayId: string;
|
|
body: UpdateToolMcpGateway;
|
|
}): Promise<ToolMcpGatewayWithTokens> {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(toolMcpGateways)
|
|
.where(
|
|
and(
|
|
eq(toolMcpGateways.companyId, input.companyId),
|
|
eq(toolMcpGateways.id, input.gatewayId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!existing)
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"MCP gateway not found",
|
|
"gateway_not_found",
|
|
);
|
|
await assertGatewayContext({
|
|
companyId: input.companyId,
|
|
profileId: input.body.profileId ?? existing.profileId,
|
|
agentId:
|
|
input.body.agentId === undefined
|
|
? existing.agentId
|
|
: input.body.agentId,
|
|
projectId:
|
|
input.body.projectId === undefined
|
|
? existing.projectId
|
|
: input.body.projectId,
|
|
issueId:
|
|
input.body.issueId === undefined
|
|
? existing.issueId
|
|
: input.body.issueId,
|
|
});
|
|
const [updated] = await db
|
|
.update(toolMcpGateways)
|
|
.set({
|
|
...(input.body.name !== undefined ? { name: input.body.name } : {}),
|
|
...(input.body.slug !== undefined ||
|
|
input.body.displaySlug !== undefined
|
|
? { slug: input.body.displaySlug ?? input.body.slug }
|
|
: {}),
|
|
...(input.body.slug !== undefined ||
|
|
input.body.displaySlug !== undefined
|
|
? { displaySlug: input.body.displaySlug ?? input.body.slug }
|
|
: {}),
|
|
...(input.body.description !== undefined
|
|
? { description: input.body.description ?? null }
|
|
: {}),
|
|
...(input.body.status !== undefined
|
|
? { status: input.body.status }
|
|
: {}),
|
|
...(input.body.profileId !== undefined
|
|
? { profileId: input.body.profileId }
|
|
: {}),
|
|
...(input.body.defaultProfileMode !== undefined
|
|
? { defaultProfileMode: input.body.defaultProfileMode }
|
|
: {}),
|
|
...(input.body.contextScopeType !== undefined
|
|
? { contextScopeType: input.body.contextScopeType }
|
|
: {}),
|
|
...(input.body.contextScopeId !== undefined
|
|
? { contextScopeId: input.body.contextScopeId ?? null }
|
|
: {}),
|
|
...(input.body.agentId !== undefined
|
|
? { agentId: input.body.agentId ?? null }
|
|
: {}),
|
|
...(input.body.projectId !== undefined
|
|
? { projectId: input.body.projectId ?? null }
|
|
: {}),
|
|
...(input.body.issueId !== undefined
|
|
? { issueId: input.body.issueId ?? null }
|
|
: {}),
|
|
...(input.body.approvalIssueId !== undefined
|
|
? { approvalIssueId: input.body.approvalIssueId ?? null }
|
|
: {}),
|
|
...(input.body.authConfig !== undefined
|
|
? { authConfig: input.body.authConfig }
|
|
: {}),
|
|
...(input.body.headerPolicy !== undefined
|
|
? { headerPolicy: input.body.headerPolicy }
|
|
: {}),
|
|
...(input.body.metadataPolicy !== undefined
|
|
? { metadataPolicy: input.body.metadataPolicy }
|
|
: {}),
|
|
...(input.body.onDemandToolsConfig !== undefined
|
|
? { onDemandToolsConfig: input.body.onDemandToolsConfig }
|
|
: {}),
|
|
...(input.body.metadata !== undefined
|
|
? { metadata: input.body.metadata ?? {} }
|
|
: {}),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolMcpGateways.companyId, input.companyId),
|
|
eq(toolMcpGateways.id, input.gatewayId),
|
|
),
|
|
)
|
|
.returning();
|
|
if (input.body.profileId && input.body.profileId !== existing.profileId) {
|
|
await db
|
|
.insert(toolProfileBindings)
|
|
.values({
|
|
companyId: input.companyId,
|
|
profileId: input.body.profileId,
|
|
targetType: "gateway",
|
|
targetId: input.gatewayId,
|
|
priority: 10,
|
|
metadata: { source: "named_mcp_gateway" },
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
return getGatewayWithTokens(input.companyId, updated.id);
|
|
},
|
|
|
|
async createNamedGatewayToken(input: {
|
|
companyId: string;
|
|
gatewayId: string;
|
|
body: CreateToolMcpGatewayToken;
|
|
actor?: { agentId?: string | null; userId?: string | null };
|
|
}): Promise<ToolMcpGatewayTokenCreated> {
|
|
const [gateway] = await db
|
|
.select()
|
|
.from(toolMcpGateways)
|
|
.where(
|
|
and(
|
|
eq(toolMcpGateways.companyId, input.companyId),
|
|
eq(toolMcpGateways.id, input.gatewayId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!gateway)
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"MCP gateway not found",
|
|
"gateway_not_found",
|
|
);
|
|
const tokenId = randomUUID();
|
|
const token = generateNamedGatewayToken(tokenId);
|
|
const tokenPrefix = `pcgw_${tokenId.slice(0, 8)}`;
|
|
const now = new Date();
|
|
const [row] = await db
|
|
.insert(toolMcpGatewayTokens)
|
|
.values({
|
|
id: tokenId,
|
|
companyId: input.companyId,
|
|
gatewayId: input.gatewayId,
|
|
name: input.body.name,
|
|
tokenHash: hashGatewayToken(token),
|
|
tokenPrefix,
|
|
subjectType: input.body.subjectType ?? "gateway_client",
|
|
subjectId: input.body.subjectId ?? null,
|
|
clientLabel: input.body.clientLabel,
|
|
ownerNote: input.body.ownerNote,
|
|
allowedActions: input.body.allowedActions ?? [
|
|
"tools/list",
|
|
"tools/call",
|
|
"resources/list",
|
|
"resources/read",
|
|
"prompts/list",
|
|
"prompts/get",
|
|
],
|
|
expiresAt: input.body.expiresAt ?? null,
|
|
expiryOverrideReason: input.body.expiryOverrideReason ?? null,
|
|
expiryOverrideByAgentId:
|
|
input.actor?.agentId && input.body.expiryOverrideReason
|
|
? input.actor.agentId
|
|
: null,
|
|
expiryOverrideByUserId:
|
|
input.actor?.userId && input.body.expiryOverrideReason
|
|
? input.actor.userId
|
|
: null,
|
|
expiryOverrideAt: input.body.expiryOverrideReason ? now : null,
|
|
createdByAgentId: input.actor?.agentId ?? null,
|
|
createdByUserId: input.actor?.userId ?? null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.returning();
|
|
return { ...toGatewayToken(row), token };
|
|
},
|
|
|
|
async revokeNamedGatewayToken(input: {
|
|
companyId: string;
|
|
tokenId: string;
|
|
revokedAt?: Date;
|
|
}): Promise<ToolMcpGatewayToken> {
|
|
const now = input.revokedAt ?? new Date();
|
|
const [row] = await db
|
|
.update(toolMcpGatewayTokens)
|
|
.set({ revokedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolMcpGatewayTokens.companyId, input.companyId),
|
|
eq(toolMcpGatewayTokens.id, input.tokenId),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!row)
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"MCP gateway token not found",
|
|
"gateway_token_not_found",
|
|
);
|
|
return toGatewayToken(row);
|
|
},
|
|
|
|
async initializeNamedGatewayProtocol(input: {
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
bearerToken: string;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
}): Promise<ToolGatewaySession> {
|
|
return namedGatewaySessionFromBearer({
|
|
gatewayId: input.gatewayId ?? null,
|
|
gatewayPublicId: input.gatewayPublicId ?? null,
|
|
bearerToken: input.bearerToken,
|
|
protocolMethod: "initialize",
|
|
callerHeaders: input.callerHeaders,
|
|
});
|
|
},
|
|
|
|
async listToolsForNamedGateway(input: {
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
bearerToken: string;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
signal?: AbortSignal;
|
|
}): Promise<ToolGatewayDescriptor[]> {
|
|
input.signal?.throwIfAborted();
|
|
const session = await namedGatewaySessionFromBearer({
|
|
gatewayId: input.gatewayId ?? null,
|
|
gatewayPublicId: input.gatewayPublicId ?? null,
|
|
bearerToken: input.bearerToken,
|
|
protocolMethod: "tools/list",
|
|
callerHeaders: input.callerHeaders,
|
|
});
|
|
await assertGatewayTokenAction(session, "tools/list");
|
|
const tools = await listToolsForContext(session, input.signal);
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: "tool_gateway.discovery",
|
|
details: {
|
|
decision: "allow",
|
|
reasonCode: "named_gateway_discovery_filtered",
|
|
visibleToolCount: tools.length,
|
|
visibleToolsHash: createHash("sha256").update(JSON.stringify(tools.map((tool) => tool.name).sort())).digest("hex"),
|
|
},
|
|
});
|
|
return tools;
|
|
},
|
|
|
|
async executeContextForNamedGateway(input: {
|
|
gatewayId?: string | null;
|
|
gatewayPublicId?: string | null;
|
|
bearerToken: string;
|
|
method:
|
|
"resources/list" | "resources/read" | "prompts/list" | "prompts/get";
|
|
params?: Record<string, unknown>;
|
|
callerHeaders?: Record<string, string | string[] | undefined>;
|
|
}): Promise<Record<string, unknown>> {
|
|
return namedGatewayContextProtocol(input);
|
|
},
|
|
|
|
async createSession(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
issueId?: string | null;
|
|
projectId?: string | null;
|
|
ttlMs?: number;
|
|
actorType?: LogActivityInput["actorType"];
|
|
actorId?: string;
|
|
}): Promise<ToolGatewaySession> {
|
|
await assertAgentInCompany(input.companyId, input.agentId);
|
|
const { issueId, projectId } = await resolveRunContext(input);
|
|
const now = new Date();
|
|
const sessionId = randomUUID();
|
|
const token = generateGatewayToken(sessionId);
|
|
const session: ToolGatewaySession = {
|
|
id: sessionId,
|
|
token,
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId,
|
|
projectId,
|
|
createdAt: now,
|
|
expiresAt: new Date(now.getTime() + sessionTtlMs(input.ttlMs)),
|
|
};
|
|
|
|
await db.insert(toolGatewaySessions).values({
|
|
id: session.id,
|
|
companyId: session.companyId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: session.issueId,
|
|
projectId: session.projectId,
|
|
tokenHash: hashGatewayToken(token),
|
|
expiresAt: session.expiresAt,
|
|
createdAt: session.createdAt,
|
|
updatedAt: session.createdAt,
|
|
} as any);
|
|
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
actorType: input.actorType,
|
|
actorId: input.actorId,
|
|
action: "tool_gateway.session_created",
|
|
details: {
|
|
decision: "allow",
|
|
reasonCode: "session_created",
|
|
expiresAt: session.expiresAt.toISOString(),
|
|
},
|
|
});
|
|
|
|
return session;
|
|
},
|
|
|
|
async listToolsForSession(
|
|
sessionToken: string,
|
|
options: { signal?: AbortSignal } = {},
|
|
): Promise<ToolGatewayDescriptor[]> {
|
|
options.signal?.throwIfAborted();
|
|
const session = await getActiveSession(sessionToken);
|
|
const tools = await listToolsForContext(session, options.signal);
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: "tool_gateway.discovery",
|
|
details: {
|
|
decision: "allow",
|
|
reasonCode: "discovery_filtered",
|
|
visibleToolCount: tools.length,
|
|
visibleToolsHash: createHash("sha256").update(JSON.stringify(tools.map((tool) => tool.name).sort())).digest("hex"),
|
|
},
|
|
});
|
|
return tools;
|
|
},
|
|
|
|
async listPluginToolsForAgent(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
}): Promise<AgentToolDescriptor[]> {
|
|
await assertAgentInCompany(input.companyId, input.agentId);
|
|
const decisions = await decideToolsForListing(pluginTools(), (tool) =>
|
|
policyInputForAgentTool({
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
tool,
|
|
}),
|
|
);
|
|
return decisions
|
|
.filter(
|
|
({ decision }) =>
|
|
decision.allowed || decision.decision === "require_approval",
|
|
)
|
|
.map(({ tool }) => {
|
|
const {
|
|
providerType: _providerType,
|
|
risk: _risk,
|
|
...descriptor
|
|
} = tool;
|
|
return descriptor;
|
|
});
|
|
},
|
|
|
|
async browserUseSessionAuthorized(binding: { companyId: string; agentId: string; runId: string; issueId: string; connectionId: string; grantId: string; invocationId: string }) {
|
|
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, binding.companyId), eq(toolConnections.id, binding.connectionId)));
|
|
const [invocation] = await db.select().from(toolInvocations).where(and(eq(toolInvocations.companyId, binding.companyId), eq(toolInvocations.id, binding.invocationId)));
|
|
if (!connection || !invocation || !connection.enabled || connection.status !== "active") return false;
|
|
const [issue] = await db.select({ projectId: issues.projectId }).from(issues).where(and(eq(issues.companyId, binding.companyId), eq(issues.id, binding.issueId)));
|
|
const session: ToolGatewaySession = { ...binding, id: randomUUID(), token: "", projectId: issue?.projectId ?? null, createdAt: new Date(), expiresAt: new Date(Date.now() + 60000) };
|
|
try {
|
|
const grant = await resolveConnectionGrant(session, connection, false);
|
|
if (grant.id !== binding.grantId) return false;
|
|
const tool = (await connectedMcpToolsForConnection(binding.companyId, binding.connectionId)).find(t => t.name === invocation.toolName);
|
|
if (!tool) return false;
|
|
const decision = await policyService.decide(policyInputForTool({ session, tool, consumeRateLimit: false }));
|
|
return decision.allowed || decision.decision === "require_approval";
|
|
} catch { return false; }
|
|
},
|
|
|
|
/** Standing instructions use exactly the same authorization as tool discovery. */
|
|
async resolveConnectionInstructionsForRun(binding: { companyId: string; agentId: string; runId: string }) {
|
|
const candidates = await db.select().from(toolConnections).where(and(
|
|
eq(toolConnections.companyId, binding.companyId), eq(toolConnections.enabled, true),
|
|
eq(toolConnections.status, "active"), sql`${toolConnections.agentInstructions}->>'enabled' = 'true'`,
|
|
));
|
|
if (!candidates.length) return null;
|
|
const runContext = await resolveRunContext(binding);
|
|
const session = await captureSessionIdentity({
|
|
...binding, ...runContext, id: randomUUID(), token: "", createdAt: new Date(), expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
await assertAgentInCompany(binding.companyId, binding.agentId);
|
|
const candidateIds = new Set(candidates.map((connection) => connection.id));
|
|
// Prompt assembly needs authorization, not a full discovery listing. Share
|
|
// descriptors and policy decisions, but only authorize candidate sources;
|
|
// discovery admission pressure must not remove guidance or reset sessions.
|
|
const tools = (await connectionToolsForContext(session))
|
|
.filter((tool) => tool.connectionId && candidateIds.has(tool.connectionId));
|
|
const decisions = await decideToolsForListing(tools, (tool) => policyInputForTool({ session, tool }));
|
|
const visible = new Set(decisions.flatMap(({ tool, decision }) =>
|
|
(decision.allowed || decision.decision === "require_approval") && tool.connectionId ? [tool.connectionId] : [],
|
|
));
|
|
const sources: Parameters<typeof composeConnectionInstructions>[0] = [];
|
|
for (const connection of candidates) {
|
|
if (!visible.has(connection.id)) continue;
|
|
try {
|
|
const grant = await resolveConnectionGrant(session, connection, false);
|
|
sources.push({ connection, grantId: grant.id });
|
|
} catch (error) {
|
|
// A missing/revoked identity makes this connection unavailable, without
|
|
// opening an interaction or blocking unrelated work at prompt assembly.
|
|
if (!(error instanceof ToolGatewayHttpError) && !(error instanceof HttpError)) throw error;
|
|
if (error.status >= 500) throw error;
|
|
}
|
|
}
|
|
return composeConnectionInstructions(sources);
|
|
},
|
|
|
|
async browserUseResources(binding: { companyId: string; agentId: string; runId?: string; issueId?: string }) {
|
|
if (!binding.runId || !binding.issueId) return [];
|
|
const session: ToolGatewaySession = { ...binding, runId: binding.runId, issueId: binding.issueId, id: randomUUID(), token: "", projectId: null, createdAt: new Date(), expiresAt: new Date(Date.now() + 60000) };
|
|
const tools = (await listToolsForContext(session)).filter(t => t.providerType === "provider_rest");
|
|
const resources: Array<{ id: string; label: string; connectionId: string }> = [];
|
|
for (const connectionId of new Set(tools.map(t => t.connectionId!))) {
|
|
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, binding.companyId), eq(toolConnections.id, connectionId)));
|
|
if (!connection || !isBrowserUseConnection(connection)) continue;
|
|
try { await resolveConnectionGrant(session, connection, false); resources.push({ id: connectionId, connectionId, label: "Browser Use Cloud" }); } catch { /* Unavailable grants do not contribute a runtime skill. */ }
|
|
}
|
|
return resources;
|
|
},
|
|
|
|
async summarizeConnectionAccessForAgent(input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
agentId: string;
|
|
}) {
|
|
await assertAgentInCompany(input.companyId, input.agentId);
|
|
const tools = await connectedMcpToolsForConnection(
|
|
input.companyId,
|
|
input.connectionId,
|
|
);
|
|
const decisions = (
|
|
await decideToolsForListing(tools, (tool) =>
|
|
policyInputForAgentTool({
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
tool,
|
|
}),
|
|
)
|
|
).map(({ tool, decision }) => {
|
|
const testDecision =
|
|
decision.decision === "require_approval"
|
|
? "ask_first"
|
|
: decision.allowed
|
|
? "allowed"
|
|
: "off";
|
|
return {
|
|
toolName: tool.upstreamToolName ?? tool.name,
|
|
gatewayToolName: tool.name,
|
|
displayName: tool.displayName,
|
|
risk: tool.risk,
|
|
decision: testDecision,
|
|
reasonCode: decision.reasonCode,
|
|
matchedPolicyIds: decision.matchedPolicyIds,
|
|
effectiveProfileIds: decision.effectiveProfileIds,
|
|
};
|
|
});
|
|
const lastChange = await summarizeAccessLastChange({
|
|
companyId: input.companyId,
|
|
connectionId: input.connectionId,
|
|
agentId: input.agentId,
|
|
policyIds: [
|
|
...new Set(
|
|
decisions.flatMap((decision) => decision.matchedPolicyIds),
|
|
),
|
|
],
|
|
profileIds: [
|
|
...new Set(
|
|
decisions.flatMap((decision) => decision.effectiveProfileIds),
|
|
),
|
|
],
|
|
});
|
|
return {
|
|
connectionId: input.connectionId,
|
|
toolCount: decisions.length,
|
|
allowedCount: decisions.filter(
|
|
(decision) => decision.decision === "allowed",
|
|
).length,
|
|
askFirstCount: decisions.filter(
|
|
(decision) => decision.decision === "ask_first",
|
|
).length,
|
|
offCount: decisions.filter((decision) => decision.decision === "off")
|
|
.length,
|
|
lastChangedAt: lastChange.lastChangedAt,
|
|
lastChangedByAgentId: lastChange.lastChangedByAgentId,
|
|
lastChangedByName: lastChange.lastChangedByName,
|
|
tools: decisions.map(
|
|
({ effectiveProfileIds: _effectiveProfileIds, ...tool }) => tool,
|
|
),
|
|
};
|
|
},
|
|
|
|
async executeTestCall(input: ExecuteTestCallInput) {
|
|
await assertAgentInCompany(input.companyId, input.agentId);
|
|
const session: ToolGatewaySession = {
|
|
id: "test-call",
|
|
token: "test-call",
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
runId: null,
|
|
issueId: null,
|
|
projectId: null,
|
|
actorType: "user",
|
|
actorId: input.userId,
|
|
responsibleUserId: input.userId,
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
|
|
};
|
|
const tool = (
|
|
await connectedMcpToolsForConnection(
|
|
input.companyId,
|
|
input.connectionId,
|
|
)
|
|
).find(
|
|
(candidate) =>
|
|
candidate.name === input.toolName ||
|
|
candidate.upstreamToolName === input.toolName,
|
|
);
|
|
if (!tool) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${input.toolName}" not found`,
|
|
"tool_not_found",
|
|
{
|
|
connectionId: input.connectionId,
|
|
tool: input.toolName,
|
|
},
|
|
);
|
|
}
|
|
|
|
const requestedParameters = await governedToolArguments(
|
|
session,
|
|
tool,
|
|
input.parameters ?? {},
|
|
);
|
|
const argumentValidation = validateToolContent({
|
|
value: requestedParameters,
|
|
direction: "arguments",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "ignore",
|
|
});
|
|
const decisionInput = policyInputForAgentTool({
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
actorType: "user",
|
|
actorId: input.userId,
|
|
tool,
|
|
parameters: requestedParameters,
|
|
idempotencyKey: `test-call:${randomUUID()}`,
|
|
consumeRateLimit: true,
|
|
});
|
|
const accessDecision = await policyService.decide(decisionInput);
|
|
const recorded = await policyService.recordInvocation(
|
|
decisionInput,
|
|
accessDecision,
|
|
);
|
|
await policyService.writeAudit(decisionInput, accessDecision);
|
|
const invocationId = recorded.invocation.id;
|
|
|
|
if (accessDecision.decision === "require_approval") {
|
|
if (!recorded.actionRequest) {
|
|
throw new ToolGatewayHttpError(
|
|
500,
|
|
"Approval request was not created",
|
|
"approval_request_missing",
|
|
{
|
|
invocationId,
|
|
tool: tool.name,
|
|
},
|
|
);
|
|
}
|
|
const canonicalArguments = canonicalToolArguments(requestedParameters);
|
|
const canonicalArgumentsHash = argumentValidation.summary.sha256 ?? "";
|
|
const approvalSnapshot = await connectedRemoteApprovalSnapshot(
|
|
session,
|
|
tool,
|
|
{
|
|
requireResolvedCredentials: true,
|
|
},
|
|
);
|
|
let signedArguments: ReturnType<typeof signToolArguments>;
|
|
try {
|
|
signedArguments = signToolArguments({
|
|
invocationId,
|
|
toolName: tool.name,
|
|
canonicalArguments,
|
|
approvalSnapshot: approvalSnapshot ?? undefined,
|
|
executionOnApprove: true,
|
|
signingSecret: options.toolActionSigningSecret,
|
|
});
|
|
} catch (error) {
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: "cancelled",
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, recorded.actionRequest.id),
|
|
eq(toolActionRequests.status, "pending"),
|
|
),
|
|
);
|
|
if (error instanceof ToolActionSigningSecretMissingError) {
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "failed",
|
|
errorCode: "signing_secret_unconfigured",
|
|
errorMessage: error.message,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, invocationId));
|
|
void emitConnectionInvoked(db, invocationId);
|
|
throw new ToolGatewayHttpError(
|
|
500,
|
|
error.message,
|
|
"signing_secret_unconfigured",
|
|
{
|
|
invocationId,
|
|
tool: tool.name,
|
|
},
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
const previewMarkdown = buildHumanizedActionPreview({
|
|
tool,
|
|
argumentsSummary: argumentValidation.summary,
|
|
});
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
canonicalArgumentsHash,
|
|
canonicalArgumentsSummary: argumentValidation.summary,
|
|
signedArguments,
|
|
previewMarkdown,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolActionRequests.id, recorded.actionRequest.id));
|
|
await writeToolCallEvent({
|
|
invocationId,
|
|
actionRequestId: recorded.actionRequest.id,
|
|
session,
|
|
eventType: "approval_requested",
|
|
outcome: "pending",
|
|
toolName: tool.name,
|
|
policyDecision: "require_approval",
|
|
reasonCode: accessDecision.reasonCode,
|
|
argumentsSummary: argumentValidation.summary,
|
|
metadata: {
|
|
source: "test",
|
|
actionRequestId: recorded.actionRequest.id,
|
|
},
|
|
tool,
|
|
});
|
|
await writeAudit({
|
|
session,
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
runId: null,
|
|
issueId: null,
|
|
actorType: "user",
|
|
actorId: input.userId,
|
|
action: "tool_gateway.approval_requested",
|
|
details: {
|
|
source: "test",
|
|
invocationId,
|
|
actionRequestId: recorded.actionRequest.id,
|
|
decision: "require_approval",
|
|
reasonCode: accessDecision.reasonCode,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
tool: tool.name,
|
|
...toolAuditMetadata(tool),
|
|
argumentsSummary: argumentValidation.summary,
|
|
},
|
|
});
|
|
return {
|
|
decision: "ask_first" as const,
|
|
invocationId,
|
|
actionRequestId: recorded.actionRequest.id,
|
|
};
|
|
}
|
|
|
|
if (!accessDecision.allowed) {
|
|
// recordInvocation inserted this row already terminal (denied or
|
|
// rate_limited), so this is its only completion boundary.
|
|
void emitConnectionInvoked(db, invocationId);
|
|
await writeAudit({
|
|
session,
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
runId: null,
|
|
issueId: null,
|
|
actorType: "user",
|
|
actorId: input.userId,
|
|
action: "tool_gateway.call_denied",
|
|
details: {
|
|
source: "test",
|
|
invocationId,
|
|
decision: accessDecision.decision,
|
|
reasonCode: accessDecision.reasonCode,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
tool: tool.name,
|
|
...toolAuditMetadata(tool),
|
|
argumentsSummary: argumentValidation.summary,
|
|
rateLimitState: accessDecision.rateLimitState ?? null,
|
|
},
|
|
});
|
|
return {
|
|
decision: "off" as const,
|
|
invocationId,
|
|
error: {
|
|
message: accessDecision.explanation,
|
|
reasonCode: accessDecision.reasonCode,
|
|
},
|
|
};
|
|
}
|
|
|
|
return runTestToolInvocation({
|
|
session,
|
|
tool,
|
|
parameters: requestedParameters,
|
|
invocationId,
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
userId: input.userId,
|
|
argumentsSummary: argumentValidation.summary,
|
|
reasonCode: accessDecision.reasonCode,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
timeoutMs: input.timeoutMs,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Live status of an ask-first test call, polled by the Test tab panel.
|
|
* Scoped to the connection the panel is bound to and to test-origin
|
|
* requests only, so it can't be used to read arbitrary action requests.
|
|
*/
|
|
async getTestCallStatus(input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
actionRequestId: string;
|
|
}): Promise<ToolConnectionTestCallStatus> {
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, input.actionRequestId))
|
|
.limit(1);
|
|
if (!actionRequest || actionRequest.companyId !== input.companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"Tool action request not found",
|
|
"action_request_not_found",
|
|
);
|
|
}
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId))
|
|
.limit(1);
|
|
if (!invocation || invocation.companyId !== input.companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"Tool invocation not found",
|
|
"invocation_not_found",
|
|
);
|
|
}
|
|
if (
|
|
invocation.connectionId !== input.connectionId ||
|
|
!isTestOriginInvocation(invocation)
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"Tool action request not found",
|
|
"action_request_not_found",
|
|
);
|
|
}
|
|
return buildTestCallStatus(actionRequest, invocation);
|
|
},
|
|
|
|
async sweepActionReviews() {
|
|
// A process may stop between persisting a provider outcome and updating the
|
|
// feed projection. Reconcile from authoritative rows before delivering it.
|
|
const unreflected = await db
|
|
.select({ request: toolActionRequests, invocation: toolInvocations })
|
|
.from(toolActionRequests)
|
|
.innerJoin(
|
|
issueThreadInteractions,
|
|
eq(issueThreadInteractions.id, toolActionRequests.interactionId),
|
|
)
|
|
.innerJoin(
|
|
toolInvocations,
|
|
eq(toolInvocations.id, toolActionRequests.invocationId),
|
|
)
|
|
.where(
|
|
and(
|
|
inArray(toolActionRequests.status, [
|
|
"executed",
|
|
"failed",
|
|
"expired",
|
|
"cancelled",
|
|
]),
|
|
sql`coalesce(${issueThreadInteractions.result}->'toolAction'->>'status', '') <> case when ${toolActionRequests.status} = 'cancelled' then 'expired' else ${toolActionRequests.status} end`,
|
|
),
|
|
)
|
|
.limit(100);
|
|
for (const { request, invocation } of unreflected)
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: request.id,
|
|
status: request.status as
|
|
"executed" | "failed" | "expired" | "cancelled",
|
|
errorCode: invocation.errorCode,
|
|
errorMessage: invocation.errorMessage,
|
|
resultSummary: invocation.resultSummary?.summary,
|
|
});
|
|
const now = new Date();
|
|
const staleAt = new Date(now.getTime() - 10 * 60_000);
|
|
let cursor: string | undefined;
|
|
let scanned = 0;
|
|
for (;;) {
|
|
const rows = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(
|
|
and(
|
|
or(
|
|
and(
|
|
eq(toolActionRequests.status, "pending"),
|
|
lte(toolActionRequests.expiresAt, now),
|
|
),
|
|
eq(toolActionRequests.status, "approved"),
|
|
and(
|
|
eq(toolActionRequests.status, "executing"),
|
|
lte(toolActionRequests.updatedAt, staleAt),
|
|
),
|
|
),
|
|
cursor ? gt(toolActionRequests.id, cursor) : undefined,
|
|
),
|
|
)
|
|
.orderBy(asc(toolActionRequests.id))
|
|
.limit(100);
|
|
for (const row of rows) {
|
|
if (row.status === "approved") {
|
|
await this.approveActionRequest({
|
|
companyId: row.companyId,
|
|
actionRequestId: row.id,
|
|
actor: { userId: row.decidedByUserId ?? row.resolvedByUserId },
|
|
}).catch((error) =>
|
|
logger.warn(
|
|
{ err: error, actionRequestId: row.id },
|
|
"Could not recover approved tool action",
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const status = row.status === "pending" ? "expired" : "failed";
|
|
const errorCode =
|
|
status === "failed"
|
|
? "tool_execution_outcome_unknown"
|
|
: "action_expired";
|
|
const errorMessage =
|
|
status === "failed"
|
|
? "Execution was interrupted; the external outcome is unknown. Inspect the provider before retrying."
|
|
: "The approval request expired before a decision.";
|
|
const [changed] = await db
|
|
.update(toolActionRequests)
|
|
.set({ status, resolvedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, row.id),
|
|
eq(toolActionRequests.status, row.status),
|
|
eq(toolActionRequests.updatedAt, row.updatedAt),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!changed) continue;
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "failed",
|
|
errorCode,
|
|
errorMessage,
|
|
completedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolInvocations.id, row.invocationId));
|
|
void emitConnectionInvoked(db, row.invocationId);
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: row.id,
|
|
status,
|
|
errorCode,
|
|
errorMessage,
|
|
});
|
|
}
|
|
scanned += rows.length;
|
|
if (rows.length < 100) break;
|
|
cursor = rows[rows.length - 1].id;
|
|
}
|
|
return { scanned };
|
|
},
|
|
|
|
async approveActionRequest(input: {
|
|
companyId: string;
|
|
rememberAction?: boolean;
|
|
issueId?: string;
|
|
interactionId?: string;
|
|
actionRequestId: string;
|
|
actor: { agentId?: string | null; userId?: string | null };
|
|
}) {
|
|
if (input.actor.agentId)
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Only a human can resolve a tool review",
|
|
"human_review_required",
|
|
);
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, input.actionRequestId))
|
|
.limit(1);
|
|
if (!actionRequest || actionRequest.companyId !== input.companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"Tool action request not found",
|
|
"action_request_not_found",
|
|
);
|
|
}
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId))
|
|
.limit(1);
|
|
if (!invocation || invocation.companyId !== input.companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"Tool invocation not found",
|
|
"invocation_not_found",
|
|
);
|
|
}
|
|
if (input.issueId !== undefined || input.interactionId !== undefined) {
|
|
if (
|
|
!input.issueId ||
|
|
!input.interactionId ||
|
|
actionRequest.issueId !== input.issueId ||
|
|
actionRequest.interactionId !== input.interactionId ||
|
|
invocation.issueId !== input.issueId
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action request does not belong to this interaction",
|
|
"action_context_mismatch",
|
|
);
|
|
}
|
|
const [originatingInteraction] = await db
|
|
.select({ id: issueThreadInteractions.id })
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, input.interactionId),
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
eq(issueThreadInteractions.issueId, input.issueId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!originatingInteraction) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action request does not belong to this interaction",
|
|
"action_context_mismatch",
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
["executing", "executed", "failed"].includes(actionRequest.status) &&
|
|
actionRequest.decidedAt
|
|
) {
|
|
await options.onToolActionSettled?.(actionRequest.id);
|
|
return actionRequestResolution(actionRequest);
|
|
}
|
|
if (
|
|
actionRequest.status !== "pending" &&
|
|
actionRequest.status !== "approved"
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action request is no longer pending",
|
|
"action_not_pending",
|
|
);
|
|
}
|
|
let signedPayload: ReturnType<typeof readSignedToolArgumentsPayload> =
|
|
null;
|
|
try {
|
|
signedPayload = readSignedToolArgumentsPayload({
|
|
signedArguments: actionRequest.signedArguments,
|
|
invocationId: invocation.id,
|
|
toolName: invocation.toolName,
|
|
signingSecret: options.toolActionSigningSecret,
|
|
});
|
|
} catch {
|
|
signedPayload = null;
|
|
}
|
|
if (!signedPayload) {
|
|
if (actionRequest.status === "pending") {
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: "cancelled",
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, actionRequest.id),
|
|
eq(toolActionRequests.status, "pending"),
|
|
),
|
|
);
|
|
}
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: actionRequest.id,
|
|
status: "cancelled",
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action request is no longer approvable; refresh the review queue",
|
|
"action_request_invalidated",
|
|
);
|
|
}
|
|
if (actionRequest.approvalId) {
|
|
const [formalApproval] = await db
|
|
.select({ status: approvals.status })
|
|
.from(approvals)
|
|
.where(
|
|
and(
|
|
eq(approvals.id, actionRequest.approvalId),
|
|
eq(approvals.companyId, input.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!formalApproval || formalApproval.status !== "approved") {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action request requires formal board approval before execution",
|
|
"formal_approval_required",
|
|
{ approvalId: actionRequest.approvalId },
|
|
);
|
|
}
|
|
}
|
|
if (actionRequest.status === "approved") {
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: actionRequest.id,
|
|
status: "approved",
|
|
});
|
|
if (
|
|
!isTestOriginInvocation(invocation) &&
|
|
signedPayload.executionOnApprove === true
|
|
) {
|
|
try {
|
|
await executeApprovedAgentInvocation({ actionRequest, invocation });
|
|
} catch {
|
|
// The execution outcome is persisted on the invocation/request and
|
|
// reflected onto the accepted interaction for the continuation wake.
|
|
}
|
|
const [settled] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id))
|
|
.limit(1);
|
|
return actionRequestResolution(settled ?? actionRequest);
|
|
}
|
|
return actionRequest;
|
|
}
|
|
if (actionRequest.expiresAt && actionRequest.expiresAt <= new Date())
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool review has expired",
|
|
"action_expired",
|
|
);
|
|
if (
|
|
!isTestOriginInvocation(invocation) &&
|
|
signedPayload.executionOnApprove === true
|
|
) {
|
|
const [issue] = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, invocation.issueId!),
|
|
eq(issues.companyId, input.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!issue || issue.status === "done" || issue.status === "cancelled")
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Task is closed",
|
|
"action_task_closed",
|
|
);
|
|
const session: ToolGatewaySession = {
|
|
id: `review:${actionRequest.id}`,
|
|
token: "",
|
|
companyId: input.companyId,
|
|
agentId: invocation.agentId,
|
|
runId: invocation.runId,
|
|
issueId: issue.id,
|
|
projectId: issue.projectId,
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
|
|
};
|
|
await restoreApprovedActionIdentity(
|
|
session,
|
|
signedPayload.identityContextId,
|
|
);
|
|
session.approvedSlackInvocationId = invocation.id;
|
|
const tool = await findToolForSession(session, invocation.toolName);
|
|
if (
|
|
!approvalSnapshotsMatch(
|
|
signedPayload.approvalSnapshot,
|
|
await connectedRemoteApprovalSnapshot(session, tool),
|
|
)
|
|
)
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool definition or connection changed; request a new review",
|
|
"approved_tool_target_changed",
|
|
);
|
|
const access = await policyService.decide(
|
|
policyInputForTool({
|
|
session,
|
|
tool,
|
|
parameters: signedPayload.arguments,
|
|
}),
|
|
);
|
|
if (!access.allowed && access.decision !== "require_approval")
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
access.explanation,
|
|
access.reasonCode,
|
|
);
|
|
}
|
|
const updated = await commitToolActionReview(db, {
|
|
...input,
|
|
decision: "approved",
|
|
});
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: updated.id,
|
|
status: "approved",
|
|
});
|
|
// A test-tab ask-first request has no agent run to carry out the parked
|
|
// call, so approving it is what runs it. Execute against the signed
|
|
// arguments and record the result on the invocation for the live panel.
|
|
if (isTestOriginInvocation(invocation)) {
|
|
await runApprovedTestInvocation(
|
|
{ ...invocation, approvalState: "approved" },
|
|
signedPayload.arguments,
|
|
updated.id,
|
|
);
|
|
} else if (signedPayload.executionOnApprove === true) {
|
|
try {
|
|
await executeApprovedAgentInvocation({
|
|
actionRequest: updated,
|
|
invocation,
|
|
});
|
|
} catch {
|
|
// Persisted failure is the approval result; accepting the card itself
|
|
// remains successful and the agent wake receives the failure context.
|
|
}
|
|
}
|
|
const [settled] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, updated.id))
|
|
.limit(1);
|
|
return actionRequestResolution(settled ?? updated);
|
|
},
|
|
|
|
async declineActionRequest(input: {
|
|
companyId: string;
|
|
issueId?: string;
|
|
interactionId?: string;
|
|
actionRequestId: string;
|
|
reason?: string;
|
|
actor: { agentId?: string | null; userId?: string | null };
|
|
}) {
|
|
const updated = await commitToolActionReview(db, {
|
|
...input,
|
|
decision: "rejected",
|
|
});
|
|
await options
|
|
.onToolActionSettled?.(updated.id)
|
|
.catch((error) =>
|
|
logger.warn(
|
|
{ err: error, actionRequestId: updated.id },
|
|
"Tool review continuation will be retried",
|
|
),
|
|
);
|
|
return updated;
|
|
},
|
|
|
|
async executeTool(input: ExecuteGatewayToolInput) {
|
|
const session = await getActiveSession(input.sessionToken, {
|
|
gatewayId: input.gatewayId ?? null,
|
|
gatewayPublicId: input.gatewayPublicId ?? null,
|
|
protocolMethod: "tools/call",
|
|
callerHeaders: input.callerHeaders,
|
|
});
|
|
await assertGatewayTokenAction(session, "tools/call");
|
|
let invocationId = String(randomUUID());
|
|
const startedAt = Date.now();
|
|
|
|
// A retry carries the signed originating operation, even if steering has
|
|
// since accepted instructions from someone else in this same run.
|
|
if (input.approvedActionRequestId) {
|
|
const [request] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, input.approvedActionRequestId),
|
|
eq(toolActionRequests.companyId, session.companyId),
|
|
),
|
|
);
|
|
const [invocation] = request
|
|
? await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(
|
|
and(
|
|
eq(toolInvocations.id, request.invocationId),
|
|
eq(toolInvocations.companyId, session.companyId),
|
|
eq(toolInvocations.runId, session.runId!),
|
|
eq(toolInvocations.agentId, session.agentId!),
|
|
),
|
|
)
|
|
: [];
|
|
const payload =
|
|
request && invocation
|
|
? readSignedToolArgumentsPayload({
|
|
signedArguments: request.signedArguments,
|
|
invocationId: invocation.id,
|
|
toolName: invocation.toolName,
|
|
signingSecret: options.toolActionSigningSecret,
|
|
})
|
|
: null;
|
|
if (payload?.identityContextId) {
|
|
const [origin] = await db
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.id, payload.identityContextId),
|
|
eq(runIdentityContexts.companyId, session.companyId),
|
|
eq(runIdentityContexts.runId, session.runId!),
|
|
eq(runIdentityContexts.status, "accepted"),
|
|
),
|
|
);
|
|
if (!origin)
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Approved action identity is unavailable",
|
|
"identity_context_unavailable",
|
|
);
|
|
session.identityContextId = origin.id;
|
|
session.responsibleUserId =
|
|
origin.cause === "company_default"
|
|
? null
|
|
: origin.responsibleUserId;
|
|
}
|
|
}
|
|
let tool = await findToolForSession(session, input.tool);
|
|
if (tool.providerType === "paperclip_slack_chat") {
|
|
const definition = SLACK_TOOLS.find(t => t.name === tool.upstreamToolName);
|
|
const args = definition?.schema.parse(input.parameters ?? {}) as Record<string, unknown> | undefined;
|
|
if (!args) throw new ToolGatewayHttpError(403, "Unknown Slack operation", "slack_task_required");
|
|
if (typeof args.idempotencyKey === "string") {
|
|
const key = `slack:${tool.connectionId}:${session.issueId}:${session.responsibleUserId}:${args.idempotencyKey}`;
|
|
const [prior] = await db.select().from(toolInvocations).where(and(eq(toolInvocations.companyId, session.companyId), eq(toolInvocations.idempotencyKey, key)));
|
|
const currentHash = validateToolContent({ value: args, direction: "arguments", sensitiveMode: "redact", promptInjectionMode: "ignore" }).summary.sha256;
|
|
if (prior && (prior.toolName !== tool.name || prior.argumentsHash !== currentHash)) throw new ToolGatewayHttpError(409, "Idempotency key belongs to a different Slack operation", "slack_idempotency_conflict");
|
|
input = { ...input, idempotencyKey: key };
|
|
}
|
|
}
|
|
let virtualToolName: string | null = null;
|
|
let requestedParameters: unknown = input.parameters ?? {};
|
|
|
|
if (
|
|
tool.name === "search_tools" &&
|
|
tool.providerType === "paperclip_virtual"
|
|
) {
|
|
const argumentValidation = validateToolContent({
|
|
value: requestedParameters,
|
|
direction: "arguments",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "ignore",
|
|
});
|
|
const result = await executeVirtualSearchTools(
|
|
session,
|
|
requestedParameters,
|
|
);
|
|
const resultValidation = validateToolContent({
|
|
value: result,
|
|
direction: "result",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "block",
|
|
});
|
|
const [invocation] = await db
|
|
.insert(toolInvocations)
|
|
.values({
|
|
companyId: session.companyId,
|
|
actorType:
|
|
session.actorType ?? (session.agentId ? "agent" : "system"),
|
|
actorId:
|
|
session.actorId ??
|
|
session.agentId ??
|
|
session.gatewayTokenId ??
|
|
session.companyId,
|
|
agentId: session.agentId,
|
|
issueId: session.issueId,
|
|
runId: session.runId,
|
|
providerType: "paperclip_virtual",
|
|
upstreamToolName: "search_tools",
|
|
riskLevel: "read",
|
|
toolName: "search_tools",
|
|
argumentsHash: argumentValidation.summary.sha256 ?? null,
|
|
argumentsSummary: argumentValidation.summary,
|
|
policyDecision: "allow",
|
|
matchedPolicyIds: [],
|
|
approvalState: "not_required",
|
|
status: "succeeded",
|
|
resultHash: resultValidation.summary.sha256 ?? null,
|
|
resultSummary: resultValidation.summary,
|
|
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
|
|
startedAt: new Date(),
|
|
completedAt: new Date(),
|
|
})
|
|
.returning();
|
|
await writeToolCallEvent({
|
|
invocationId: invocation.id,
|
|
session,
|
|
eventType: "call_completed",
|
|
outcome: "success",
|
|
toolName: "search_tools",
|
|
policyDecision: "allow",
|
|
reasonCode: "virtual_tool_completed",
|
|
argumentsSummary: argumentValidation.summary,
|
|
resultSummary: resultValidation.summary,
|
|
metadata: { virtualToolName: "search_tools" },
|
|
tool,
|
|
});
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: "tool_gateway.call_completed",
|
|
details: {
|
|
invocationId: invocation.id,
|
|
decision: "allow",
|
|
reasonCode: "virtual_tool_completed",
|
|
tool: "search_tools",
|
|
virtualToolName: "search_tools",
|
|
durationMs: Date.now() - startedAt,
|
|
argumentsSummary: argumentValidation.summary,
|
|
result: summarizeResult(resultValidation.value),
|
|
resultSummary: resultValidation.summary,
|
|
},
|
|
});
|
|
return {
|
|
invocationId: invocation.id,
|
|
status: "completed" as const,
|
|
tool: "search_tools",
|
|
result: resultValidation.value,
|
|
};
|
|
}
|
|
|
|
if (
|
|
tool.name === "run_tool" &&
|
|
tool.providerType === "paperclip_virtual"
|
|
) {
|
|
const { targetToolName, targetParameters } =
|
|
virtualRunToolInput(requestedParameters);
|
|
const targetTool = await findToolForSession(session, targetToolName);
|
|
if (!isOnDemandRemoteTool(targetTool)) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${targetToolName}" not found`,
|
|
"tool_not_found",
|
|
{ tool: targetToolName },
|
|
);
|
|
}
|
|
virtualToolName = "run_tool";
|
|
tool = targetTool;
|
|
requestedParameters = targetParameters;
|
|
}
|
|
|
|
// Managed provider arguments are part of the governed call, not a
|
|
// transport decoration. Project them before hashing, policy evaluation,
|
|
// approval signing, previews, and audit summaries. Approved retries
|
|
// re-project only for a compatibility comparison and dispatch the
|
|
// already-reviewed signed payload unchanged.
|
|
if (!input.approvedActionRequestId) {
|
|
requestedParameters = await governedToolArguments(
|
|
session,
|
|
tool,
|
|
requestedParameters,
|
|
);
|
|
}
|
|
|
|
const argumentValidation = validateToolContent({
|
|
value: requestedParameters,
|
|
direction: "arguments",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "ignore",
|
|
});
|
|
let effectiveParameters: unknown = requestedParameters;
|
|
let effectiveArgumentsSummary = argumentValidation.summary;
|
|
|
|
if (!input.approvedActionRequestId) {
|
|
const replay = await replayMatchingAgentAction({
|
|
session,
|
|
toolName: tool.name,
|
|
argumentsHash: argumentValidation.summary.sha256 ?? "",
|
|
});
|
|
if (replay?.matched) {
|
|
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,
|
|
identityContextId: signedPayload.identityContextId,
|
|
signingSecret: options.toolActionSigningSecret,
|
|
})
|
|
) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Approved tool action arguments do not match reviewed hash",
|
|
"signed_arguments_mismatch",
|
|
);
|
|
}
|
|
if (
|
|
!(await approvedManagedArgumentsRemainCurrent(
|
|
session,
|
|
tool,
|
|
storedParameters,
|
|
))
|
|
) {
|
|
throw await expireApprovedActionForManagedArgumentDrift({
|
|
actionRequestId: actionRequest.id,
|
|
invocationId: storedInvocation.id,
|
|
toolName: storedInvocation.toolName,
|
|
});
|
|
}
|
|
const claimedAt = new Date();
|
|
const [claimed] = await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: "executing",
|
|
resolvedByAgentId: session.agentId,
|
|
updatedAt: claimedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, actionRequest.id),
|
|
eq(toolActionRequests.status, "approved"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!claimed) {
|
|
throw new ToolGatewayHttpError(
|
|
409,
|
|
"Tool action request was already consumed",
|
|
"action_already_consumed",
|
|
);
|
|
}
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: claimed.id,
|
|
status: "executing",
|
|
});
|
|
invocationId = storedInvocation.id as typeof invocationId;
|
|
effectiveParameters = storedParameters;
|
|
effectiveArgumentsSummary = storedArgumentValidation.summary;
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "executing",
|
|
approvalState: "approved",
|
|
startedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, invocationId));
|
|
} else {
|
|
const decisionInput = policyInputForTool({
|
|
session,
|
|
tool,
|
|
parameters: effectiveParameters,
|
|
idempotencyKey: input.idempotencyKey,
|
|
consumeRateLimit: true,
|
|
});
|
|
let accessDecision = await policyService.decide(decisionInput);
|
|
if (accessDecision.allowed && tool.providerType === "paperclip_slack_chat" && SLACK_TOOLS.some(t => t.name === tool.upstreamToolName && t.risk === "approval")) {
|
|
accessDecision = { ...accessDecision, allowed: false, decision: "require_approval", reasonCode: "requires_approval_policy", explanation: "Slack destructive actions, channel creation and invitations require approval." };
|
|
}
|
|
const recorded = await policyService.recordInvocation(
|
|
decisionInput,
|
|
accessDecision,
|
|
);
|
|
await policyService.writeAudit(decisionInput, accessDecision);
|
|
invocationId = recorded.invocation.id;
|
|
const retryingSlackRateLimit = recorded.replayed && accessDecision.allowed && tool.providerType === "paperclip_slack_chat" && session.agentId && session.runId && session.issueId
|
|
? await claimSlackRateLimitRetry(db, { companyId: session.companyId, agentId: session.agentId, runId: session.runId, issueId: session.issueId, endpointId: String(asRecord(tool.providerMetadata)?.endpointId ?? ""), identityContextId: session.identityContextId }, invocationId)
|
|
: false;
|
|
if (recorded.replayed && !retryingSlackRateLimit) {
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: "tool_gateway.call_completed",
|
|
details: {
|
|
invocationId,
|
|
decision: "allow",
|
|
reasonCode: "idempotent_replay",
|
|
tool: tool.name,
|
|
...toolAuditMetadata(tool),
|
|
replayed: true,
|
|
},
|
|
});
|
|
return {
|
|
invocationId,
|
|
status: "replayed" as const,
|
|
tool: tool.name,
|
|
result: recorded.invocation.resultSummary ?? null,
|
|
};
|
|
}
|
|
if (accessDecision.decision === "require_approval") {
|
|
await requestApprovalForRecordedToolCall({
|
|
invocation: recorded.invocation,
|
|
actionRequest: recorded.actionRequest,
|
|
session,
|
|
tool,
|
|
parameters: effectiveParameters,
|
|
argumentsSummary: argumentValidation.summary,
|
|
policyDecision: accessDecision,
|
|
});
|
|
}
|
|
if (!accessDecision.allowed) {
|
|
// recordInvocation inserted this row already terminal (denied or
|
|
// rate_limited), so this is its only completion boundary.
|
|
void emitConnectionInvoked(db, invocationId);
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: "tool_gateway.call_denied",
|
|
details: {
|
|
invocationId,
|
|
decision: accessDecision.decision,
|
|
reasonCode: accessDecision.reasonCode,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
tool: tool.name,
|
|
virtualToolName,
|
|
targetToolName: virtualToolName ? tool.name : undefined,
|
|
...toolAuditMetadata(tool),
|
|
argumentsSummary: effectiveArgumentsSummary,
|
|
rateLimitState: accessDecision.rateLimitState ?? null,
|
|
},
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
policyErrorStatus(accessDecision),
|
|
accessDecision.explanation,
|
|
accessDecision.reasonCode,
|
|
{
|
|
invocationId,
|
|
tool: tool.name,
|
|
decision: accessDecision.decision,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
rateLimitState: accessDecision.rateLimitState ?? null,
|
|
},
|
|
);
|
|
}
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "executing",
|
|
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" || tool.providerType === "provider_rest")
|
|
? await executeRemoteHttpTool(
|
|
session,
|
|
tool,
|
|
effectiveParameters,
|
|
executionTimeoutMs,
|
|
invocationId,
|
|
input.callerHeaders,
|
|
input.timeoutMs === undefined,
|
|
)
|
|
: tool.providerType === "mcp_local_stdio"
|
|
? await executeLocalStdioTool(
|
|
session,
|
|
tool,
|
|
effectiveParameters,
|
|
executionTimeoutMs,
|
|
input.timeoutMs === undefined,
|
|
)
|
|
: null;
|
|
const result = connectedMcpExecution
|
|
? connectedMcpExecution.result
|
|
: tool.providerType === "paperclip_plugin"
|
|
? await runWithTimeout(
|
|
pluginToolDispatcher!.executeTool(
|
|
tool.name,
|
|
effectiveParameters,
|
|
{
|
|
agentId: session.agentId!,
|
|
runId: session.runId!,
|
|
companyId: session.companyId,
|
|
projectId: session.projectId ?? "",
|
|
},
|
|
),
|
|
executionTimeoutMs,
|
|
)
|
|
: await runWithTimeout(
|
|
executeBuiltinTool(session, tool, effectiveParameters, invocationId),
|
|
executionTimeoutMs,
|
|
);
|
|
|
|
const resultValidation = validateToolContent({
|
|
value: result,
|
|
direction: "result",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "block",
|
|
});
|
|
const completedAt = new Date();
|
|
const validatedMcpResult = connectedMcpExecution
|
|
? asRecord(resultValidation.value)
|
|
: null;
|
|
if (validatedMcpResult?.error) {
|
|
throw new ToolGatewayHttpError(
|
|
502,
|
|
String(validatedMcpResult.content || validatedMcpResult.error),
|
|
"tool_error",
|
|
{ execution: connectedMcpExecution?.execution },
|
|
);
|
|
}
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "succeeded",
|
|
resultHash: resultValidation.summary.sha256 ?? null,
|
|
resultSummary: resultValidation.summary,
|
|
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
|
|
completedAt,
|
|
updatedAt: completedAt,
|
|
})
|
|
.where(eq(toolInvocations.id, invocationId));
|
|
if (input.approvedActionRequestId) {
|
|
const [executedRequest] = await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: "executed",
|
|
resolvedAt: completedAt,
|
|
updatedAt: completedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, input.approvedActionRequestId),
|
|
eq(toolActionRequests.status, "executing"),
|
|
),
|
|
)
|
|
.returning({ id: toolActionRequests.id });
|
|
if (executedRequest) {
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: executedRequest.id,
|
|
status: "executed",
|
|
});
|
|
}
|
|
}
|
|
await writeToolCallEvent({
|
|
invocationId,
|
|
actionRequestId: input.approvedActionRequestId ?? null,
|
|
session,
|
|
eventType: "call_completed",
|
|
outcome: "success",
|
|
toolName: tool.name,
|
|
policyDecision: input.approvedActionRequestId ? "allow" : "allow",
|
|
reasonCode: "tool_completed",
|
|
argumentsSummary: effectiveArgumentsSummary,
|
|
resultSummary: resultValidation.summary,
|
|
metadata: {
|
|
...(virtualToolName
|
|
? { virtualToolName, targetToolName: tool.name }
|
|
: {}),
|
|
...(connectedMcpExecution?.headerSummary
|
|
? { headerSummary: connectedMcpExecution.headerSummary }
|
|
: {}),
|
|
...(connectedMcpExecution
|
|
? { execution: connectedMcpExecution.execution }
|
|
: {}),
|
|
},
|
|
tool,
|
|
});
|
|
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: "tool_gateway.call_completed",
|
|
details: {
|
|
invocationId,
|
|
decision: "allow",
|
|
reasonCode: "tool_completed",
|
|
tool: tool.name,
|
|
virtualToolName,
|
|
targetToolName: virtualToolName ? tool.name : undefined,
|
|
...toolAuditMetadata(tool),
|
|
durationMs: Date.now() - startedAt,
|
|
argumentsSummary: effectiveArgumentsSummary,
|
|
result: summarizeResult(resultValidation.value),
|
|
resultSummary: resultValidation.summary,
|
|
headerSummary: connectedMcpExecution?.headerSummary ?? undefined,
|
|
execution: connectedMcpExecution?.execution ?? undefined,
|
|
},
|
|
});
|
|
// After the bookkeeping above: a throw there is caught below, overwrites
|
|
// the row to failed, and emits — an earlier success emit would make one
|
|
// execution report both outcomes.
|
|
void emitConnectionInvoked(db, invocationId);
|
|
return {
|
|
invocationId,
|
|
status: "completed" as const,
|
|
tool: virtualToolName ?? tool.name,
|
|
targetTool: virtualToolName ? tool.name : undefined,
|
|
result: resultValidation.value,
|
|
};
|
|
} catch (err) {
|
|
const normalizedError =
|
|
err instanceof ToolRuntimeSupervisorError
|
|
? new ToolGatewayHttpError(
|
|
err.status,
|
|
err.message,
|
|
err.reasonCode,
|
|
err.details,
|
|
)
|
|
: err;
|
|
const status =
|
|
normalizedError instanceof ToolGatewayHttpError
|
|
? normalizedError.status
|
|
: 502;
|
|
const reasonCode =
|
|
normalizedError instanceof ToolContentValidationError
|
|
? normalizedError.reasonCode
|
|
: normalizedError instanceof ToolGatewayHttpError
|
|
? normalizedError.reasonCode
|
|
: "tool_execution_failed";
|
|
const isRuntimeDeferred =
|
|
status === 429 &&
|
|
(reasonCode === "runtime_capacity_unavailable" ||
|
|
reasonCode === "runtime_restart_backoff" ||
|
|
reasonCode === "runtime_restart_suppressed");
|
|
const isDeferred = status === 504 || isRuntimeDeferred;
|
|
const message =
|
|
normalizedError instanceof Error
|
|
? normalizedError.message
|
|
: String(normalizedError);
|
|
if (reasonCode === "elicitation_required") {
|
|
throw normalizedError;
|
|
}
|
|
const completedAt = new Date();
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status:
|
|
status === 504
|
|
? "timed_out"
|
|
: status === 429
|
|
? "rate_limited"
|
|
: "failed",
|
|
errorCode: reasonCode,
|
|
errorMessage: message,
|
|
completedAt,
|
|
updatedAt: completedAt,
|
|
})
|
|
.where(eq(toolInvocations.id, invocationId));
|
|
void emitConnectionInvoked(db, invocationId);
|
|
if (input.approvedActionRequestId) {
|
|
const [failedRequest] = await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: "failed",
|
|
resolvedAt: completedAt,
|
|
updatedAt: completedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.id, input.approvedActionRequestId),
|
|
eq(toolActionRequests.status, "executing"),
|
|
),
|
|
)
|
|
.returning({ id: toolActionRequests.id });
|
|
if (failedRequest) {
|
|
await reflectToolActionInteractionLifecycle({
|
|
actionRequestId: failedRequest.id,
|
|
status: "failed",
|
|
errorCode: reasonCode,
|
|
errorMessage: message,
|
|
});
|
|
}
|
|
}
|
|
await writeToolCallEvent({
|
|
invocationId,
|
|
actionRequestId: input.approvedActionRequestId ?? null,
|
|
session,
|
|
eventType: status === 504 ? "call_failed" : "call_failed",
|
|
outcome: status === 504 ? "timeout" : "failure",
|
|
toolName: tool.name,
|
|
policyDecision: isDeferred ? "defer_runtime" : "deny",
|
|
reasonCode,
|
|
argumentsSummary: effectiveArgumentsSummary,
|
|
metadata: {
|
|
...(virtualToolName
|
|
? { virtualToolName, targetToolName: tool.name }
|
|
: {}),
|
|
...(normalizedError instanceof ToolContentValidationError
|
|
? { findings: normalizedError.findings }
|
|
: {}),
|
|
...(executionAuditFromError(normalizedError)
|
|
? { execution: executionAuditFromError(normalizedError) }
|
|
: {}),
|
|
},
|
|
tool,
|
|
});
|
|
await writeAudit({
|
|
session,
|
|
companyId: session.companyId,
|
|
agentId: session.agentId,
|
|
runId: session.runId,
|
|
issueId: session.issueId,
|
|
action: isDeferred
|
|
? "tool_gateway.call_deferred"
|
|
: "tool_gateway.call_failed",
|
|
details: {
|
|
invocationId,
|
|
decision: isDeferred ? "defer_runtime" : "deny",
|
|
reasonCode,
|
|
tool: tool.name,
|
|
virtualToolName,
|
|
targetToolName: virtualToolName ? tool.name : undefined,
|
|
...toolAuditMetadata(tool),
|
|
argumentsSummary: effectiveArgumentsSummary,
|
|
durationMs: Date.now() - startedAt,
|
|
error: message,
|
|
...(executionAuditFromError(normalizedError)
|
|
? { execution: executionAuditFromError(normalizedError) }
|
|
: {}),
|
|
},
|
|
});
|
|
if (normalizedError instanceof ToolContentValidationError) {
|
|
throw new ToolGatewayHttpError(422, message, reasonCode, {
|
|
findings: normalizedError.findings,
|
|
});
|
|
}
|
|
throw normalizedError;
|
|
}
|
|
},
|
|
|
|
async executePluginTool(input: ExecutePluginToolInput) {
|
|
if (!pluginToolDispatcher) {
|
|
throw new ToolGatewayHttpError(
|
|
501,
|
|
"Plugin tool dispatch is not enabled",
|
|
"plugin_tools_disabled",
|
|
);
|
|
}
|
|
if (input.actor.type === "agent") {
|
|
if (input.actor.companyId !== input.runContext.companyId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Agent key cannot access another company",
|
|
"actor_company_mismatch",
|
|
);
|
|
}
|
|
if (input.actor.agentId !== input.runContext.agentId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Agent cannot execute tools as another agent",
|
|
"actor_agent_mismatch",
|
|
);
|
|
}
|
|
if (input.actor.runId && input.actor.runId !== input.runContext.runId) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Agent cannot execute tools for another run",
|
|
"actor_run_mismatch",
|
|
);
|
|
}
|
|
}
|
|
|
|
const context = await resolveRunContext({
|
|
companyId: input.runContext.companyId,
|
|
agentId: input.runContext.agentId,
|
|
runId: input.runContext.runId,
|
|
projectId: input.runContext.projectId,
|
|
});
|
|
let invocationId = String(randomUUID());
|
|
const sessionLike: ToolGatewaySession = {
|
|
id: "plugin-route",
|
|
token: "plugin-route",
|
|
companyId: input.runContext.companyId,
|
|
agentId: input.runContext.agentId,
|
|
runId: input.runContext.runId,
|
|
issueId: context.issueId,
|
|
projectId: input.runContext.projectId ?? context.projectId,
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
|
|
};
|
|
|
|
const tool = findStaticTool(input.tool);
|
|
|
|
if (tool.providerType !== "paperclip_plugin") {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
`Tool "${input.tool}" is not a plugin tool`,
|
|
"tool_not_found",
|
|
);
|
|
}
|
|
|
|
const requestedParameters = input.parameters ?? {};
|
|
const argumentValidation = validateToolContent({
|
|
value: requestedParameters,
|
|
direction: "arguments",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "ignore",
|
|
});
|
|
|
|
const decisionInput = policyInputForTool({
|
|
session: sessionLike,
|
|
tool,
|
|
parameters: requestedParameters,
|
|
consumeRateLimit: true,
|
|
});
|
|
const accessDecision = await policyService.decide(decisionInput);
|
|
const recorded = await policyService.recordInvocation(
|
|
decisionInput,
|
|
accessDecision,
|
|
);
|
|
await policyService.writeAudit(decisionInput, accessDecision);
|
|
invocationId = recorded.invocation.id;
|
|
|
|
if (recorded.replayed) {
|
|
return recorded.invocation.resultSummary;
|
|
}
|
|
|
|
if (accessDecision.decision === "require_approval") {
|
|
await requestApprovalForRecordedToolCall({
|
|
invocation: recorded.invocation,
|
|
actionRequest: recorded.actionRequest,
|
|
session: sessionLike,
|
|
tool,
|
|
parameters: requestedParameters,
|
|
argumentsSummary: argumentValidation.summary,
|
|
policyDecision: accessDecision,
|
|
});
|
|
}
|
|
|
|
if (!accessDecision.allowed) {
|
|
// recordInvocation inserted this row already terminal (denied or
|
|
// rate_limited), so this is its only completion boundary.
|
|
void emitConnectionInvoked(db, invocationId);
|
|
await writeAudit({
|
|
session: sessionLike,
|
|
companyId: input.runContext.companyId,
|
|
agentId: input.runContext.agentId,
|
|
runId: input.runContext.runId,
|
|
issueId: context.issueId,
|
|
action: "tool_gateway.call_denied",
|
|
details: {
|
|
invocationId,
|
|
decision: accessDecision.decision,
|
|
reasonCode: accessDecision.reasonCode,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
tool: input.tool,
|
|
...toolAuditMetadata(tool),
|
|
argumentsSummary: argumentValidation.summary,
|
|
rateLimitState: accessDecision.rateLimitState ?? null,
|
|
},
|
|
});
|
|
throw new ToolGatewayHttpError(
|
|
policyErrorStatus(accessDecision),
|
|
accessDecision.explanation,
|
|
accessDecision.reasonCode,
|
|
{
|
|
invocationId,
|
|
tool: input.tool,
|
|
decision: accessDecision.decision,
|
|
matchedPolicyIds: accessDecision.matchedPolicyIds,
|
|
rateLimitState: accessDecision.rateLimitState ?? null,
|
|
},
|
|
);
|
|
}
|
|
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "executing",
|
|
startedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, invocationId));
|
|
|
|
await writeAudit({
|
|
session: sessionLike,
|
|
companyId: input.runContext.companyId,
|
|
agentId: input.runContext.agentId,
|
|
runId: input.runContext.runId,
|
|
issueId: context.issueId,
|
|
action: "tool_gateway.call_allowed",
|
|
details: {
|
|
invocationId,
|
|
decision: "allow",
|
|
reasonCode: "profile_allows_tool",
|
|
tool: input.tool,
|
|
...toolAuditMetadata(tool),
|
|
argumentsSummary: argumentValidation.summary,
|
|
},
|
|
});
|
|
|
|
const startedAt = Date.now();
|
|
try {
|
|
const result = await pluginToolDispatcher.executeTool(
|
|
input.tool,
|
|
requestedParameters,
|
|
input.runContext,
|
|
);
|
|
const resultValidation = validateToolContent({
|
|
value: result,
|
|
direction: "result",
|
|
sensitiveMode: "redact",
|
|
promptInjectionMode: "block",
|
|
});
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "succeeded",
|
|
resultHash: resultValidation.summary.sha256 ?? null,
|
|
resultSummary: resultValidation.summary,
|
|
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, invocationId));
|
|
await writeToolCallEvent({
|
|
invocationId,
|
|
session: sessionLike,
|
|
eventType: "call_completed",
|
|
outcome: "success",
|
|
toolName: tool.name,
|
|
policyDecision: "allow",
|
|
reasonCode: "tool_completed",
|
|
argumentsSummary: argumentValidation.summary,
|
|
resultSummary: resultValidation.summary,
|
|
tool,
|
|
});
|
|
await writeAudit({
|
|
session: sessionLike,
|
|
companyId: input.runContext.companyId,
|
|
agentId: input.runContext.agentId,
|
|
runId: input.runContext.runId,
|
|
issueId: context.issueId,
|
|
action: "tool_gateway.call_completed",
|
|
details: {
|
|
invocationId,
|
|
decision: "allow",
|
|
reasonCode: "tool_completed",
|
|
tool: input.tool,
|
|
...toolAuditMetadata(tool),
|
|
durationMs: Date.now() - startedAt,
|
|
result: summarizeResult(
|
|
(resultValidation.value as typeof result).result,
|
|
),
|
|
resultSummary: resultValidation.summary,
|
|
},
|
|
});
|
|
// After the bookkeeping above: a throw there is caught below, overwrites
|
|
// the row to failed, and emits — an earlier success emit would make one
|
|
// execution report both outcomes.
|
|
void emitConnectionInvoked(db, invocationId);
|
|
return resultValidation.value as typeof result;
|
|
} catch (err) {
|
|
const status = err instanceof ToolGatewayHttpError ? err.status : 502;
|
|
const reasonCode =
|
|
err instanceof ToolContentValidationError
|
|
? err.reasonCode
|
|
: err instanceof ToolGatewayHttpError
|
|
? err.reasonCode
|
|
: "tool_execution_failed";
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: status === 504 ? "timed_out" : "failed",
|
|
errorCode: reasonCode,
|
|
errorMessage: message,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolInvocations.id, invocationId));
|
|
void emitConnectionInvoked(db, invocationId);
|
|
await writeToolCallEvent({
|
|
invocationId,
|
|
session: sessionLike,
|
|
eventType: "call_failed",
|
|
outcome: status === 504 ? "timeout" : "failure",
|
|
toolName: tool.name,
|
|
policyDecision: status === 504 ? "defer_runtime" : "deny",
|
|
reasonCode,
|
|
argumentsSummary: argumentValidation.summary,
|
|
metadata:
|
|
err instanceof ToolContentValidationError
|
|
? { findings: err.findings }
|
|
: null,
|
|
tool,
|
|
});
|
|
await writeAudit({
|
|
session: sessionLike,
|
|
companyId: input.runContext.companyId,
|
|
agentId: input.runContext.agentId,
|
|
runId: input.runContext.runId,
|
|
issueId: context.issueId,
|
|
action: "tool_gateway.call_failed",
|
|
details: {
|
|
invocationId,
|
|
decision: "deny",
|
|
reasonCode,
|
|
tool: input.tool,
|
|
...toolAuditMetadata(tool),
|
|
argumentsSummary: argumentValidation.summary,
|
|
durationMs: Date.now() - startedAt,
|
|
error: message,
|
|
},
|
|
});
|
|
if (err instanceof ToolContentValidationError) {
|
|
throw new ToolGatewayHttpError(422, message, reasonCode, {
|
|
findings: err.findings,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
async revokeSession(input: {
|
|
companyId: string;
|
|
sessionId: string;
|
|
revokedAt?: Date;
|
|
actor?: {
|
|
actorType?: LogActivityInput["actorType"];
|
|
actorId?: string;
|
|
agentId?: string | null;
|
|
runId?: string | null;
|
|
};
|
|
agentScope?: { agentId: string; runId?: string | null } | null;
|
|
}) {
|
|
const now = input.revokedAt ?? new Date();
|
|
const [existing] = await db
|
|
.select()
|
|
.from(toolGatewaySessions)
|
|
.where(
|
|
and(
|
|
eq(toolGatewaySessions.companyId, input.companyId),
|
|
eq(toolGatewaySessions.id, input.sessionId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!existing) {
|
|
throw new ToolGatewayHttpError(
|
|
404,
|
|
"Tool gateway session not found",
|
|
"session_not_found",
|
|
);
|
|
}
|
|
if (input.agentScope) {
|
|
const runMatches = input.agentScope.runId
|
|
? existing.runId === input.agentScope.runId
|
|
: true;
|
|
if (existing.agentId !== input.agentScope.agentId || !runMatches) {
|
|
throw new ToolGatewayHttpError(
|
|
403,
|
|
"Tool gateway session is outside the authenticated agent scope",
|
|
"session_scope_mismatch",
|
|
);
|
|
}
|
|
}
|
|
const [session] = await db
|
|
.update(toolGatewaySessions)
|
|
.set({ revokedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolGatewaySessions.companyId, input.companyId),
|
|
eq(toolGatewaySessions.id, input.sessionId),
|
|
),
|
|
)
|
|
.returning();
|
|
const sessionView = gatewaySessionFromRow(session!);
|
|
await writeAudit({
|
|
session: sessionView,
|
|
companyId: sessionView.companyId,
|
|
agentId: sessionView.agentId,
|
|
runId: sessionView.runId,
|
|
issueId: sessionView.issueId,
|
|
actorType: input.actor?.actorType,
|
|
actorId: input.actor?.actorId,
|
|
action: "tool_gateway.session_revoked",
|
|
details: {
|
|
decision: "revoke",
|
|
reasonCode: "session_revoked",
|
|
revokedAt: now.toISOString(),
|
|
previousRevokedAt: existing.revokedAt?.toISOString() ?? null,
|
|
},
|
|
});
|
|
return { ...sessionView, revokedAt: session!.revokedAt ?? now };
|
|
},
|
|
|
|
async cleanupExpiredSessions(input: { now?: Date } = {}) {
|
|
const now = input.now ?? new Date();
|
|
const expiredTokens = await db.select({ id: toolMcpGatewayTokens.id })
|
|
.from(toolMcpGatewayTokens)
|
|
.where(lte(toolMcpGatewayTokens.expiresAt, now))
|
|
.orderBy(asc(toolMcpGatewayTokens.expiresAt), asc(toolMcpGatewayTokens.id))
|
|
.limit(500);
|
|
if (expiredTokens.length > 0) {
|
|
await db.delete(toolMcpGatewayTokens)
|
|
.where(and(inArray(toolMcpGatewayTokens.id, expiredTokens.map((token) => token.id)), lte(toolMcpGatewayTokens.expiresAt, now)))
|
|
.returning({ id: toolMcpGatewayTokens.id });
|
|
}
|
|
const rows = await db
|
|
.delete(toolGatewaySessions)
|
|
.where(lte(toolGatewaySessions.expiresAt, now))
|
|
.returning({ id: toolGatewaySessions.id });
|
|
return { deletedCount: rows.length };
|
|
},
|
|
|
|
async listRuntimeSlots(companyId?: string) {
|
|
return runtimeSupervisor.listSlots(companyId);
|
|
},
|
|
|
|
async stopRuntimeSlot(input: {
|
|
companyId: string;
|
|
slotId: string;
|
|
actor?: { agentId?: string | null; runId?: string | null };
|
|
}) {
|
|
try {
|
|
return await runtimeSupervisor.stopSlot({
|
|
companyId: input.companyId,
|
|
slotId: input.slotId,
|
|
agentId: input.actor?.agentId ?? null,
|
|
runId: input.actor?.runId ?? null,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof ToolRuntimeSupervisorError) {
|
|
throw new ToolGatewayHttpError(
|
|
err.status,
|
|
err.message,
|
|
err.reasonCode,
|
|
err.details,
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
async restartRuntimeSlot(input: {
|
|
companyId: string;
|
|
slotId: string;
|
|
actor?: { agentId?: string | null; runId?: string | null };
|
|
}) {
|
|
try {
|
|
return await runtimeSupervisor.restartSlot({
|
|
companyId: input.companyId,
|
|
slotId: input.slotId,
|
|
agentId: input.actor?.agentId ?? null,
|
|
runId: input.actor?.runId ?? null,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof ToolRuntimeSupervisorError) {
|
|
throw new ToolGatewayHttpError(
|
|
err.status,
|
|
err.message,
|
|
err.reasonCode,
|
|
err.details,
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export type ToolGatewayService = ReturnType<typeof createToolGatewayService>;
|