diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 59863929ff..406ff4fd5a 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -642,7 +642,7 @@ Input delivered to a hosted agent-service detached execution callback. | `createAgUiChatUiTrackedBrowserResponse` | Response payload for create AG-UI chat UI tracked browser. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chat-ui-chunk-browser-encoder.ts#L279) | | `createAgUiChunkEncoderBridge` | Create AG-UI chunk encoder bridge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/chunk-encoder-bridge.ts#L31) | | `createAgUiDetachedStartHandler` | Handler for create AG-UI detached start. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/detached-start.ts#L407) | -| `createAgUiHandler` | Handler for create AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L511) | +| `createAgUiHandler` | Handler for create AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L527) | | `createAgUiResumeHandler` | Handler for create AG-UI resume. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/run-control.ts#L78) | | `createAgUiRunErrorEvent` | Event emitted for create AG-UI run error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L429) | | `createAgUiRuntimeBrowserResponse` | Response payload for create AG-UI runtime browser. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/runtime-browser-response.ts#L29) | @@ -1196,8 +1196,8 @@ Input delivered to a hosted agent-service detached execution callback. | `AgUiDetachedStartHandlerOptions` | Options accepted by AG-UI detached start handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/detached-start.ts#L226) | | `AgUiDetachedStartRequest` | Request payload for AG-UI detached start. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/detached-start.ts#L106) | | `AgUiForwardedConfigOptions` | Options accepted by AG-UI forwarded config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/forwarded-context.ts#L6) | -| `AgUiHandlerConfigWithAgent` | Public API contract for AG-UI handler config with agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L498) | -| `AgUiHandlerOptions` | Options accepted by AG-UI handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L483) | +| `AgUiHandlerConfigWithAgent` | Public API contract for AG-UI handler config with agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L514) | +| `AgUiHandlerOptions` | Options accepted by AG-UI handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L499) | | `AgUiInjectedTool` | Public API contract for AG-UI injected tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L124) | | `AgUiOnComplete` | Called once after a successful AG-UI run with the finalized conversation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L84) | | `AgUiRequest` | Request payload for AG-UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/host-support.ts#L128) | diff --git a/docs/api-reference/veryfront/integrations.md b/docs/api-reference/veryfront/integrations.md index 0195ba3cbb..bf0f942136 100644 --- a/docs/api-reference/veryfront/integrations.md +++ b/docs/api-reference/veryfront/integrations.md @@ -54,13 +54,13 @@ const runtimeTools = await getRemoteIntegrationToolDefinitions(); | Name | Description | Source | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `executeRemoteIntegrationTool` | Execute a remote integration tool via the API. Called by the agent runtime when a tool isn't found in the local registry. The request, response, and caller-supplied cancellation signal remain bounded for the complete network and response-body lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/remote-tools.ts#L791) | +| `executeRemoteIntegrationTool` | Execute a remote integration tool via the API. Called by the agent runtime when a tool isn't found in the local registry. The request, response, and caller-supplied cancellation signal remain bounded for the complete network and response-body lifecycle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/remote-tools.ts#L796) | | `getConnector` | Return connector. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/index.ts#L51) | | `getConnectorNames` | Return connector names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/index.ts#L62) | | `getIcon` | Return icon. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/index.ts#L67) | -| `getRemoteIntegrationToolDefinitions` | Fetch integration tool definitions for the current request context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/remote-tools.ts#L770) | -| `getRemoteIntegrationToolDiscovery` | Discover integration tools for the current request context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/remote-tools.ts#L721) | -| `isRemoteIntegrationTool` | Check if a tool name looks like a remote integration tool. Integration tools use "integration__tool_id" format (double underscore separator). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/remote-tools.ts#L781) | +| `getRemoteIntegrationToolDefinitions` | Fetch integration tool definitions for the current request context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/remote-tools.ts#L775) | +| `getRemoteIntegrationToolDiscovery` | Discover integration tools for the current request context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/remote-tools.ts#L726) | +| `isRemoteIntegrationTool` | Check if a tool name looks like a remote integration tool. Integration tools use "integration__tool_id" format (double underscore separator). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/remote-tools.ts#L786) | | `listConnectors` | List connectors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/integrations/index.ts#L57) | ### Types diff --git a/docs/api-reference/veryfront/tool.md b/docs/api-reference/veryfront/tool.md index db09caa580..91c9b81985 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#L946) | -| `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#L978) | +| `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) | | `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) | @@ -169,19 +169,19 @@ Create a typed tool definition. | `ProjectScopedRemoteToolExecution` | Public API contract for project scoped remote tool execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/project-scoped-remote-tools.ts#L45) | | `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#L51) | -| `RemoteMCPToolSourceTransportOptions` | Deployment-owned transport policy for exact, immutable MCP endpoints. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L953) | +| `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) | | `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#L228) | +| `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) | | `SleepToolOutput` | Output from sleep tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/sleep.ts#L48) | | `SleepToolWait` | Public API contract for sleep tool wait. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/sleep.ts#L11) | -| `Tool` | Tool instance (returned by tool() function) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L150) | +| `Tool` | Tool instance (returned by tool() function) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L153) | | `ToolConfig` | Tool configuration options | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L13) | -| `ToolDefinition` | Provider-facing tool definition used for model/tool registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L215) | +| `ToolDefinition` | Provider-facing tool definition used for model/tool registration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L218) | | `ToolExecutionContext` | Context passed to tool execution | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L74) | -| `ToolExecutionDataEvent` | Event emitted for tool execution data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L130) | -| `ToolSet` | Runtime tool map keyed by the tool name exposed to an agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L210) | +| `ToolExecutionDataEvent` | Event emitted for tool execution data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L133) | +| `ToolSet` | Runtime tool map keyed by the tool name exposed to an agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/types.ts#L213) | | `TraceHostToolsOptions` | Options accepted by trace host tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/tracing.ts#L21) | ### Constants diff --git a/src/agent/ag-ui/handler.test.ts b/src/agent/ag-ui/handler.test.ts index 7a6d54de8f..d84777a60f 100644 --- a/src/agent/ag-ui/handler.test.ts +++ b/src/agent/ag-ui/handler.test.ts @@ -157,6 +157,7 @@ describe("agent/ag-ui-handler", () => { assertEquals(testAgent.capturedContext?.tenant, "acme"); assertEquals(testAgent.capturedContext?.threadId !== undefined, true); assertEquals(testAgent.capturedContext?.runId !== undefined, true); + assertEquals(testAgent.capturedContext?.runIdBindsToolAuthorization, false); assertEquals( testAgent.capturedContext?.agUi, { @@ -178,6 +179,59 @@ describe("agent/ag-ui-handler", () => { assertStringIncludes(body, '"provider":"anthropic"'); assertStringIncludes(body, '"model":"anthropic/claude-sonnet-4-6"'); assertStringIncludes(body, '"delta":"hello from runtime"'); + assertStringIncludes(body, `"runId":"${testAgent.capturedContext?.runId}"`); + }); + + it("keeps a client-supplied direct AG-UI run ID eligible for binding", async () => { + const testAgent = createTestAgent(); + const handler = createAgUiHandler({ agent: testAgent.agent }); + + const response = await handler( + new Request("http://localhost/api/ag-ui", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + runId: "run_client_1", + messages: [{ + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }], + }), + }), + ); + + assertEquals(response.status, 200); + assertEquals(testAgent.capturedContext?.runId, "run_client_1"); + assertEquals(testAgent.capturedContext?.runIdBindsToolAuthorization, undefined); + assertStringIncludes(await response.text(), '"runId":"run_client_1"'); + }); + + it("keeps client run IDs non-binding in a trusted local eval context", async () => { + const testAgent = createTestAgent(); + const handler = createAgUiHandler({ + agent: testAgent.agent, + context: { runIdBindsToolAuthorization: false }, + }); + + const response = await handler( + new Request("http://localhost/api/ag-ui", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + runId: "eval-run-local", + messages: [{ + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }], + }), + }), + ); + + assertEquals(response.status, 200); + assertEquals(testAgent.capturedContext?.runId, "eval-run-local"); + assertEquals(testAgent.capturedContext?.runIdBindsToolAuthorization, false); }); it("omits provider-owned remote tool history before direct streaming", async () => { @@ -370,6 +424,8 @@ describe("agent/ag-ui-handler", () => { _messages, context, ): Promise> { + assertEquals(context?.runId, "run_data_1"); + assertEquals(context?.runIdBindsToolAuthorization, undefined); const publishDataEvent = context?.publishDataEvent; if (typeof publishDataEvent === "function") { await publishDataEvent({ @@ -452,7 +508,11 @@ describe("agent/ag-ui-handler", () => { text: `Retrieved context for: ${lastUserText}`, }], }], - context: { ...context, retrieval: "complete" }, + context: { + threadId: context.threadId, + runId: context.runId, + retrieval: "complete", + }, }; }, }); @@ -491,6 +551,7 @@ describe("agent/ag-ui-handler", () => { ); assertEquals(testAgent.capturedMessages[1]?.id, "msg-1"); assertEquals(testAgent.capturedContext?.retrieval, "complete"); + assertEquals(testAgent.capturedContext?.runIdBindsToolAuthorization, false); }); it("lets beforeStream short-circuit AG-UI requests", async () => { @@ -734,6 +795,7 @@ describe("agent/ag-ui-handler", () => { isError: boolean; }>(); const originalStream = AgentRuntime.prototype.stream; + let streamedRunId: string | undefined; AgentRuntime.prototype.stream = async function ( messages, @@ -812,7 +874,11 @@ describe("agent/ag-ui-handler", () => { }); assertEquals(messages[0]?.role, "user"); - assertEquals(context?.runId, "run_1"); + if (typeof context?.runId !== "string") throw new Error("Expected a generated run ID"); + streamedRunId = context.runId; + assertMatch(streamedRunId, /^run_[a-z0-9]+$/); + assertEquals(context.replacement, true); + assertEquals(context.runIdBindsToolAuthorization, false); return stream; }; @@ -820,6 +886,13 @@ describe("agent/ag-ui-handler", () => { const handler = createAgUiHandler({ agent: createTestAgent().agent, sessionManager, + beforeStream: ({ context }) => ({ + context: { + threadId: context.threadId, + runId: context.runId, + replacement: true, + }, + }), }); const response = await handler( @@ -827,7 +900,6 @@ describe("agent/ag-ui-handler", () => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - runId: "run_1", threadId: crypto.randomUUID(), messages: [{ id: "msg-1", @@ -840,9 +912,10 @@ describe("agent/ag-ui-handler", () => { ); assertEquals(response.status, 200); + if (streamedRunId === undefined) throw new Error("Expected the runtime to capture a run ID"); const bodyPromise = response.text(); - const submitOutcome = sessionManager.submitSignal("run_1", { + const submitOutcome = sessionManager.submitSignal(streamedRunId, { waitKey: "tool-call-1", value: { result: { approved: true }, isError: false }, }); diff --git a/src/agent/ag-ui/handler.ts b/src/agent/ag-ui/handler.ts index bacba2260b..57bc26a098 100644 --- a/src/agent/ag-ui/handler.ts +++ b/src/agent/ag-ui/handler.ts @@ -143,10 +143,18 @@ function buildStreamContext( threadId: string, runId: string, ): Record { + const configuredRunIdBinding = baseContext.runIdBindsToolAuthorization; return { ...baseContext, threadId, runId, + // A trusted server context can mark locally generated client IDs, such as + // eval IDs, as non-binding. Other client-supplied IDs keep existing behavior. + runIdBindsToolAuthorization: typeof configuredRunIdBinding === "boolean" + ? configuredRunIdBinding + : request.runId === undefined + ? false + : undefined, agUi: { context: request.context, forwardedProps: request.forwardedProps, @@ -338,7 +346,11 @@ async function createAgUiDirectStreamResponse( if (isResponseLike(beforeStreamResult)) return beforeStreamResult; messages = applyBeforeStreamResult(messages, beforeStreamResult ?? undefined); - const finalContext = beforeStreamResult?.context ?? context; + // beforeStream may return a fresh context, dropping the generated-run marker. + const finalContext = { + ...(beforeStreamResult?.context ?? context), + runIdBindsToolAuthorization: context.runIdBindsToolAuthorization, + }; await agent.clearMemory(); @@ -409,7 +421,11 @@ async function createAgUiInjectedToolsStreamResponse( if (isResponseLike(beforeStreamResult)) return beforeStreamResult; messages = applyBeforeStreamResult(messages, beforeStreamResult ?? undefined); - const finalContext = beforeStreamResult?.context ?? context; + // beforeStream may return a fresh context, dropping the generated-run marker. + const finalContext = { + ...(beforeStreamResult?.context ?? context), + runIdBindsToolAuthorization: context.runIdBindsToolAuthorization, + }; try { sessionManager.startRun({ runId, threadId }); diff --git a/src/agent/runtime/mcp-server-tool-sources.cross-runtime.test.ts b/src/agent/runtime/mcp-server-tool-sources.cross-runtime.test.ts new file mode 100644 index 0000000000..c385583f0d --- /dev/null +++ b/src/agent/runtime/mcp-server-tool-sources.cross-runtime.test.ts @@ -0,0 +1,64 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { RemoteToolSource, ToolExecutionContext } from "#veryfront/tool"; +import { + bindRuntimeRemoteToolSourcesToCredentialOwner, + VERYFRONT_STUDIO_MCP_SOURCE_ID, +} from "./mcp-server-tool-sources.ts"; + +function createCapturingSource( + capture: (context: ToolExecutionContext | undefined) => void, +): RemoteToolSource { + return { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool(_toolName, _args, context) { + capture(context); + return Promise.resolve({ ok: true }); + }, + }; +} + +describe("bindRuntimeRemoteToolSourcesToCredentialOwner", () => { + it("replaces a nested run and copies its non-binding marker", async () => { + let executeContext: ToolExecutionContext | undefined; + const bound = bindRuntimeRemoteToolSourcesToCredentialOwner([ + createCapturingSource((context) => executeContext = context), + ], { + authToken: "owner-token", + runId: "owner-run", + runIdBindsToolAuthorization: false, + }); + + await bound?.[0]?.executeTool("get_file", {}, { + runId: "nested-run", + runIdBindsToolAuthorization: true, + }); + + assertEquals(executeContext, { + authToken: "owner-token", + runId: "owner-run", + runIdBindsToolAuthorization: false, + }); + }); + + it("replaces a nested run and clears a marker absent from its owner", async () => { + let executeContext: ToolExecutionContext | undefined; + const bound = bindRuntimeRemoteToolSourcesToCredentialOwner([ + createCapturingSource((context) => executeContext = context), + ], { + authToken: "owner-token", + runId: "owner-run", + }); + + await bound?.[0]?.executeTool("get_file", {}, { + runId: "nested-run", + runIdBindsToolAuthorization: false, + }); + + assertEquals(executeContext, { + authToken: "owner-token", + runId: "owner-run", + }); + }); +}); diff --git a/src/agent/runtime/mcp-server-tool-sources.ts b/src/agent/runtime/mcp-server-tool-sources.ts index 3e80de331c..681d6bb1b2 100644 --- a/src/agent/runtime/mcp-server-tool-sources.ts +++ b/src/agent/runtime/mcp-server-tool-sources.ts @@ -135,7 +135,12 @@ export function constrainRuntimeRemoteToolSources( return sourcesToConstrain.map((source) => createMcpToolPolicySource(source, policy)); } -const REMOTE_TOOL_CREDENTIAL_CONTEXT_KEYS = ["authToken", "runId", "agentId"] as const; +const REMOTE_TOOL_CREDENTIAL_CONTEXT_KEYS = [ + "authToken", + "runId", + "runIdBindsToolAuthorization", + "agentId", +] as const; function withBoundRemoteToolContext( context: ToolExecutionContext | undefined, @@ -144,8 +149,17 @@ function withBoundRemoteToolContext( ): ToolExecutionContext { const mergedContext = { ...(context ?? {}) }; for (const key of keys) { + if (key === "runIdBindsToolAuthorization") { + if (boundContext.runId === undefined) continue; + if (boundContext.runIdBindsToolAuthorization !== undefined) { + mergedContext.runIdBindsToolAuthorization = boundContext.runIdBindsToolAuthorization; + } else { + delete mergedContext.runIdBindsToolAuthorization; + } + continue; + } if (boundContext[key] !== undefined) { - mergedContext[key] = boundContext[key]; + (mergedContext as Record)[key] = boundContext[key]; } } return mergedContext; diff --git a/src/integrations/remote-tools.test.ts b/src/integrations/remote-tools.test.ts index 27a655709c..96efcfc267 100644 --- a/src/integrations/remote-tools.test.ts +++ b/src/integrations/remote-tools.test.ts @@ -730,6 +730,67 @@ describe("integrations/remote-tools", () => { }); }); + it("suppresses run_id only on strict false, not on other falsy markers", async () => { + setRemoteToolEnv({ + VERYFRONT_API_BASE_URL: "https://api.test", + VERYFRONT_API_TOKEN: "environment-token", + VERYFRONT_PROJECT_SLUG: "environment-project", + }); + + for (const marker of [true, undefined, 0, "false"]) { + let requestBody: Record | undefined; + 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({ structuredContent: { ok: true } }); + }, + async () => + await executeRemoteIntegrationTool("gmail__list_emails", {}, { + runId: "run-platform-123", + runIdBindsToolAuthorization: marker as boolean | undefined, + }), + ); + + assertEquals( + (requestBody as { run_id?: string } | undefined)?.run_id, + "run-platform-123", + ); + } + }); + + it("omits a non-binding run ID while retaining other call metadata", async () => { + setRemoteToolEnv({ + VERYFRONT_API_BASE_URL: "https://api.test", + VERYFRONT_API_TOKEN: "environment-token", + }); + + let requestBody: Record | undefined; + 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({ structuredContent: { ok: true } }); + }, + async () => + await executeRemoteIntegrationTool( + "gmail__list_emails", + { maxResults: 10 }, + { + runId: "run-local-123", + runIdBindsToolAuthorization: false, + agentId: "agent-123", + }, + ), + ); + + assertEquals(requestBody, { + name: "gmail__list_emails", + arguments: { maxResults: 10 }, + agent_id: "agent-123", + }); + }); + it("prefers structuredContent for MCP error results without text content", async () => { setRemoteToolEnv({ VERYFRONT_API_BASE_URL: "https://api.test", diff --git a/src/integrations/remote-tools.ts b/src/integrations/remote-tools.ts index 99c0088a21..5d0eb92707 100644 --- a/src/integrations/remote-tools.ts +++ b/src/integrations/remote-tools.ts @@ -159,6 +159,11 @@ function snapshotToolExecutionContext( const agentId = includeCallMetadata ? readOwnDataProperty("agentId") : { present: false, value: undefined }; + // Strict `=== false` only: an absent marker means the id is a real + // control-plane run and stays bindable. + const runIdBinds = includeCallMetadata + ? readOwnDataProperty("runIdBindsToolAuthorization") + : { present: false, value: undefined }; const abortSignal = readOwnDataProperty("abortSignal"); if ( abortSignal.value !== undefined && @@ -171,7 +176,7 @@ function snapshotToolExecutionContext( hasExplicitCredential: authToken.present, authToken: authToken.value, projectSlug: projectSlug.value, - runId: runId.value, + runId: runIdBinds.value === false ? undefined : runId.value, agentId: agentId.value, abortSignal: abortSignal.value as AbortSignal | undefined, }); diff --git a/src/server/handlers/request/project-run-execute.handler.test.ts b/src/server/handlers/request/project-run-execute.handler.test.ts index 87d1557344..d723a1cb8d 100644 --- a/src/server/handlers/request/project-run-execute.handler.test.ts +++ b/src/server/handlers/request/project-run-execute.handler.test.ts @@ -33,6 +33,7 @@ function createStreamingAgent( id: string, text: string, usage?: { promptTokens: number; completionTokens: number; totalTokens: number }, + onContext?: (context: Record | undefined) => void, ): Agent { let capturedMessages: Message[] = []; @@ -47,6 +48,7 @@ function createStreamingAgent( throw new Error("not used"); }, stream: async (input) => { + onContext?.(input.context); capturedMessages = input.messages ?? []; const stream = new ReadableStream({ start(controller) { @@ -953,13 +955,14 @@ describe("server/handlers/request/project-run-execute.handler", () => { }); it("runs localized eval AG-UI requests through discovered source agents", async () => { + let capturedContext: Record | undefined; agentRegistry.register( "researcher", createStreamingAgent("researcher", "Paris", { promptTokens: 12, completionTokens: 8, totalTokens: 20, - }), + }, (context) => capturedContext = context), ); const handler = new ProjectRunExecuteHandler(createDeps({ findEvalById: async (target) => @@ -1021,6 +1024,7 @@ describe("server/handlers/request/project-run-execute.handler", () => { totalTokens: 20, }); assertStringIncludes(JSON.stringify(payload.result.records[0]?.output), "Paris"); + assertEquals(capturedContext?.runIdBindsToolAuthorization, false); } finally { agentRegistry.delete("researcher"); } diff --git a/src/server/handlers/request/project-run-execute.handler.ts b/src/server/handlers/request/project-run-execute.handler.ts index 2e05c2ccbf..16696d0c23 100644 --- a/src/server/handlers/request/project-run-execute.handler.ts +++ b/src/server/handlers/request/project-run-execute.handler.ts @@ -613,7 +613,10 @@ function createLocalEvalAgentFetch(input: { const agent = agentRegistry.get(input.agentId); if (!agent) return undefined; - const handler = createAgUiHandler({ agent }); + const handler = createAgUiHandler({ + agent, + context: { runIdBindsToolAuthorization: false }, + }); return async (requestInput, init) => { const request = new Request(requestInput, init); if (!isLocalAgUiEndpoint(request.url)) return fetch(request); diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index ff3910c0aa..443efbbca2 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -6,6 +6,7 @@ import { assertThrows, } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { withEnv } from "#veryfront/testing/deno-compat.ts"; import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { createRemoteMCPToolSource, @@ -294,6 +295,80 @@ describe("tool/remote-mcp", () => { }); }); + it("omits non-binding run ids from MCP call 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/mcp", + }); + + 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, { + jsonrpc: "2.0", + id: "veryfront-mcp:tools:call:gmail__get_profile", + method: "tools/call", + params: { + name: "gmail__get_profile", + arguments: {}, + _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 () => { + const source = createRemoteMCPToolSource({ + id: "third-party-mcp", + endpoint: "https://93.184.216.34/custom-mcp", + }); + + 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: "third-party-mcp:tools:call:search", + result: { content: [], structuredContent: { ok: true } }, + }); + }, + async () => + await source.executeTool("search", {}, { + runId: "run-local", + runIdBindsToolAuthorization: false, + agentId: "gmail-agent", + }), + ); + + // The marker means "not a Veryfront authorization binding", not "secret". + // Third-party servers still get the id for correlation. + assertEquals( + (requestBody as { params?: { _meta?: Record } }).params?._meta, + { run_id: "run-local", agent_id: "gmail-agent" }, + ); + }); + }); + it("prefers structuredContent for MCP isError tool results", async () => { const source = createRemoteMCPToolSource({ id: "docs", diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index 5b9dea30e0..40ce252de4 100644 --- a/src/tool/remote-mcp.ts +++ b/src/tool/remote-mcp.ts @@ -1,4 +1,5 @@ import { NETWORK_ERROR, TIMEOUT_ERROR } from "#veryfront/errors"; +import { getApiBaseUrlEnv } from "#veryfront/config/env.ts"; import type { ToolAnnotations } from "#veryfront/mcp/types.ts"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; import type { JsonSchema } from "./schema/json-schema.ts"; @@ -804,11 +805,34 @@ function normalizeCallToolResult(input: { return result; } +/** + * True when `endpoint` targets the Veryfront control plane, whose MCP handler + * reads `_meta.run_id` back into the integration authorization gate. Every + * other server treats it as opaque correlation metadata. + */ +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; +} + function buildRunContextMeta( context: ToolExecutionContext | undefined, + endpoint: string, ): Record | undefined { const meta: Record = {}; - if (typeof context?.runId === "string" && context.runId.length > 0) { + // Suppress only where the run id would be read as an authorization binding. + // Third-party servers keep receiving it as correlation metadata. + const suppressRunId = context?.runIdBindsToolAuthorization === false && + endpointBindsToolAuthorization(endpoint); + if ( + !suppressRunId && + typeof context?.runId === "string" && context.runId.length > 0 + ) { meta.run_id = context.runId; } if (typeof context?.agentId === "string" && context.agentId.length > 0) { @@ -897,7 +921,7 @@ function createRemoteMCPToolSourceWithFetch( async executeTool(toolName, args, context) { const endpoint = validateEndpoint(await resolveValue(config.endpoint, context)); const headers = await resolveHeaders(config.headers, context); - const meta = buildRunContextMeta(context); + const meta = buildRunContextMeta(context, endpoint); const requestId = `${id}:tools:call:${toolName}`; try { diff --git a/src/tool/types.ts b/src/tool/types.ts index 656dfb135e..a235763839 100644 --- a/src/tool/types.ts +++ b/src/tool/types.ts @@ -76,6 +76,9 @@ export interface ToolExecutionContext { agentId?: string; /** ID of the current agent run when the runtime is tracking run lifecycles */ runId?: string; + /** False when `runId` is not a control-plane run and must not be sent as a + * run authorization binding. Absent means it may be. */ + runIdBindsToolAuthorization?: boolean; /** Stable ID for the current tool call when the runtime is tracking tool lifecycles */ toolCallId?: string; /** Project identity used by integration token resolution */