diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index a0ef5be75d..685ad68ecd 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -922,7 +922,7 @@ Input delivered to a hosted agent-service detached execution callback. | `prepareConversationRunExternalEvents` | Prepare conversation run external events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-preparation.ts#L78) | | `prepareConversationRunStreamEvents` | Prepare conversation run stream events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/run-event-preparation.ts#L59) | | `prepareDefaultHostedChildForkRuntimeTools` | Prepare default hosted child fork runtime tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L312) | -| `prepareDefaultHostedChildForkSandboxToolSources` | Prepare default hosted child fork sandbox tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L196) | +| `prepareDefaultHostedChildForkSandboxToolSources` | Prepare default hosted child fork sandbox tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L197) | | `prepareDefaultHostedChildForkToolAssembly` | Prepare default hosted child fork tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L366) | | `prepareDefaultHostedChildForkToolSources` | Prepare default hosted child fork tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-tool-sources.ts#L88) | | `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L482) | diff --git a/docs/api-reference/veryfront/tool.md b/docs/api-reference/veryfront/tool.md index c644f32971..1b8055667a 100644 --- a/docs/api-reference/veryfront/tool.md +++ b/docs/api-reference/veryfront/tool.md @@ -127,8 +127,8 @@ Create a typed tool definition. | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `createContext7ToolSource` | Create context7 tool source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/context7.ts#L28) | | `createProjectScopedRemoteToolCatalog` | Create project scoped remote tool catalog. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/project-scoped-remote-tools.ts#L348) | -| `createRemoteMCPToolSource` | Create a remote MCP source with the framework's guarded outbound transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L970) | -| `createRemoteMCPToolSourceFactoryWithTransport` | Create a remote MCP source factory with narrowly scoped host transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L1002) | +| `createRemoteMCPToolSource` | Create a remote MCP source with the framework's guarded outbound transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L983) | +| `createRemoteMCPToolSourceFactoryWithTransport` | Create a remote MCP source factory with narrowly scoped host transport. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L1038) | | `createSleepTool` | Create sleep tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/sleep.ts#L54) | | `createToolsFromHostDefinitions` | Create tools from host definitions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/host-tools.ts#L96) | | `createToolsFromRemoteDefinitions` | Create tools from remote definitions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-source-tools.ts#L29) | @@ -170,7 +170,7 @@ Create a typed tool definition. | `ProjectScopedRemoteToolExecutionInput` | Input payload for project scoped remote tool execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/project-scoped-remote-tools.ts#L38) | | `ProjectScopedRemoteToolOptions` | Options accepted by project scoped remote tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/project-scoped-remote-tools.ts#L6) | | `RemoteMCPToolSourceConfig` | Configuration used by remote MCP tool source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L52) | -| `RemoteMCPToolSourceTransportOptions` | Deployment-owned transport policy for exact, immutable MCP endpoints. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L977) | +| `RemoteMCPToolSourceTransportOptions` | Deployment-owned transport policy for trusted MCP endpoint roots. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L990) | | `RemoteToolMaterializationOptions` | Options accepted by remote tool materialization. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-source-tools.ts#L8) | | `RemoteToolSource` | Remote tool source loaded dynamically at runtime. Hosts can provide these to expose tools from remote MCP-compatible systems without registering those tools globally inside the framework. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L231) | | `SleepToolInput` | Input payload for sleep tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/sleep.ts#L45) | diff --git a/src/agent/hosted/child-fork-tool-sources.test.ts b/src/agent/hosted/child-fork-tool-sources.test.ts index 0afb22bd41..3f635178c0 100644 --- a/src/agent/hosted/child-fork-tool-sources.test.ts +++ b/src/agent/hosted/child-fork-tool-sources.test.ts @@ -132,6 +132,7 @@ Deno.test("prepareDefaultHostedChildForkToolSources loads API, live Studio, and const fixtures = createRemoteSourceFixtures(); const switchedProjectIds: string[] = []; const switchedProjects: Array<{ projectId: string; projectSlug?: string }> = []; + let activeProjectId = "project-1"; const result = await prepareDefaultHostedChildForkToolSources({ authToken: "token-1", @@ -139,7 +140,7 @@ Deno.test("prepareDefaultHostedChildForkToolSources loads API, live Studio, and mcpServers: [{ kind: "veryfront-api" }, { kind: "veryfront-studio" }], studioMcpUrl: "https://studio.example/mcp", clientProfile: trustedStudioProfile, - getProjectId: () => "project-1", + getProjectId: () => activeProjectId, conversationId: "conversation-1", globalTools: { sleep: { @@ -148,6 +149,7 @@ Deno.test("prepareDefaultHostedChildForkToolSources loads API, live Studio, and }, }, onConfirmedStudioProjectSwitch: (projectId, confirmedProject) => { + activeProjectId = projectId; switchedProjectIds.push(projectId); if (confirmedProject) { switchedProjects.push(confirmedProject); @@ -163,9 +165,14 @@ Deno.test("prepareDefaultHostedChildForkToolSources loads API, live Studio, and assertEquals(Object.keys(result.forkTools), ["sleep", "studio_open_project", "update_file"]); assertEquals( - fixtures.createdConfigs.map((config) => [config.id, config.endpoint]), + await Promise.all( + fixtures.createdConfigs.map(async (config) => [ + config.id, + typeof config.endpoint === "function" ? await config.endpoint() : config.endpoint, + ]), + ), [ - ["veryfront-mcp-fork", "https://api.example/mcp"], + ["veryfront-mcp-fork", "https://api.example/projects/project-1/mcp"], ["studio-mcp-live-tools", "https://studio.example/mcp"], ], ); @@ -174,6 +181,11 @@ Deno.test("prepareDefaultHostedChildForkToolSources loads API, live Studio, and assertEquals(switchedProjectIds, ["project-2"]); assertEquals(switchedProjects, [{ projectId: "project-2", projectSlug: "project-two" }]); + const apiConfig = fixtures.createdConfigs.find((config) => config.id === "veryfront-mcp-fork"); + assertEquals( + typeof apiConfig?.endpoint === "function" ? await apiConfig.endpoint() : apiConfig?.endpoint, + "https://api.example/projects/project-2/mcp", + ); assertEquals(fixtures.executeCalls, [ { sourceId: "veryfront-mcp-fork", diff --git a/src/agent/hosted/child-fork-tool-sources.ts b/src/agent/hosted/child-fork-tool-sources.ts index 65bfcd0265..5e8252f819 100644 --- a/src/agent/hosted/child-fork-tool-sources.ts +++ b/src/agent/hosted/child-fork-tool-sources.ts @@ -131,6 +131,7 @@ export async function prepareDefaultHostedChildForkToolSources( server, authToken: input.authToken, apiMcpUrl: input.apiMcpUrl, + getProjectId: input.getProjectId, defaultSourceId: "veryfront-mcp-fork", }); if (!remoteConfig) { diff --git a/src/agent/hosted/project-remote-tool-source.test.ts b/src/agent/hosted/project-remote-tool-source.test.ts index 9016ac7bee..66aac4706b 100644 --- a/src/agent/hosted/project-remote-tool-source.test.ts +++ b/src/agent/hosted/project-remote-tool-source.test.ts @@ -95,6 +95,12 @@ async function resolveTestHeaders( return typeof headers === "function" ? await headers(context) : headers; } +async function resolveTestEndpoint( + endpoint: RemoteMCPToolSourceConfig["endpoint"], +): Promise { + return typeof endpoint === "function" ? await endpoint() : endpoint; +} + Deno.test("hosted remote tool sources do not carry legacy end-user identity plumbing", async () => { const forbidden = [ ["get", "End", "User", "Id"].join(""), @@ -673,8 +679,8 @@ Deno.test("createHostedProjectRemoteToolSources defaults to first-party MCP serv }); assertEquals(sources.map((source) => source.id), ["veryfront-mcp", "studio-mcp"]); - assertEquals(configs.map((config) => config.endpoint), [ - "https://api.example/mcp", + assertEquals(await Promise.all(configs.map((config) => resolveTestEndpoint(config.endpoint))), [ + "https://api.example/projects/project-1/mcp", "https://studio.example/mcp", ]); }); @@ -790,8 +796,8 @@ Deno.test("createHostedProjectRemoteToolSources builds API and explicit gated St }); assertEquals(sources.map((source) => source.id), ["veryfront-mcp", "studio-mcp"]); - assertEquals(configs.map((config) => config.endpoint), [ - "https://api.example/mcp", + assertEquals(await Promise.all(configs.map((config) => resolveTestEndpoint(config.endpoint))), [ + "https://api.example/projects/project-1/mcp", "https://studio.example/mcp", ]); assertEquals(await resolveTestHeaders(configs[0]?.headers), { @@ -802,6 +808,10 @@ Deno.test("createHostedProjectRemoteToolSources builds API and explicit gated St }); activeProjectId = "project-2"; + assertEquals( + await resolveTestEndpoint(configs[0]?.endpoint ?? ""), + "https://api.example/projects/project-2/mcp", + ); assertEquals(await resolveTestHeaders(configs[1]?.headers), { Authorization: "Bearer token-1", "x-conversation-id": "conversation-1", @@ -885,8 +895,8 @@ Deno.test("createHostedProjectRemoteToolSources infers Studio MCP from allowed S }); assertEquals(sources.map((source) => source.id), ["veryfront-mcp", "studio-mcp"]); - assertEquals(configs.map((config) => config.endpoint), [ - "https://api.example/mcp", + assertEquals(await Promise.all(configs.map((config) => resolveTestEndpoint(config.endpoint))), [ + "https://api.example/projects/project-1/mcp", "https://studio.example/mcp", ]); assertEquals( @@ -895,7 +905,7 @@ Deno.test("createHostedProjectRemoteToolSources infers Studio MCP from allowed S ); }); -Deno.test("createHostedProjectRemoteToolSources does not infer Studio when explicit API-only MCP is set", () => { +it("does not infer Studio when explicit API-only MCP is set", async () => { const configs: RemoteMCPToolSourceConfig[] = []; const sources = createHostedProjectRemoteToolSources({ authToken: "token-1", @@ -920,7 +930,9 @@ Deno.test("createHostedProjectRemoteToolSources does not infer Studio when expli }); assertEquals(sources.map((source) => source.id), ["veryfront-mcp"]); - assertEquals(configs.map((config) => config.endpoint), ["https://api.example/mcp"]); + assertEquals(await Promise.all(configs.map((config) => resolveTestEndpoint(config.endpoint))), [ + "https://api.example/projects/project-1/mcp", + ]); }); Deno.test("createHostedProjectRemoteToolSources preserves an explicit MCP opt-out", () => { @@ -1027,8 +1039,8 @@ Deno.test("createHostedProjectRemoteToolSources builds explicit MCP server lists }); assertEquals(sources.map((source) => source.id), ["veryfront-mcp", "studio-mcp", "linear"]); - assertEquals(configs.map((config) => config.endpoint), [ - "https://api.example/mcp", + assertEquals(await Promise.all(configs.map((config) => resolveTestEndpoint(config.endpoint))), [ + "https://api.example/projects/project-1/mcp", "https://studio.example/mcp", "https://linear.example/mcp", ]); diff --git a/src/agent/hosted/veryfront-cloud-agent-service.test.ts b/src/agent/hosted/veryfront-cloud-agent-service.test.ts index b07a8378fc..2d58a53a19 100644 --- a/src/agent/hosted/veryfront-cloud-agent-service.test.ts +++ b/src/agent/hosted/veryfront-cloud-agent-service.test.ts @@ -1,4 +1,5 @@ import { toolRegistryInternal } from "#veryfront/tool/registry.ts"; +import { clearModelProviders, registerModelProvider } from "#veryfront/provider"; import "#veryfront/schemas/_test-setup.ts"; import { assert, @@ -24,6 +25,7 @@ import { toolRegistry, } from "#veryfront/tool"; import { defineSchema } from "#veryfront/schemas/index.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { __resetLogRecordEmitterForTests, agentLogger } from "#veryfront/utils/logger/index.ts"; import { createExecuteSkillScriptTool, @@ -73,6 +75,8 @@ Deno.test("public agent service options expose deployment-owned remote MCP compo Deno.test("root and child runtimes use the deployment-owned remote MCP factory", async () => { const createdConfigs: RemoteMCPToolSourceConfig[] = []; let failStudioListing = false; + let modelCallCount = 0; + let switchedTaskContext: { projectId: string; projectSlug?: string } | undefined; const injectedFactory = (config: RemoteMCPToolSourceConfig): RemoteToolSource => { createdConfigs.push(config); return { @@ -80,8 +84,21 @@ Deno.test("root and child runtimes use the deployment-owned remote MCP factory", listTools: () => failStudioListing && config.endpoint === "https://studio.example/mcp" ? Promise.reject(new Error("stop after transport capture")) - : Promise.resolve([]), - executeTool: () => Promise.resolve(null), + : Promise.resolve( + config.id === "studio-mcp" + ? [{ + name: "studio_open_project", + description: "Open a project.", + parameters: { type: "object", properties: {} }, + }] + : [], + ), + executeTool: (toolName) => + Promise.resolve( + config.id === "studio-mcp" && toolName === "studio_open_project" + ? { success: true, project_id: "project-2", slug: "project-two" } + : null, + ), }; }; const context = { @@ -92,8 +109,8 @@ Deno.test("root and child runtimes use the deployment-owned remote MCP factory", }, infrastructure: { getConfig: () => ({ - VERYFRONT_API_URL: "https://api.example", - VERYFRONT_MCP_URL: "https://api.example/mcp", + VERYFRONT_API_URL: "https://93.184.216.34", + VERYFRONT_MCP_URL: "https://93.184.216.34/mcp", VERYFRONT_STUDIO_MCP_URL: "https://studio.example/mcp", VERYFRONT_ENABLE_DURABLE_INVOKE_AGENT: false, }), @@ -123,6 +140,10 @@ Deno.test("root and child runtimes use the deployment-owned remote MCP factory", inputSchema: defineSchema((v) => v.object({}))(), execute: () => ({ ok: true }), }), + refreshProjectSkillIds: (taskContext: { projectId: string; projectSlug?: string }) => { + switchedTaskContext = taskContext; + return Promise.resolve(); + }, }]]), trace: (_name: string, operation: () => unknown) => operation(), } as never; @@ -133,29 +154,88 @@ Deno.test("root and child runtimes use the deployment-owned remote MCP factory", capabilities: ["ui_panels"], }; - await createAgentRuntime(context, { + clearModelProviders(); + registerModelProvider("test", () => ({ + provider: "test", + modelId: "test/hosted-project-switch", + doGenerate: () => Promise.reject(new Error("unused")), + doStream: () => { + modelCallCount++; + return Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + if (modelCallCount === 1) { + controller.enqueue({ + type: "tool-call", + toolCallId: "open-project-1", + toolName: "studio_open_project", + input: { project_reference: "project-two" }, + }); + controller.enqueue({ type: "finish", finishReason: "tool-calls", usage: {} }); + } else { + controller.enqueue({ type: "text-delta", text: "opened" }); + controller.enqueue({ type: "finish", finishReason: "stop", usage: {} }); + } + controller.close(); + }, + }), + }); + }, + })); + + const rootRuntime = await createAgentRuntime(context, { projectId: "project-1", branchId: "branch-1", authToken: "token-1", instructions: "Use the available tools.", agentId: "root-agent", - allowedTools: [], + model: "test/hosted-project-switch", + allowedTools: ["studio_open_project"], allowDelegation: false, clientProfile, }); assertEquals( - createdConfigs.map(({ id, endpoint }) => ({ id, endpoint })), + await Promise.all(createdConfigs.map(async ({ id, endpoint }) => ({ + id, + endpoint: typeof endpoint === "function" ? await endpoint() : endpoint, + }))), [ - { id: "veryfront-mcp", endpoint: "https://api.example/mcp" }, + { id: "veryfront-mcp", endpoint: "https://93.184.216.34/projects/project-1/mcp" }, { id: "studio-mcp", endpoint: "https://studio.example/mcp" }, ], ); + try { + await withMockFetch( + () => Promise.resolve(Response.json({ tools: [] })), + async () => { + const stream = await rootRuntime.agent.stream({ + messages: [], + abortSignal: new AbortController().signal, + }); + for await (const _chunk of stream.toUIMessageStream()) { + // Consume the project-switch tool round trip. + } + }, + ); + } finally { + await rootRuntime.cleanup(); + clearModelProviders(); + } + assertEquals(switchedTaskContext?.projectId, "project-2"); + const rootApiConfig = createdConfigs.find((config) => config.id === "veryfront-mcp"); + assertEquals( + typeof rootApiConfig?.endpoint === "function" + ? await rootApiConfig.endpoint() + : rootApiConfig?.endpoint, + "https://93.184.216.34/projects/project-2/mcp", + ); + createdConfigs.length = 0; failStudioListing = true; const invokeAgent = createInvokeAgentTool(context, { authToken: "token-1", - projectId: "project-1", + projectId: switchedTaskContext?.projectId ?? "project-1", branchId: "branch-1", agentId: "orchestrator", clientProfile, @@ -166,9 +246,12 @@ Deno.test("root and child runtimes use the deployment-owned remote MCP factory", prompt: "Inspect the available tools.", }, { toolCallId: "tool-call-1" }); assertEquals( - createdConfigs.map(({ id, endpoint }) => ({ id, endpoint })), + await Promise.all(createdConfigs.map(async ({ id, endpoint }) => ({ + id, + endpoint: typeof endpoint === "function" ? await endpoint() : endpoint, + }))), [ - { id: "veryfront-mcp-fork", endpoint: "https://api.example/mcp" }, + { id: "veryfront-mcp-fork", endpoint: "https://93.184.216.34/projects/project-2/mcp" }, { id: "studio-mcp-live-tools", endpoint: "https://studio.example/mcp" }, ], ); diff --git a/src/agent/runtime/mcp-server-tool-sources.test.ts b/src/agent/runtime/mcp-server-tool-sources.test.ts index 8b24fbc52f..243c0122cf 100644 --- a/src/agent/runtime/mcp-server-tool-sources.test.ts +++ b/src/agent/runtime/mcp-server-tool-sources.test.ts @@ -32,6 +32,13 @@ type FetchCall = { init: RequestInit; }; +async function resolveRemoteEndpoint( + endpoint: RemoteMCPToolSourceConfig["endpoint"] | undefined, +): Promise { + if (endpoint === undefined) return undefined; + return typeof endpoint === "function" ? await endpoint() : endpoint; +} + function createMcpFetch(calls: FetchCall[]): typeof fetch { return ((url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init: init ?? {} }); @@ -170,7 +177,10 @@ Deno.test("getRuntimeRemoteToolSources hydrates a Veryfront API MCP server from ); assertEquals(sources?.length, 1); - assertEquals(remoteConfig?.endpoint, "https://api.example/mcp"); + assertEquals( + await resolveRemoteEndpoint(remoteConfig?.endpoint), + "https://api.example/projects/server-project/mcp", + ); assertEquals( await (remoteConfig?.headers as (context?: ToolExecutionContext) => HeadersInit)?.({ authToken: "browser-token", @@ -322,7 +332,10 @@ Deno.test("getRuntimeRemoteToolSources implicitly connects unresolved named tool }, ); - assertEquals(remoteConfig?.endpoint, "https://api.example/mcp"); + assertEquals( + await resolveRemoteEndpoint(remoteConfig?.endpoint), + "https://api.example/projects/server-project/mcp", + ); assertEquals((await sources?.[0]?.listTools())?.map((tool) => tool.name), ["get_file"]); }); diff --git a/src/agent/service/mcp-server-config.test.ts b/src/agent/service/mcp-server-config.test.ts index f1b2e74d60..0d2e1e786b 100644 --- a/src/agent/service/mcp-server-config.test.ts +++ b/src/agent/service/mcp-server-config.test.ts @@ -1,24 +1,36 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; import { createAgentServiceRemoteMcpConfig, + createProjectScopedMcpUrl, defaultAgentServiceMcpServers, } from "./mcp-server-config.ts"; -Deno.test("defaultAgentServiceMcpServers enables first-party MCP servers", () => { +it("defaultAgentServiceMcpServers enables first-party MCP servers", () => { assertEquals(defaultAgentServiceMcpServers(), [ { kind: "veryfront-api" }, { kind: "veryfront-studio" }, ]); }); -Deno.test("createAgentServiceRemoteMcpConfig builds Veryfront API MCP config", async () => { +it("createAgentServiceRemoteMcpConfig builds Veryfront API MCP config", async () => { + let projectId = "project-1"; const config = createAgentServiceRemoteMcpConfig({ server: { kind: "veryfront-api" }, authToken: "token-1", apiMcpUrl: "https://api.example/mcp", + getProjectId: () => projectId, }); assertEquals(config?.id, "veryfront-mcp"); - assertEquals(config?.endpoint, "https://api.example/mcp"); + assertEquals( + typeof config?.endpoint === "function" ? await config.endpoint() : config?.endpoint, + "https://api.example/projects/project-1/mcp", + ); + projectId = "project-2"; + assertEquals( + typeof config?.endpoint === "function" ? await config.endpoint() : config?.endpoint, + "https://api.example/projects/project-2/mcp", + ); assertEquals( typeof config?.headers === "function" ? await config.headers() : config?.headers, { @@ -45,7 +57,35 @@ Deno.test("createAgentServiceRemoteMcpConfig builds Veryfront API MCP config", a ); }); -Deno.test("createAgentServiceRemoteMcpConfig builds generic MCP config without dropping options", () => { +describe("createProjectScopedMcpUrl", () => { + it("normalizes and replaces the project segment", () => { + assertEquals( + createProjectScopedMcpUrl("https://api.example", " project/1 "), + "https://api.example/projects/project%2F1/mcp", + ); + assertEquals( + createProjectScopedMcpUrl("https://api.example/projects/old/mcp", "new"), + "https://api.example/projects/new/mcp", + ); + assertEquals( + createProjectScopedMcpUrl("https://api.example/mcp", " "), + "https://api.example/mcp", + ); + assertEquals( + createProjectScopedMcpUrl( + "https://api.example/mcp/?environment=staging", + "project-1", + ), + "https://api.example/projects/project-1/mcp?environment=staging", + ); + assertEquals( + createProjectScopedMcpUrl("not an absolute URL", "project-1"), + "not an absolute URL", + ); + }); +}); + +it("createAgentServiceRemoteMcpConfig builds generic MCP config without dropping options", () => { const headers = { Authorization: "Bearer external-token" }; assertEquals( createAgentServiceRemoteMcpConfig({ @@ -69,7 +109,7 @@ Deno.test("createAgentServiceRemoteMcpConfig builds generic MCP config without d ); }); -Deno.test("createAgentServiceRemoteMcpConfig gates Studio MCP by client profile", async () => { +it("createAgentServiceRemoteMcpConfig gates Studio MCP by client profile", async () => { const blockedConfig = createAgentServiceRemoteMcpConfig({ server: { kind: "veryfront-studio" }, authToken: "token-1", diff --git a/src/agent/service/mcp-server-config.ts b/src/agent/service/mcp-server-config.ts index 6ba44f8dc6..90d9f6b7cb 100644 --- a/src/agent/service/mcp-server-config.ts +++ b/src/agent/service/mcp-server-config.ts @@ -45,6 +45,29 @@ export function defaultAgentServiceMcpServers(): AgentServiceMcpServerConfig[] { return [{ kind: "veryfront-api" }, { kind: "veryfront-studio" }]; } +/** Build the project-scoped control-plane MCP URL for the active project. */ +export function createProjectScopedMcpUrl( + apiMcpUrl: string, + projectId: string | null | undefined, +): string { + const normalizedProjectId = projectId?.trim(); + if (!normalizedProjectId) return apiMcpUrl; + + let url: URL; + try { + url = new URL(apiMcpUrl); + } catch { + // Let the remote MCP boundary produce its standard configuration error. + return apiMcpUrl; + } + const basePath = url.pathname + .replace(/\/projects\/[^/]+\/mcp\/?$/, "") + .replace(/\/mcp\/?$/, "") + .replace(/\/+$/, ""); + url.pathname = `${basePath}/projects/${encodeURIComponent(normalizedProjectId)}/mcp`; + return url.toString(); +} + function createGenericRemoteMcpConfig( server: AgentServiceGenericMcpServerConfig, ): RemoteMCPToolSourceConfig { @@ -63,13 +86,13 @@ function createGenericRemoteMcpConfig( function createVeryfrontApiRemoteMcpConfig( input: Pick< CreateAgentServiceRemoteMcpConfigInput, - "apiMcpUrl" | "authToken" | "defaultSourceId" + "apiMcpUrl" | "authToken" | "defaultSourceId" | "getProjectId" >, server: AgentServiceVeryfrontApiMcpServerConfig, ): RemoteMCPToolSourceConfig { return { id: server.id ?? input.defaultSourceId ?? "veryfront-mcp", - endpoint: input.apiMcpUrl, + endpoint: () => createProjectScopedMcpUrl(input.apiMcpUrl, input.getProjectId?.()), headers: (context) => { const authToken = typeof context?.authToken === "string" && context.authToken.length > 0 ? context.authToken diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index 443efbbca2..b335018b0f 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -87,7 +87,7 @@ describe("tool/remote-mcp", () => { assertEquals(transportCalls, 0); }); - it("keeps unmatched and dynamic endpoints on guarded transport", async () => { + it("keeps unmatched endpoints on guarded transport", async () => { let transportCalls = 0; const createSource = createRemoteMCPToolSourceFactoryWithTransport({ trustedEndpoints: ["http://veryfront-api/mcp"], @@ -100,7 +100,7 @@ describe("tool/remote-mcp", () => { for ( const endpoint of [ "http://169.254.169.254/latest/meta-data", - () => "http://veryfront-api/mcp", + () => "http://169.254.169.254/latest/meta-data", ] ) { const source = createSource({ id: "untrusted", endpoint }); @@ -113,6 +113,68 @@ describe("tool/remote-mcp", () => { assertEquals(transportCalls, 0); }); + it("uses host transport for dynamic project-scoped endpoints with query parameters", async () => { + let transportCalls = 0; + const requestUrls: string[] = []; + const createSource = createRemoteMCPToolSourceFactoryWithTransport({ + trustedEndpoints: ["http://veryfront-api/mcp"], + requestFetch: async (input, init) => { + transportCalls++; + requestUrls.push(String(input)); + const body = JSON.parse(String(init && "body" in init ? init.body : undefined)) as { + id: string; + }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: { tools: [] } }); + }, + }); + let projectId = "project-1"; + const source = createSource({ + endpoint: () => `http://veryfront-api/projects/${projectId}/mcp?environment=staging`, + }); + + assertEquals(await source.listTools(), []); + projectId = "project-2"; + assertEquals(await source.listTools(), []); + assertEquals(transportCalls, 2); + assertEquals(requestUrls, [ + "http://veryfront-api/projects/project-1/mcp?environment=staging", + "http://veryfront-api/projects/project-2/mcp?environment=staging", + ]); + }); + + it("uses the request URL's origin and path, not query values, for trusted transport", async () => { + let transportCalls = 0; + const createSource = createRemoteMCPToolSourceFactoryWithTransport({ + trustedEndpoints: ["http://veryfront-api/mcp"], + requestFetch: async (_input, init) => { + transportCalls++; + const body = JSON.parse(String(init && "body" in init ? init.body : undefined)) as { + id: string; + }; + return Response.json({ jsonrpc: "2.0", id: body.id, result: { tools: [] } }); + }, + }); + + await createSource({ + endpoint: "http://veryfront-api/mcp?redirect=http://169.254.169.254/latest/meta-data", + }).listTools(); + assertEquals(transportCalls, 1); + + for ( + const endpoint of [ + "http://veryfront-api.evil.com/mcp?target=http://veryfront-api/mcp", + "http://veryfront-api/mcp/../../../admin?target=/mcp", + ] + ) { + await assertRejects( + () => createSource({ endpoint }).listTools(), + Error, + "Outbound network egress blocked", + ); + } + assertEquals(transportCalls, 1); + }); + it("rejects invalid trusted endpoints when the factory is created", () => { const error = assertThrows( () => @@ -334,6 +396,39 @@ describe("tool/remote-mcp", () => { }); }); + it("omits non-binding run ids from project-scoped control-plane MCP metadata", async () => { + let requestBody: Record | undefined; + await withEnv({ VERYFRONT_API_BASE_URL: "https://93.184.216.34" }, async () => { + const source = createRemoteMCPToolSource({ + id: "veryfront-mcp", + endpoint: "https://93.184.216.34/projects/project-1/mcp?environment=staging", + }); + + await withMockFetch( + async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + requestBody = await request.json(); + return Response.json({ + jsonrpc: "2.0", + id: "veryfront-mcp:tools:call:gmail__get_profile", + result: { content: [], structuredContent: { ok: true } }, + }); + }, + async () => + await source.executeTool("gmail__get_profile", {}, { + runId: "run-local", + runIdBindsToolAuthorization: false, + agentId: "gmail-agent", + }), + ); + + assertEquals( + (requestBody as { params?: { _meta?: Record } }).params?._meta, + { agent_id: "gmail-agent" }, + ); + }); + }); + it("keeps run ids for same-origin MCP servers outside the control-plane path", async () => { let requestBody: Record | undefined; await withEnv({ VERYFRONT_API_BASE_URL: "https://93.184.216.34" }, async () => { diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index 40ce252de4..8cfab202a2 100644 --- a/src/tool/remote-mcp.ts +++ b/src/tool/remote-mcp.ts @@ -813,11 +813,24 @@ function normalizeCallToolResult(input: { function endpointBindsToolAuthorization(endpoint: string): boolean { const apiBaseUrl = getApiBaseUrlEnv(); if (typeof apiBaseUrl !== "string" || apiBaseUrl.length === 0) return false; - const normalizedEndpoint = normalizeTrustedEndpoint(endpoint); - const controlPlaneEndpoint = normalizeTrustedEndpoint( - `${apiBaseUrl.replace(/\/+$/, "")}/mcp`, - ); - return normalizedEndpoint !== undefined && normalizedEndpoint === controlPlaneEndpoint; + const endpointUrl = parseMcpRequestEndpoint(endpoint); + if (!endpointUrl) return false; + + try { + const apiBase = new URL(apiBaseUrl); + if (endpointUrl.origin !== apiBase.origin) return false; + + const basePath = apiBase.pathname.replace(/\/+$/, ""); + const controlPlanePath = `${basePath}/mcp`; + if (endpointUrl.pathname === controlPlanePath) return true; + + const projectPathPrefix = `${basePath}/projects/`; + if (!endpointUrl.pathname.startsWith(projectPathPrefix)) return false; + const projectScopedPath = endpointUrl.pathname.slice(projectPathPrefix.length); + return /^[^/]+\/mcp\/?$/.test(projectScopedPath); + } catch { + return false; + } } function buildRunContextMeta( @@ -843,7 +856,7 @@ function buildRunContextMeta( function createRemoteMCPToolSourceWithFetch( config: RemoteMCPToolSourceConfig, - requestFetch: typeof fetch, + getRequestFetch: (endpoint: string) => typeof fetch, ): RemoteToolSource { const id = config.id ?? "remote-mcp"; const listMethod = config.listMethod ?? "tools/list"; @@ -870,7 +883,7 @@ function createRemoteMCPToolSourceWithFetch( method: listMethod, ...(cursor !== undefined ? { params: { cursor } } : {}), }, - requestFetch, + getRequestFetch(endpoint), context?.abortSignal, MAX_REMOTE_MCP_TOOL_LIST_RESPONSE_BYTES, ); @@ -938,7 +951,7 @@ function createRemoteMCPToolSourceWithFetch( ...(meta ? { _meta: meta } : {}), }, }, - requestFetch, + getRequestFetch(endpoint), context?.abortSignal, MAX_REMOTE_MCP_CALL_RESPONSE_BYTES, ); @@ -970,10 +983,10 @@ function createRemoteMCPToolSourceWithFetch( export function createRemoteMCPToolSource( config: RemoteMCPToolSourceConfig, ): RemoteToolSource { - return createRemoteMCPToolSourceWithFetch(config, guardedOutboundFetch); + return createRemoteMCPToolSourceWithFetch(config, () => guardedOutboundFetch); } -/** Deployment-owned transport policy for exact, immutable MCP endpoints. */ +/** Deployment-owned transport policy for trusted MCP endpoint roots. */ export interface RemoteMCPToolSourceTransportOptions { /** Complete endpoint URLs allowed to use {@link requestFetch}. */ trustedEndpoints: readonly string[]; @@ -981,46 +994,97 @@ export interface RemoteMCPToolSourceTransportOptions { requestFetch: typeof fetch; } -function normalizeTrustedEndpoint(value: string): string | undefined { +function parseTrustedEndpoint(value: string): URL | undefined { try { const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; if (url.username || url.password || url.search || url.hash) return undefined; - return url.toString(); + return url; } catch { return undefined; } } +/** + * Parse a safe MCP request URL while preserving its query string. + * + * Trusted transport is selected from the parsed scheme, origin, and path only; + * query parameters remain part of the request URL and cannot change that target. + */ +function parseMcpRequestEndpoint(value: string): URL | undefined { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + if (url.username || url.password || url.hash) return undefined; + return url; + } catch { + return undefined; + } +} + +function normalizeMcpRequestEndpoint(value: string): string | undefined { + const url = parseMcpRequestEndpoint(value); + if (!url) return undefined; + url.search = ""; + return url.toString(); +} + /** * Create a remote MCP source factory with narrowly scoped host transport. * - * Only static endpoint strings that exactly match a normalized deployment - * allowlist use the supplied transport. Invalid, unmatched, or resolver-based - * endpoints retain {@link createRemoteMCPToolSource}'s guarded outbound path. + * Exact trusted endpoints and their project-scoped MCP routes use the supplied + * transport. All other resolved endpoints retain the guarded outbound path. */ export function createRemoteMCPToolSourceFactoryWithTransport( options: RemoteMCPToolSourceTransportOptions, ): (config: RemoteMCPToolSourceConfig) => RemoteToolSource { const trustedEndpoints = new Set(); + const trustedEndpointUrls: URL[] = []; for (const value of options.trustedEndpoints) { - const endpoint = normalizeTrustedEndpoint(value); - if (!endpoint) { + const endpointUrl = parseTrustedEndpoint(value); + if (!endpointUrl) { throw new TypeError("Invalid trusted endpoint"); } - trustedEndpoints.add(endpoint); + trustedEndpoints.add(endpointUrl.toString()); + trustedEndpointUrls.push(endpointUrl); } - return (config) => { - const endpoint = typeof config.endpoint === "string" - ? normalizeTrustedEndpoint(config.endpoint) - : undefined; - if (!endpoint || !trustedEndpoints.has(endpoint)) { - return createRemoteMCPToolSource(config); - } - return createRemoteMCPToolSourceWithFetch( - { ...config, endpoint }, - options.requestFetch, + return (config) => + createRemoteMCPToolSourceWithFetch( + config, + (endpoint) => + isTrustedDeploymentMcpEndpoint(endpoint, trustedEndpoints, trustedEndpointUrls) + ? options.requestFetch + : guardedOutboundFetch, ); - }; +} + +function isTrustedDeploymentMcpEndpoint( + endpoint: string, + trustedEndpoints: ReadonlySet, + trustedEndpointUrls: readonly URL[], +): boolean { + const normalizedEndpoint = normalizeMcpRequestEndpoint(endpoint); + if (!normalizedEndpoint) return false; + if (trustedEndpoints.has(normalizedEndpoint)) return true; + + let endpointUrl: URL; + try { + endpointUrl = new URL(normalizedEndpoint); + } catch { + return false; + } + + for (const trustedUrl of trustedEndpointUrls) { + if (endpointUrl.origin !== trustedUrl.origin) continue; + + const trustedPath = trustedUrl.pathname.replace(/\/+$/, ""); + if (!trustedPath.endsWith("/mcp")) continue; + const projectPathPrefix = `${trustedPath.slice(0, -4)}/projects/`; + if (!endpointUrl.pathname.startsWith(projectPathPrefix)) continue; + const projectScopedPath = endpointUrl.pathname.slice(projectPathPrefix.length); + if (/^[^/]+\/mcp\/?$/.test(projectScopedPath)) return true; + } + + return false; }