Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/agent/mcp-tool-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ function remoteTool(name: string): ToolDefinition {
};
}

function platformTool(name: string, referenceName: string): ToolDefinition {
return {
...remoteTool(name),
identity: {
type: "platform",
canonicalName: `veryfront__${referenceName}`,
referenceName,
},
};
}

function remoteSource(tools: ToolDefinition[], calls: string[] = []): RemoteToolSource {
return {
id: "docs",
Expand Down Expand Up @@ -128,6 +139,55 @@ describe("agent/mcp-tool-policy", () => {
]);
});

it("matches trusted platform aliases without widening integration names", () => {
const canonical = platformTool("veryfront__list_projects", "list_projects");
const integration = remoteTool("github__list_projects");
const legacyAllowedCanonicalDenied = createMcpToolPolicyGate({
allow: ["list_projects", "github__list_projects"],
deny: ["veryfront__list_projects"],
});
assertEquals(
legacyAllowedCanonicalDenied.filterDefinitions([canonical, integration]).map((tool) =>
tool.name
),
["github__list_projects"],
);

const canonicalAllowedLegacyDenied = createMcpToolPolicyGate({
allow: ["veryfront__list_projects", "github__list_projects"],
deny: ["list_projects"],
});
assertEquals(
canonicalAllowedLegacyDenied.filterDefinitions([canonical, integration]).map((tool) =>
tool.name
),
["github__list_projects"],
);
});

it("applies scoped platform aliases to execution after trusted discovery", async () => {
const calls: string[] = [];
const source = remoteSource([
platformTool("veryfront__list_projects", "list_projects"),
remoteTool("github__list_projects"),
], calls);
const wrapped = wrapRemoteToolSourceWithMcpPolicy(source, {
allow: ["list_projects", "github__list_projects"],
deny: ["veryfront__list_projects"],
});

assertEquals((await wrapped.listTools()).map((tool) => tool.name), ["github__list_projects"]);
const error = captureThrown(() =>
wrapped.executeTool("veryfront__list_projects", { value: "blocked" })
);
assertPermissionDenied(error, 'Tool "veryfront__list_projects" is not allowed for this run');
assertEquals(
await wrapped.executeTool("github__list_projects", { value: "allowed" }),
{ ok: true, toolName: "github__list_projects" },
);
assertEquals(calls, ["github__list_projects:allowed:undefined"]);
});

