mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connections provide governed tools, chat channels, model accounts,
and gateways.
> - Several management routes require a human board actor even when an
agent has the required grant.
> - Default agents must configure these resources within the responsible
user's authority.
> - This pull request adds a shared authorization path to the existing
REST and native API surface.
> - Human authentication, execution approvals, and local host boundaries
remain enforced.
> - The benefit is that a standard agent can complete authorized setup
with a bounded human handoff.
## Linked Issues or Issue Description
**Subsystem affected**
Server authorization, connection services, shared contracts, native API
discovery, database schema.
**Problem or motivation**
Connection administration is available in the web app but often
unavailable to agents. Agent grants, responsible-user authority,
credential ownership, and actor attribution need separate checks.
Opening gateway-token creation also requires preserving the issuing
agent's delegated authority during token use.
**Proposed solution**
Use the existing authorization evaluator for every connection family.
Enforce current responsible-user authority, effective trust, grant
scope, resource visibility, and target-agent access. Keep existing agent
grants unchanged. Expose authorized configuration through default
`search_api` and `call_api`. Save human authentication steps and let
agents continue after completion.
**Roadmap alignment**
This change completes the API access path for the existing Connected
Apps, Personal & Shared AI Accounts, and governed MCP Gateway
capabilities in `ROADMAP.md`. It follows the maintainer's requested
design. Related work: #11899 enforces connection execution access and
creator/manager rules; this PR extends agent management across
connection families. It does not replace that execution-policy work.
## What Changed
- Add shared connection management context and mandatory
responsible-user, trust, scope, and target checks.
- Allow authorized agents to configure tool, chat/email, GitHub/Slack,
AI, profile, and gateway resources through existing routes.
- Preserve human credential ownership and record the actual agent,
responsible user, and run or key in audit data.
- Preserve saved browser authentication handoffs and callback session
checks.
- Bind agent-issued durable gateway tokens to their responsible user and
recheck delegated authority at use.
- Add explicit REST transport and management capability metadata,
built-in guidance, and sensitive-response handling.
- Intersect caller and endpoint-agent Slack test/invitation policies,
including independent and shared rates. Require the addressed human’s
current endpoint identity link before completing identity handoffs.
- Add production-authenticated integration scenarios. Companion #17739
contains the saved setup UI (including the required human
import/rotation step), product and permission documentation,
catalog/Vercel credential and callback ownership, and operation maps to
keep each review below 100 files. Both PRs must ship together; merge
this implementation first, then its companion.
## Verification
- Current implementation head:
`2ecd6451d0d0c8d82121a4e3fa4ce94c5befbbd6`, including master
`ea727dc3c2`, 99 files. The latest follow-up fixes the two valid
findings from the preceding f9 review. Exact-head Greptile has completed
at5/5 with no findings and Superagent passed. Full exact-head Linux CI
workflow40446606056 completed SUCCESS; only the queued external
Paperclip Review remains pending; earlier Linux passes do not qualify
this successor.
- Latest preserved-stack verification passed **2,165 tests** in fresh
PostgreSQL: **70 actual-auth scenarios**, 27 OpenAPI contracts, 2,022
native API tests and 46 saved GitHub wizard cases. **141 existing
gateway/policy acceptance tests** also passed. Workspace
typecheck/build, maps and token gates passed. The first workspace
typecheck was blocked by sandbox tsx IPC permissions; its authorized
rerun passed. This is focused local evidence; the full Linux CI matrix
is still required.
- Issuer/target gateway policy regressions were red before the fix. Both
decisions now intersect, including Block/Ask first/rates, same
credential identity and current token/user/agent authority after
approval. Invocations retain their token and actual requesting agent;
deleted tokens fail closed. Thirteen actual-auth cases cover revocation,
narrowed scope, approvals, shared rates and board compatibility.
Intermediate missing-token attribution, review-context and
implicit-session actor failures remain in the qualification record; all
final cases pass.
- Activity pagination now retains a bounded cursor across private-only
windows. The actual-JWT regression reaches three older readable events
after 27 private ones without exposing private details. A native
device-login regression was red for plaintext receipts; local check and
test-call results now use existing protected-response handling. The
passing actual-auth case proves encrypted persistence, exact replay,
redacted history and no response asset.
- Previous-head `5b55195593` Linux CI initially failed e2e shard 4/8 in
the existing connection-intent continuation test: periodic recovery
queued a repair at 01:21:47, the scripted journey completed the task at
01:21:48, and the extra queued run was cancelled at 01:21:49 because the
task was terminal. The assertion selected that latest cancelled run.
GitHub refused the first attempt while the workflow was nonterminal.
After completion, the single evidence-supported failed-job rerun was
accepted as **attempt 2** on the same head (workflow 40431202466). The
failed shard and final E2E aggregate passed on attempt 2, and the
complete Linux workflow for that head is successful. The original
failure remains part of qualification. No additional unchanged-code
rerun will be used for this failure.
- Companion CI also has an unchanged login-cleanup idleness timeout,
retained in its qualification record. That same 16-case suite passed in
this parent Linux run and a fresh local database. Both preceding
workflows passed after their single failed-job reruns as attempt 2; the
original failures remain recorded. No additional unchanged-code rerun
was used.
- Migration `0380_broken_aqueduct.sql` was regenerated through the
repository workflow after master took 0379 for project Git
authorization. It adds the responsible-user column with `IF NOT EXISTS`,
preserves upstream migrations, and satisfies snapshot retention and
migration safety checks.
- Upstream published Copilot v62 and v63 identities remain byte-for-byte
unchanged. The combined guidance is now v64, digest
`sha256:22e31f95ef972bcead98a4a707c9f33433558fd111e390e027a2bbb4346e112a`.
The normal shared TypeScript/Rust identity check and **61 Copilot
installation tests passed**. The old source-identity assertion first
failed against unchanged published v63; regenerating a new v64 preserves
the published release.
- Workspace `pnpm -r typecheck` and `pnpm build`, map validation, and UI
token gates passed on the final combined stack after the upstream merge.
Node 24.22.1 / pnpm 9.15.4 were used.
- **Earlier 82 API/OpenAPI tests passed** in fresh PostgreSQL 18: 55
assembled-server scenarios and 27 contracts. They use real persistent
agent keys, run JWTs, human sessions, production defaults, and
deterministic provider fixtures. No actor injection establishes access.
- The new email regression was red before per-account scope filtering.
The Arcade regression was red before human ownership and inventory-key
fixes; it also verifies actual-agent audit identity and a human-session
inventory read. The activity regression measured **200 authorization
checks before the fix and one afterward** for the same one-item page.
- **82 GitHub UI tests passed**, including all three new
failed-replacement regressions that were red before their fix. The UI
preserves an earlier copy only after confirming its committed
generation. Changed or unreadable results block copying and reconnecting
until reconciled. Human-only secret responses now include their
nonsecret committed generation.
- Private test status now rechecks the originating principal and linked
task, including mandatory agent/responsible-user privacy. Cross-agent
tests require the full caller tool access decision and the same
credential identity, regardless of auth label; caller and target
policies/approvals and rate limits apply before execution and after
human approval. A profile-only precondition was found to reject valid
allow-policy and scoped-grant access and has been removed. Both
permitted-access cases and a no-auth-label identity regression were red
before the correction; caller-specific Block still prevents provider
execution. The private-status regression was also red before its fix.
- The tool-activity regression measured 100 capability decisions and 102
run captures on a 100-event page before the fix. It now uses two task
decisions and at most two captures while retaining the same 50 visible
events, private-event filtering, and 403 after audit-grant revocation.
The regression was red before request-context reuse and task-decision
sharing; final server typecheck/build also pass.
- Generic chat configure/reconnect now hands agent-supplied verification
credentials to the addressed human before provider work or vault
changes. Actual key/JWT regressions were red for Slack signing-key
replacement and generic channel secret-reference rebinding. They now
prove unchanged refs and vaulted signing value, human setup, permitted
agent bot-token repair, and governed test messages. All 82 API/OpenAPI
cases pass in fresh PostgreSQL. Eight native receipt protections and
final server typecheck/build also pass.
- The preceding review found that old healthy state could retire a
credential-entry card before a human changed anything. The actual-JWT
regression was red. Saved field generations now change only with
protected values and commit with refs plus a verification fence; retries
retain the baseline. Old health, bot-token repair and unchanged entry
stay pending. New signing verification and current authority permit
continuation. A separate red race proved that user revocation during
provider authentication could return 403 after refs changed; authority
is now checked before persistence and inside the credential transaction.
The final 82 API/OpenAPI cases, four neighboring real-database
Slack/GitHub lifecycle cases, and final server typecheck/build pass.
- A preceding full run crossed a rate-window boundary. Database rows
prove one consumed slot at 23:35 and another at 23:36 for the same
policy/bucket. Only Date is frozen in the rate test cases; transport and
asynchronous timers remain real. Production rate behavior is unchanged,
and the final full run passes. An intermediate fixture failure that
overwrote hidden generation metadata with an API projection is retained
in the qualification record.
- Linux CI on an earlier head found two rate-admission assertions that
omitted the newly persisted bucket key. The failures were reproduced
locally; both assertions now verify the exact actor/tool bucket and all
49 policy-service tests pass. The companion qualification record retains
the initial and intermediate failures. Catalog creator attribution is
preserved in the companion to keep this implementation review at 99
files.
- The latest review reproduced three regressions before repair: complete
human replacements failed on unavailable old signing secrets, fresh
GitHub links incorrectly selected reconnect, and different
credential-entry requests shared an idempotency key. Each old protected
ref now has a narrow known-unavailable fallback; unexpected resolver
failures and company/scope violations still abort. Fresh imports keep
their credential form, configured endpoints keep reconnect, and
canonical field sets/completion modes distinguish handoffs while
matching retries retain their baseline. All 55 actual-auth scenarios and
27 separately run OpenAPI contracts pass, as do server typecheck/build.
Companion #17739 also passes all 41 wizard tests including actual
fresh-form submission, UI typecheck, maps and token gates. Earlier red
results and a mistyped OpenAPI filename are retained in qualification.
- The configured GitHub handoff regression was red in the
browser-component tests and actual-auth link/persistence tests. Saved
links now name requested fields and all/any completion mode; the
companion form submits those supplied fields through reconnect and
retains other saved credentials. Webhook/client-secret reconnect remains
human-only; first-setup secret restrictions remain. All 46 wizard tests
pass, including combined modes and legacy links. A duplicate review
referenced the older companion bf760912; the current form is already
fixed, linked in the reply, and its thread is resolved.
- Linux CI on bf760912 failed automatic Slack provisioning because vault
persistence cleared an already verified manifest-update challenge. The
same regression was reproduced locally. The provider-owned helper now
preserves proof only for the current signing fingerprint and callback
URL. Eight existing compatibility cases pass, including this case and
initial GitHub secret rejection. The first uncommitted GitHub-field
variant also failed that initial boundary and was narrowed to reconnect;
both failures remain in qualification.
- The upstream merge conflicted only in generated migration metadata.
Upstream 0379 is preserved, normal generation/pruning produced 0380, and
two repeated applications of the idempotent additive SQL passed. The
final combined stack passed **128 tests** (55 actual-auth, 27 OpenAPI
and 46 wizard cases) in fresh PostgreSQL. Eight Google UI cases also
pass; the two protocol cases could not initialize embedded PostgreSQL in
this fresh local attempt. Their earlier passing evidence remains
historical; fresh Linux CI must execute them. Vercel personal credential
attribution is preserved in companion #17739 with its other
ownership/consent follow-ups, keeping this PR at 99 files.
- The suite covers fresh defaults, restricted users/agents, scopes,
personal isolation, token revocation, human handoffs, callback replay,
execution approvals, authorized self-targeted policy changes,
self-approval denial, and current authority before commit. Reproduce API
checks with `pnpm exec vitest run
server/src/__tests__/connection-management.integration.test.ts
server/src/__tests__/openapi-routes.test.ts`, using a fresh database
through `PAPERCLIP_CONNECTION_MANAGEMENT_TEST_DATABASE_URL` or the
default embedded test database.
- Companion #17739 also records consent separately from token refresh so
saved Google choices survive refresh. Its eight service-picker and two
local-HTTP OAuth tests pass; both new UI cases were red before the fix.
Normal migration generation confirms no SQL change is needed for the
additive JSON metadata.
- The integration module shares one assembled server, authentication
factory, and provider lifecycle to preserve the
HTTP-to-vault-to-task-continuation proof. A reused external database
caused a Slack fixture identity conflict; the fresh final run passed.
Earlier fixture/transport and contract-update failures remain in the
companion qualification record.
- The broad local `pnpm test:run` attempt remains blocked by macOS
embedded PostgreSQL's host semaphore limit. It is not a clean pass. The
fresh Linux CI matrix must complete. Provider fixtures do not prove live
setup or an empirical LLM success rate.
- A current-head completed Paperclip assessment is not yet recorded. Its
earlier external recovery boundary has not been bypassed, and no
incomplete assessment is reported as passing. The user authorized both
merges when checks pass; merge this implementation first, then replay,
retarget, and requalify companion #17739 against the squash-merged
master.
- After the master refresh, local workspace typecheck/build, **97
actual-auth/OpenAPI cases** and **141 gateway/policy cases** pass. The
original fixed database port was occupied; Docker assigned a free
localhost port. The first neighbor setup command could not resolve root
`tsx`, leaving its schema uninitialized; the built database package
migrated the separate fixture and all unchanged cases passed. Both setup
failures are retained in qualification. Fresh Linux CI remains required;
no full local test-sweep pass is claimed.
- The f9 Greptile review scored **2/5** for two reproduced findings:
Slack test sends skipped the caller’s tool policy, and healthy App
credentials completed an unconfirmed identity handoff. Both are fixed in
this head and have resolved evidence threads
([Slack](https://github.com/paperclipai/paperclip/pull/17737#discussion_r4384927765),
[identity](https://github.com/paperclipai/paperclip/pull/17737#discussion_r4384927895)).
Actual key/JWT cases cover twelve Slack caller/target/recipient
combinations; the human-session GitHub OAuth callback alone remains
pending until explicit confirmation. All **99 API/OpenAPI tests** (72
actual-auth + 27 contracts), workspace typecheck and build pass. On the
combined companion, all four focused cases (including the twelve Slack
combinations), typecheck/build and maps also pass. The continuation
suite’s 50 embedded-PG cases were skipped because its database could not
start locally; fresh Linux CI must run them. Initial fixture
credential-collision and audit-projection assertion failures are
retained with the red reproductions in the companion qualification
record. No full local sweep or live consent is claimed.
## Risks
- The maintainer explicitly confirmed authorized policy administration,
including self-targeted changes that loosen controls. These writes
require management grants and the responsible user’s current authority.
Agents cannot approve their own governed action requests or edit their
own principal permission grants. The actual-auth regression exercises
both permitted administration and those denials.
- This is a broad authorization change. Incorrect scope or owner
resolution could deny permitted setup or expose another user's
resources. Integration cases cover both permitted and denied requests
with actual authentication.
- The additive nullable gateway-token column does not infer identities
for legacy clients or backfill agent grants. Legacy agent tokens without
a responsible user fail closed.
- Provider fixtures test supported setup and callback contracts. They do
not prove live provider availability.
## Model Used
- OpenAI Codex, GPT-6 family, with reasoning, repository tools, and code
execution. The runtime does not expose a more specific deployment ID or
context-window size.
## 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 — focused suites above;
full local sweep remains blocked as documented
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green — exact `2ecd6451d0` Linux CI
completed SUCCESS; external Paperclip Review is still queued
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
exact `2ecd6451d0` review completed; both f9 findings have resolved
evidence replies
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
6079 lines
248 KiB
TypeScript
6079 lines
248 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { createServer, type IncomingMessage } from "node:http";
|
|
import express from "express";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import request from "supertest";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
agentWakeupRequests,
|
|
companySecretBindings,
|
|
companySecrets,
|
|
companySecretVersions,
|
|
companyMemberships,
|
|
companies,
|
|
connectionGrantMembers,
|
|
connectionGrantDelegations,
|
|
connectionGrants,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueThreadInteractions,
|
|
issues,
|
|
principalPermissionGrants,
|
|
projects,
|
|
toolAccessAuditEvents,
|
|
toolActionRequests,
|
|
toolApplications,
|
|
toolCatalogEntries,
|
|
toolCallEvents,
|
|
toolConnectionInstalls,
|
|
toolConnections,
|
|
toolGatewayRateLimitCounters,
|
|
toolGatewaySessions,
|
|
toolInvocations,
|
|
toolMcpGateways,
|
|
toolMcpGatewayTokens,
|
|
toolPolicies,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
toolStdioCommandTemplates,
|
|
toolRuntimeSlots,
|
|
secretAccessEvents,
|
|
userSecretDeclarations,
|
|
userSecretDefinitions,
|
|
} from "@paperclipai/db";
|
|
import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js";
|
|
import { resolveNativeRuntimeMcpSnapshot } from "../services/native-runtime/runtime-context.js";
|
|
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
|
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "../routes/tool-gateway.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import {
|
|
canonicalToolArguments,
|
|
readSignedToolArgumentsPayload,
|
|
signToolArguments,
|
|
summarizeToolValue,
|
|
} from "../services/tool-content-guards.js";
|
|
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
|
|
import { resolveConnectionGrantSecret } from "../services/connection-credentials.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import * as cogneeBridge from "../services/cognee-connection.js";
|
|
import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
const externalTestDatabaseUrl = process.env.PAPERCLIP_TOOL_GATEWAY_TEST_DATABASE_URL;
|
|
const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
const testToolActionSigningSecret = "test-tool-action-signing-secret";
|
|
|
|
type Db = ReturnType<typeof createDb>;
|
|
type ToolGatewayServiceOptions = NonNullable<Parameters<typeof createToolGatewayService>[1]>;
|
|
|
|
async function createCompany(db: Db) {
|
|
return db
|
|
.insert(companies)
|
|
.values({
|
|
name: `Gateway ${randomUUID()}`,
|
|
issuePrefix: `TG${randomUUID().slice(0, 6).toUpperCase()}`,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createAgent(db: Db, companyId: string, permissions: Record<string, unknown> = {}) {
|
|
return db
|
|
.insert(agents)
|
|
.values({
|
|
companyId,
|
|
name: `Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createIssueAndRun(db: Db, companyId: string, agentId: string) {
|
|
const project = await db
|
|
.insert(projects)
|
|
.values({ companyId, name: `Project ${randomUUID()}` })
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const issue = await db
|
|
.insert(issues)
|
|
.values({
|
|
companyId,
|
|
projectId: project.id,
|
|
title: `Gateway issue ${randomUUID()}`,
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const run = await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
contextSnapshot: { issueId: issue.id, projectId: project.id },
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
return { project, issue, run };
|
|
}
|
|
|
|
async function createActiveMember(db: Db, companyId: string, userId: string) {
|
|
await db.insert(companyMemberships).values({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
});
|
|
}
|
|
|
|
async function allowToolsForAgent(db: Db, companyId: string, agentId: string, toolNames: string[]) {
|
|
const profile = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey: `gateway-${randomUUID()}`,
|
|
name: `Gateway profile ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId,
|
|
profileId: profile.id,
|
|
targetType: "agent",
|
|
targetId: agentId,
|
|
});
|
|
if (toolNames.length > 0) {
|
|
await db.insert(toolProfileEntries).values(toolNames.map((toolName) => ({
|
|
companyId,
|
|
profileId: profile.id,
|
|
selectorType: "tool_name" as const,
|
|
effect: "include" as const,
|
|
toolName,
|
|
})));
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
async function allowAllToolsForAgent(db: Db, companyId: string, agentId: string) {
|
|
const profile = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey: `gateway-all-${randomUUID()}`,
|
|
name: `Gateway all profile ${randomUUID()}`,
|
|
defaultAction: "allow",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId,
|
|
profileId: profile.id,
|
|
targetType: "agent",
|
|
targetId: agentId,
|
|
});
|
|
return profile;
|
|
}
|
|
|
|
async function createRemoteMcpTool(
|
|
db: Db,
|
|
companyId: string,
|
|
input: {
|
|
applicationKey?: string | null;
|
|
connectionName?: string;
|
|
url?: string;
|
|
toolName?: string;
|
|
title?: string | null;
|
|
connectionEnabled?: boolean;
|
|
connectionStatus?: "draft" | "active" | "disabled" | "archived";
|
|
healthStatus?: "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret";
|
|
catalogStatus?: "active" | "disabled" | "quarantined" | "removed";
|
|
quarantinedAt?: Date | null;
|
|
credentialRefs?: typeof toolConnections.$inferInsert["credentialRefs"];
|
|
credentialSecretRefs?: typeof toolConnections.$inferInsert["credentialSecretRefs"];
|
|
riskLevel?: "read" | "write" | "destructive";
|
|
stdioScript?: string;
|
|
envKeys?: string[];
|
|
connectionConfig?: Record<string, unknown>;
|
|
} = {},
|
|
) {
|
|
const applicationKey = input.applicationKey ?? `app-${randomUUID().slice(0, 8)}`;
|
|
let application = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(and(eq(toolApplications.companyId, companyId), eq(toolApplications.applicationKey, applicationKey)))
|
|
.limit(1)
|
|
.then((rows) => rows[0]);
|
|
if (!application) {
|
|
[application] = await db.insert(toolApplications).values({
|
|
companyId,
|
|
applicationKey,
|
|
name: `Remote app ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
}).returning();
|
|
}
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId,
|
|
applicationId: application.id,
|
|
name: input.connectionName ?? `Remote connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: input.connectionStatus ?? "active",
|
|
enabled: input.connectionEnabled ?? true,
|
|
healthStatus: input.healthStatus ?? "ok",
|
|
config: { url: input.url ?? "https://mcp.example.test/mcp", ...(input.connectionConfig ?? {}) },
|
|
transportConfig: { url: input.url ?? "https://mcp.example.test/mcp", ...(input.connectionConfig ?? {}) },
|
|
credentialRefs: input.credentialRefs ?? [],
|
|
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
|
}).returning();
|
|
await db.insert(connectionGrants).values({
|
|
companyId,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
credentialSecretRefs: connection.credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: true,
|
|
});
|
|
if (input.credentialRefs?.length || input.credentialSecretRefs?.length) {
|
|
await db.insert(companySecretBindings).values([
|
|
...(input.credentialRefs ?? []).map((ref) => ({
|
|
companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection" as const,
|
|
targetId: connection!.id,
|
|
configPath: `credentials.${ref.name}`,
|
|
})),
|
|
...(input.credentialSecretRefs ?? []).map((ref) => ({
|
|
companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection" as const,
|
|
targetId: connection!.id,
|
|
configPath: ref.configPath,
|
|
versionSelector: String(ref.versionSelector ?? "latest"),
|
|
required: ref.required ?? true,
|
|
label: ref.label ?? null,
|
|
})),
|
|
]).onConflictDoNothing();
|
|
}
|
|
const toolName = input.toolName ?? "kv_set";
|
|
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
|
|
companyId,
|
|
applicationId: application.id,
|
|
connectionId: connection!.id,
|
|
entryKind: "tool",
|
|
name: `${toolName}-${randomUUID()}`,
|
|
toolName,
|
|
title: input.title ?? "KV Set",
|
|
description: `Call ${toolName}`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { key: { type: "string" }, value: { type: "string" } },
|
|
required: ["key", "value"],
|
|
additionalProperties: false,
|
|
},
|
|
annotations: { readOnlyHint: false },
|
|
riskLevel: input.riskLevel ?? "write",
|
|
isReadOnly: (input.riskLevel ?? "write") === "read",
|
|
isWrite: (input.riskLevel ?? "write") === "write",
|
|
isDestructive: (input.riskLevel ?? "write") === "destructive",
|
|
status: input.catalogStatus ?? "active",
|
|
versionHash: randomUUID(),
|
|
quarantinedAt: input.quarantinedAt ?? null,
|
|
}).returning();
|
|
return { application, connection: connection!, catalogEntry: catalogEntry! };
|
|
}
|
|
|
|
async function createLocalStdioMcpTool(
|
|
db: Db,
|
|
companyId: string,
|
|
input: {
|
|
applicationKey?: string | null;
|
|
connectionName?: string;
|
|
toolName?: string;
|
|
title?: string | null;
|
|
connectionEnabled?: boolean;
|
|
connectionStatus?: "draft" | "active" | "disabled" | "archived";
|
|
healthStatus?: "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret";
|
|
catalogStatus?: "active" | "disabled" | "quarantined" | "removed";
|
|
riskLevel?: "read" | "write" | "destructive";
|
|
credentialPolicy?: "shared" | "per_user" | "per_user_with_fallback";
|
|
credentialSecretRefs?: typeof toolConnections.$inferInsert["credentialSecretRefs"];
|
|
stdioScript?: string;
|
|
envKeys?: string[];
|
|
connectionConfig?: Record<string, unknown>;
|
|
} = {},
|
|
) {
|
|
const applicationKey = input.applicationKey ?? `local-app-${randomUUID().slice(0, 8)}`;
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId,
|
|
applicationKey,
|
|
name: `Local stdio app ${randomUUID()}`,
|
|
type: "mcp_stdio",
|
|
status: "active",
|
|
}).returning();
|
|
const toolName = input.toolName ?? "echo";
|
|
const templateKey = `test.local-stdio.${randomUUID()}`;
|
|
const stdioScript = input.stdioScript ?? `
|
|
const readline = require("node:readline");
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on("line", (line) => {
|
|
const message = JSON.parse(line);
|
|
if (message.method === "initialize") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "test-stdio", version: "0.0.0" } } }) + "\\n");
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: "local:" + String(message.params?.arguments?.message ?? "") }], structuredContent: { echoed: message.params?.arguments?.message ?? null } } }) + "\\n");
|
|
}
|
|
});
|
|
`;
|
|
await db.insert(toolStdioCommandTemplates).values({
|
|
companyId,
|
|
templateKey,
|
|
name: `Local stdio template ${randomUUID()}`,
|
|
command: process.execPath,
|
|
args: ["-e", stdioScript],
|
|
envKeys: input.envKeys ?? [],
|
|
tools: [
|
|
{
|
|
name: toolName,
|
|
title: input.title ?? "Local Echo",
|
|
description: `Call ${toolName}`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
additionalProperties: false,
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
],
|
|
});
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
name: input.connectionName ?? `Local stdio connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "local_stdio",
|
|
status: input.connectionStatus ?? "active",
|
|
enabled: input.connectionEnabled ?? true,
|
|
healthStatus: input.healthStatus ?? "ok",
|
|
credentialPolicy: input.credentialPolicy ?? "shared",
|
|
config: { templateId: templateKey, ...(input.connectionConfig ?? {}) },
|
|
transportConfig: { templateId: templateKey, ...(input.connectionConfig ?? {}) },
|
|
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
|
}).returning();
|
|
await db.insert(connectionGrants).values({
|
|
companyId,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
credentialSecretRefs: connection.credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: true,
|
|
});
|
|
if (input.credentialSecretRefs?.length) {
|
|
await db.insert(companySecretBindings).values(input.credentialSecretRefs.map((ref) => ({
|
|
companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection" as const,
|
|
targetId: connection.id,
|
|
configPath: ref.configPath,
|
|
versionSelector: String(ref.versionSelector ?? "latest"),
|
|
required: ref.required ?? true,
|
|
label: ref.label ?? null,
|
|
}))).onConflictDoNothing();
|
|
}
|
|
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
entryKind: "tool",
|
|
name: `${toolName}-${randomUUID()}`,
|
|
toolName,
|
|
title: input.title ?? "Local Echo",
|
|
description: `Call ${toolName}`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
additionalProperties: false,
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
riskLevel: input.riskLevel ?? "read",
|
|
isReadOnly: (input.riskLevel ?? "read") === "read",
|
|
isWrite: (input.riskLevel ?? "read") === "write",
|
|
isDestructive: (input.riskLevel ?? "read") === "destructive",
|
|
status: input.catalogStatus ?? "active",
|
|
versionHash: randomUUID(),
|
|
}).returning();
|
|
return { application: application!, connection: connection!, catalogEntry: catalogEntry!, templateKey };
|
|
}
|
|
|
|
function expectedConnectedToolName(input: { applicationKey: string | null; connectionId: string; toolName: string }) {
|
|
const applicationSegment = (input.applicationKey ?? "mcp")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 64) || "mcp";
|
|
const toolSegment = input.toolName
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 64) || "tool";
|
|
return `mcp.${applicationSegment}-${input.connectionId.replace(/-/g, "").slice(0, 8)}:${toolSegment}`;
|
|
}
|
|
|
|
function expectGatewayError(error: unknown, status: number, reasonCode: string) {
|
|
expect(error).toBeInstanceOf(ToolGatewayHttpError);
|
|
const gatewayError = error as ToolGatewayHttpError;
|
|
expect(gatewayError.status).toBe(status);
|
|
expect(gatewayError.reasonCode).toBe(reasonCode);
|
|
}
|
|
|
|
function tamperToken(token: string) {
|
|
const replacement = token.endsWith("A") ? "B" : "A";
|
|
return `${token.slice(0, -1)}${replacement}`;
|
|
}
|
|
|
|
function createTestToolGatewayService(db: Db, options: ToolGatewayServiceOptions = {}) {
|
|
return createToolGatewayService(db, {
|
|
...options,
|
|
toolActionSigningSecret: options.toolActionSigningSecret ?? testToolActionSigningSecret,
|
|
});
|
|
}
|
|
|
|
function createGatewayRouteApp(
|
|
db: Db,
|
|
gateway = createTestToolGatewayService(db),
|
|
actor?: Express.Request["actor"],
|
|
) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
if (actor) {
|
|
app.use((req, _res, next) => {
|
|
req.actor = actor;
|
|
next();
|
|
});
|
|
}
|
|
app.use(mcpGatewayProtocolRoutes(gateway));
|
|
app.use("/api", toolGatewayRoutes(db, gateway));
|
|
return app;
|
|
}
|
|
|
|
type FakeMcpRequest = {
|
|
headers: IncomingMessage["headers"];
|
|
body: Record<string, unknown> | null;
|
|
};
|
|
|
|
async function startFakeRemoteMcpServer(handler: (request: FakeMcpRequest) => Promise<{
|
|
status?: number;
|
|
headers?: Record<string, string>;
|
|
body?: unknown;
|
|
rawBody?: string;
|
|
delayMs?: number;
|
|
bodyDelayMs?: number;
|
|
}> | {
|
|
status?: number;
|
|
headers?: Record<string, string>;
|
|
body?: unknown;
|
|
rawBody?: string;
|
|
delayMs?: number;
|
|
bodyDelayMs?: number;
|
|
}) {
|
|
const requests: FakeMcpRequest[] = [];
|
|
const server = createServer(async (req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
req.on("end", async () => {
|
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
let body: Record<string, unknown> | null = null;
|
|
try {
|
|
body = raw ? JSON.parse(raw) as Record<string, unknown> : null;
|
|
} catch {
|
|
body = null;
|
|
}
|
|
const requestRecord = { headers: req.headers, body };
|
|
requests.push(requestRecord);
|
|
const response = await handler(requestRecord);
|
|
if (response.delayMs) {
|
|
await new Promise((resolve) => setTimeout(resolve, response.delayMs));
|
|
}
|
|
res.statusCode = response.status ?? 200;
|
|
for (const [key, value] of Object.entries(response.headers ?? {})) {
|
|
res.setHeader(key, value);
|
|
}
|
|
if (response.bodyDelayMs) {
|
|
res.setHeader("content-type", "application/json");
|
|
res.flushHeaders();
|
|
await new Promise((resolve) => setTimeout(resolve, response.bodyDelayMs));
|
|
}
|
|
if (response.rawBody !== undefined) {
|
|
res.end(response.rawBody);
|
|
} else {
|
|
if (!res.headersSent) res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify(response.body ?? {
|
|
jsonrpc: "2.0",
|
|
id: body?.id ?? "test",
|
|
result: { content: [{ type: "text", text: "ok" }] },
|
|
}));
|
|
}
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Expected TCP fake MCP server address");
|
|
return {
|
|
url: `http://127.0.0.1:${address.port}/mcp`,
|
|
requests,
|
|
close: () => new Promise<void>((resolve, reject) => {
|
|
server.close((error) => error ? reject(error) : resolve());
|
|
}),
|
|
};
|
|
}
|
|
|
|
describeEmbeddedPostgres("tool gateway acceptance", () => {
|
|
let db!: Db;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
if (!externalTestDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-");
|
|
db = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString);
|
|
}, 90_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(toolCallEvents);
|
|
await db.delete(toolRuntimeSlots);
|
|
await db.delete(toolGatewaySessions);
|
|
await db.delete(toolGatewayRateLimitCounters);
|
|
await db.delete(toolActionRequests);
|
|
await db.delete(toolInvocations);
|
|
await db.delete(toolAccessAuditEvents);
|
|
await db.delete(toolPolicies);
|
|
await db.delete(toolMcpGatewayTokens);
|
|
await db.delete(toolMcpGateways);
|
|
await db.delete(toolProfileEntries);
|
|
await db.delete(toolProfileBindings);
|
|
await db.delete(toolProfiles);
|
|
await db.delete(toolConnections);
|
|
await db.delete(toolApplications);
|
|
await db.delete(secretAccessEvents);
|
|
await db.delete(userSecretDeclarations);
|
|
await db.delete(companySecretBindings);
|
|
await db.delete(companySecretVersions);
|
|
await db.delete(companySecrets);
|
|
await db.delete(userSecretDefinitions);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(agentWakeupRequests);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(issues);
|
|
await db.delete(projects);
|
|
await db.delete(principalPermissionGrants);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("preserves provider tool errors in MCP responses and failed invocation audits", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(({ body }) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: body?.id,
|
|
result: { content: [{ type: "text", text: "Search denied: outside consented tag" }], isError: true },
|
|
},
|
|
}));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url, toolName: "search_memory", riskLevel: "read",
|
|
});
|
|
await db.update(toolCatalogEntries).set({
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
}).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
const toolName = expectedConnectedToolName({
|
|
applicationKey: application.applicationKey,
|
|
connectionId: connection.id,
|
|
toolName: catalogEntry.toolName,
|
|
});
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id, profileKey: `error-${randomUUID()}`,
|
|
name: "Provider error test", defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id, profileId: profile.id,
|
|
selectorType: "tool_name", effect: "include", toolName,
|
|
});
|
|
const gateway = createTestToolGatewayService(db);
|
|
const named = await gateway.createNamedGateway({
|
|
companyId: company.id, body: { name: "Provider error test", profileId: profile.id },
|
|
});
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id, gatewayId: named.id, body: { name: "Test" },
|
|
});
|
|
const response = await request(createGatewayRouteApp(db, gateway))
|
|
.post(named.endpointPath)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: {} } })
|
|
.expect(200);
|
|
expect(response.body.result).toEqual({
|
|
content: [{ type: "text", text: "Search denied: outside consented tag" }], isError: true,
|
|
});
|
|
expect(await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id)))
|
|
.toEqual([expect.objectContaining({ status: "failed", errorCode: "tool_error" })]);
|
|
const events = await db.select().from(toolCallEvents).where(eq(toolCallEvents.companyId, company.id));
|
|
expect(events).toContainEqual(expect.objectContaining({ eventType: "call_failed", outcome: "failure", reasonCode: "tool_error" }));
|
|
expect(events.some((event) => event.eventType === "call_completed")).toBe(false);
|
|
const agent = await createAgent(db, company.id);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user",
|
|
principalId: "test-user", status: "active", membershipRole: "member" });
|
|
const testCall = await gateway.executeTestCall({ companyId: company.id, connectionId: connection.id,
|
|
agentId: agent.id, userId: "test-user", toolName, parameters: {} });
|
|
expect(testCall).toMatchObject({ decision: "allowed", error: { reasonCode: "tool_error" } });
|
|
expect(await db.select().from(toolInvocations).where(eq(toolInvocations.id, testCall.invocationId)))
|
|
.toEqual([expect.objectContaining({ status: "failed", errorCode: "tool_error" })]);
|
|
const testEvents = await db.select().from(toolCallEvents).where(eq(toolCallEvents.invocationId, testCall.invocationId));
|
|
expect(testEvents.some(event => event.eventType === "call_failed")).toBe(true);
|
|
expect(testEvents.some(event => event.eventType === "call_completed")).toBe(false);
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("returns a JSON-RPC reconciliation error instead of repeating an unverifiable legacy write", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(({ body }) => ({
|
|
body: { jsonrpc: "2.0", id: body?.id, result: { content: [{ type: "text", text: "saved" }] } },
|
|
}));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, { url: remote.url });
|
|
const toolName = expectedConnectedToolName({ applicationKey: application.applicationKey,
|
|
connectionId: connection.id, toolName: catalogEntry.toolName });
|
|
const [profile] = await db.insert(toolProfiles).values({ companyId: company.id,
|
|
profileKey: `legacy-${randomUUID()}`, name: "Legacy write test", defaultAction: "deny" }).returning();
|
|
await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id,
|
|
selectorType: "tool_name", effect: "include", toolName });
|
|
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow fixture writes",
|
|
policyType: "allow", selectors: { toolName } });
|
|
const gateway = createTestToolGatewayService(db);
|
|
const named = await gateway.createNamedGateway({ companyId: company.id,
|
|
body: { name: "Legacy write test", profileId: profile.id } });
|
|
const token = await gateway.createNamedGatewayToken({ companyId: company.id,
|
|
gatewayId: named.id, body: { name: "Test" } });
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const parameters = { key: "large-note", value: `${"x".repeat(500)}original` };
|
|
await request(app).post(named.endpointPath).set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: "initial-write", method: "tools/call", params: { name: toolName, arguments: parameters } }).expect(200);
|
|
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id));
|
|
expect(invocation.status).toBe("succeeded");
|
|
const oldHash = invocation.argumentsSummary!.sha256!;
|
|
const scope = { companyId: invocation.companyId, runId: invocation.runId, issueId: invocation.issueId,
|
|
applicationId: invocation.applicationId, connectionId: invocation.connectionId,
|
|
catalogEntryId: invocation.catalogEntryId, toolName: invocation.toolName, argumentsHash: oldHash };
|
|
const oldKey = `side_effect:${createHash("sha256").update(JSON.stringify(Object.fromEntries(
|
|
Object.entries(scope).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0),
|
|
))).digest("hex")}`;
|
|
await db.update(toolInvocations).set({ argumentsHash: oldHash, idempotencyKey: oldKey })
|
|
.where(eq(toolInvocations.id, invocation.id));
|
|
const requestCount = remote.requests.length;
|
|
const replay = await request(app).post(named.endpointPath).set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: "legacy-write", method: "tools/call", params: { name: toolName, arguments: parameters } }).expect(409);
|
|
expect(replay.body).toMatchObject({ jsonrpc: "2.0", id: "legacy-write", error: {
|
|
code: -32000, data: { reasonCode: "legacy_tool_idempotency_unverifiable", invocationId: invocation.id },
|
|
} });
|
|
expect(replay.body.error.message).toContain("Reconcile");
|
|
expect(remote.requests).toHaveLength(requestCount);
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("exposes a named gateway with scoped bearer-token auth and revocation", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(async ({ body }) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: body?.id,
|
|
result: { content: [{ type: "text", text: "read ok" }], structuredContent: { ok: true } },
|
|
},
|
|
}));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url,
|
|
applicationKey: "named-gateway-app",
|
|
toolName: "read_note",
|
|
title: "Read note",
|
|
riskLevel: "read",
|
|
});
|
|
const gatewayToolName = expectedConnectedToolName({
|
|
applicationKey: application.applicationKey,
|
|
connectionId: connection.id,
|
|
toolName: catalogEntry.toolName,
|
|
});
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `named-gateway-${randomUUID()}`,
|
|
name: `Named gateway ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: gatewayToolName,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "External reader", profileId: profile.id },
|
|
});
|
|
expect(created.gatewayPublicId).toMatch(/^gw_[a-f0-9]{32}$/);
|
|
expect(created.endpointPath).toBe(`/mcp/gateways/${created.gatewayPublicId}`);
|
|
expect(created.clientSnippets.length).toBeGreaterThan(0);
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Cursor", clientLabel: "Cursor desktop", ownerNote: "QA fixture token" },
|
|
});
|
|
expect(token.subjectType).toBe("gateway_client");
|
|
expect(token.clientLabel).toBe("Cursor desktop");
|
|
expect(token.ownerNote).toBe("QA fixture token");
|
|
expect(token.tokenPrefix).toMatch(/^pcgw_[a-f0-9]{8}$/);
|
|
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const publicEndpoint = created.endpointPath;
|
|
const queryOnly = await request(app)
|
|
.post(`${publicEndpoint}?paperclip_capability=${encodeURIComponent(token.token)}`)
|
|
.send({ jsonrpc: "2.0", id: "query-only", method: "tools/list" })
|
|
.expect(401);
|
|
expect(queryOnly.body.error).toBe("Bearer token is required");
|
|
|
|
const listed = await request(app)
|
|
.post(publicEndpoint)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(200);
|
|
const visibleToolNames = listed.body.result.tools.map((tool: { name: string }) => tool.name);
|
|
expect(visibleToolNames).toContain(gatewayToolName);
|
|
expect(visibleToolNames).not.toContain("mcp-remote-fixture:update_note");
|
|
|
|
const toolOnlyResources = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: "resources", method: "resources/list" })
|
|
.expect(200);
|
|
expect(toolOnlyResources.body.result.resources).toEqual([]);
|
|
|
|
const called = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: { name: gatewayToolName, arguments: { key: "a", value: "b" } },
|
|
})
|
|
.expect(200);
|
|
expect(called.body.result.content).toEqual([{ type: "text", text: "read ok" }]);
|
|
const upstreamRequestCountAfterAllowedCall = remote.requests.length;
|
|
|
|
const denied = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 3,
|
|
method: "tools/call",
|
|
params: { name: "mcp-remote-fixture:update_note", arguments: { noteId: "n1", body: "blocked" } },
|
|
})
|
|
.expect(403);
|
|
expect(denied.body.error.data.reasonCode).toBe("deny_default");
|
|
expect(remote.requests.length).toBe(upstreamRequestCountAfterAllowedCall);
|
|
const deniedAuditRows = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(and(eq(activityLog.companyId, company.id), eq(activityLog.action, "tool_gateway.call_completed")));
|
|
expect(JSON.stringify(deniedAuditRows)).not.toContain("blocked");
|
|
|
|
const listOnlyToken = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: {
|
|
name: "Discovery only",
|
|
clientLabel: "Discovery client",
|
|
ownerNote: "List-only regression token",
|
|
allowedActions: ["tools/list"],
|
|
},
|
|
});
|
|
await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${listOnlyToken.token}`)
|
|
.send({ jsonrpc: "2.0", id: 4, method: "tools/list" })
|
|
.expect(200);
|
|
const scopedDenied = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${listOnlyToken.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 5,
|
|
method: "tools/call",
|
|
params: { name: gatewayToolName, arguments: { key: "a", value: "b" } },
|
|
})
|
|
.expect(403);
|
|
expect(scopedDenied.body.error.data.reasonCode).toBe("gateway_token_action_denied");
|
|
|
|
await gateway.revokeNamedGatewayToken({ companyId: company.id, tokenId: token.id });
|
|
const revoked = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 6, method: "tools/list" })
|
|
.expect(401);
|
|
expect(revoked.body.error.data.reasonCode).toBe("gateway_token_revoked");
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("keeps additive app-gallery assignments out of gateway-only runtimes", async () => {
|
|
const company = await createCompany(db);
|
|
const assigned = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "gateway-assigned-app",
|
|
connectionName: "Dedicated GitHub identity",
|
|
toolName: "get_me",
|
|
riskLevel: "read",
|
|
});
|
|
const unassigned = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "gateway-unassigned-app",
|
|
connectionName: "Personal GitHub identity",
|
|
toolName: "get_me",
|
|
riskLevel: "read",
|
|
});
|
|
const assignedToolName = expectedConnectedToolName({
|
|
applicationKey: assigned.application.applicationKey,
|
|
connectionId: assigned.connection.id,
|
|
toolName: assigned.catalogEntry.toolName,
|
|
});
|
|
const unassignedToolName = expectedConnectedToolName({
|
|
applicationKey: unassigned.application.applicationKey,
|
|
connectionId: unassigned.connection.id,
|
|
toolName: unassigned.catalogEntry.toolName,
|
|
});
|
|
const [gatewayProfile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `runtime-gateway-${randomUUID()}`,
|
|
name: "Resolved runtime identity",
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: gatewayProfile.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: assigned.connection.id,
|
|
});
|
|
const [appProfile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `app:${unassigned.connection.id}`,
|
|
name: "Personal GitHub",
|
|
defaultAction: "deny",
|
|
metadata: { source: "app_gallery_finish", connectionId: unassigned.connection.id },
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: appProfile.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: unassigned.connection.id,
|
|
});
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId: company.id,
|
|
profileId: appProfile.id,
|
|
targetType: "company",
|
|
targetId: company.id,
|
|
priority: 100,
|
|
metadata: { source: "app_gallery_finish" },
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: {
|
|
name: "Resolved runtime GitHub",
|
|
profileId: gatewayProfile.id,
|
|
defaultProfileMode: "gateway_only",
|
|
},
|
|
});
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Runtime token" },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
|
|
const listed = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(200);
|
|
const visibleToolNames = listed.body.result.tools.map((tool: { name: string }) => tool.name);
|
|
expect(visibleToolNames).toContain(assignedToolName);
|
|
expect(visibleToolNames).not.toContain(unassignedToolName);
|
|
|
|
const denied = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: { name: unassignedToolName, arguments: {} },
|
|
})
|
|
.expect(403);
|
|
expect(denied.body.error.data.reasonCode).toBe("deny_default");
|
|
});
|
|
|
|
it("proxies namespaced resources and prompts only for fully assigned MCP connections", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(async ({ body }) => {
|
|
const method = body?.method;
|
|
if (method === "resources/list") {
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: { resources: [{ uri: "notes://one", name: "Note one", mimeType: "text/plain" }] } } };
|
|
}
|
|
if (method === "resources/read") {
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: { contents: [{ uri: String((body?.params as Record<string, unknown>)?.uri), mimeType: "text/plain", text: "resource body" }] } } };
|
|
}
|
|
if (method === "prompts/list") {
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: { prompts: [{ name: "summarize", title: "Summarize note" }] } } };
|
|
}
|
|
if (method === "prompts/get") {
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: { description: "Summary prompt", messages: [{ role: "user", content: { type: "text", text: "Summarize it" } }] } } };
|
|
}
|
|
return { body: { jsonrpc: "2.0", id: body?.id, result: {} } };
|
|
});
|
|
try {
|
|
const assigned = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url,
|
|
applicationKey: "context-app",
|
|
connectionName: "Assigned context",
|
|
toolName: "search_notes",
|
|
riskLevel: "read",
|
|
});
|
|
await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url,
|
|
applicationKey: "unassigned-context-app",
|
|
connectionName: "Unassigned context",
|
|
toolName: "private_search",
|
|
riskLevel: "read",
|
|
});
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `context-${randomUUID()}`,
|
|
name: `Context ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: assigned.connection.id,
|
|
});
|
|
const gateway = createTestToolGatewayService(db);
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Context gateway", profileId: profile.id },
|
|
});
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Native runner" },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
|
|
const resources = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "resources/list" })
|
|
.expect(200);
|
|
expect(resources.body.result.resources).toHaveLength(1);
|
|
expect(resources.body.result.resources[0]).toMatchObject({
|
|
uri: expect.stringMatching(new RegExp(`^paperclip-resource://${assigned.connection.id}/`)),
|
|
name: "Assigned context: Note one",
|
|
});
|
|
const resourceUri = resources.body.result.resources[0].uri as string;
|
|
|
|
const read = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 2, method: "resources/read", params: { uri: resourceUri } })
|
|
.expect(200);
|
|
expect(read.body.result.contents[0]).toMatchObject({ uri: resourceUri, text: "resource body" });
|
|
|
|
const prompts = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 3, method: "prompts/list" })
|
|
.expect(200);
|
|
expect(prompts.body.result.prompts).toHaveLength(1);
|
|
expect(prompts.body.result.prompts[0].title).toBe("Assigned context: Summarize note");
|
|
const promptName = prompts.body.result.prompts[0].name as string;
|
|
|
|
const wrapper = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${created.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "paperclip_get_prompt", arguments: { name: promptName } } })
|
|
.expect(200);
|
|
expect(wrapper.body.result.structuredContent).toMatchObject({ description: "Summary prompt" });
|
|
expect(remote.requests.filter((entry) => entry.body?.method === "resources/read")[0]?.body?.params).toEqual({ uri: "notes://one" });
|
|
expect(remote.requests.filter((entry) => entry.body?.method === "prompts/get")[0]?.body?.params).toEqual({ name: "summarize", arguments: {} });
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("omits archived gateways from listNamedGateways", async () => {
|
|
const company = await createCompany(db);
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `archived-list-${randomUUID()}`,
|
|
name: `Archived list ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
const gateway = createTestToolGatewayService(db);
|
|
|
|
const kept = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Kept gateway", profileId: profile.id },
|
|
});
|
|
const retired = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Retired gateway", profileId: profile.id },
|
|
});
|
|
|
|
// Both are visible while active.
|
|
let listed = await gateway.listNamedGateways(company.id);
|
|
expect(listed.map((g) => g.id).sort()).toEqual([kept.id, retired.id].sort());
|
|
|
|
// Archiving one drops it from the list (but not the active one).
|
|
await gateway.updateNamedGateway({
|
|
companyId: company.id,
|
|
gatewayId: retired.id,
|
|
body: { status: "archived" },
|
|
});
|
|
listed = await gateway.listNamedGateways(company.id);
|
|
expect(listed.map((g) => g.id)).toEqual([kept.id]);
|
|
});
|
|
|
|
it("throttles named gateway bearer auth failures without leaking bearer material", async () => {
|
|
const company = await createCompany(db);
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `auth-throttle-${randomUUID()}`,
|
|
name: `Auth throttle ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
const gateway = createTestToolGatewayService(db, {
|
|
mcpGatewayProtocolLimits: {
|
|
authFailures: { max: 1, windowMs: 60_000 },
|
|
},
|
|
});
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Public auth throttle", profileId: profile.id },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const badToken = `pcgw_${randomUUID()}.not-a-real-secret`;
|
|
|
|
const first = await request(app)
|
|
.post(`/mcp/gateways/${created.gatewayPublicId}`)
|
|
.set("authorization", `Bearer ${badToken}`)
|
|
.set("x-paperclip-client-name", "Noisy client")
|
|
.set("x-request-id", "auth-throttle-test")
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(401);
|
|
expect(first.body.error.data.reasonCode).toBe("gateway_token_invalid");
|
|
|
|
const throttled = await request(app)
|
|
.post(`/mcp/gateways/${created.gatewayPublicId}`)
|
|
.set("authorization", `Bearer ${badToken}`)
|
|
.set("x-paperclip-client-name", "Noisy client")
|
|
.set("x-request-id", "auth-throttle-test")
|
|
.send({ jsonrpc: "2.0", id: 2, method: "tools/list" })
|
|
.expect(429);
|
|
expect(throttled.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_auth_throttled",
|
|
reasonText: "The MCP gateway authentication attempt was throttled after repeated failures.",
|
|
});
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.reasonCode, "gateway_auth_throttled"));
|
|
expect(audits).toHaveLength(1);
|
|
expect(audits[0]).toMatchObject({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
gatewayPublicId: created.gatewayPublicId,
|
|
clientName: "Noisy client",
|
|
correlationId: "auth-throttle-test",
|
|
});
|
|
expect(audits[0]!.details).toMatchObject({
|
|
limiterKeyClass: "gateway_auth",
|
|
tokenPrefix: `pcgw_${badToken.slice(5, 13)}`,
|
|
});
|
|
expect(JSON.stringify(audits)).not.toContain(badToken);
|
|
expect(JSON.stringify(audits)).not.toContain("authorization");
|
|
});
|
|
|
|
it("prunes expired persisted public gateway auth limiter counters", async () => {
|
|
let now = Date.now();
|
|
const company = await createCompany(db);
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `auth-limiter-prune-${randomUUID()}`,
|
|
name: `Auth limiter prune ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
const gateway = createTestToolGatewayService(db, {
|
|
now: () => now,
|
|
mcpGatewayProtocolLimits: {
|
|
authFailures: { max: 100, windowMs: 100 },
|
|
},
|
|
});
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Public auth limiter prune", profileId: profile.id },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const endpoint = `/mcp/gateways/${created.gatewayPublicId}`;
|
|
|
|
await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer pcgw_${randomUUID()}.bad-secret`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(401);
|
|
const initialCounters = await db.select().from(toolGatewayRateLimitCounters);
|
|
expect(initialCounters.length).toBeGreaterThan(0);
|
|
|
|
now += 60_001;
|
|
await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer pcgw_${randomUUID()}.bad-secret`)
|
|
.send({ jsonrpc: "2.0", id: 2, method: "tools/list" })
|
|
.expect(401);
|
|
|
|
const remainingCounters = await db.select().from(toolGatewayRateLimitCounters);
|
|
expect(remainingCounters.every((counter) => counter.resetAt.getTime() > now)).toBe(true);
|
|
});
|
|
|
|
it("shares public gateway auth limiter counters across service instances", async () => {
|
|
const company = await createCompany(db);
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `auth-limiter-shared-${randomUUID()}`,
|
|
name: `Auth limiter shared ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
const serviceA = createTestToolGatewayService(db, {
|
|
mcpGatewayProtocolLimits: {
|
|
authFailures: { max: 1, windowMs: 60_000 },
|
|
},
|
|
});
|
|
const created = await serviceA.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Public auth limiter shared", profileId: profile.id },
|
|
});
|
|
const serviceB = createTestToolGatewayService(db, {
|
|
mcpGatewayProtocolLimits: {
|
|
authFailures: { max: 1, windowMs: 60_000 },
|
|
},
|
|
});
|
|
const badToken = `pcgw_${randomUUID()}.not-a-real-secret`;
|
|
|
|
await request(createGatewayRouteApp(db, serviceA))
|
|
.post(`/mcp/gateways/${created.gatewayPublicId}`)
|
|
.set("authorization", `Bearer ${badToken}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(401);
|
|
|
|
const throttled = await request(createGatewayRouteApp(db, serviceB))
|
|
.post(`/mcp/gateways/${created.gatewayPublicId}`)
|
|
.set("authorization", `Bearer ${badToken}`)
|
|
.set("x-paperclip-client-name", "Shared counter client")
|
|
.set("x-request-id", "auth-limiter-shared-test")
|
|
.send({ jsonrpc: "2.0", id: 2, method: "tools/list" })
|
|
.expect(429);
|
|
expect(throttled.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_auth_throttled",
|
|
reasonText: "The MCP gateway authentication attempt was throttled after repeated failures.",
|
|
});
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.reasonCode, "gateway_auth_throttled"));
|
|
expect(audits).toHaveLength(1);
|
|
expect(audits[0]).toMatchObject({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
gatewayPublicId: created.gatewayPublicId,
|
|
clientName: "Shared counter client",
|
|
correlationId: "auth-limiter-shared-test",
|
|
});
|
|
expect(audits[0]!.details).toMatchObject({
|
|
limiterKeyClass: "gateway_auth",
|
|
tokenPrefix: `pcgw_${badToken.slice(5, 13)}`,
|
|
});
|
|
expect(JSON.stringify(audits)).not.toContain(badToken);
|
|
expect(JSON.stringify(audits)).not.toContain("authorization");
|
|
});
|
|
|
|
it("rate limits public named gateway session setup, discovery, and calls with redacted audits", async () => {
|
|
const company = await createCompany(db);
|
|
const remote = await startFakeRemoteMcpServer(async ({ body }) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: body?.id,
|
|
result: { content: [{ type: "text", text: "read ok" }], structuredContent: { ok: true } },
|
|
},
|
|
}));
|
|
try {
|
|
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: remote.url,
|
|
applicationKey: "limited-named-gateway-app",
|
|
toolName: "read_note",
|
|
title: "Read note",
|
|
riskLevel: "read",
|
|
});
|
|
const gatewayToolName = expectedConnectedToolName({
|
|
applicationKey: application.applicationKey,
|
|
connectionId: connection.id,
|
|
toolName: catalogEntry.toolName,
|
|
});
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `protocol-limit-${randomUUID()}`,
|
|
name: `Protocol limit ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
}).returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: gatewayToolName,
|
|
});
|
|
// Pin the clock so every request in this test shares one rate-limit
|
|
// window. The window boundary aligns to wall-clock time, so a real clock
|
|
// can advance past the boundary between two paired requests and reset the
|
|
// counter. That reset makes the second request return 200 instead of 429.
|
|
const fixedNow = Date.now();
|
|
const gateway = createTestToolGatewayService(db, {
|
|
now: () => fixedNow,
|
|
mcpGatewayProtocolLimits: {
|
|
gatewayRequests: { max: 1, windowMs: 60_000 },
|
|
tokenRequests: { max: 1, windowMs: 60_000 },
|
|
sessionSetup: { max: 1, windowMs: 60_000 },
|
|
},
|
|
});
|
|
const created = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: { name: "Public protocol limits", profileId: profile.id },
|
|
});
|
|
const tokenA = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Client A", clientLabel: "Client A" },
|
|
});
|
|
const tokenB = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: created.id,
|
|
body: { name: "Client B", clientLabel: "Client B" },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const endpoint = `/mcp/gateways/${created.gatewayPublicId}`;
|
|
|
|
const initialized = await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenA.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "initialize" })
|
|
.expect(200);
|
|
expect(initialized.body.result).toMatchObject({
|
|
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
_meta: { "paperclip/mcp-app-ui": "unsupported" },
|
|
});
|
|
const setupLimited = await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenA.token}`)
|
|
.send({ jsonrpc: "2.0", id: 2, method: "initialize" })
|
|
.expect(429);
|
|
expect(setupLimited.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_rate_limited",
|
|
limiterKeyClass: "token",
|
|
protocolMethod: "initialize",
|
|
});
|
|
|
|
await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenA.token}`)
|
|
.send({ jsonrpc: "2.0", id: 3, method: "tools/list" })
|
|
.expect(200);
|
|
const discoveryLimited = await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenB.token}`)
|
|
.send({ jsonrpc: "2.0", id: 4, method: "tools/list" })
|
|
.expect(429);
|
|
expect(discoveryLimited.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_rate_limited",
|
|
limiterKeyClass: "gateway",
|
|
protocolMethod: "tools/list",
|
|
});
|
|
|
|
await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenB.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 5,
|
|
method: "tools/call",
|
|
params: { name: gatewayToolName, arguments: { key: "a", value: "b" } },
|
|
})
|
|
.expect(200);
|
|
const callLimited = await request(app)
|
|
.post(endpoint)
|
|
.set("authorization", `Bearer ${tokenB.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 6,
|
|
method: "tools/call",
|
|
params: { name: gatewayToolName, arguments: { key: "a", value: "b" } },
|
|
})
|
|
.expect(429);
|
|
expect(callLimited.body.error.data).toMatchObject({
|
|
reasonCode: "gateway_rate_limited",
|
|
limiterKeyClass: "token",
|
|
protocolMethod: "tools/call",
|
|
});
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.reasonCode, "gateway_rate_limited"));
|
|
expect(audits).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ gatewayId: created.id, gatewayPublicId: created.gatewayPublicId }),
|
|
]));
|
|
expect(audits.map((audit) => audit.details)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ protocolMethod: "initialize", limiterKeyClass: "token" }),
|
|
expect.objectContaining({ protocolMethod: "tools/list", limiterKeyClass: "gateway" }),
|
|
expect.objectContaining({ protocolMethod: "tools/call", limiterKeyClass: "token" }),
|
|
]));
|
|
const serializedAudits = JSON.stringify(audits);
|
|
expect(serializedAudits).not.toContain(tokenA.token);
|
|
expect(serializedAudits).not.toContain(tokenB.token);
|
|
expect(serializedAudits).not.toContain("authorization");
|
|
} finally {
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
it("hides and denies every external tool when an agent has no gateway profile", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 25 } });
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
await expect(gateway.listToolsForSession(session.token)).resolves.toEqual([]);
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:echo",
|
|
parameters: { message: "not allowed" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected unauthorized tool call to fail");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "deny_default"),
|
|
);
|
|
|
|
const [deniedAudit] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_denied"));
|
|
expect(deniedAudit).toMatchObject({
|
|
companyId: company.id,
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
});
|
|
|
|
it("fences an uncertain upstream mutation after fresh Retry while allowing independent reads", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const remote = await createRemoteMcpTool(db, company.id);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token))
|
|
.find(item => item.connectionId === remote.connection.id)!;
|
|
const [source] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
|
|
issueId: issue.id, status: "failed", createdAt: new Date(Date.now() - 60_000) }).returning();
|
|
const intentId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({ id: intentId, companyId: company.id, agentId: agent.id,
|
|
runId: run.id, source: "on_demand", reason: "task_retry", status: "coalesced",
|
|
requestedByActorType: "user", requestedByActorId: "board",
|
|
payload: { issueId: issue.id, taskRetry: { schema: "paperclip.task-retry.v1",
|
|
generationId: intentId, sourceRunId: source.id, actorId: "board", strategy: "fresh_isolated_workspace" } } });
|
|
const [uncertain] = await db.insert(toolInvocations).values({ companyId: company.id,
|
|
issueId: issue.id, runId: source.id, agentId: agent.id, connectionId: remote.connection.id,
|
|
toolName: tool.name, riskLevel: "write", status: "timed_out", startedAt: new Date() }).returning();
|
|
const fetcher = vi.spyOn(globalThis, "fetch");
|
|
try {
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
|
|
idempotencyKey: randomUUID(), parameters: { key: "new-key", value: "do not repeat" } }))
|
|
.rejects.toMatchObject({ status: 409, reasonCode: "recovery_effect_outcome_unresolved" });
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
expect(await gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:add",
|
|
parameters: { a: 2, b: 3 } })).toMatchObject({ status: "completed", result: { content: "5" } });
|
|
const [retained] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, uncertain.id));
|
|
expect(retained.status).toBe("timed_out");
|
|
} finally { fetcher.mockRestore(); }
|
|
});
|
|
|
|
it("filters discovery, executes a remote HTTP fixture, and audits run and issue links", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
"mcp-remote-fixture:add",
|
|
"mcp-stdio-fixture:increment_counter",
|
|
"mcp-stdio-fixture:runtime_status",
|
|
]);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 25 } });
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const toolNames = (await gateway.listToolsForSession(session.token)).map((tool) => tool.name);
|
|
expect(toolNames).toContain("mcp-remote-fixture:add");
|
|
expect(toolNames).toContain("mcp-stdio-fixture:increment_counter");
|
|
expect(toolNames).not.toContain("mcp-remote-fixture:echo");
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:add",
|
|
parameters: { a: 4, b: 7 },
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
tool: "mcp-remote-fixture:add",
|
|
result: {
|
|
content: "11",
|
|
data: {
|
|
result: 11,
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const [invocation] = await db.select().from(toolInvocations);
|
|
expect(invocation).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
toolName: "mcp-remote-fixture:add",
|
|
status: "succeeded",
|
|
});
|
|
const [callEvent] = await db.select().from(toolCallEvents);
|
|
expect(callEvent).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
toolName: "mcp-remote-fixture:add",
|
|
outcome: "success",
|
|
});
|
|
const [dedicatedAudit] = await db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(eq(toolCallEvents.eventType, "call_completed"));
|
|
expect(dedicatedAudit).toMatchObject({
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
toolName: "mcp-remote-fixture:add",
|
|
});
|
|
});
|
|
|
|
it("lists connected remote MCP catalog tools only for the scoped company and agent policy", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const unprofiledAgent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { run: unprofiledRun } = await createIssueAndRun(db, company.id, unprofiledAgent.id);
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
});
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const { run: otherRun } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
|
const otherRemoteTool = await createRemoteMcpTool(db, otherCompany.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
});
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
catalogEntryId: remoteTool.catalogEntry.id,
|
|
});
|
|
const otherProfile = await allowToolsForAgent(db, otherCompany.id, otherAgent.id, []);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: otherCompany.id,
|
|
profileId: otherProfile.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: otherRemoteTool.connection.id,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const unprofiledSession = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: unprofiledAgent.id,
|
|
runId: unprofiledRun.id,
|
|
});
|
|
const otherSession = await gateway.createSession({
|
|
companyId: otherCompany.id,
|
|
agentId: otherAgent.id,
|
|
runId: otherRun.id,
|
|
});
|
|
|
|
const tools = await gateway.listToolsForSession(session.token);
|
|
const connectedTool = tools.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toMatchObject({
|
|
name: expect.stringMatching(/^mcp\.kv-demo-[0-9a-f]{8}:kv-set$/),
|
|
displayName: "Set KV value",
|
|
providerType: "mcp_remote_http",
|
|
risk: "write",
|
|
applicationId: remoteTool.application.id,
|
|
applicationKey: "kv-demo",
|
|
connectionId: remoteTool.connection.id,
|
|
catalogEntryId: remoteTool.catalogEntry.id,
|
|
upstreamToolName: "kv_set",
|
|
parametersSchema: expect.objectContaining({ type: "object" }),
|
|
providerMetadata: expect.objectContaining({
|
|
applicationKey: "kv-demo",
|
|
connectionId: remoteTool.connection.id,
|
|
catalogEntryId: remoteTool.catalogEntry.id,
|
|
transport: "mcp_remote",
|
|
upstreamToolName: "kv_set",
|
|
annotations: { readOnlyHint: false },
|
|
risk: expect.objectContaining({ level: "write", isWrite: true }),
|
|
}),
|
|
});
|
|
|
|
await expect(gateway.listToolsForSession(unprofiledSession.token)).resolves.toEqual([]);
|
|
const otherTools = await gateway.listToolsForSession(otherSession.token);
|
|
expect(otherTools).toEqual([
|
|
expect.objectContaining({
|
|
providerType: "mcp_remote_http",
|
|
connectionId: otherRemoteTool.connection.id,
|
|
catalogEntryId: otherRemoteTool.catalogEntry.id,
|
|
}),
|
|
]);
|
|
expect(otherTools.map((tool) => tool.catalogEntryId)).not.toContain(remoteTool.catalogEntry.id);
|
|
});
|
|
|
|
|
|
it("hides retired Composio child tools and denies a previously discovered tool without upstream traffic", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const remote = await createRemoteMcpTool(db, company.id, { applicationKey: "composio", connectionName: "Old toolkit", toolName: "read", title: "Read" });
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
|
|
await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id, selectorType: "catalog_entry", effect: "include", catalogEntryId: remote.catalogEntry.id });
|
|
const remoteHttpRequest = vi.fn();
|
|
const gateway = createTestToolGatewayService(db, { remoteHttpRequest });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const listed = await gateway.listToolsForSession(session.token);
|
|
const tool = listed.find((entry) => entry.connectionId === remote.connection.id)!;
|
|
expect(tool).toBeDefined();
|
|
await db.update(toolConnections).set({ config: { provider: "composio", parentConnectionId: randomUUID(), toolkitSlug: "github", url: "https://legacy.example/mcp" } }).where(eq(toolConnections.id, remote.connection.id));
|
|
expect((await gateway.listToolsForSession(session.token)).some((entry) => entry.connectionId === remote.connection.id)).toBe(false);
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).rejects.toThrow();
|
|
expect(remoteHttpRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lists and executes connected local stdio MCP catalog tools through the gateway", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const unprofiledAgent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { run: unprofiledRun } = await createIssueAndRun(db, company.id, unprofiledAgent.id);
|
|
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "local-demo",
|
|
connectionName: "Local Demo",
|
|
toolName: "echo",
|
|
title: "Local echo",
|
|
});
|
|
const expectedName = expectedConnectedToolName({
|
|
applicationKey: "local-demo",
|
|
connectionId: localTool.connection.id,
|
|
toolName: "echo",
|
|
});
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
catalogEntryId: localTool.catalogEntry.id,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const unprofiledSession = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: unprofiledAgent.id,
|
|
runId: unprofiledRun.id,
|
|
});
|
|
|
|
const tools = await gateway.listToolsForSession(session.token);
|
|
expect(tools).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: expectedName,
|
|
displayName: "Local echo",
|
|
providerType: "mcp_local_stdio",
|
|
risk: "read",
|
|
applicationId: localTool.application.id,
|
|
applicationKey: "local-demo",
|
|
connectionId: localTool.connection.id,
|
|
catalogEntryId: localTool.catalogEntry.id,
|
|
upstreamToolName: "echo",
|
|
providerMetadata: expect.objectContaining({
|
|
transport: "local_stdio",
|
|
connectionId: localTool.connection.id,
|
|
catalogEntryId: localTool.catalogEntry.id,
|
|
upstreamToolName: "echo",
|
|
}),
|
|
}),
|
|
]));
|
|
await expect(gateway.listToolsForSession(unprofiledSession.token)).resolves.toEqual([]);
|
|
|
|
await expect(gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: expectedName,
|
|
parameters: { message: "hello" },
|
|
})).resolves.toMatchObject({
|
|
status: "completed",
|
|
result: {
|
|
content: "local:hello",
|
|
data: {
|
|
structuredContent: { echoed: "hello" },
|
|
transport: "local_stdio",
|
|
spawnedLocalProcess: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
|
|
expect(slot).toMatchObject({
|
|
status: "idle",
|
|
commandTemplateKey: localTool.templateKey,
|
|
healthStatus: "ok",
|
|
});
|
|
});
|
|
|
|
it("passes only approved env values to local stdio MCP processes", async () => {
|
|
const previousDatabaseUrl = process.env.DATABASE_URL;
|
|
process.env.DATABASE_URL = "postgres://server-secret.example/paperclip";
|
|
try {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const allowedToken = await secretService(db).create(company.id, {
|
|
name: `Local stdio token ${randomUUID()}`,
|
|
key: `local_stdio_token_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: "allowed-token",
|
|
});
|
|
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "local-env-demo",
|
|
connectionName: "Local Env Demo",
|
|
toolName: "inspect_env",
|
|
title: "Inspect env",
|
|
envKeys: ["ALLOWED_TOKEN"],
|
|
credentialSecretRefs: [{
|
|
secretId: allowedToken.id,
|
|
versionSelector: "latest",
|
|
configPath: "env.ALLOWED_TOKEN",
|
|
required: true,
|
|
label: "Allowed token",
|
|
}],
|
|
connectionConfig: { env: { ALLOWED_TOKEN: "connection-level-token", EXTRA_CONFIG: "extra-value", NODE_OPTIONS: "--trace-warnings" } },
|
|
stdioScript: `
|
|
const readline = require("node:readline");
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on("line", (line) => {
|
|
const message = JSON.parse(line);
|
|
if (message.method === "initialize") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "env-stdio", version: "0.0.0" } } }) + "\\n");
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") {
|
|
process.stdout.write(JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
content: [{ type: "text", text: "env" }],
|
|
structuredContent: {
|
|
databaseUrl: process.env.DATABASE_URL ?? null,
|
|
allowedToken: process.env.ALLOWED_TOKEN ?? null,
|
|
extraConfig: process.env.EXTRA_CONFIG ?? null,
|
|
nodeOptions: process.env.NODE_OPTIONS ?? null,
|
|
hasPath: Boolean(process.env.PATH || process.env.Path),
|
|
},
|
|
},
|
|
}) + "\\n");
|
|
}
|
|
});
|
|
`,
|
|
});
|
|
const expectedName = expectedConnectedToolName({
|
|
applicationKey: "local-env-demo",
|
|
connectionId: localTool.connection.id,
|
|
toolName: "inspect_env",
|
|
});
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
catalogEntryId: localTool.catalogEntry.id,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
|
|
await expect(gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: expectedName,
|
|
parameters: { message: "hello" },
|
|
})).resolves.toMatchObject({
|
|
status: "completed",
|
|
result: {
|
|
data: {
|
|
structuredContent: {
|
|
databaseUrl: null,
|
|
allowedToken: "***REDACTED***",
|
|
extraConfig: null,
|
|
nodeOptions: null,
|
|
hasPath: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
if (previousDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
else process.env.DATABASE_URL = previousDatabaseUrl;
|
|
}
|
|
});
|
|
|
|
it.each(["credentials.authorization", "headers.X-Api-Key", "oauth.access_token"])(
|
|
"resolves a personal remote credential using its declared %s path",
|
|
async (configPath) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
|
const secrets = secretService(db);
|
|
const definition = await secrets.createUserSecretDefinition(company.id, {
|
|
name: "Personal remote token",
|
|
key: `personal_remote_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
});
|
|
const secret = await secrets.createCurrentUserSecretValue(company.id, "alice", {
|
|
definitionId: definition.id, value: "personal-remote-test-token",
|
|
});
|
|
const remote = await createRemoteMcpTool(db, company.id);
|
|
await db.update(toolConnections).set({
|
|
credentialPolicy: "per_user",
|
|
credentialRefs: [{ name: configPath, secretId: secret.id, placement: "header", key: "Authorization", prefix: "Bearer " }],
|
|
}).where(eq(toolConnections.id, remote.connection.id));
|
|
await secrets.syncUserSecretDeclarationsForTarget(company.id,
|
|
{ targetType: "tool_connection", targetId: remote.connection.id },
|
|
[{ definitionKey: definition.key, configPath, envKey: "Authorization", versionSelector: "latest", required: true }],
|
|
{ replaceAll: true });
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id, connectionId: remote.connection.id, kind: "user", subjectUserId: "alice",
|
|
credentialSecretRefs: [{ secretId: secret.id, configPath, versionSelector: "latest" }],
|
|
status: "active", isDefault: false,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const headers: string[] = [];
|
|
const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => {
|
|
headers.push(new Headers(init.headers).get("authorization") ?? "");
|
|
const body = JSON.parse(String(init.body)) as { id: string };
|
|
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id,
|
|
result: { content: [{ type: "text", text: "retrieved synthetic memory" }] } }),
|
|
{ headers: { "content-type": "application/json" } });
|
|
} });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === remote.connection.id)!;
|
|
expect((await gateway.executeTool({ sessionToken: session.token, tool: tool.name,
|
|
parameters: { key: "test", value: "synthetic" } })).status).toBe("completed");
|
|
expect(headers).toEqual(["Bearer personal-remote-test-token"]);
|
|
},
|
|
);
|
|
|
|
it("passes only the selected grant identity to local stdio MCP processes", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await createActiveMember(db, company.id, "bob");
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
|
const values = {
|
|
organization: `organization-${randomUUID()}`,
|
|
alice: `alice-${randomUUID()}`,
|
|
bob: `bob-${randomUUID()}`,
|
|
};
|
|
const organizationSecret = await secretService(db).create(company.id, {
|
|
name: `Organization stdio token ${randomUUID()}`,
|
|
key: `organization_stdio_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: values.organization,
|
|
});
|
|
const secrets = secretService(db);
|
|
const identityDefinition = await secrets.createUserSecretDefinition(company.id, {
|
|
name: `Personal stdio token ${randomUUID()}`,
|
|
key: `personal_stdio_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
});
|
|
const aliceSecret = await secrets.createCurrentUserSecretValue(company.id, "alice", {
|
|
definitionId: identityDefinition.id,
|
|
value: values.alice,
|
|
});
|
|
const bobSecret = await secrets.createCurrentUserSecretValue(company.id, "bob", {
|
|
definitionId: identityDefinition.id,
|
|
value: values.bob,
|
|
});
|
|
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "local-grant-identity",
|
|
toolName: "identity",
|
|
title: "Grant identity",
|
|
envKeys: ["IDENTITY_TOKEN"],
|
|
credentialSecretRefs: [{
|
|
secretId: organizationSecret.id,
|
|
versionSelector: "latest",
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
required: true,
|
|
label: "Organization identity",
|
|
}],
|
|
connectionConfig: { env: { IDENTITY_TOKEN: "legacy-connection-identity" } },
|
|
stdioScript: `
|
|
const readline = require("node:readline");
|
|
const identities = ${JSON.stringify(values)};
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on("line", (line) => {
|
|
const message = JSON.parse(line);
|
|
if (message.method === "initialize") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "identity-stdio", version: "0.0.0" } } }) + "\\n");
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") {
|
|
const identity = Object.entries(identities).find(([, value]) => value === process.env.IDENTITY_TOKEN)?.[0] ?? "unknown";
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: identity }], structuredContent: { identity } } }) + "\\n");
|
|
}
|
|
});
|
|
`,
|
|
});
|
|
await secrets.syncUserSecretDeclarationsForTarget(
|
|
company.id,
|
|
{ targetType: "tool_connection", targetId: localTool.connection.id },
|
|
[{
|
|
definitionKey: identityDefinition.key,
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
envKey: "IDENTITY_TOKEN",
|
|
versionSelector: "latest",
|
|
required: true,
|
|
label: "Personal identity",
|
|
}],
|
|
{ replaceAll: true },
|
|
);
|
|
const grantRef = (secretId: string, label: string) => ({
|
|
secretId,
|
|
versionSelector: "latest" as const,
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
required: true,
|
|
label,
|
|
});
|
|
const [aliceGrant] = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: localTool.connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
credentialSecretRefs: [grantRef(aliceSecret.id, "Alice identity")],
|
|
status: "active",
|
|
isDefault: false,
|
|
}).returning();
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: localTool.connection.id,
|
|
kind: "user",
|
|
subjectUserId: "bob",
|
|
credentialSecretRefs: [grantRef(bobSecret.id, "Bob identity")],
|
|
status: "active",
|
|
isDefault: false,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.connectionId === localTool.connection.id)!;
|
|
const executeIdentity = async () => {
|
|
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
|
|
return (result.result as { data?: { structuredContent?: { identity?: string } } }).data?.structuredContent?.identity;
|
|
};
|
|
|
|
expect(await executeIdentity()).toBe("organization");
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
expect(await executeIdentity()).toBe("alice");
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
expect(await executeIdentity()).toBe("alice");
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, aliceGrant.id));
|
|
expect(await executeIdentity()).toBe("organization");
|
|
|
|
await db.delete(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
|
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
|
|
|
const [organizationGrant] = await db.select().from(connectionGrants).where(and(
|
|
eq(connectionGrants.connectionId, localTool.connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
));
|
|
await db.insert(connectionGrantMembers).values({
|
|
companyId: company.id,
|
|
grantId: organizationGrant.id,
|
|
subjectType: "user",
|
|
subjectId: "sales-user",
|
|
});
|
|
await db.update(toolConnections).set({ credentialPolicy: "shared" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.rejects.toMatchObject({ status: 403, reasonCode: "grant_audience_denied" });
|
|
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
|
|
|
await db.insert(connectionGrantMembers).values({
|
|
companyId: company.id,
|
|
grantId: organizationGrant.id,
|
|
subjectType: "user",
|
|
subjectId: "alice",
|
|
});
|
|
expect(await executeIdentity()).toBe("organization");
|
|
|
|
await db.delete(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, organizationGrant.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.rejects.toMatchObject({ status: 409, reasonCode: "organization_authorization_required" });
|
|
|
|
await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, organizationGrant.id));
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, run.id));
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
|
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
|
});
|
|
|
|
it("uses the signed-in tester's personal grant for Test-tab calls", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const userId = `test-user-${randomUUID()}`;
|
|
await createActiveMember(db, company.id, userId);
|
|
const personalValue = `personal-${randomUUID()}`;
|
|
const definitionKey = `personal_test_${randomUUID().replace(/-/g, "")}`;
|
|
const [definition] = await db.insert(userSecretDefinitions).values({
|
|
companyId: company.id,
|
|
key: definitionKey,
|
|
name: `Personal Test token ${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
managedMode: "paperclip_managed",
|
|
}).returning();
|
|
const personalSecret = await secretService(db).createCurrentUserSecretValue(company.id, userId, {
|
|
definitionId: definition.id,
|
|
value: personalValue,
|
|
});
|
|
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "personal-test-tab",
|
|
toolName: "identity",
|
|
title: "Personal test identity",
|
|
envKeys: ["IDENTITY_TOKEN"],
|
|
stdioScript: `
|
|
const readline = require("node:readline");
|
|
const expected = ${JSON.stringify(personalValue)};
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on("line", (line) => {
|
|
const message = JSON.parse(line);
|
|
if (message.method === "initialize") {
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "test-identity", version: "0.0.0" } } }) + "\\n");
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") {
|
|
const identity = process.env.IDENTITY_TOKEN === expected ? "personal" : "wrong";
|
|
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: identity }], structuredContent: { identity } } }) + "\\n");
|
|
}
|
|
});
|
|
`,
|
|
});
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(
|
|
toolConnections.id,
|
|
localTool.connection.id,
|
|
));
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: localTool.connection.id,
|
|
kind: "user",
|
|
subjectUserId: userId,
|
|
credentialSecretRefs: [{
|
|
secretId: personalSecret.id,
|
|
versionSelector: "latest",
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
required: true,
|
|
label: "Personal identity",
|
|
}],
|
|
status: "active",
|
|
isDefault: false,
|
|
});
|
|
await secretService(db).syncUserSecretDeclarationsForTarget(
|
|
company.id,
|
|
{ targetType: "tool_connection", targetId: localTool.connection.id },
|
|
[{
|
|
definitionKey,
|
|
configPath: "env.IDENTITY_TOKEN",
|
|
envKey: "IDENTITY_TOKEN",
|
|
versionSelector: "latest",
|
|
required: true,
|
|
label: "Personal identity",
|
|
}],
|
|
{ replaceAll: true },
|
|
);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
|
|
|
await expect(gateway.executeTestCall({
|
|
companyId: company.id,
|
|
connectionId: localTool.connection.id,
|
|
agentId: agent.id,
|
|
userId,
|
|
toolName: "identity",
|
|
parameters: {},
|
|
})).resolves.toMatchObject({
|
|
decision: "allowed",
|
|
result: { data: { structuredContent: { identity: "personal" } } },
|
|
});
|
|
});
|
|
|
|
it("keeps connected remote MCP gateway names collision-safe and excludes inactive catalog sources", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const first = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo Primary",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
});
|
|
const second = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo Secondary",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
});
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "disabled-demo",
|
|
connectionName: "Disabled Demo",
|
|
toolName: "kv_set",
|
|
connectionEnabled: false,
|
|
});
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "unhealthy-demo",
|
|
connectionName: "Unhealthy Demo",
|
|
toolName: "kv_set",
|
|
healthStatus: "error",
|
|
});
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "quarantined-demo",
|
|
connectionName: "Quarantined Demo",
|
|
toolName: "kv_set",
|
|
catalogStatus: "quarantined",
|
|
quarantinedAt: new Date(),
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
|
|
const connectedTools = (await gateway.listToolsForSession(session.token))
|
|
.filter((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTools).toHaveLength(2);
|
|
expect(connectedTools.map((tool) => tool.catalogEntryId).sort()).toEqual([
|
|
first.catalogEntry.id,
|
|
second.catalogEntry.id,
|
|
].sort());
|
|
expect(new Set(connectedTools.map((tool) => tool.name)).size).toBe(2);
|
|
expect(connectedTools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
|
|
expect.stringMatching(new RegExp(`^mcp\\.kv-demo-${first.connection.id.replace(/-/g, "").slice(0, 8)}:kv-set$`)),
|
|
expect.stringMatching(new RegExp(`^mcp\\.kv-demo-${second.connection.id.replace(/-/g, "").slice(0, 8)}:kv-set$`)),
|
|
]));
|
|
});
|
|
|
|
it("dispatches a bounded alias to the original long upstream tool name", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const upstreamName = "file_download_batch_exports_count_rows_create";
|
|
const fake = await startFakeRemoteMcpServer((request) => {
|
|
expect(request.body).toMatchObject({ method: "tools/call", params: { name: upstreamName, arguments: {} } });
|
|
return { body: { jsonrpc: "2.0", id: request.body?.id, result: { content: [{ type: "text", text: "ok" }] } } };
|
|
});
|
|
try {
|
|
const { catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: `app-gallery:posthog:${randomUUID()}`, toolName: upstreamName, url: fake.url,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.catalogEntryId === catalogEntry.id)!;
|
|
expect(`mcp__paperclip-assigned__${tool.name}`.length).toBeLessThanOrEqual(128);
|
|
expect((await gateway.listToolsForSession(session.token)).find((entry) => entry.catalogEntryId === catalogEntry.id)?.name).toBe(tool.name);
|
|
await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["local_trusted", { deploymentMode: "local_trusted" as const, deploymentExposure: "private" as const }],
|
|
["authenticated/private", { deploymentMode: "authenticated" as const, deploymentExposure: "private" as const }],
|
|
["authenticated/public", { deploymentMode: "authenticated" as const, deploymentExposure: "public" as const }],
|
|
])("always blocks link-local gateway dispatch in %s before fetch", async (_label, deployment) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "private-endpoint",
|
|
toolName: "kv_set",
|
|
url: "http://169.254.169.254/mcp",
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch should not be called"));
|
|
try {
|
|
const gateway = createTestToolGatewayService(db, deployment);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected private endpoint to be blocked");
|
|
},
|
|
(error) => expectGatewayError(error, 422, "remote_http_private_endpoint"),
|
|
);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
fetchSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ connectionMethodKey: "managed", transportConfigOnly: false },
|
|
{ connectionMethodKey: "mcp-key", transportConfigOnly: false },
|
|
{ connectionMethodKey: "managed", transportConfigOnly: true },
|
|
{ connectionMethodKey: "mcp-key", transportConfigOnly: true },
|
|
])("sends the GitHub Actions toolset for $connectionMethodKey (legacy config: $transportConfigOnly)", async ({ connectionMethodKey, transportConfigOnly }) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const parameters = { method: "run_workflow", owner: "example", repo: "release", workflow_id: "nightly.yml", ref: "main" };
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers["x-mcp-toolsets"]).toBe("default,actions");
|
|
expect(fakeRequest.body).toMatchObject({
|
|
method: "tools/call",
|
|
params: { name: "actions_run_trigger", arguments: parameters },
|
|
});
|
|
return { body: { jsonrpc: "2.0", id: fakeRequest.body?.id, result: { content: [{ type: "text", text: "Workflow dispatched" }] } } };
|
|
});
|
|
try {
|
|
const { connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "github",
|
|
url: fake.url,
|
|
toolName: "actions_run_trigger",
|
|
riskLevel: "destructive",
|
|
connectionConfig: { sourceTemplateKey: "github", connectionMethodKey },
|
|
});
|
|
if (transportConfigOnly) {
|
|
await db.update(toolConnections).set({ config: { url: fake.url } }).where(eq(toolConnections.id, connection.id));
|
|
}
|
|
await db.update(toolCatalogEntries).set({
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: Object.fromEntries(Object.keys(parameters).map((key) => [key, { type: "string" }])),
|
|
required: Object.keys(parameters),
|
|
additionalProperties: false,
|
|
},
|
|
}).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.catalogEntryId === catalogEntry.id);
|
|
expect(tool).toBeTruthy();
|
|
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters });
|
|
expect(result.status).toBe("completed");
|
|
expect(fake.requests).toHaveLength(1);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("executes a connected remote HTTP MCP tool with stored credentials and redacted audit state", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const credentialValue = `remote-secret-${randomUUID()}`;
|
|
const secret = await secretService(db).create(company.id, {
|
|
name: `Remote MCP token ${randomUUID()}`,
|
|
key: `remote_mcp_token_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: credentialValue,
|
|
});
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers.authorization).toBe(`Bearer ${credentialValue}`);
|
|
expect(fakeRequest.headers["x-posthog-project-id"]).toBe("12345");
|
|
const params = fakeRequest.body?.params as Record<string, unknown>;
|
|
const args = params.arguments as Record<string, unknown>;
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: `stored ${String(args.key)}=${String(args.value)}` }],
|
|
structuredContent: { saved: true, key: args.key },
|
|
},
|
|
},
|
|
};
|
|
});
|
|
try {
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
url: fake.url,
|
|
credentialRefs: [{
|
|
name: "credentials.authorization",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
}],
|
|
credentialSecretRefs: [{
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.authorization",
|
|
required: true,
|
|
label: "Remote MCP token",
|
|
}],
|
|
connectionConfig: {
|
|
sourceTemplateKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
methodConfig: {
|
|
projectId: "12345",
|
|
readOnly: true,
|
|
features: "insights",
|
|
mode: "tools",
|
|
},
|
|
},
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
tool: connectedTool!.name,
|
|
result: {
|
|
content: "stored alpha=one",
|
|
data: {
|
|
structuredContent: { saved: true, key: "alpha" },
|
|
isError: false,
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
},
|
|
});
|
|
expect(fake.requests).toHaveLength(1);
|
|
// Streamable HTTP requires advertising both JSON and SSE on the call (PAP-11096).
|
|
expect(fake.requests[0]!.headers.accept).toBe("application/json, text/event-stream");
|
|
expect(fake.requests[0]!.body).toMatchObject({
|
|
method: "tools/call",
|
|
params: {
|
|
name: "kv_set",
|
|
arguments: { key: "alpha", value: "one" },
|
|
},
|
|
});
|
|
|
|
const [invocation] = await db.select().from(toolInvocations);
|
|
expect(invocation).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
toolName: connectedTool!.name,
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "kv-demo",
|
|
upstreamToolName: "kv_set",
|
|
riskLevel: "write",
|
|
status: "succeeded",
|
|
});
|
|
expect(invocation.applicationId).toBe(connectedTool!.applicationId);
|
|
expect(invocation.connectionId).toBe(connectedTool!.connectionId);
|
|
expect(invocation.catalogEntryId).toBe(connectedTool!.catalogEntryId);
|
|
expect(invocation.argumentsSummary).toMatchObject({
|
|
summary: expect.stringContaining("\"key\":\"alpha\""),
|
|
});
|
|
expect(invocation.resultSummary).toMatchObject({
|
|
summary: expect.stringContaining("\"saved\":true"),
|
|
});
|
|
|
|
const callEvents = await db.select().from(toolCallEvents);
|
|
expect(callEvents).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
eventType: "policy_decision",
|
|
applicationId: connectedTool!.applicationId,
|
|
connectionId: connectedTool!.connectionId,
|
|
catalogEntryId: connectedTool!.catalogEntryId,
|
|
toolName: connectedTool!.name,
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
}),
|
|
expect.objectContaining({
|
|
eventType: "call_completed",
|
|
applicationId: connectedTool!.applicationId,
|
|
connectionId: connectedTool!.connectionId,
|
|
catalogEntryId: connectedTool!.catalogEntryId,
|
|
toolName: connectedTool!.name,
|
|
metadata: expect.objectContaining({
|
|
applicationKey: "kv-demo",
|
|
providerType: "mcp_remote_http",
|
|
upstreamToolName: "kv_set",
|
|
risk: "write",
|
|
}),
|
|
}),
|
|
]));
|
|
|
|
const gatewayAudits = await db.select().from(toolAccessAuditEvents);
|
|
expect(gatewayAudits).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
action: "tool_access.policy_decision",
|
|
connectionId: connectedTool!.connectionId,
|
|
catalogEntryId: connectedTool!.catalogEntryId,
|
|
reasonCode: "allow_profile",
|
|
details: expect.objectContaining({
|
|
applicationKey: "kv-demo",
|
|
providerType: "mcp_remote_http",
|
|
upstreamToolName: "kv_set",
|
|
riskLevel: "write",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
action: "call_completed",
|
|
connectionId: connectedTool!.connectionId,
|
|
catalogEntryId: connectedTool!.catalogEntryId,
|
|
reasonCode: "tool_completed",
|
|
details: expect.objectContaining({
|
|
applicationKey: "kv-demo",
|
|
providerType: "mcp_remote_http",
|
|
upstreamToolName: "kv_set",
|
|
risk: "write",
|
|
resultSummary: expect.objectContaining({ summary: expect.stringContaining("\"saved\":true") }),
|
|
}),
|
|
}),
|
|
]));
|
|
|
|
const persisted = JSON.stringify({
|
|
invocations: await db.select().from(toolInvocations),
|
|
callEvents: await db.select().from(toolCallEvents),
|
|
audits: await db.select().from(toolAccessAuditEvents),
|
|
activity: await db.select().from(activityLog),
|
|
});
|
|
expect(persisted).not.toContain(credentialValue);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("starts with another user's personal app and requests authorization only when used", async () => {
|
|
const originalApiUrl = process.env.PAPERCLIP_API_URL;
|
|
process.env.PAPERCLIP_API_URL = "http://paperclip.example.test";
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "carol");
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "carol" }).where(eq(heartbeatRuns.id, run.id));
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "connected" }] },
|
|
},
|
|
}));
|
|
try {
|
|
const { connection, application, catalogEntry } = await createRemoteMcpTool(db, company.id, {
|
|
url: fake.url,
|
|
toolName: "whoami",
|
|
riskLevel: "read",
|
|
});
|
|
// Personal credentials live on grants, so a company-level health probe
|
|
// may be unable to authenticate even though this user's grant is valid.
|
|
// The cached catalog must remain visible so grant resolution can happen.
|
|
await db.update(toolConnections).set({
|
|
credentialPolicy: "per_user",
|
|
healthStatus: "error",
|
|
healthMessage: "This app needs you to sign in.",
|
|
}).where(eq(toolConnections.id, connection.id));
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await db.delete(connectionGrants).where(eq(connectionGrants.connectionId, connection.id));
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id, connectionId: connection.id, kind: "user",
|
|
subjectUserId: "alice", credentialSecretRefs: [], status: "active", isDefault: false,
|
|
});
|
|
await db.insert(toolConnectionInstalls).values({
|
|
companyId: company.id, connectionId: connection.id, targetType: "agent", targetId: agent.id,
|
|
});
|
|
const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent, runId: run.id });
|
|
expect(snapshot.bindingId).toBe(`native-mcp:${run.id}`);
|
|
const runtime = await buildPaperclipRuntimeMcpServers({ db, agent, runId: run.id, expectedAssignmentDigest: snapshot.digest });
|
|
expect(runtime).toHaveLength(1);
|
|
// Startup and tool discovery never contact the provider or ask Carol to sign in.
|
|
expect(fake.requests).toHaveLength(0);
|
|
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id))).toEqual([]);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
const endpoint = new URL(runtime[0]!.url!).pathname;
|
|
const listed = await request(app).post(endpoint).set("authorization", `Bearer ${runtime[0]!.token}`)
|
|
.send({ jsonrpc: "2.0", id: "list", method: "tools/list" }).expect(200);
|
|
const tool = listed.body.result.tools.find((entry: { name: string }) => entry.name === expectedConnectedToolName({
|
|
applicationKey: application.applicationKey, connectionId: connection.id, toolName: catalogEntry.toolName,
|
|
}));
|
|
expect(tool).toBeDefined();
|
|
const call = () => request(app).post(endpoint).set("authorization", `Bearer ${runtime[0]!.token}`)
|
|
.send({ jsonrpc: "2.0", id: "call", method: "tools/call", params: { name: tool.name, arguments: {} } });
|
|
|
|
await db.insert(issueThreadInteractions).values({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "none",
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
idempotencyKey: `connection-authorization:${connection.id}:carol`,
|
|
title: "Connect your account",
|
|
summary: `Connect ${connection.name} to continue`,
|
|
payload: {
|
|
version: 1,
|
|
prompt: `Connect your account to ${connection.name}`,
|
|
acceptLabel: "Open authorization",
|
|
rejectLabel: "Not now",
|
|
},
|
|
});
|
|
|
|
const missingGrant = await call().expect(409);
|
|
expect(missingGrant.body.error.data.reasonCode).toBe("user_authorization_required");
|
|
expect(fake.requests).toHaveLength(0); // Alice's grant must never authorize Carol's call.
|
|
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id));
|
|
expect(interaction).toMatchObject({
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
addresseeUserId: "carol",
|
|
});
|
|
expect(interaction!.payload).toMatchObject({
|
|
prompt: `Connect your ${connection.name} account to continue`,
|
|
target: { key: `connection:${connection.uid}:user:carol` },
|
|
});
|
|
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "carol",
|
|
credentialSecretRefs: [],
|
|
status: "active",
|
|
isDefault: false,
|
|
});
|
|
const result = await call().expect(200);
|
|
expect(result.body.result.content).toEqual([{ type: "text", text: "connected" }]);
|
|
expect(fake.requests).toHaveLength(1);
|
|
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
|
|
eq(toolConnections.id, connection.id),
|
|
)).resolves.toEqual([{ healthStatus: "ok" }]);
|
|
} finally {
|
|
if (originalApiUrl === undefined) delete process.env.PAPERCLIP_API_URL;
|
|
else process.env.PAPERCLIP_API_URL = originalApiUrl;
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it.each(([
|
|
"zapier", "url", "bearer", "header", "public",
|
|
] as const).flatMap((kind) => (kind === "public" ? ["setup"] : ["setup", "inline"])
|
|
.map((reconnectMode) => ({ kind, reconnectMode }))))(
|
|
"executes $kind setup and $reconnectMode reconnect through a run-scoped gateway with canonical vault declarations",
|
|
async ({ kind, reconnectMode }) => {
|
|
for (const grantKind of ["user", "organization"] as const) {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
|
const secretUrl = kind === "zapier"
|
|
? "https://mcp.zapier.com/api/v1/connect?token=fixture-canary"
|
|
: "https://8.8.8.8/mcp?token=fixture-canary";
|
|
const calls: { url: string; headers: Headers; method: unknown }[] = [];
|
|
const remoteHttpRequest: NonNullable<ToolGatewayServiceOptions["remoteHttpRequest"]> = async (url, init) => {
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
calls.push({ url: String(url), headers: new Headers(init.headers), method: body.method });
|
|
if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
|
|
return Response.json({ jsonrpc: "2.0", id: body.id, result:
|
|
body.method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
|
|
: body.method === "tools/list" ? { tools: [
|
|
{ name: "read_fixture", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } },
|
|
{ name: "write_fixture", inputSchema: { type: "object" }, annotations: { readOnlyHint: false, destructiveHint: false } },
|
|
] } : { content: [{ type: "text", text: "performed" }] },
|
|
});
|
|
};
|
|
const options = { remoteHttpRequest, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] };
|
|
const service = toolAccessService(db, options);
|
|
const setupInput: Parameters<typeof service.connectGalleryApp>[1] = {
|
|
name: `Fixture ${kind}`, grantKind,
|
|
...(kind === "zapier" ? { galleryKey: "zapier", connectionMethodKey: "generated-url", link: secretUrl }
|
|
: kind === "url" ? { link: secretUrl }
|
|
: kind === "bearer" ? { link: "https://8.8.8.8/mcp", authMode: "bearer" as const, credentialValues: { "credentials.authorization": "fixture-canary" } }
|
|
: kind === "header" ? { link: "https://8.8.8.8/mcp", authMode: "custom_headers" as const, credentialValues: { "headers.X-Api-Key": "fixture-canary" } }
|
|
: { link: "https://8.8.8.8/mcp", authMode: "none" as const }),
|
|
};
|
|
const connected = await service.connectGalleryApp(company.id, setupInput, { actorType: "user", actorId: "alice" });
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId));
|
|
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id));
|
|
let activeSecretId = secret?.id;
|
|
if (kind !== "public") {
|
|
expect(secret).toMatchObject({ scope: grantKind === "user" ? "user" : "company", ownerUserId: grantKind === "user" ? "alice" : null });
|
|
const expectedPath = kind === "header" ? "headers.X-Api-Key" : kind === "bearer" ? "credentials.authorization" : "remote.url";
|
|
expect(grant!.credentialSecretRefs[0]!.configPath).toBe(expectedPath);
|
|
const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection!.id));
|
|
const bindings = await db.select().from(companySecretBindings).where(eq(companySecretBindings.targetId, connection!.id));
|
|
expect(grantKind === "user" ? declarations : bindings).toEqual([expect.objectContaining({ configPath: expectedPath })]);
|
|
expect(grantKind === "user" ? bindings : declarations).toEqual([]);
|
|
} else expect(secret).toBeUndefined();
|
|
await service.finishGalleryAppConnection(company.id, connected.connectionId, {
|
|
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] },
|
|
}, { actorType: "user", actorId: "alice" });
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, options);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tools = (await gateway.listToolsForSession(session.token)).filter((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(tools).toHaveLength(2);
|
|
for (const tool of tools) {
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.resolves.toMatchObject({ status: "completed", result: { content: "performed" } });
|
|
}
|
|
for (const call of calls.filter((call) => call.method === "tools/call")) {
|
|
if (kind === "zapier" || kind === "url") expect(call.url).toBe(secretUrl);
|
|
if (kind === "bearer") expect(call.headers.get("authorization")).toBe("Bearer fixture-canary");
|
|
if (kind === "header") expect(call.headers.get("x-api-key")).toBe("fixture-canary");
|
|
}
|
|
expect(JSON.stringify(await db.select().from(toolAccessAuditEvents))).not.toContain("fixture-canary");
|
|
if (grantKind === "user" && secret) {
|
|
await expect(resolveConnectionGrantSecret(db, connection!, { ...grant!, subjectUserId: "bob" }, grant!.credentialSecretRefs[0]!, {}))
|
|
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
|
// Health must reject the exact legacy ownership mismatch that invocation rejects.
|
|
await db.update(companySecrets).set({ scope: "company", ownerUserId: null, userSecretDefinitionId: null }).where(eq(companySecrets.id, secret.id));
|
|
await expect(service.checkHealth(connection!.id, { actorType: "user", actorId: "alice" }))
|
|
.rejects.toMatchObject({ details: { code: "grant_credential_invalid", connection: { healthStatus: "missing_secret" } } });
|
|
await expect(service.refreshCatalog(connection!.id, { actorType: "user", actorId: "alice" }))
|
|
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
|
const otherCompany = await createCompany(db);
|
|
// The owner reconnects explicitly; no startup process adopts the old row.
|
|
await db.update(connectionGrants).set({ updatedAt: sql`'2026-09-30T12:00:00.123456Z'::timestamptz` })
|
|
.where(eq(connectionGrants.id, grant!.id));
|
|
const freshValue = "reconnected-fixture";
|
|
if (reconnectMode === "inline") {
|
|
if (kind === "url" || kind === "zapier") {
|
|
await expect(service.reconnectGalleryApp(connection!.id, company.id, {
|
|
credentialValues: { "remote.url": "https://8.8.8.8/different-endpoint?token=rejected-fixture" },
|
|
}, { actorType: "user", actorId: "alice" }))
|
|
.rejects.toMatchObject({ details: { code: "mcp_remote_url_credential_mismatch" } });
|
|
}
|
|
const configPath = kind === "url" || kind === "zapier" ? "remote.url" : kind === "header" ? "headers.X-Api-Key" : "credentials.authorization";
|
|
const reconnected = await service.reconnectGalleryApp(connection!.id, company.id, {
|
|
credentialValues: { [configPath]: kind === "url" || kind === "zapier" ? secretUrl.replace("fixture-canary", freshValue) : freshValue },
|
|
}, { actorType: "user", actorId: "alice" });
|
|
expect(reconnected.connection.id).toBe(connection!.id);
|
|
expect(reconnected.connection.healthStatus).toBe("ok");
|
|
} else {
|
|
const reconnected = await service.connectGalleryApp(company.id, {
|
|
...setupInput, reconnectConnectionId: connection!.id,
|
|
...(kind === "zapier" || kind === "url" ? { link: secretUrl.replace("fixture-canary", freshValue) }
|
|
: { credentialValues: { [kind === "header" ? "headers.X-Api-Key" : "credentials.authorization"]: freshValue } }),
|
|
}, { actorType: "user", actorId: "alice" });
|
|
expect(reconnected.connectionId).toBe(connection!.id);
|
|
await service.finishGalleryAppConnection(company.id, reconnected.connectionId, {
|
|
enabledCatalogEntryIds: reconnected.catalog.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] }, preserveExistingAccess: true,
|
|
}, { actorType: "user", actorId: "alice" });
|
|
}
|
|
const [restoredGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id));
|
|
expect(restoredGrant).toMatchObject({ id: grant!.id, kind: "user", subjectUserId: "alice", status: "active" });
|
|
activeSecretId = restoredGrant!.credentialSecretRefs[0]!.secretId;
|
|
expect(activeSecretId).not.toBe(secret.id);
|
|
const [freshSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, activeSecretId));
|
|
expect(freshSecret).toMatchObject({ scope: "user", ownerUserId: "alice" });
|
|
await expect(resolveConnectionGrantSecret(db, { ...connection!, companyId: otherCompany.id }, restoredGrant!, restoredGrant!.credentialSecretRefs[0]!, {}))
|
|
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
|
expect(await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection!.id)))
|
|
.toEqual([expect.objectContaining({ userSecretDefinitionId: freshSecret!.userSecretDefinitionId,
|
|
configPath: restoredGrant!.credentialSecretRefs[0]!.configPath })]);
|
|
expect(await db.select().from(companySecretBindings).where(eq(companySecretBindings.targetId, connection!.id))).toEqual([]);
|
|
expect((await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))[0])
|
|
.toMatchObject({ scope: "company", ownerUserId: null, userSecretDefinitionId: null });
|
|
calls.length = 0;
|
|
const restoredSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const restoredTools = (await gateway.listToolsForSession(restoredSession.token)).filter((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(restoredTools).toHaveLength(2);
|
|
for (const tool of restoredTools) {
|
|
await expect(gateway.executeTool({ sessionToken: restoredSession.token, tool: tool.name, parameters: { verification: "after-reconnect" } }))
|
|
.resolves.toMatchObject({ status: "completed", result: { content: "performed" } });
|
|
}
|
|
const restoredCalls = calls.filter((call) => call.method === "tools/call");
|
|
expect(restoredCalls).toHaveLength(2);
|
|
for (const call of restoredCalls) {
|
|
if (kind === "zapier" || kind === "url") expect(call.url).toBe(secretUrl.replace("fixture-canary", freshValue));
|
|
if (kind === "bearer") expect(call.headers.get("authorization")).toBe(`Bearer ${freshValue}`);
|
|
if (kind === "header") expect(call.headers.get("x-api-key")).toBe(freshValue);
|
|
}
|
|
}
|
|
if (secret) {
|
|
const removed = await service.archiveConnection(connection!.id, company.id, { actorType: "user", actorId: "alice" });
|
|
expect(removed.removal.secretsRevoked).toBe(1);
|
|
const [deleted] = await db.select().from(companySecrets).where(eq(companySecrets.id, activeSecretId!));
|
|
expect(deleted?.status ?? "deleted").toBe("deleted");
|
|
}
|
|
}
|
|
}, 30_000,
|
|
);
|
|
|
|
it.each(["header", "json"] as const)("returns an actionable insufficient-scope error from %s without exposing provider text", async (format) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createRemoteMcpTool(db, company.id, { url: "https://8.8.8.8/mcp", riskLevel: "write" });
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db, {
|
|
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
|
|
remoteHttpRequest: async () => Response.json({ error: "insufficient_scope", message: "provider-secret-canary" }, {
|
|
status: 403, headers: format === "header" ? { "www-authenticate": 'Bearer error="insufficient_scope"' } : {},
|
|
}),
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.providerType === "mcp_remote_http")!;
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { key: "key", value: "value" } }))
|
|
.rejects.toMatchObject({ status: 403, reasonCode: "oauth_insufficient_scope", message: expect.stringContaining("Reconnect") });
|
|
expect(JSON.stringify(await db.select().from(toolAccessAuditEvents))).not.toContain("provider-secret-canary");
|
|
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
|
|
expect(updated!.healthStatus).toBe("degraded");
|
|
});
|
|
|
|
it("uses the responsible user's identity directly and requires delegation only without one", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await createActiveMember(db, company.id, "alice");
|
|
await db.update(heartbeatRuns).set({
|
|
responsibleUserId: "alice",
|
|
invocationSource: "automation",
|
|
}).where(eq(heartbeatRuns.id, run.id));
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "delegated" }] },
|
|
},
|
|
}));
|
|
try {
|
|
const { connection } = await createRemoteMcpTool(db, company.id, {
|
|
url: fake.url,
|
|
toolName: "whoami",
|
|
riskLevel: "read",
|
|
});
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
|
|
const grant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
credentialSecretRefs: [],
|
|
status: "active",
|
|
isDefault: false,
|
|
}).returning().then((rows) => rows[0]!);
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.providerType === "mcp_remote_http")!;
|
|
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
|
.resolves.toMatchObject({ status: "completed", result: { content: "delegated" } });
|
|
expect(fake.requests).toHaveLength(1);
|
|
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id)))
|
|
.toEqual([]);
|
|
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, run.id));
|
|
const unattendedSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const unattendedTool = (await gateway.listToolsForSession(unattendedSession.token))
|
|
.find((item) => item.providerType === "mcp_remote_http")!;
|
|
await expect(gateway.executeTool({
|
|
sessionToken: unattendedSession.token,
|
|
tool: unattendedTool.name,
|
|
parameters: {},
|
|
})).rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
|
expect(fake.requests).toHaveLength(1);
|
|
|
|
await db.insert(connectionGrantDelegations).values({
|
|
companyId: company.id,
|
|
grantId: grant.id,
|
|
agentId: agent.id,
|
|
createdByUserId: "alice",
|
|
});
|
|
await expect(gateway.executeTool({
|
|
sessionToken: unattendedSession.token,
|
|
tool: unattendedTool.name,
|
|
parameters: {},
|
|
}))
|
|
.resolves.toMatchObject({ status: "completed", result: { content: "delegated" } });
|
|
expect(fake.requests).toHaveLength(2);
|
|
|
|
await db.update(companyMemberships).set({ status: "suspended" }).where(and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "alice"),
|
|
));
|
|
await expect(gateway.executeTool({
|
|
sessionToken: unattendedSession.token,
|
|
tool: unattendedTool.name,
|
|
parameters: {},
|
|
}))
|
|
.rejects.toMatchObject({ status: 403, reasonCode: "grant_owner_membership_inactive" });
|
|
expect(fake.requests).toHaveLength(2);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("keeps managed credentials authoritative even when legacy override flags are set", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const credentialValue = `managed-credential-${randomUUID()}`;
|
|
const secret = await secretService(db).create(company.id, {
|
|
name: `Header policy token ${randomUUID()}`,
|
|
key: `header_policy_token_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: credentialValue,
|
|
});
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers.authorization).toBe(`Bearer ${credentialValue}`);
|
|
expect(fakeRequest.headers["x-client-request-id"]).toBe("caller-123");
|
|
expect(fakeRequest.headers["x-static-mode"]).toBe("canary");
|
|
expect(fakeRequest.headers["x-paperclip-agent-id"]).toBe(agent.id);
|
|
expect(fakeRequest.headers["x-paperclip-issue-id"]).toBe(issue.id);
|
|
expect(fakeRequest.headers["x-paperclip-tool-gateway-token"]).toBeUndefined();
|
|
expect(fakeRequest.headers["x-unlisted-header"]).toBeUndefined();
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "headers ok" }] },
|
|
},
|
|
};
|
|
});
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "header-policy",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
credentialRefs: [{
|
|
name: "authorization",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
}],
|
|
credentialSecretRefs: [{
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.authorization",
|
|
required: true,
|
|
label: "Remote MCP token",
|
|
}],
|
|
});
|
|
await db.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
url: fake.url,
|
|
headerPolicy: {
|
|
allowManagedCredentialOverride: true,
|
|
passthrough: {
|
|
allowedHeaders: ["x-client-request-id", "authorization", "x-paperclip-tool-gateway-token"],
|
|
allowManagedCredentialOverride: true,
|
|
},
|
|
staticHeaders: [{ name: "x-static-mode", value: "canary" }],
|
|
metadata: { forward: ["agent_id", "issue_id"] },
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, remoteTool.connection.id));
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === remoteTool.connection.id);
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
callerHeaders: {
|
|
authorization: "Bearer caller-must-not-win",
|
|
"x-client-request-id": "caller-123",
|
|
"x-paperclip-tool-gateway-token": "caller-session-token",
|
|
"x-unlisted-header": "drop-me",
|
|
},
|
|
});
|
|
|
|
const [activity] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_completed"));
|
|
expect(activity.details).toMatchObject({
|
|
headerSummary: {
|
|
credentialHeaderNames: ["authorization"],
|
|
passthroughHeaderNames: ["x-client-request-id"],
|
|
droppedPassthroughHeaderNames: expect.arrayContaining([
|
|
"authorization",
|
|
"x-paperclip-tool-gateway-token",
|
|
"x-unlisted-header",
|
|
]),
|
|
staticHeaderNames: ["x-static-mode"],
|
|
metadataHeaderNames: ["x-paperclip-agent-id", "x-paperclip-issue-id"],
|
|
collisionRules: expect.arrayContaining([
|
|
{ header: "authorization", source: "caller", action: "kept_managed_credential" },
|
|
{ header: "x-paperclip-tool-gateway-token", source: "caller", action: "dropped_sensitive_header" },
|
|
]),
|
|
},
|
|
});
|
|
const persisted = JSON.stringify({
|
|
activity: await db.select().from(activityLog),
|
|
events: await db.select().from(toolCallEvents),
|
|
invocations: await db.select().from(toolInvocations),
|
|
});
|
|
expect(persisted).not.toContain(credentialValue);
|
|
expect(persisted).not.toContain("caller-must-not-win");
|
|
expect(persisted).not.toContain("caller-123");
|
|
expect(persisted).not.toContain("caller-session-token");
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("drops auth-bearing and Paperclip session headers from passthrough allowlists", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers.authorization).toBeUndefined();
|
|
expect(fakeRequest.headers["x-auth-token"]).toBeUndefined();
|
|
expect(fakeRequest.headers["x-paperclip-tool-gateway-token"]).toBeUndefined();
|
|
expect(fakeRequest.headers["x-client-request-id"]).toBe("caller-456");
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "headers ok" }] },
|
|
},
|
|
};
|
|
});
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "header-policy-sensitive",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await db.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
url: fake.url,
|
|
headerPolicy: {
|
|
passthrough: {
|
|
allowedHeaders: [
|
|
"authorization",
|
|
"x-auth-token",
|
|
"x-client-request-id",
|
|
"x-paperclip-tool-gateway-token",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, remoteTool.connection.id));
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === remoteTool.connection.id);
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "beta", value: "two" },
|
|
callerHeaders: {
|
|
authorization: "Bearer caller-should-drop",
|
|
"x-auth-token": "drop-auth-token",
|
|
"x-client-request-id": "caller-456",
|
|
"x-paperclip-tool-gateway-token": "drop-gateway-token",
|
|
},
|
|
});
|
|
|
|
const [activity] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_completed"));
|
|
expect(activity.details).toMatchObject({
|
|
headerSummary: {
|
|
credentialHeaderNames: [],
|
|
passthroughHeaderNames: ["x-client-request-id"],
|
|
droppedPassthroughHeaderNames: expect.arrayContaining([
|
|
"authorization",
|
|
"x-auth-token",
|
|
"x-paperclip-tool-gateway-token",
|
|
]),
|
|
collisionRules: expect.arrayContaining([
|
|
{ header: "authorization", source: "caller", action: "dropped_sensitive_header" },
|
|
{ header: "x-auth-token", source: "caller", action: "dropped_sensitive_header" },
|
|
{ header: "x-paperclip-tool-gateway-token", source: "caller", action: "dropped_sensitive_header" },
|
|
]),
|
|
},
|
|
});
|
|
const persisted = JSON.stringify({
|
|
activity: await db.select().from(activityLog),
|
|
events: await db.select().from(toolCallEvents),
|
|
invocations: await db.select().from(toolInvocations),
|
|
});
|
|
expect(persisted).not.toContain("caller-should-drop");
|
|
expect(persisted).not.toContain("drop-auth-token");
|
|
expect(persisted).not.toContain("drop-gateway-token");
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("uses virtual on-demand run_tool while applying target tool policy and audit metadata", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: "virtual ok" }],
|
|
structuredContent: { receivedArguments: (fakeRequest.body?.params as Record<string, unknown>).arguments },
|
|
},
|
|
},
|
|
}));
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "virtual-demo",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await db.update(toolConnections)
|
|
.set({ config: { url: fake.url, onDemandTools: { enabled: true } } })
|
|
.where(eq(toolConnections.id, remoteTool.connection.id));
|
|
const targetToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [targetToolName]);
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const visibleTools = await gateway.listToolsForSession(session.token);
|
|
expect(visibleTools.map((tool) => tool.name)).toEqual(expect.arrayContaining(["search_tools", "run_tool"]));
|
|
expect(visibleTools.map((tool) => tool.name)).not.toContain(targetToolName);
|
|
|
|
const search = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "search_tools",
|
|
parameters: { query: "kv", limit: 5 },
|
|
});
|
|
expect(JSON.stringify(search.result)).toContain(targetToolName);
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "run_tool",
|
|
parameters: {
|
|
tool: targetToolName,
|
|
arguments: { key: "virtual-key", value: "virtual-value" },
|
|
},
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
tool: "run_tool",
|
|
targetTool: targetToolName,
|
|
});
|
|
expect(fake.requests.at(-1)!.body).toMatchObject({
|
|
params: {
|
|
name: "kv_set",
|
|
arguments: { key: "virtual-key", value: "virtual-value" },
|
|
},
|
|
});
|
|
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.toolName, targetToolName));
|
|
expect(invocation).toMatchObject({
|
|
providerType: "mcp_remote_http",
|
|
connectionId: remoteTool.connection.id,
|
|
catalogEntryId: remoteTool.catalogEntry.id,
|
|
status: "succeeded",
|
|
});
|
|
const completedEvents = await db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(eq(toolCallEvents.eventType, "call_completed"));
|
|
expect(completedEvents).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: targetToolName,
|
|
metadata: expect.objectContaining({
|
|
virtualToolName: "run_tool",
|
|
targetToolName,
|
|
}),
|
|
}),
|
|
]));
|
|
const completedActivity = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_completed"));
|
|
expect(completedActivity).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
details: expect.objectContaining({
|
|
virtualToolName: "run_tool",
|
|
targetToolName,
|
|
connectionId: remoteTool.connection.id,
|
|
}),
|
|
}),
|
|
]));
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("decodes an SSE-framed tools/call response from a spec-compliant Streamable HTTP server", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
// Spec-compliant server: 406 unless the request advertises both content
|
|
// types, and replies with an SSE-framed body (PAP-11096).
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
const accept = String(fakeRequest.headers.accept ?? "");
|
|
if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
|
|
return { status: 406, rawBody: "Not Acceptable" };
|
|
}
|
|
const message = {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "sse ok" }], structuredContent: { via: "sse" } },
|
|
};
|
|
return {
|
|
headers: { "content-type": "text/event-stream" },
|
|
rawBody: `event: message\ndata: ${JSON.stringify(message)}\n\n`,
|
|
};
|
|
});
|
|
try {
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "kv-demo",
|
|
connectionName: "KV Demo SSE",
|
|
toolName: "kv_set",
|
|
title: "Set KV value",
|
|
url: fake.url,
|
|
credentialRefs: [],
|
|
credentialSecretRefs: [],
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
result: { content: "sse ok", data: { structuredContent: { via: "sse" }, transport: "mcp_http" } },
|
|
});
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("discovers and calls the SDK-backed KV demo MCP server over Streamable HTTP", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const kvDemo: KvDemoHttpServer = createKvDemoHttpServer();
|
|
const port = await kvDemo.listen(0, "127.0.0.1");
|
|
try {
|
|
const access = toolAccessService(db);
|
|
const connection = await access.createConnection(company.id, {
|
|
name: "KV demo SDK fixture",
|
|
transport: "mcp_remote",
|
|
config: { url: `http://127.0.0.1:${port}/mcp` },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const refresh = await access.refreshCatalog(connection.id, { actorType: "user", actorId: "board" });
|
|
expect(refresh.catalog.map((entry) => entry.toolName).sort()).toEqual([
|
|
"kv_delete",
|
|
"kv_get",
|
|
"kv_list",
|
|
"kv_set",
|
|
]);
|
|
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http" && tool.upstreamToolName === "kv_set");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
const result = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "streamable-key", value: "streamable-value" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
tool: connectedTool!.name,
|
|
result: {
|
|
data: {
|
|
isError: false,
|
|
transport: "mcp_http",
|
|
spawnedLocalProcess: false,
|
|
},
|
|
},
|
|
});
|
|
expect(kvDemo.store.snapshot().entries).toEqual([
|
|
expect.objectContaining({ key: "streamable-key", value: "streamable-value" }),
|
|
]);
|
|
} finally {
|
|
await kvDemo.close();
|
|
}
|
|
});
|
|
|
|
it("expires legacy managed-connector approvals before provider dispatch", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "should not run" }] },
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "shopify",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
connectionConfig: {
|
|
sourceTemplateKey: "shopify",
|
|
connectionMethodKey: "ucp-commerce",
|
|
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
|
},
|
|
});
|
|
const toolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review managed Shopify writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: toolName,
|
|
parameters: { key: "legacy", value: "reviewed" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected managed Shopify call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
const signedPayload = readSignedToolArgumentsPayload({
|
|
signedArguments: actionRequest.signedArguments,
|
|
invocationId: actionRequest.invocationId,
|
|
toolName,
|
|
signingSecret: testToolActionSigningSecret,
|
|
});
|
|
expect(signedPayload?.executionOnApprove).toBe(true);
|
|
|
|
// Model an approval signed before Shopify's UCP agent profile became a
|
|
// required managed argument. Its signature and hash are valid for that
|
|
// historical payload, but it is no longer compatible with dispatch.
|
|
const legacyParameters = { key: "legacy", value: "reviewed" };
|
|
const legacyCanonical = canonicalToolArguments(legacyParameters);
|
|
const legacySummary = summarizeToolValue(legacyParameters);
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
signedArguments: signToolArguments({
|
|
invocationId: actionRequest.invocationId,
|
|
toolName,
|
|
canonicalArguments: legacyCanonical,
|
|
approvalSnapshot: signedPayload?.approvalSnapshot,
|
|
executionOnApprove: true,
|
|
signingSecret: testToolActionSigningSecret,
|
|
}),
|
|
canonicalArgumentsHash: legacySummary.sha256,
|
|
canonicalArgumentsSummary: legacySummary,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
|
|
await expect(gateway.approveActionRequest({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
interactionId: actionRequest.interactionId!,
|
|
actionRequestId: actionRequest.id,
|
|
actor: { userId: "board-user" },
|
|
})).resolves.toMatchObject({ status: "expired" });
|
|
|
|
expect(fake.requests).toHaveLength(0);
|
|
const [expiredRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
expect(expiredRequest.status).toBe("expired");
|
|
const [failedInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
expect(failedInvocation).toMatchObject({
|
|
status: "failed",
|
|
approvalState: "expired",
|
|
errorCode: "approved_tool_managed_arguments_changed",
|
|
});
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("does not expire a managed-connector provider execution already in flight", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "concurrent execution won" }] },
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "shopify",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
connectionConfig: {
|
|
sourceTemplateKey: "shopify",
|
|
connectionMethodKey: "ucp-commerce",
|
|
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
|
},
|
|
});
|
|
const toolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review raced managed Shopify writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
let driftExpiryReached!: () => void;
|
|
const driftExpiryStarted = new Promise<void>((resolve) => {
|
|
driftExpiryReached = resolve;
|
|
});
|
|
let resumeDriftExpiry!: () => void;
|
|
const driftExpiryResume = new Promise<void>((resolve) => {
|
|
resumeDriftExpiry = resolve;
|
|
});
|
|
const gateway = createTestToolGatewayService(db, {
|
|
beforeManagedArgumentDriftExpiry: async () => {
|
|
driftExpiryReached();
|
|
await driftExpiryResume;
|
|
},
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: toolName,
|
|
parameters: { key: "raced", value: "reviewed" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected managed Shopify call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
const signedPayload = readSignedToolArgumentsPayload({
|
|
signedArguments: actionRequest.signedArguments,
|
|
invocationId: actionRequest.invocationId,
|
|
toolName,
|
|
signingSecret: testToolActionSigningSecret,
|
|
});
|
|
const legacyParameters = { key: "raced", value: "reviewed" };
|
|
const legacyCanonical = canonicalToolArguments(legacyParameters);
|
|
const legacySummary = summarizeToolValue(legacyParameters);
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
signedArguments: signToolArguments({
|
|
invocationId: actionRequest.invocationId,
|
|
toolName,
|
|
canonicalArguments: legacyCanonical,
|
|
approvalSnapshot: signedPayload?.approvalSnapshot,
|
|
executionOnApprove: true,
|
|
signingSecret: testToolActionSigningSecret,
|
|
}),
|
|
canonicalArgumentsHash: legacySummary.sha256,
|
|
canonicalArgumentsSummary: legacySummary,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
|
|
const approvedAt = new Date();
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({ status: "accepted", resolvedByAgentId: agent.id, resolvedAt: approvedAt, updatedAt: approvedAt })
|
|
.where(eq(issueThreadInteractions.id, actionRequest.interactionId!));
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "approved", resolvedByAgentId: agent.id, resolvedAt: approvedAt, updatedAt: approvedAt })
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({ approvalState: "approved", updatedAt: approvedAt })
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
|
|
const retrying = gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: toolName,
|
|
parameters: legacyParameters,
|
|
approvedActionRequestId: actionRequest.id,
|
|
});
|
|
await driftExpiryStarted;
|
|
|
|
const executingAt = new Date();
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "executing", updatedAt: executingAt })
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "executing",
|
|
approvalState: "approved",
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
startedAt: executingAt,
|
|
completedAt: null,
|
|
updatedAt: executingAt,
|
|
})
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
resumeDriftExpiry();
|
|
|
|
await retrying.then(
|
|
() => {
|
|
throw new Error("Expected the stale approved retry to request a new approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approved_tool_managed_arguments_changed"),
|
|
);
|
|
const [inFlightRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
const [inFlightInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
expect(inFlightRequest.status).toBe("executing");
|
|
expect(inFlightInvocation).toMatchObject({
|
|
status: "executing",
|
|
approvalState: "approved",
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
});
|
|
|
|
const completedAt = new Date();
|
|
const winnerSummary = summarizeToolValue({ winner: "concurrent execution" });
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ status: "executed", resolvedAt: completedAt, updatedAt: completedAt })
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
await db
|
|
.update(toolInvocations)
|
|
.set({
|
|
status: "completed",
|
|
resultSummary: winnerSummary,
|
|
completedAt,
|
|
updatedAt: completedAt,
|
|
})
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
const [winnerInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, actionRequest.invocationId));
|
|
expect(winnerInvocation).toMatchObject({
|
|
status: "completed",
|
|
approvalState: "approved",
|
|
resultSummary: winnerSummary,
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
});
|
|
expect(fake.requests).toHaveLength(0);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("enforces policy, approvals, retries, rate limits, and company boundaries for connected remote MCP calls", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const { run: otherRun } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
|
let pauseApprovedExecution = false;
|
|
let approvedExecutionReached!: () => void;
|
|
const approvedExecutionStarted = new Promise<void>((resolve) => {
|
|
approvedExecutionReached = resolve;
|
|
});
|
|
let releaseApprovedExecution = () => {};
|
|
const approvedExecutionRelease = new Promise<void>((resolve) => {
|
|
releaseApprovedExecution = resolve;
|
|
});
|
|
const fake = await startFakeRemoteMcpServer(async (fakeRequest) => {
|
|
const requestArguments = (fakeRequest.body?.params as Record<string, unknown> | undefined)?.arguments;
|
|
if (
|
|
pauseApprovedExecution
|
|
&& requestArguments
|
|
&& typeof requestArguments === "object"
|
|
&& (requestArguments as Record<string, unknown>).key === "approved"
|
|
) {
|
|
pauseApprovedExecution = false;
|
|
approvedExecutionReached();
|
|
await approvedExecutionRelease;
|
|
}
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: "connected ok" }],
|
|
structuredContent: {
|
|
receivedArguments: requestArguments,
|
|
leakedToken: "sk-connected-mcp-secret-123456",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
const denyTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "deny-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
const denyToolName = expectedConnectedToolName({
|
|
applicationKey: denyTool.application.applicationKey,
|
|
connectionId: denyTool.connection.id,
|
|
toolName: denyTool.catalogEntry.toolName,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: denyToolName,
|
|
parameters: { key: "blocked", value: "secret=sk-denied-secret-123456" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to be denied by default");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "deny_default"),
|
|
);
|
|
|
|
const [deniedInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.toolName, denyToolName));
|
|
expect(deniedInvocation).toMatchObject({
|
|
companyId: company.id,
|
|
status: "denied",
|
|
errorCode: "deny_default",
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "deny-app",
|
|
upstreamToolName: "kv_set",
|
|
riskLevel: "write",
|
|
});
|
|
expect(JSON.stringify(deniedInvocation)).not.toContain("sk-denied-secret-123456");
|
|
|
|
const approvalTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "shopify",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
connectionConfig: {
|
|
sourceTemplateKey: "shopify",
|
|
connectionMethodKey: "ucp-commerce",
|
|
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
|
|
},
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
expectedConnectedToolName({
|
|
applicationKey: approvalTool.application.applicationKey,
|
|
connectionId: approvalTool.connection.id,
|
|
toolName: approvalTool.catalogEntry.toolName,
|
|
}),
|
|
]);
|
|
const approvalToolName = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === approvalTool.connection.id)!.name;
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: approvalTool.connection.id },
|
|
description: "Connected MCP writes need review.",
|
|
priority: 10,
|
|
});
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: approvalToolName,
|
|
parameters: { key: "approved", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
const [approvalRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
expect(approvalRequest).toMatchObject({
|
|
issueId: issue.id,
|
|
status: "pending",
|
|
canonicalArgumentsHash: expect.any(String),
|
|
canonicalArgumentsSummary: {
|
|
summary: expect.stringContaining("valid-with-capabilities.json"),
|
|
},
|
|
});
|
|
const [approvalInteraction] = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, approvalRequest.interactionId!));
|
|
expect(approvalInteraction).toMatchObject({
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve KV Set?",
|
|
detailsMarkdown: expect.stringContaining('"value":"original"'),
|
|
target: {
|
|
type: "custom",
|
|
key: `tool-action:${approvalRequest.id}`,
|
|
},
|
|
toolAction: {
|
|
version: 1,
|
|
actionRequestId: approvalRequest.id,
|
|
invocationId: approvalRequest.invocationId,
|
|
toolName: approvalToolName,
|
|
toolDisplayName: expect.any(String),
|
|
connectionId: approvalTool.connection.id,
|
|
applicationId: approvalTool.application.id,
|
|
appDisplayName: approvalTool.application.name,
|
|
risk: "write",
|
|
previewMarkdown: approvalRequest.previewMarkdown,
|
|
argumentsSummaryJson: expect.stringContaining('"value":"original"'),
|
|
argumentsHash: approvalRequest.canonicalArgumentsHash,
|
|
expiresAt: approvalRequest.expiresAt!.toISOString(),
|
|
},
|
|
},
|
|
});
|
|
const [approvalInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.id, approvalRequest.invocationId));
|
|
expect(approvalInvocation).toMatchObject({
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
status: "awaiting_approval",
|
|
policyDecision: "require_approval",
|
|
connectionId: approvalTool.connection.id,
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "shopify",
|
|
upstreamToolName: "kv_set",
|
|
});
|
|
|
|
// Matching persisted company/connection rows must still be hidden from
|
|
// the Test tab when a real agent run owns the approval.
|
|
await expect(gateway.getTestCallStatus({
|
|
companyId: company.id,
|
|
connectionId: approvalTool.connection.id,
|
|
actionRequestId: approvalRequest.id,
|
|
})).rejects.toMatchObject({ status: 404, reasonCode: "action_request_not_found" });
|
|
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
resolvedByAgentId: agent.id,
|
|
resolvedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, approvalRequest.interactionId!));
|
|
|
|
pauseApprovedExecution = true;
|
|
const approvedExecution = gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: approvalToolName,
|
|
parameters: { key: "approved", value: "tampered" },
|
|
approvedActionRequestId: approvalRequest.id,
|
|
});
|
|
await approvedExecutionStarted;
|
|
const [executingApproval] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, approvalRequest.id));
|
|
expect(executingApproval.status).toBe("executing");
|
|
|
|
const concurrentRetry = gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: approvalToolName,
|
|
parameters: { key: "approved", value: "original" },
|
|
});
|
|
releaseApprovedExecution();
|
|
|
|
await expect(approvedExecution).resolves.toMatchObject({
|
|
status: "completed",
|
|
tool: approvalToolName,
|
|
result: {
|
|
data: {
|
|
structuredContent: {
|
|
leakedToken: "***REDACTED***",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await expect(concurrentRetry).resolves.toMatchObject({
|
|
status: "replayed",
|
|
result: expect.anything(),
|
|
});
|
|
expect(fake.requests.at(-1)!.body).toMatchObject({
|
|
params: {
|
|
name: "kv_set",
|
|
arguments: {
|
|
key: "approved",
|
|
value: "original",
|
|
meta: {
|
|
"ucp-agent": {
|
|
profile: "https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const [executedApproval] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, approvalRequest.id));
|
|
expect(executedApproval.status).toBe("executed");
|
|
const [completedInteraction] = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, approvalRequest.interactionId!));
|
|
expect(completedInteraction.result).toMatchObject({
|
|
version: 1,
|
|
outcome: "accepted",
|
|
toolAction: {
|
|
version: 1,
|
|
status: "executed",
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
updatedAt: expect.any(String),
|
|
},
|
|
});
|
|
const approvedCompletion = (await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.call_completed")))
|
|
.find((event) => event.details?.invocationId === approvalRequest.invocationId);
|
|
expect(approvedCompletion?.details).toMatchObject({
|
|
argumentsSummary: {
|
|
summary: expect.stringContaining('"value":"original"'),
|
|
},
|
|
execution: {
|
|
transport: "mcp_remote",
|
|
request: {
|
|
protocol: "MCP JSON-RPC 2.0",
|
|
httpMethod: "POST",
|
|
endpoint: fake.url,
|
|
mcpMethod: "tools/call",
|
|
requestId: expect.stringMatching(/^paperclip-tool-/),
|
|
upstreamToolName: "kv_set",
|
|
dispatched: true,
|
|
},
|
|
response: {
|
|
httpStatus: 200,
|
|
contentType: "application/json",
|
|
bodySizeBytes: expect.any(Number),
|
|
},
|
|
},
|
|
});
|
|
|
|
const rejectedTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "rejected-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
expectedConnectedToolName({
|
|
applicationKey: rejectedTool.application.applicationKey,
|
|
connectionId: rejectedTool.connection.id,
|
|
toolName: rejectedTool.catalogEntry.toolName,
|
|
}),
|
|
]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Reject connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: rejectedTool.connection.id },
|
|
description: "This approval will be rejected.",
|
|
priority: 5,
|
|
});
|
|
const rejectedToolName = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === rejectedTool.connection.id)!.name;
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rejectedToolName,
|
|
parameters: { key: "rejected", value: "never-run" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval before rejection");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
const rejectedRequest = (await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id)))
|
|
.find((requestRow) => requestRow.id !== approvalRequest.id)!;
|
|
await gateway.declineActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: rejectedRequest.id,
|
|
actor: { userId: "board-user" },
|
|
});
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rejectedToolName,
|
|
parameters: { key: "rejected", value: "retry" },
|
|
approvedActionRequestId: rejectedRequest.id,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected rejected approval to block retry");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "action_not_approved"),
|
|
);
|
|
|
|
const rateTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "rate-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
expectedConnectedToolName({
|
|
applicationKey: rateTool.application.applicationKey,
|
|
connectionId: rateTool.connection.id,
|
|
toolName: rateTool.catalogEntry.toolName,
|
|
}),
|
|
]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "One connected call",
|
|
policyType: "rate_limit",
|
|
selectors: { connectionId: rateTool.connection.id },
|
|
config: { limit: 1, windowSeconds: 60 },
|
|
priority: 1,
|
|
});
|
|
const rateToolName = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.connectionId === rateTool.connection.id)!.name;
|
|
await expect(gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rateToolName,
|
|
parameters: { key: "rate", value: "first" },
|
|
})).resolves.toMatchObject({ status: "completed" });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: rateToolName,
|
|
parameters: { key: "rate", value: "second" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to be rate limited");
|
|
},
|
|
(error) => expectGatewayError(error, 429, "rate_limited"),
|
|
);
|
|
const [rateLimitedInvocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.toolName, rateToolName))
|
|
.then((rows) => rows.filter((row) => row.status === "rate_limited"));
|
|
expect(rateLimitedInvocation).toMatchObject({
|
|
connectionId: rateTool.connection.id,
|
|
providerType: "mcp_remote_http",
|
|
applicationKey: "rate-app",
|
|
errorCode: "rate_limited",
|
|
});
|
|
|
|
const otherTool = await createRemoteMcpTool(db, otherCompany.id, {
|
|
applicationKey: "other-company-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await allowAllToolsForAgent(db, otherCompany.id, otherAgent.id);
|
|
const otherGateway = createTestToolGatewayService(db);
|
|
const otherSession = await otherGateway.createSession({ companyId: otherCompany.id, agentId: otherAgent.id, runId: otherRun.id });
|
|
const otherToolName = (await otherGateway.listToolsForSession(otherSession.token))
|
|
.find((tool) => tool.connectionId === otherTool.connection.id)!.name;
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: otherToolName,
|
|
parameters: { key: "cross", value: "company" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected cross-company connected MCP tool name to be hidden");
|
|
},
|
|
(error) => expectGatewayError(error, 404, "tool_not_found"),
|
|
);
|
|
|
|
const persisted = JSON.stringify({
|
|
invocations: await db.select().from(toolInvocations),
|
|
callEvents: await db.select().from(toolCallEvents),
|
|
audits: await db.select().from(toolAccessAuditEvents),
|
|
activity: await db.select().from(activityLog),
|
|
});
|
|
expect(persisted).not.toContain("sk-connected-mcp-secret-123456");
|
|
expect(persisted).not.toContain("sk-denied-secret-123456");
|
|
expect(persisted).toContain("mcp_remote_http");
|
|
expect(persisted).toContain("shopify");
|
|
expect(persisted).toContain("kv_set");
|
|
} finally {
|
|
releaseApprovedExecution();
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("requires re-review when an approved connected MCP replay target changed", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: "should not run after target drift" }],
|
|
},
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "approval-drift-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
const remoteToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [remoteToolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review driftable connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "approved", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
resolvedByAgentId: agent.id,
|
|
resolvedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, actionRequest.interactionId!));
|
|
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: { url: "https://changed.example.invalid/mcp" },
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, remoteTool.connection.id));
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "approved", value: "tampered" },
|
|
approvedActionRequestId: actionRequest.id,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected approved connected MCP retry to fail after target drift");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approved_tool_target_changed"),
|
|
);
|
|
|
|
expect(fake.requests).toHaveLength(0);
|
|
const [afterReplayAttempt] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
expect(afterReplayAttempt).toMatchObject({
|
|
issueId: issue.id,
|
|
status: "approved",
|
|
});
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("expires an abandoned unsigned ask-first request so a later retry can proceed", async () => {
|
|
// 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. If the gateway stops between the two steps, the row stays pending
|
|
// and unsigned forever, and the review queue hides it. A later retry of the
|
|
// same tool call must not replay that dead row. It must expire the row and
|
|
// create a fresh, signable request.
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "should not run while pending approval" }] },
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "abandoned-unsigned-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
const remoteToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [remoteToolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review abandoned connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "abandoned", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [firstRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
|
|
// Rewind the row to the abandoned state: created long ago, pending, never
|
|
// signed. The old createdAt proves the create stopped, not that a parallel
|
|
// create still runs, so a later retry can expire the row.
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
signedArguments: null,
|
|
expiresAt: null,
|
|
interactionId: null,
|
|
createdAt: new Date(Date.now() - 10 * 60 * 1000),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolActionRequests.id, firstRequest.id));
|
|
|
|
// Retry the same tool call. The gateway must not replay the dead row.
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "abandoned", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected retry to require a fresh approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
// The abandoned row is expired, not replayed as a live approval.
|
|
const [afterRetry] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, firstRequest.id));
|
|
expect(afterRetry.status).toBe("expired");
|
|
|
|
// The retry created a fresh, signed request that the queue can show.
|
|
const allRequests = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
const freshRequest = allRequests.find((request) => request.id !== firstRequest.id);
|
|
expect(freshRequest).toBeTruthy();
|
|
expect(freshRequest!.status).toBe("pending");
|
|
expect(freshRequest!.signedArguments).not.toBeNull();
|
|
expect(freshRequest!.expiresAt).not.toBeNull();
|
|
|
|
// The tool never executed while the approval stayed pending.
|
|
expect(fake.requests).toHaveLength(0);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("does not expire a recent unsigned ask-first request that a parallel create still owns", async () => {
|
|
// The create signs the row in two steps. A concurrent matching call can see
|
|
// the row after the insert but before the sign. A recent createdAt means a
|
|
// parallel create still runs, so the concurrent call must replay the live
|
|
// request. It must not expire the row and must not create a duplicate.
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: { content: [{ type: "text", text: "should not run while pending approval" }] },
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "inflight-unsigned-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
const remoteToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [remoteToolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review inflight connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "inflight", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [firstRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
|
|
// Rewind to the in-flight window: unsigned, but created just now. This is
|
|
// the state a concurrent matching call sees while the create still runs.
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({ signedArguments: null, expiresAt: null, interactionId: null, updatedAt: new Date() })
|
|
.where(eq(toolActionRequests.id, firstRequest.id));
|
|
|
|
// The concurrent matching call replays the live request; it does not expire.
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "inflight", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected the concurrent call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
// The row stays pending and is not expired.
|
|
const [afterRetry] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, firstRequest.id));
|
|
expect(afterRetry.status).toBe("pending");
|
|
|
|
// No duplicate request was created for the same tool call.
|
|
const allRequests = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
expect(allRequests).toHaveLength(1);
|
|
|
|
// The tool never executed while the approval stayed pending.
|
|
expect(fake.requests).toHaveLength(0);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("requires re-review when an approved connected MCP replay credential latest version changed", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const originalCredential = `approved-replay-token-${randomUUID()}`;
|
|
const rotatedCredential = `rotated-replay-token-${randomUUID()}`;
|
|
const secret = await secretService(db).create(company.id, {
|
|
name: `Approved replay MCP token ${randomUUID()}`,
|
|
key: `approved_replay_mcp_token_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted",
|
|
value: originalCredential,
|
|
});
|
|
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
|
expect(fakeRequest.headers.authorization).toBe(`Bearer ${originalCredential}`);
|
|
return {
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: fakeRequest.body?.id,
|
|
result: {
|
|
content: [{ type: "text", text: "should not run after credential drift" }],
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
const remoteTool = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "approval-credential-drift-app",
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
credentialRefs: [{
|
|
name: "authorization",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
}],
|
|
credentialSecretRefs: [{
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.authorization",
|
|
required: true,
|
|
label: "Remote MCP token",
|
|
}],
|
|
});
|
|
const remoteToolName = expectedConnectedToolName({
|
|
applicationKey: remoteTool.application.applicationKey,
|
|
connectionId: remoteTool.connection.id,
|
|
toolName: remoteTool.catalogEntry.toolName,
|
|
});
|
|
await allowToolsForAgent(db, company.id, agent.id, [remoteToolName]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review credential driftable connected writes",
|
|
policyType: "require_approval",
|
|
selectors: { connectionId: remoteTool.connection.id },
|
|
priority: 10,
|
|
});
|
|
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "approved", value: "original" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected connected MCP call to require approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
resolvedByAgentId: agent.id,
|
|
resolvedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, actionRequest.interactionId!));
|
|
|
|
await secretService(db).rotate(secret.id, { value: rotatedCredential });
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: remoteToolName,
|
|
parameters: { key: "approved", value: "tampered" },
|
|
approvedActionRequestId: actionRequest.id,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected approved connected MCP retry to fail after credential drift");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approved_tool_target_changed"),
|
|
);
|
|
|
|
expect(fake.requests).toHaveLength(0);
|
|
const [afterReplayAttempt] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, actionRequest.id));
|
|
expect(afterReplayAttempt).toMatchObject({
|
|
issueId: issue.id,
|
|
status: "approved",
|
|
});
|
|
const persisted = JSON.stringify({
|
|
actionRequests: await db.select().from(toolActionRequests),
|
|
callEvents: await db.select().from(toolCallEvents),
|
|
});
|
|
expect(persisted).not.toContain(originalCredential);
|
|
expect(persisted).not.toContain(rotatedCredential);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
const remoteFailureCases = [
|
|
{
|
|
name: "HTTP status",
|
|
reasonCode: "mcp_remote_status",
|
|
status: 502,
|
|
response: () => ({ status: 503, body: { error: "unavailable" } }),
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
reasonCode: "mcp_remote_invalid_json",
|
|
status: 502,
|
|
response: () => ({ rawBody: "not json" }),
|
|
},
|
|
{
|
|
name: "malformed MCP response",
|
|
reasonCode: "remote_mcp_malformed_response",
|
|
status: 502,
|
|
response: () => ({ body: { jsonrpc: "2.0", id: "bad", result: { content: { type: "text", text: "bad" } } } }),
|
|
},
|
|
{
|
|
name: "response size",
|
|
reasonCode: "mcp_remote_response_too_large",
|
|
status: 502,
|
|
response: () => {
|
|
const rawBody = "x".repeat(1_000_001);
|
|
return { rawBody, headers: { "content-length": String(Buffer.byteLength(rawBody)) } };
|
|
},
|
|
},
|
|
{
|
|
name: "timeout abort",
|
|
reasonCode: "tool_timeout",
|
|
status: 504,
|
|
timeoutMs: 10,
|
|
response: () => ({ delayMs: 75, body: { jsonrpc: "2.0", id: "slow", result: { content: [{ type: "text", text: "late" }] } } }),
|
|
},
|
|
];
|
|
|
|
for (const scenario of remoteFailureCases) {
|
|
it(`returns a controlled gateway error for remote MCP ${scenario.name}`, async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const fake = await startFakeRemoteMcpServer(() => scenario.response());
|
|
try {
|
|
await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: `failure-${scenario.reasonCode}`,
|
|
toolName: "kv_set",
|
|
url: fake.url,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "alpha", value: "one" },
|
|
timeoutMs: scenario.timeoutMs,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected remote MCP call to fail");
|
|
},
|
|
(error) => expectGatewayError(error, scenario.status, scenario.reasonCode),
|
|
);
|
|
|
|
const [invocation] = await db.select().from(toolInvocations);
|
|
expect(invocation).toMatchObject({
|
|
status: scenario.status === 504 ? "timed_out" : "failed",
|
|
errorCode: scenario.reasonCode,
|
|
});
|
|
const [failureAudit] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, scenario.status === 504 ? "tool_gateway.call_deferred" : "tool_gateway.call_failed"));
|
|
expect(failureAudit.details).toMatchObject({
|
|
argumentsSummary: {
|
|
summary: expect.stringContaining('"key":"alpha"'),
|
|
},
|
|
execution: {
|
|
transport: "mcp_remote",
|
|
request: {
|
|
endpoint: fake.url,
|
|
mcpMethod: "tools/call",
|
|
dispatched: true,
|
|
},
|
|
},
|
|
});
|
|
if (scenario.reasonCode === "mcp_remote_status") {
|
|
expect(failureAudit.details).toMatchObject({
|
|
execution: { response: { httpStatus: 503 } },
|
|
});
|
|
}
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
function initializeFakeMcpSession(request: FakeMcpRequest, sessionId: string) {
|
|
return {
|
|
headers: { "mcp-session-id": sessionId },
|
|
body: {
|
|
jsonrpc: "2.0",
|
|
id: request.body?.id,
|
|
result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } },
|
|
},
|
|
};
|
|
}
|
|
|
|
const perCallResponseFailures = [
|
|
{
|
|
name: "a tool call timeout",
|
|
reasonCode: "tool_timeout",
|
|
status: 504,
|
|
timeoutMs: 300,
|
|
response: () => ({ delayMs: 1_000, body: {} }),
|
|
},
|
|
{
|
|
name: "a response body timeout",
|
|
reasonCode: "tool_timeout",
|
|
status: 504,
|
|
timeoutMs: 300,
|
|
response: () => ({ bodyDelayMs: 1_000, body: {} }),
|
|
},
|
|
{
|
|
name: "an oversized response",
|
|
reasonCode: "mcp_remote_response_too_large",
|
|
response: () => ({ headers: { "content-type": "application/json" }, rawBody: "x".repeat(1_200_000) }),
|
|
check: (error: ToolGatewayHttpError) => {
|
|
expect(error.message).toContain("smaller page size");
|
|
expect(error.details).toMatchObject({ maxBytes: 1_000_000 });
|
|
},
|
|
},
|
|
{
|
|
name: "an invalid JSON response",
|
|
reasonCode: "mcp_remote_invalid_json",
|
|
response: () => ({ headers: { "content-type": "application/json" }, rawBody: "not json" }),
|
|
},
|
|
{
|
|
name: "a response without the requested message ID",
|
|
reasonCode: "remote_mcp_malformed_response",
|
|
response: () => ({ body: { jsonrpc: "2.0", id: "someone-else", result: { content: [{ type: "text", text: "wrong id" }] } } }),
|
|
},
|
|
];
|
|
|
|
for (const scenario of perCallResponseFailures) {
|
|
it(`keeps a remote MCP connection healthy and starts a fresh MCP session after ${scenario.name}`, async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
let initializeCount = 0;
|
|
let toolCallCount = 0;
|
|
const fake = await startFakeRemoteMcpServer((request) => {
|
|
if (request.body?.method === "initialize") {
|
|
initializeCount += 1;
|
|
return initializeFakeMcpSession(request, `session-${initializeCount}`);
|
|
}
|
|
if (request.body?.method === "notifications/initialized") return { status: 202, rawBody: "" };
|
|
toolCallCount += 1;
|
|
if (toolCallCount === 1) return scenario.response();
|
|
return { body: { jsonrpc: "2.0", id: request.body?.id, result: { content: [{ type: "text", text: "small page" }] } } };
|
|
});
|
|
try {
|
|
const { connection } = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: `per-call-${scenario.reasonCode}`,
|
|
toolName: "kv_get",
|
|
url: fake.url,
|
|
connectionConfig: { mcpSessionRequired: true },
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const connectedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "everything" },
|
|
timeoutMs: scenario.timeoutMs,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected the remote MCP call to fail");
|
|
},
|
|
(error) => {
|
|
expectGatewayError(error, scenario.status ?? 502, scenario.reasonCode);
|
|
scenario.check?.(error as ToolGatewayHttpError);
|
|
},
|
|
);
|
|
|
|
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
|
|
eq(toolConnections.id, connection.id),
|
|
)).resolves.toEqual([{ healthStatus: "ok" }]);
|
|
|
|
expect(toolCallCount).toBe(1);
|
|
const [failedInvocation] = await db.select().from(toolInvocations);
|
|
expect(failedInvocation).toMatchObject({
|
|
status: scenario.status === 504 ? "timed_out" : "failed",
|
|
errorCode: scenario.reasonCode,
|
|
});
|
|
|
|
const nextSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await expect(gateway.listToolsForSession(nextSession.token)).resolves.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ name: connectedTool!.name })]),
|
|
);
|
|
await expect(gateway.executeTool({
|
|
sessionToken: nextSession.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "page-1" },
|
|
})).resolves.toMatchObject({ status: "completed" });
|
|
|
|
const toolCallSessionIds = fake.requests
|
|
.filter((request) => request.body?.method === "tools/call")
|
|
.map((request) => request.headers["mcp-session-id"]);
|
|
expect(toolCallSessionIds).toEqual(["session-1", "session-2"]);
|
|
} finally {
|
|
await fake.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
it("resets only the failing agent's MCP session when another agent is initializing on the same connection", async () => {
|
|
const company = await createCompany(db);
|
|
const agentA = await createAgent(db, company.id);
|
|
const agentB = await createAgent(db, company.id);
|
|
const { run: runA } = await createIssueAndRun(db, company.id, agentA.id);
|
|
const { run: runB } = await createIssueAndRun(db, company.id, agentB.id);
|
|
let initializeCount = 0;
|
|
let markOversizedCallStarted!: () => void;
|
|
const oversizedCallStarted = new Promise<void>((resolve) => { markOversizedCallStarted = resolve; });
|
|
let markSecondInitializeStarted!: () => void;
|
|
const secondInitializeStarted = new Promise<void>((resolve) => { markSecondInitializeStarted = resolve; });
|
|
let releaseSecondInitialize!: () => void;
|
|
const secondInitializeReleased = new Promise<void>((resolve) => { releaseSecondInitialize = resolve; });
|
|
const fake = await startFakeRemoteMcpServer(async (request) => {
|
|
if (request.body?.method === "initialize") {
|
|
initializeCount += 1;
|
|
const sessionId = `session-${initializeCount}`;
|
|
if (initializeCount === 2) {
|
|
markSecondInitializeStarted();
|
|
await secondInitializeReleased;
|
|
}
|
|
return initializeFakeMcpSession(request, sessionId);
|
|
}
|
|
if (request.body?.method === "notifications/initialized") return { status: 202, rawBody: "" };
|
|
if (request.headers["mcp-session-id"] === "session-1") {
|
|
markOversizedCallStarted();
|
|
// Fail agent A's call only while agent B's initialization is in flight.
|
|
await secondInitializeStarted;
|
|
return { headers: { "content-type": "application/json" }, rawBody: "x".repeat(1_200_000) };
|
|
}
|
|
return { body: { jsonrpc: "2.0", id: request.body?.id, result: { content: [{ type: "text", text: "ok" }] } } };
|
|
});
|
|
let callA: Promise<unknown> | undefined;
|
|
let callB: Promise<unknown> | undefined;
|
|
try {
|
|
const { connection } = await createRemoteMcpTool(db, company.id, {
|
|
applicationKey: "overlapping-sessions",
|
|
toolName: "kv_get",
|
|
url: fake.url,
|
|
connectionConfig: { mcpSessionRequired: true },
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agentA.id);
|
|
await allowAllToolsForAgent(db, company.id, agentB.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const sessionA = await gateway.createSession({ companyId: company.id, agentId: agentA.id, runId: runA.id });
|
|
const sessionB = await gateway.createSession({ companyId: company.id, agentId: agentB.id, runId: runB.id });
|
|
const connectedTool = (await gateway.listToolsForSession(sessionA.token))
|
|
.find((tool) => tool.providerType === "mcp_remote_http");
|
|
expect(connectedTool).toBeTruthy();
|
|
|
|
callA = gateway.executeTool({
|
|
sessionToken: sessionA.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "everything" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected agent A's oversized remote MCP response to fail");
|
|
},
|
|
(error) => expectGatewayError(error, 502, "mcp_remote_response_too_large"),
|
|
);
|
|
await oversizedCallStarted;
|
|
callB = gateway.executeTool({
|
|
sessionToken: sessionB.token,
|
|
tool: connectedTool!.name,
|
|
parameters: { key: "page-1" },
|
|
});
|
|
await callA;
|
|
releaseSecondInitialize();
|
|
await expect(callB).resolves.toMatchObject({ status: "completed" });
|
|
|
|
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
|
|
eq(toolConnections.id, connection.id),
|
|
)).resolves.toEqual([{ healthStatus: "ok" }]);
|
|
const toolCallSessionIds = fake.requests
|
|
.filter((request) => request.body?.method === "tools/call")
|
|
.map((request) => request.headers["mcp-session-id"]);
|
|
expect(toolCallSessionIds).toEqual(["session-1", "session-2"]);
|
|
} finally {
|
|
// Open both gates and settle both calls so a failed assertion can't leave a fake-server request hanging.
|
|
markSecondInitializeStarted();
|
|
releaseSecondInitialize();
|
|
await Promise.allSettled([callA, callB]);
|
|
await fake.close();
|
|
}
|
|
});
|
|
|
|
it("persists hashed sessions and accepts them across gateway service instances", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-remote-fixture:add"]);
|
|
|
|
const gatewayA = createTestToolGatewayService(db);
|
|
const session = await gatewayA.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const [storedSession] = await db
|
|
.select()
|
|
.from(toolGatewaySessions)
|
|
.where(eq(toolGatewaySessions.id, session.id));
|
|
expect(storedSession).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
tokenHash: createHash("sha256").update(session.token).digest("hex"),
|
|
});
|
|
expect(JSON.stringify(storedSession)).not.toContain(session.token);
|
|
|
|
const gatewayB = createTestToolGatewayService(db);
|
|
await expect(gatewayB.listToolsForSession(session.token)).resolves.toEqual([
|
|
expect.objectContaining({ name: "mcp-remote-fixture:add" }),
|
|
]);
|
|
await expect(gatewayB.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:add",
|
|
parameters: { a: 2, b: 5 },
|
|
})).resolves.toMatchObject({
|
|
status: "completed",
|
|
result: { content: "7" },
|
|
});
|
|
|
|
const [usedSession] = await db
|
|
.select()
|
|
.from(toolGatewaySessions)
|
|
.where(eq(toolGatewaySessions.id, session.id));
|
|
expect(usedSession.lastUsedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("rejects gateway session tokens passed through query strings", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
|
|
const listWithQueryToken = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.query({ sessionToken: session.token });
|
|
expect(listWithQueryToken.status).toBe(401);
|
|
expect(listWithQueryToken.body).toEqual({ error: "Tool gateway session token is required" });
|
|
|
|
const callWithQueryToken = await request(app)
|
|
.post("/api/tool-gateway/tools/call")
|
|
.query({ sessionToken: session.token })
|
|
.send({ tool: "mcp-remote-fixture:add", parameters: { a: 1, b: 2 } });
|
|
expect(callWithQueryToken.status).toBe(401);
|
|
expect(callWithQueryToken.body).toEqual({ error: "Tool gateway session token is required" });
|
|
|
|
const listWithHeaderToken = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(listWithHeaderToken.status).toBe(200);
|
|
});
|
|
|
|
it("revokes a gateway session through the authenticated route and audits without token values", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const beforeRevoke = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(beforeRevoke.status).toBe(200);
|
|
|
|
const revoked = await request(app)
|
|
.post(`/api/tool-gateway/sessions/${session.id}/revoke`)
|
|
.send({ companyId: company.id });
|
|
expect(revoked.status).toBe(200);
|
|
expect(revoked.body).toEqual({
|
|
sessionId: session.id,
|
|
revokedAt: expect.any(String),
|
|
});
|
|
expect(JSON.stringify(revoked.body)).not.toContain(session.token);
|
|
|
|
const afterRevoke = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(afterRevoke.status).toBe(401);
|
|
expect(afterRevoke.body.reasonCode).toBe("session_revoked");
|
|
|
|
const [activity] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.session_revoked"));
|
|
expect(activity).toMatchObject({
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
expect(activity.details).toMatchObject({
|
|
gatewaySessionId: session.id,
|
|
reasonCode: "session_revoked",
|
|
previousRevokedAt: null,
|
|
});
|
|
|
|
const [accessAudit] = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.action, "session_revoked"));
|
|
expect(accessAudit).toMatchObject({
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
action: "session_revoked",
|
|
outcome: "success",
|
|
reasonCode: "session_revoked",
|
|
});
|
|
const serializedAudits = JSON.stringify({ activity, accessAudit });
|
|
expect(serializedAudits).not.toContain(session.token);
|
|
});
|
|
|
|
it("denies wrong-company gateway session revocation without revoking the session", async () => {
|
|
const company = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "board",
|
|
userId: "other-board-user",
|
|
source: "session",
|
|
companyIds: [otherCompany.id],
|
|
memberships: [{ companyId: otherCompany.id, membershipRole: "operator", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const revoked = await request(app)
|
|
.post(`/api/tool-gateway/sessions/${session.id}/revoke`)
|
|
.send({ companyId: otherCompany.id });
|
|
expect(revoked.status).toBe(404);
|
|
expect(revoked.body.reasonCode).toBe("session_not_found");
|
|
|
|
const stillActive = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(stillActive.status).toBe(200);
|
|
|
|
const revokedRows = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.session_revoked"));
|
|
expect(revokedRows).toHaveLength(0);
|
|
});
|
|
|
|
it("scopes agent gateway session revocation to the authenticated run", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { run: otherRun } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const otherRunSession = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: otherRun.id,
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "agent",
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
source: "agent_jwt",
|
|
});
|
|
|
|
const wrongRun = await request(app)
|
|
.post(`/api/tool-gateway/sessions/${otherRunSession.id}/revoke`)
|
|
.send();
|
|
expect(wrongRun.status).toBe(403);
|
|
expect(wrongRun.body.reasonCode).toBe("session_scope_mismatch");
|
|
|
|
const otherRunStillActive = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", otherRunSession.token);
|
|
expect(otherRunStillActive.status).toBe(200);
|
|
|
|
const ownRun = await request(app)
|
|
.post(`/api/tool-gateway/sessions/${session.id}/revoke`)
|
|
.send();
|
|
expect(ownRun.status).toBe(200);
|
|
|
|
const ownRunDenied = await request(app)
|
|
.get("/api/tool-gateway/tools")
|
|
.set("x-paperclip-tool-gateway-token", session.token);
|
|
expect(ownRunDenied.status).toBe(401);
|
|
expect(ownRunDenied.body.reasonCode).toBe("session_revoked");
|
|
});
|
|
|
|
it("keeps action request approval routes viewer-safe", async () => {
|
|
const company = await createCompany(db);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "board",
|
|
userId: "viewer-user",
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [{ companyId: company.id, membershipRole: "viewer", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const approve = await request(app)
|
|
.post(`/api/tool-gateway/action-requests/${randomUUID()}/approve`)
|
|
.send({ companyId: company.id });
|
|
const decline = await request(app)
|
|
.post(`/api/tool-gateway/action-requests/${randomUUID()}/decline`)
|
|
.send({ companyId: company.id });
|
|
|
|
for (const res of [approve, decline]) {
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Viewer access is read-only");
|
|
}
|
|
});
|
|
|
|
it("denies agents without responsible-user authority from runtime control and gateway audit routes", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "agent",
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
source: "agent_jwt",
|
|
});
|
|
|
|
const list = await request(app)
|
|
.get("/api/tool-gateway/runtime-slots")
|
|
.query({ companyId: company.id });
|
|
expect(list.status).toBe(403);
|
|
|
|
|
|
const stop = await request(app)
|
|
.post("/api/tool-gateway/runtime-slots/slot-1/stop")
|
|
.send({ companyId: company.id });
|
|
expect(stop.status).toBe(403);
|
|
|
|
|
|
const restart = await request(app)
|
|
.post("/api/tool-gateway/runtime-slots/slot-1/restart")
|
|
.send({ companyId: company.id });
|
|
expect(restart.status).toBe(403);
|
|
|
|
|
|
const audit = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id });
|
|
expect(audit.status).toBe(403);
|
|
|
|
});
|
|
|
|
it("allows board runtime control and audit reads through explicit board permissions", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const userId = `board-${randomUUID()}`;
|
|
await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
});
|
|
await db.insert(principalPermissionGrants).values([
|
|
{
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey: "tools:manage_runtime",
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey: "tools:view_audit",
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
},
|
|
]);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const gateway = createTestToolGatewayService(db, {
|
|
runtimeSupervisor: { restartBackoffMs: 0, idleTtlMs: 10_000 },
|
|
});
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const slotId = (first.result as { data: { slotId: string } }).data.slotId;
|
|
|
|
const app = createGatewayRouteApp(db, gateway, {
|
|
type: "board",
|
|
userId,
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [{ companyId: company.id, membershipRole: "operator", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const list = await request(app)
|
|
.get("/api/tool-gateway/runtime-slots")
|
|
.query({ companyId: company.id });
|
|
expect(list.status).toBe(200);
|
|
expect(list.body).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: slotId, status: "idle" }),
|
|
]));
|
|
|
|
const stop = await request(app)
|
|
.post(`/api/tool-gateway/runtime-slots/${slotId}/stop`)
|
|
.send({ companyId: company.id });
|
|
expect(stop.status).toBe(200);
|
|
expect(stop.body).toMatchObject({ id: slotId, status: "stopped" });
|
|
|
|
const restart = await request(app)
|
|
.post(`/api/tool-gateway/runtime-slots/${slotId}/restart`)
|
|
.send({ companyId: company.id });
|
|
expect(restart.status).toBe(200);
|
|
expect(restart.body).toMatchObject({ id: slotId, status: "running" });
|
|
|
|
const audit = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, limit: 20 });
|
|
expect(audit.status).toBe(200);
|
|
expect(audit.body.events).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
action: expect.stringMatching(/^tool_gateway\./),
|
|
}),
|
|
]));
|
|
expect(audit.body).toHaveProperty("nextCursor");
|
|
});
|
|
|
|
it("aggregates connection activity with server-side filters, pagination, and enrichment", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const otherAgent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
name: "Plugin: acme.plugin-mail",
|
|
type: "mcp_stdio",
|
|
}).returning();
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Plugin: acme.plugin-mail",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "local_stdio",
|
|
status: "active",
|
|
enabled: true,
|
|
}).returning();
|
|
const [profile] = await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `audit-${randomUUID()}`,
|
|
name: `Audit ${randomUUID()}`,
|
|
}).returning();
|
|
const [gateway, otherGateway] = await db.insert(toolMcpGateways).values([
|
|
{
|
|
companyId: company.id,
|
|
name: `Audit gateway ${randomUUID()}`,
|
|
slug: `audit-${randomUUID()}`,
|
|
profileId: profile!.id,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
name: `Other gateway ${randomUUID()}`,
|
|
slug: `other-${randomUUID()}`,
|
|
profileId: profile!.id,
|
|
},
|
|
]).returning();
|
|
const [newerInvocation, olderInvocation, otherInvocation] = await db.insert(toolInvocations).values([
|
|
{
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
gatewayId: gateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
toolName: "mail:send_email",
|
|
argumentsSummary: { summary: JSON.stringify({ to: "person@example.test", token: "***REDACTED***" }) },
|
|
resultSummary: { summary: JSON.stringify({ delivered: true }) },
|
|
policyDecision: "allow",
|
|
status: "succeeded",
|
|
startedAt: new Date(Date.now() - 1_500),
|
|
completedAt: new Date(Date.now() - 1_000),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
gatewayId: gateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
toolName: "mail:read_email",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
actorId: otherAgent.id,
|
|
agentId: otherAgent.id,
|
|
runId: run.id,
|
|
gatewayId: otherGateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
toolName: "other:delete_everything",
|
|
},
|
|
]).returning();
|
|
const now = Date.now();
|
|
const callEvents = await db.insert(toolCallEvents).values([
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_completed",
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
gatewayId: gateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
invocationId: newerInvocation!.id,
|
|
toolName: "mail:send_email",
|
|
decision: "allow",
|
|
reasonCode: "tool_completed",
|
|
outcome: "success",
|
|
metadata: { upstreamToolName: "fixture.todo.list" },
|
|
createdAt: new Date(now - 1_000),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_completed",
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
gatewayId: gateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
invocationId: olderInvocation!.id,
|
|
toolName: "mail:read_email",
|
|
decision: "allow",
|
|
reasonCode: "profile_allows_tool",
|
|
outcome: "success",
|
|
createdAt: new Date(now - 2_000),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_denied",
|
|
actorType: "agent",
|
|
actorId: otherAgent.id,
|
|
agentId: otherAgent.id,
|
|
runId: run.id,
|
|
gatewayId: otherGateway!.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
invocationId: otherInvocation!.id,
|
|
toolName: "other:delete_everything",
|
|
decision: "deny",
|
|
reasonCode: "deny_policy_block",
|
|
outcome: "denied",
|
|
createdAt: new Date(now - 500),
|
|
},
|
|
]).returning();
|
|
const [connectedEvent] = await db.insert(activityLog).values({
|
|
companyId: company.id,
|
|
actorType: "system",
|
|
actorId: "system",
|
|
action: "tool_app.connected",
|
|
entityType: "tool_connection",
|
|
entityId: connection!.id,
|
|
details: { galleryKey: "mail" },
|
|
createdAt: new Date(now - 45 * 24 * 60 * 60 * 1000),
|
|
}).returning();
|
|
|
|
const app = createGatewayRouteApp(db, createTestToolGatewayService(db), {
|
|
type: "board",
|
|
userId: "instance-admin",
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [{ companyId: company.id, membershipRole: "owner", status: "active" }],
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const allActivity = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id });
|
|
expect(allActivity.status).toBe(200);
|
|
expect(allActivity.body.events.map((event: { id: string }) => event.id)).toEqual([
|
|
callEvents[2]!.id,
|
|
callEvents[0]!.id,
|
|
callEvents[1]!.id,
|
|
connectedEvent!.id,
|
|
]);
|
|
expect(allActivity.body.events.find((event: { id: string }) => event.id === connectedEvent!.id)).toMatchObject({
|
|
action: "tool_connection.app_connected",
|
|
connectionId: connection!.id,
|
|
applicationId: application!.id,
|
|
appDisplayName: "Mail",
|
|
lifecycleType: "app_connected",
|
|
});
|
|
|
|
const firstPage = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, gateway: gateway!.id, app: connection!.id, agent: agent.id, outcome: "allowed", window: "24h", limit: 1 });
|
|
expect(firstPage.status).toBe(200);
|
|
expect(firstPage.body.events).toEqual([
|
|
expect.objectContaining({
|
|
action: "tool_gateway.call_completed",
|
|
agentId: agent.id,
|
|
agentDisplayName: agent.name,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
appDisplayName: "Mail",
|
|
toolDisplayName: "Send Email",
|
|
normalizedOutcome: "allowed",
|
|
invocation: expect.objectContaining({
|
|
id: newerInvocation!.id,
|
|
toolName: "mail:send_email",
|
|
status: "succeeded",
|
|
policyDecision: "allow",
|
|
argumentsSummary: expect.objectContaining({ summary: expect.stringContaining("***REDACTED***") }),
|
|
resultSummary: expect.objectContaining({ summary: expect.stringContaining("delivered") }),
|
|
}),
|
|
}),
|
|
]);
|
|
expect(typeof firstPage.body.nextCursor).toBe("string");
|
|
|
|
const secondPage = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({
|
|
companyId: company.id,
|
|
gateway: gateway!.id,
|
|
app: connection!.id,
|
|
agent: agent.id,
|
|
outcome: "allowed",
|
|
window: "24h",
|
|
limit: 1,
|
|
cursor: firstPage.body.nextCursor,
|
|
});
|
|
expect(secondPage.status).toBe(200);
|
|
expect(secondPage.body.events).toEqual([
|
|
expect.objectContaining({
|
|
action: "tool_gateway.call_completed",
|
|
toolDisplayName: "Read Email",
|
|
}),
|
|
]);
|
|
expect(secondPage.body.nextCursor).toBeNull();
|
|
|
|
// Free-text search resolves against the raw tool name server-side.
|
|
const byToolName = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, window: "24h", search: "delete_everything" });
|
|
expect(byToolName.status).toBe(200);
|
|
expect(byToolName.body.events).toEqual([
|
|
expect.objectContaining({ action: "tool_gateway.call_denied", toolDisplayName: "Delete Everything" }),
|
|
]);
|
|
|
|
const byUpstreamToolName = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, window: "24h", search: "fixture.todo.list" });
|
|
expect(byUpstreamToolName.status).toBe(200);
|
|
expect(byUpstreamToolName.body.events).toEqual([
|
|
expect.objectContaining({ action: "tool_gateway.call_completed", toolDisplayName: "Send Email" }),
|
|
]);
|
|
|
|
// ...and against the humanized agent name (resolved to the agent's events).
|
|
const byAgentName = await request(app)
|
|
.get("/api/tool-gateway/audit")
|
|
.query({ companyId: company.id, window: "24h", search: otherAgent.name });
|
|
expect(byAgentName.status).toBe(200);
|
|
expect(byAgentName.body.events).toEqual([
|
|
expect.objectContaining({ agentId: otherAgent.id }),
|
|
]);
|
|
});
|
|
|
|
it("rejects durable sessions after the heartbeat run is no longer active", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "succeeded", completedAt: new Date() })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
|
|
await expect(gateway.listToolsForSession(session.token)).rejects.toMatchObject({
|
|
status: 401,
|
|
reasonCode: "session_run_inactive",
|
|
});
|
|
await expect(gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
})).rejects.toMatchObject({
|
|
status: 403,
|
|
reasonCode: "run_inactive",
|
|
});
|
|
|
|
const [audit] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.session_rejected"));
|
|
expect(audit).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
expect(audit.details).toMatchObject({
|
|
decision: "deny",
|
|
reasonCode: "session_run_inactive",
|
|
runStatus: "succeeded",
|
|
});
|
|
expect(JSON.stringify(audit)).not.toContain(session.token);
|
|
});
|
|
|
|
it("binds runtime gateway tokens to active runs and preserves run attribution", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { project, issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: "runtime-owner" }).where(eq(heartbeatRuns.id, run.id));
|
|
const profile = await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:runtime_status"]);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const namedGateway = await gateway.createNamedGateway({
|
|
companyId: company.id,
|
|
body: {
|
|
name: `Runtime gateway ${randomUUID()}`,
|
|
profileId: profile.id,
|
|
defaultProfileMode: "gateway_only",
|
|
},
|
|
});
|
|
const token = await gateway.createNamedGatewayToken({
|
|
companyId: company.id,
|
|
gatewayId: namedGateway.id,
|
|
body: {
|
|
name: "Runtime token",
|
|
subjectType: "heartbeat_run",
|
|
subjectId: run.id,
|
|
clientLabel: "Heartbeat runtime",
|
|
ownerNote: "Run-bound regression token",
|
|
allowedActions: ["tools/list", "tools/call"],
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
actor: { agentId: agent.id },
|
|
});
|
|
const app = createGatewayRouteApp(db, gateway);
|
|
await expect(gateway.initializeNamedGatewayProtocol({
|
|
gatewayId: namedGateway.id,
|
|
bearerToken: token.token,
|
|
})).resolves.toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
projectId: project.id,
|
|
responsibleUserId: "runtime-owner",
|
|
});
|
|
|
|
await request(app)
|
|
.post(`/api/tool-gateway/gateways/${namedGateway.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
.expect(200);
|
|
await request(app)
|
|
.post(`/api/tool-gateway/gateways/${namedGateway.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: { name: "mcp-stdio-fixture:runtime_status", arguments: {} },
|
|
})
|
|
.expect(200);
|
|
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.runId, run.id))
|
|
.limit(1);
|
|
expect(invocation).toMatchObject({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
});
|
|
const attributedActivity = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(and(eq(activityLog.runId, run.id), eq(activityLog.action, "tool_gateway.discovery")));
|
|
expect(attributedActivity).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
entityId: issue.id,
|
|
details: expect.objectContaining({ issueId: issue.id, runId: run.id }),
|
|
}),
|
|
]));
|
|
const attributedToolEvents = await db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(eq(toolCallEvents.runId, run.id));
|
|
expect(attributedToolEvents).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
metadata: expect.objectContaining({ projectId: project.id }),
|
|
}),
|
|
]));
|
|
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "succeeded", completedAt: new Date() })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
|
|
const replay = await request(app)
|
|
.post(`/api/tool-gateway/gateways/${namedGateway.id}/mcp`)
|
|
.set("authorization", `Bearer ${token.token}`)
|
|
.send({ jsonrpc: "2.0", id: 3, method: "tools/list" })
|
|
.expect(401);
|
|
expect(replay.body.error.data.reasonCode).toBe("gateway_token_run_inactive");
|
|
});
|
|
|
|
it("rejects expired, revoked, and tampered durable sessions without auditing token values", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
|
|
const expired = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
await db
|
|
.update(toolGatewaySessions)
|
|
.set({ expiresAt: new Date(Date.now() - 1_000), updatedAt: new Date() })
|
|
.where(eq(toolGatewaySessions.id, expired.id));
|
|
await expect(gateway.listToolsForSession(expired.token)).rejects.toMatchObject({
|
|
status: 401,
|
|
reasonCode: "session_expired",
|
|
});
|
|
|
|
const revoked = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
await gateway.revokeSession({ companyId: company.id, sessionId: revoked.id });
|
|
await expect(gateway.listToolsForSession(revoked.token)).rejects.toMatchObject({
|
|
status: 401,
|
|
reasonCode: "session_revoked",
|
|
});
|
|
|
|
const tampered = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
const badToken = tamperToken(tampered.token);
|
|
await expect(gateway.listToolsForSession(badToken)).rejects.toMatchObject({
|
|
status: 401,
|
|
reasonCode: "session_invalid",
|
|
});
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_gateway.session_rejected"));
|
|
expect(audits).toHaveLength(3);
|
|
const serializedAudits = JSON.stringify(audits);
|
|
expect(serializedAudits).toContain("session_expired");
|
|
expect(serializedAudits).toContain("session_revoked");
|
|
expect(serializedAudits).toContain("session_invalid");
|
|
expect(serializedAudits).not.toContain(expired.token);
|
|
expect(serializedAudits).not.toContain(revoked.token);
|
|
expect(serializedAudits).not.toContain(tampered.token);
|
|
expect(serializedAudits).not.toContain(badToken);
|
|
|
|
const dedicatedAudits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.action, "call_denied"));
|
|
expect(dedicatedAudits).toHaveLength(3);
|
|
expect(dedicatedAudits.every((event) => event.outcome === "denied")).toBe(true);
|
|
});
|
|
|
|
it("cleans up expired durable sessions explicitly", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
const oldSession = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
await db
|
|
.update(toolGatewaySessions)
|
|
.set({ expiresAt: new Date(Date.now() - 1_000), updatedAt: new Date() })
|
|
.where(eq(toolGatewaySessions.id, oldSession.id));
|
|
|
|
await expect(gateway.cleanupExpiredSessions()).resolves.toEqual({ deletedCount: 1 });
|
|
|
|
const remaining = await db.select().from(toolGatewaySessions);
|
|
expect(remaining).toHaveLength(1);
|
|
expect(remaining[0]!.id).not.toBe(oldSession.id);
|
|
});
|
|
|
|
it("lazy-starts, reuses, and idles down the local stdio fixture slot", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
"mcp-stdio-fixture:increment_counter",
|
|
"mcp-stdio-fixture:runtime_status",
|
|
]);
|
|
// Drive idle-down off an injected clock so the assertions below do not
|
|
// depend on real wall-clock elapsing under 25ms (the source of the flake).
|
|
// The supervisor computes idleDeadlineAt = now() + idleTtlMs and reaps lazily
|
|
// on every listRuntimeSlots call, so a fixed clock keeps the slot alive until
|
|
// we deliberately advance past the TTL.
|
|
let clockMs = Date.now();
|
|
const gateway = createTestToolGatewayService(db, {
|
|
runtimeSupervisor: { idleTtlMs: 25, now: () => new Date(clockMs) },
|
|
});
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const second = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:runtime_status",
|
|
parameters: {},
|
|
});
|
|
|
|
const firstData = (first.result as { data: Record<string, unknown> }).data;
|
|
const secondData = (second.result as { data: Record<string, unknown> }).data;
|
|
expect(firstData).toMatchObject({ lazyStarted: true, reusedRuntimeSlot: false, counter: 1 });
|
|
expect(secondData).toMatchObject({ lazyStarted: false, reusedRuntimeSlot: true, counter: 1 });
|
|
expect(secondData.slotId).toBe(firstData.slotId);
|
|
// Clock has not advanced past the deadline, so the slot is deterministically present.
|
|
await expect(gateway.listRuntimeSlots(company.id)).resolves.toHaveLength(1);
|
|
const [idleSlot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.companyId, company.id));
|
|
expect(idleSlot).toMatchObject({
|
|
status: "idle",
|
|
commandTemplateKey: "paperclip.slow-stateful-stdio",
|
|
healthStatus: "ok",
|
|
});
|
|
expect(idleSlot.metadata).toMatchObject({
|
|
counter: 1,
|
|
useCount: 2,
|
|
process: expect.objectContaining({ simulated: true }),
|
|
resourceLimits: expect.objectContaining({ memoryCeilingSupported: expect.any(Boolean) }),
|
|
});
|
|
|
|
// Advance the injected clock past the idle TTL to deterministically reap the slot.
|
|
clockMs += 35;
|
|
await expect(gateway.listRuntimeSlots(company.id)).resolves.toEqual([]);
|
|
const [stoppedSlot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, idleSlot.id));
|
|
expect(stoppedSlot).toMatchObject({
|
|
status: "stopped",
|
|
healthMessage: "Stopped after idle TTL.",
|
|
});
|
|
});
|
|
|
|
it("supports explicit stop and restart actions for local stdio slots", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { restartBackoffMs: 0 } });
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const slotId = (first.result as { data: { slotId: string } }).data.slotId;
|
|
|
|
await expect(gateway.stopRuntimeSlot({ companyId: company.id, slotId, actor: { agentId: agent.id, runId: run.id } }))
|
|
.resolves.toMatchObject({ id: slotId, status: "stopped" });
|
|
await expect(gateway.listRuntimeSlots(company.id)).resolves.toEqual([]);
|
|
|
|
await expect(gateway.restartRuntimeSlot({ companyId: company.id, slotId, actor: { agentId: agent.id, runId: run.id } }))
|
|
.resolves.toMatchObject({ id: slotId, status: "running" });
|
|
});
|
|
|
|
it("returns structured runtime defer when local stdio host capacity is exhausted", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const { run: otherRun } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
|
await allowToolsForAgent(db, otherCompany.id, otherAgent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const gateway = createTestToolGatewayService(db, {
|
|
runtimeSupervisor: { idleTtlMs: 10_000, maxHostSlots: 1, hostId: "shared-host" },
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const otherSession = await gateway.createSession({ companyId: otherCompany.id, agentId: otherAgent.id, runId: otherRun.id });
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: otherSession.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected host capacity to defer the second stdio slot");
|
|
},
|
|
(error) => expectGatewayError(error, 429, "runtime_capacity_unavailable"),
|
|
);
|
|
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, otherCompany.id));
|
|
const [deferAudit] = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.action, "runtime_deferred"));
|
|
expect(invocation).toMatchObject({
|
|
status: "rate_limited",
|
|
errorCode: "runtime_capacity_unavailable",
|
|
});
|
|
expect(deferAudit).toMatchObject({
|
|
outcome: "failure",
|
|
reasonCode: "runtime_host_capacity_exhausted",
|
|
});
|
|
});
|
|
|
|
it("calls bundled Cognee in public mode and recovers from provider errors without runtime backoff", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const refs = await Promise.all(Object.entries({
|
|
COGNEE_BASE_URL: "https://fixture.aws.cognee.ai",
|
|
COGNEE_API_KEY: "fixture-key",
|
|
}).map(async ([key, value]) => {
|
|
const secret = await secretService(db).create(company.id, {
|
|
name: key, key: `${key}_${randomUUID().replace(/-/g, "")}`,
|
|
provider: "local_encrypted", value,
|
|
});
|
|
return { secretId: secret.id, versionSelector: "latest", configPath: `env.${key}`, required: true };
|
|
}));
|
|
const local = await createLocalStdioMcpTool(db, company.id, {
|
|
applicationKey: "cognee", toolName: "recall",
|
|
connectionConfig: { templateId: "paperclip.cognee-cloud" }, credentialSecretRefs: refs,
|
|
});
|
|
await allowAllToolsForAgent(db, company.id, agent.id);
|
|
const bridge = vi.spyOn(cogneeBridge, "callCogneeCloud")
|
|
.mockRejectedValueOnce(new Error("Cloud temporarily unavailable"))
|
|
.mockResolvedValue({ content: [{ type: "text", text: "recalled synthetic memory" }],
|
|
structuredContent: { result: "recalled synthetic memory" }, isError: false });
|
|
const gateway = createTestToolGatewayService(db, {
|
|
deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: null,
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === local.connection.id)!;
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
|
|
parameters: { message: "synthetic" } })).rejects.toThrow("Cloud temporarily unavailable");
|
|
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
|
|
parameters: { message: "synthetic" } })).resolves.toMatchObject({ status: "completed" });
|
|
expect(bridge).toHaveBeenCalledTimes(2);
|
|
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, local.connection.id))).toHaveLength(0);
|
|
bridge.mockRestore();
|
|
});
|
|
|
|
it("fails closed for hosted public local stdio unless a trusted runtime host is configured", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const hostedGateway = createTestToolGatewayService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
trustedLocalStdioRuntimeHost: null,
|
|
});
|
|
const session = await hostedGateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
|
|
await hostedGateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected public hosted local stdio to fail closed");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "local_stdio_unavailable_in_public_mode"),
|
|
);
|
|
|
|
const trustedGateway = createTestToolGatewayService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
trustedLocalStdioRuntimeHost: "trusted-worker-1",
|
|
runtimeSupervisor: { idleTtlMs: 10_000 },
|
|
});
|
|
const trustedSession = await trustedGateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
await expect(trustedGateway.executeTool({
|
|
sessionToken: trustedSession.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
})).resolves.toMatchObject({ status: "completed" });
|
|
});
|
|
|
|
it("suppresses restart storms with backoff-visible slot health", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, ["mcp-stdio-fixture:increment_counter"]);
|
|
const gateway = createTestToolGatewayService(db, {
|
|
runtimeSupervisor: {
|
|
restartBackoffMs: 0,
|
|
restartStormLimit: 1,
|
|
restartStormWindowMs: 10_000,
|
|
},
|
|
});
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const slotId = (first.result as { data: { slotId: string } }).data.slotId;
|
|
|
|
await gateway.restartRuntimeSlot({ companyId: company.id, slotId, actor: { agentId: agent.id, runId: run.id } });
|
|
await gateway.restartRuntimeSlot({ companyId: company.id, slotId, actor: { agentId: agent.id, runId: run.id } }).then(
|
|
() => {
|
|
throw new Error("Expected restart storm suppression");
|
|
},
|
|
(error) => expectGatewayError(error, 429, "runtime_restart_suppressed"),
|
|
);
|
|
|
|
const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, slotId));
|
|
expect(slot).toMatchObject({
|
|
status: "failed",
|
|
healthStatus: "error",
|
|
lastError: "restart_storm_suppressed",
|
|
});
|
|
expect(slot.metadata).toMatchObject({
|
|
restartSuppressedUntil: expect.any(String),
|
|
});
|
|
});
|
|
|
|
it("recovers stuck local stdio slots before reuse", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
"mcp-stdio-fixture:increment_counter",
|
|
"mcp-stdio-fixture:runtime_status",
|
|
]);
|
|
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { stuckSlotMs: 1, idleTtlMs: 10_000 } });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
const first = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:increment_counter",
|
|
parameters: {},
|
|
});
|
|
const slotId = (first.result as { data: { slotId: string } }).data.slotId;
|
|
const staleAt = new Date(Date.now() - 60_000);
|
|
await db
|
|
.update(toolRuntimeSlots)
|
|
.set({
|
|
status: "running",
|
|
lastUsedAt: staleAt,
|
|
startedAt: staleAt,
|
|
idleDeadlineAt: null,
|
|
idleExpiresAt: null,
|
|
updatedAt: staleAt,
|
|
})
|
|
.where(eq(toolRuntimeSlots.id, slotId));
|
|
|
|
const recovered = await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-stdio-fixture:runtime_status",
|
|
parameters: {},
|
|
});
|
|
|
|
expect((recovered.result as { data: { slotId: string; reusedRuntimeSlot: boolean } }).data).toMatchObject({
|
|
slotId,
|
|
reusedRuntimeSlot: true,
|
|
});
|
|
const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, slotId));
|
|
expect(slot).toMatchObject({
|
|
status: "idle",
|
|
healthStatus: "ok",
|
|
});
|
|
expect(slot.metadata).toMatchObject({
|
|
stuckRecoveries: 1,
|
|
lastRestartReason: "stuck_slot_recovered",
|
|
});
|
|
});
|
|
|
|
it("defers write-risk tool calls into issue-thread approval requests", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await allowToolsForAgent(db, company.id, agent.id, [
|
|
"mcp-remote-fixture:echo",
|
|
"mcp-remote-fixture:update_note",
|
|
]);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: "Review note updates",
|
|
policyType: "require_approval",
|
|
selectors: { toolName: "mcp-remote-fixture:update_note" },
|
|
description: "Note updates require review.",
|
|
});
|
|
const gateway = createTestToolGatewayService(db);
|
|
const session = await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
});
|
|
|
|
const listedTool = (await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.name === "mcp-remote-fixture:update_note");
|
|
expect(listedTool?.description).toBe(
|
|
"Remote HTTP MCP fixture that simulates a side-effecting write. Requires human approval: calling it posts an approval card on your task and you will be woken with the result once decided.",
|
|
);
|
|
expect((await gateway.listToolsForSession(session.token))
|
|
.find((tool) => tool.name === "mcp-remote-fixture:echo")?.description).toBe(
|
|
"Remote HTTP MCP fixture that echoes a message without spawning a local process.",
|
|
);
|
|
|
|
await gateway.executeTool({
|
|
sessionToken: session.token,
|
|
tool: "mcp-remote-fixture:update_note",
|
|
parameters: { noteId: "n1", body: "review this write" },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected write-risk tool call to request approval");
|
|
},
|
|
(error) => expectGatewayError(error, 409, "approval_required"),
|
|
);
|
|
|
|
const [actionRequest] = await db.select().from(toolActionRequests);
|
|
const [interaction] = await db.select().from(issueThreadInteractions);
|
|
expect(actionRequest).toMatchObject({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
status: "pending",
|
|
requestedByAgentId: agent.id,
|
|
});
|
|
expect(interaction).toMatchObject({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
});
|
|
});
|
|
|
|
it.each([1, 2])("wraps plugin tool discovery and execution behind the same gateway policy (%i copies)", async copies => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run, project } = await createIssueAndRun(db, company.id, agent.id);
|
|
const calls: unknown[] = [];
|
|
const dispatcher: PluginToolDispatcher = {
|
|
initialize: async () => {},
|
|
teardown: () => {},
|
|
listToolsForAgent: () => Array.from({ length: copies }, () => ({
|
|
name: "demo-plugin:read_status",
|
|
displayName: "Read status",
|
|
description: "Read status through a plugin tool.",
|
|
parametersSchema: { type: "object" },
|
|
pluginId: "demo-plugin",
|
|
})),
|
|
getTool: () => null,
|
|
executeTool: async (tool, parameters, runContext) => {
|
|
calls.push({ tool, parameters, runContext });
|
|
return {
|
|
pluginId: "demo-plugin",
|
|
toolName: "read_status",
|
|
result: { content: "plugin ok", data: { ok: true } },
|
|
};
|
|
},
|
|
registerPluginTools: () => {},
|
|
unregisterPluginTools: () => {},
|
|
toolCount: () => 1,
|
|
getRegistry: () => {
|
|
throw new Error("not used");
|
|
},
|
|
};
|
|
const gateway = createTestToolGatewayService(db, { pluginToolDispatcher: dispatcher });
|
|
|
|
await expect(gateway.listPluginToolsForAgent({ companyId: company.id, agentId: agent.id })).resolves.toEqual([]);
|
|
await gateway.executePluginTool({
|
|
actor: { type: "agent", companyId: company.id, agentId: agent.id, runId: run.id },
|
|
tool: "demo-plugin:read_status",
|
|
parameters: {},
|
|
runContext: { companyId: company.id, agentId: agent.id, runId: run.id, projectId: project.id },
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected plugin tool call without profile to fail");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "deny_default"),
|
|
);
|
|
|
|
await allowToolsForAgent(db, company.id, agent.id, ["demo-plugin:read_status"]);
|
|
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, projectId: project.id });
|
|
expect((await gateway.listToolsForSession(session.token)).filter(tool => tool.pluginId === "demo-plugin"))
|
|
.toEqual([expect.objectContaining({ name: "demo-plugin:read_status" })]);
|
|
|
|
await expect(gateway.listPluginToolsForAgent({ companyId: company.id, agentId: agent.id })).resolves.toEqual([
|
|
expect.objectContaining({ name: "demo-plugin:read_status" }),
|
|
]);
|
|
await expect(gateway.executePluginTool({
|
|
actor: { type: "agent", companyId: company.id, agentId: agent.id, runId: run.id },
|
|
tool: "demo-plugin:read_status",
|
|
parameters: { id: "1" },
|
|
runContext: { companyId: company.id, agentId: agent.id, runId: run.id, projectId: project.id },
|
|
})).resolves.toMatchObject({
|
|
pluginId: "demo-plugin",
|
|
toolName: "read_status",
|
|
result: { content: "plugin ok", data: { ok: true } },
|
|
});
|
|
expect(calls).toEqual([
|
|
expect.objectContaining({
|
|
tool: "demo-plugin:read_status",
|
|
parameters: { id: "1" },
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("rejects caller-supplied issue context outside the run company", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const run = await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
contextSnapshot: {},
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const otherCompany = await createCompany(db);
|
|
const otherAgent = await createAgent(db, otherCompany.id);
|
|
const { issue: otherIssue } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
|
|
const gateway = createTestToolGatewayService(db);
|
|
|
|
await gateway.createSession({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: otherIssue.id,
|
|
}).then(
|
|
() => {
|
|
throw new Error("Expected cross-company issue context to fail");
|
|
},
|
|
(error) => expectGatewayError(error, 403, "run_context_mismatch"),
|
|
);
|
|
});
|
|
});
|