fix: preserve installed tools on mixed gateways

Keep managed gateways that expose at least one installed connection, and scope heartbeat-run gateway discovery and calls to the agent's installed connection set.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 19:57:37 +00:00
1 parent f17bfd73ba
commit b0e5fc2af6
3 files changed
+85 -6

No files matched your search

@@ -23,6 +23,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildPaperclipRuntimeMcpServers, createManagedMcpRunConfig } from "../services/heartbeat.js";
import { createToolGatewayService } from "../services/tool-gateway.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -307,6 +308,18 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
name: "Allow one uninstalled tool",
defaultAction: "deny" as const,
},
{
companyId: company!.id,
profileKey: `gateway:mixed-allow:${randomUUID()}`,
name: "Allow both connections by default",
defaultAction: "allow" as const,
},
{
companyId: company!.id,
profileKey: `gateway:mixed-deny:${randomUUID()}`,
name: "Explicitly allow both connections",
defaultAction: "deny" as const,
},
]).returning();
const uninstalledGatewayToolName = [
`mcp.${application!.applicationKey}`,
@@ -327,10 +340,17 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
effect: "include",
toolName: uninstalledGatewayToolName,
},
...connections.map((connection) => ({
companyId: company!.id,
profileId: profiles[3]!.id,
selectorType: "connection" as const,
effect: "include" as const,
connectionId: connection.id,
})),
]);
const gateways = await db.insert(toolMcpGateways).values(profiles.map((profile, index) => ({
companyId: company!.id,
name: `${connections[index]!.name} gateway`,
name: `${profile.name} gateway`,
slug: `gateway-${index}-${randomUUID().slice(0, 8)}`,
profileId: profile.id,
status: "active" as const,
@@ -365,8 +385,23 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
issueId: null,
});
expect(config?.gateways).toHaveLength(1);
expect(config?.gateways[0]).toMatchObject({ id: gateways[0]!.id, name: gateways[0]!.name });
expect(config?.gateways).toHaveLength(3);
expect(config?.gateways.some((gateway) => gateway.id === gateways[0]!.id)).toBe(true);
expect(config?.gateways.some((gateway) => gateway.id === gateways[1]!.id)).toBe(false);
expect(config?.gateways.some((gateway) => gateway.id === gateways[2]!.id)).toBe(true);
expect(config?.gateways.some((gateway) => gateway.id === gateways[3]!.id)).toBe(true);
const mixedGateway = config?.gateways.find((gateway) => gateway.id === gateways[2]!.id);
const visibleTools = await createToolGatewayService(db).listToolsForNamedGateway({
gatewayId: mixedGateway!.id,
bearerToken: mixedGateway!.bearerToken,
});
expect(visibleTools.some((tool) => tool.connectionId === connections[0]!.id)).toBe(true);
expect(visibleTools.some((tool) => tool.connectionId === connections[1]!.id)).toBe(false);
await expect(createToolGatewayService(db).executeTool({
sessionToken: mixedGateway!.bearerToken,
gatewayId: mixedGateway!.id,
tool: uninstalledGatewayToolName,
})).rejects.toMatchObject({ status: 403, reasonCode: "installation_required" });
});
});
+1 -1
View File
@@ -3535,7 +3535,7 @@ export async function createManagedMcpRunConfig(input: {
}))))
.filter(({ connectionIds }) =>
connectionIds.size > 0
&& [...connectionIds].every((connectionId) => installedConnectionIds.has(connectionId)))
&& [...connectionIds].some((connectionId) => installedConnectionIds.has(connectionId)))
.map(({ gateway }) => gateway);
if (gateways.length === 0) return null;
+46 -2
View File
@@ -17,6 +17,7 @@ import {
toolApplications,
toolCallEvents,
toolCatalogEntries,
toolConnectionInstalls,
toolConnections,
toolGatewayRateLimitCounters,
toolGatewaySessions,
@@ -172,6 +173,7 @@ export interface ToolGatewaySession {
gatewayName?: string | null;
gatewayTokenId?: string | null;
gatewayTokenAllowedActions?: ToolMcpGatewayTokenAction[];
enforceConnectionInstalls?: boolean;
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
createdAt: Date;
@@ -1893,7 +1895,10 @@ export function createToolGatewayService(
}
async function searchableOnDemandTools(session: ToolGatewaySession): Promise<ToolGatewayDescriptor[]> {
const tools = (await connectedMcpToolsForCompany(session.companyId)).filter(isOnDemandRemoteTool);
const tools = (await installedToolsForSession(
session,
await connectedMcpToolsForCompany(session.companyId),
)).filter(isOnDemandRemoteTool);
const decisions = await Promise.all(tools.map(async (tool) => ({
tool,
decision: await policyService.decide(policyInputForTool({ session, tool })),
@@ -1942,7 +1947,10 @@ export function createToolGatewayService(
if (session.agentId) {
await assertAgentInCompany(session.companyId, session.agentId);
}
const allConnectedTools = await connectedMcpToolsForCompany(session.companyId);
const allConnectedTools = await installedToolsForSession(
session,
await connectedMcpToolsForCompany(session.companyId),
);
const onDemandTargets = allConnectedTools.filter(isOnDemandRemoteTool);
const tools = [...allTools(), ...allConnectedTools.filter((tool) => !isOnDemandRemoteTool(tool))].filter(
(tool) => session.agentId || (tool.providerType !== "paperclip_self" && tool.providerType !== "paperclip_plugin"),
@@ -1971,6 +1979,39 @@ export function createToolGatewayService(
return visibleTools;
}
async function installedConnectionIdsForSession(session: ToolGatewaySession): Promise<Set<string> | null> {
if (!session.enforceConnectionInstalls || !session.agentId) return null;
const installs = await db
.select({ connectionId: toolConnectionInstalls.connectionId })
.from(toolConnectionInstalls)
.where(and(
eq(toolConnectionInstalls.companyId, session.companyId),
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${session.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${session.agentId}))`,
));
return new Set(installs.map((install) => install.connectionId));
}
async function installedToolsForSession(
session: ToolGatewaySession,
tools: ToolGatewayDescriptor[],
): Promise<ToolGatewayDescriptor[]> {
const installedConnectionIds = await installedConnectionIdsForSession(session);
if (!installedConnectionIds) return tools;
return tools.filter((tool) => !tool.connectionId || installedConnectionIds.has(tool.connectionId));
}
async function assertToolInstalledForSession(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
if (!tool.connectionId) return;
const installedConnectionIds = await installedConnectionIdsForSession(session);
if (!installedConnectionIds || installedConnectionIds.has(tool.connectionId)) return;
throw new ToolGatewayHttpError(
403,
"This connection is not installed for the current agent",
"installation_required",
{ connectionId: tool.connectionId },
);
}
async function executeBuiltinTool(session: ToolGatewaySession, tool: ToolGatewayDescriptor, parameters: unknown) {
const params = asRecord(parameters) ?? {};
@@ -3723,6 +3764,7 @@ export function createToolGatewayService(
gatewayName: row.gateway.name,
gatewayTokenId: row.token.id || tokenId,
gatewayTokenAllowedActions: normalizeGatewayTokenActions(row.token.allowedActions),
enforceConnectionInstalls: row.token.subjectType === "heartbeat_run",
actorType: runId ? "agent" : "system",
actorId: runId ? agentId : row.token.id,
createdAt: row.token.createdAt,
@@ -5535,6 +5577,8 @@ export function createToolGatewayService(
requestedParameters = targetParameters;
}
await assertToolInstalledForSession(session, tool);
const argumentValidation = validateToolContent({
value: requestedParameters,
direction: "arguments",