it("allow filters definition order without sorting", () => {
const gate = createMcpToolPolicyGate({
allow: ["beta", "alpha"],
Expand Down
55 changes: 43 additions & 12 deletions src/agent/mcp-tool-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@ import type { AgentMcpToolPolicy } from "./types.ts";
const ReflectApply = Reflect.apply;
const ArrayIncludes = Array.prototype.includes;

function includesName(names: readonly string[], toolName: string): boolean {
return ReflectApply(ArrayIncludes, names, [toolName]);
function includesName(
names: readonly string[],
toolName: string,
identity?: { canonicalName: string; referenceName: string },
): boolean {
return ReflectApply(ArrayIncludes, names, [toolName]) ||
(identity !== undefined &&
(ReflectApply(ArrayIncludes, names, [identity.canonicalName]) ||
ReflectApply(ArrayIncludes, names, [identity.referenceName])));
}

export type McpToolPolicyGate = {
allows(toolName: string): boolean;
allows(toolName: string, identity?: { canonicalName: string; referenceName: string }): boolean;
filterDefinitions<T extends { name: string }>(definitions: readonly T[]): T[];
assertAllowed(toolName: string): void;
assertAllowed(
toolName: string,
identity?: { canonicalName: string; referenceName: string },
): void;
};

function isPolicyEmpty(policy: AgentMcpToolPolicy | undefined): boolean {
Expand All @@ -29,12 +39,15 @@ export function createMcpToolPolicyGate(
): McpToolPolicyGate {
const deniedDetail = options?.deniedDetail ?? defaultDeniedDetail;

const allows = (toolName: string): boolean => {
const allows = (
toolName: string,
identity?: { canonicalName: string; referenceName: string },
): boolean => {
const deny = policy?.deny;
if (deny !== undefined && includesName(deny, toolName)) return false;
if (deny !== undefined && includesName(deny, toolName, identity)) return false;

const allow = policy?.allow;
if (allow !== undefined) return includesName(allow, toolName);
if (allow !== undefined) return includesName(allow, toolName, identity);

return true;
};
Expand All @@ -43,15 +56,25 @@ export function createMcpToolPolicyGate(
const filtered: T[] = [];
for (let index = 0; index < definitions.length; index++) {
const definition = definitions[index];
if (definition !== undefined && allows(definition.name)) {
if (
definition !== undefined && allows(
definition.name,
"identity" in definition && definition.identity !== undefined
? definition.identity as { canonicalName: string; referenceName: string }
: undefined,
)
) {
filtered[filtered.length] = definition;
}
}
return filtered;
};

const assertAllowed = (toolName: string): void => {
if (allows(toolName)) return;
const assertAllowed = (
toolName: string,
identity?: { canonicalName: string; referenceName: string },
): void => {
if (allows(toolName, identity)) return;

throw PERMISSION_DENIED.create({ detail: deniedDetail(toolName) });
};
Expand All @@ -71,13 +94,21 @@ export function wrapRemoteToolSourceWithMcpPolicy(
options?.deniedDetail?.(toolName, source.id) ??
defaultDeniedDetail(toolName),
});
const identities = new Map<string, { canonicalName: string; referenceName: string }>();

return {
...source,
id: source.id,
listTools: async (context) => gate.filterDefinitions(await source.listTools(context)),
listTools: async (context) => {
const definitions = await source.listTools(context);
identities.clear();
for (const definition of definitions) {
if (definition.identity) identities.set(definition.name, definition.identity);
}
return gate.filterDefinitions(definitions);
},
executeTool: (toolName, args, context) => {
gate.assertAllowed(toolName);
gate.assertAllowed(toolName, identities.get(toolName));
return source.executeTool(toolName, args, context);
},
};
Expand Down
4 changes: 4 additions & 0 deletions src/agent/service/mcp-server-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ it("createAgentServiceRemoteMcpConfig builds Veryfront API MCP config", async ()
typeof config?.endpoint === "function" ? await config.endpoint() : config?.endpoint,
"https://api.example/projects/project-1/mcp",
);
assertEquals(config?.listParams, {
_meta: { "veryfront/tool-names": "canonical" },
});
assertEquals(config?.toolIdentity, "veryfront");
projectId = "project-2";
assertEquals(
typeof config?.endpoint === "function" ? await config.endpoint() : config?.endpoint,
Expand Down
4 changes: 4 additions & 0 deletions src/agent/service/mcp-server-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ function createVeryfrontApiRemoteMcpConfig(
id: server.id ?? input.defaultSourceId ?? "veryfront-mcp",
endpoint: () => createProjectScopedMcpUrl(input.apiMcpUrl, input.getProjectId?.()),
headers: () => ({ Authorization: `Bearer ${input.authToken}` }),
listParams: {
_meta: { "veryfront/tool-names": "canonical" },
},
toolIdentity: "veryfront",
};
}

Expand Down
9 changes: 8 additions & 1 deletion src/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
*/

export type {
RemoteToolIdentity,
RemoteToolSource,
Tool,
ToolConfig,
Expand All @@ -98,10 +99,16 @@ export type {
export {
createRemoteMCPToolSource,
createRemoteMCPToolSourceFactoryWithTransport,
finalizeRemoteMCPToolDefinitions,
type RemoteMCPToolSourceTransportOptions,
} from "./remote-mcp.ts";
export { hasToolExecutionErrorMarker, isErroredToolExecutionResult } from "./result.ts";
export type { RemoteMCPToolSourceConfig } from "./remote-mcp.ts";
export type {
RemoteMCPToolIdentityMode,
RemoteMCPToolListParams,
RemoteMCPToolSourceConfig,
ResolvableRemoteMCPToolListParams,
} from "./remote-mcp.ts";
export { createContext7ToolSource } from "./context7.ts";
export type { Context7ToolSourceConfig } from "./context7.ts";
export { createToolsFromHostDefinitions } from "./host-tools.ts";
Expand Down
103 changes: 103 additions & 0 deletions src/tool/remote-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { withMockFetch } from "#veryfront/testing/mock-fetch.ts";
import {
createRemoteMCPToolSource,
createRemoteMCPToolSourceFactoryWithTransport,
finalizeRemoteMCPToolDefinitions,
MAX_REMOTE_MCP_CALL_RESPONSE_BYTES,
MAX_REMOTE_MCP_TOOL_DEFINITIONS,
MAX_REMOTE_MCP_TOOL_LIST_PAGES,
Expand All @@ -19,6 +20,108 @@ import {
import { getToolResultError } from "./result.ts";

describe("tool/remote-mcp", () => {
it("preserves static and resolved tools/list params across pagination without mutation", async () => {
const staticParams = {
_meta: { "veryfront/tool-names": "canonical" },
futureOption: { enabled: true },
};
const resolvedParams = {
_meta: { "veryfront/tool-names": "canonical" },
source: "resolved",
};
const context = { projectId: "project-1", marker: { retained: true } };
const requests: Array<Record<string, unknown>> = [];
let page = 0;
const staticSource = createRemoteMCPToolSource({
id: "static",
endpoint: "https://93.184.216.34",
listParams: staticParams,
});
const resolvedSource = createRemoteMCPToolSource({
id: "resolved",
endpoint: "https://93.184.216.34",
listParams: (receivedContext) => {
assertEquals(receivedContext, context);
return resolvedParams;
},
});

const list = (source: ReturnType<typeof createRemoteMCPToolSource>) =>
withMockFetch(
async (_input, init) => {
requests.push(JSON.parse(String(init?.body)));
page += 1;
return Response.json({
jsonrpc: "2.0",
id: page <= 2 ? "static:tools:list" : "resolved:tools:list",
result: {
tools: [],
...(page === 1 || page === 3 ? { nextCursor: `page-${page + 1}` } : {}),
},
});
},
async () => await source.listTools(context),
);

await list(staticSource);
await list(resolvedSource);

assertEquals(requests.map((request) => request.params), [
staticParams,
{ ...staticParams, cursor: "page-2" },
resolvedParams,
{ ...resolvedParams, cursor: "page-4" },
]);
assertEquals(staticParams, {
_meta: { "veryfront/tool-names": "canonical" },
futureOption: { enabled: true },
});
assertEquals(resolvedParams, {
_meta: { "veryfront/tool-names": "canonical" },
source: "resolved",
});
assertEquals(context, { projectId: "project-1", marker: { retained: true } });
});

it("finalizes trusted identities before filtering and rejects cross-page aliases", () => {
const legacy = {
name: "list_projects",
_meta: {
"veryfront/tool-identity": {
type: "platform",
canonicalName: "veryfront__list_projects",
referenceName: "list_projects",
},
},
};
const canonical = {
name: "veryfront__list_projects",
_meta: legacy._meta,
};

assertThrows(
() => finalizeRemoteMCPToolDefinitions([legacy, canonical], { identity: "veryfront" }),
Error,
'competing tool identities "list_projects" and "veryfront__list_projects"',
);
assertEquals(
finalizeRemoteMCPToolDefinitions([
legacy,
{
name: "github__list_projects",
_meta: {
"veryfront/tool-identity": {
type: "integration",
canonicalName: "github__list_projects",
referenceName: "github__list_projects",
},
},
},
], { identity: "veryfront" }).map((entry) => entry.name),
["list_projects", "github__list_projects"],
);
});

it("uses host transport only for an exact trusted endpoint", async () => {
let transportCalls = 0;
const createSource = createRemoteMCPToolSourceFactoryWithTransport({
Expand Down
Loading
Loading