From 02d5f2be0ab586793d159bbb367690dcdadd5186 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 18:49:30 +0200 Subject: [PATCH 01/13] Do not send a locally minted run id as a run authorization binding veryfront dev mints run_ in-process. That id is not a control-plane run, so agentRunRepository.getByRunId finds nothing and every integration tool call is denied with "Run context is not authorized for this integration tool". The run id itself must stay: AG-UI RUN_STARTED/RUN_FINISHED, the resume session manager and the run.id trace attribute all depend on it. Only its export as an authorization binding is wrong. buildStreamContext now marks the id non-binding when the client did not supply one, and snapshotToolExecutionContext drops runId on strict === false. Absence of the marker preserves the previous behaviour exactly, so every other context producer is unchanged. Cloud runs are unaffected: runtime-auth-token.ts mints tokens with runId: run.runId on both project-backed branches, and the API computes runId = claimedRunId ?? suppliedRunId, so the seal still engages from the token claim. --- src/agent/ag-ui/handler.ts | 26 ++++++++++++++++++++++---- src/integrations/remote-tools.ts | 8 +++++++- src/tool/types.ts | 7 +++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/agent/ag-ui/handler.ts b/src/agent/ag-ui/handler.ts index bacba2260b..840cd77051 100644 --- a/src/agent/ag-ui/handler.ts +++ b/src/agent/ag-ui/handler.ts @@ -142,11 +142,15 @@ function buildStreamContext( baseContext: Record, threadId: string, runId: string, + runIdBindsToolAuthorization: boolean, ): Record { return { ...baseContext, threadId, runId, + // Only mark the run id as non-binding. Absence preserves the previous + // behaviour byte for byte, so no other context producer has to change. + ...(runIdBindsToolAuthorization ? {} : { runIdBindsToolAuthorization: false }), agUi: { context: request.context, forwardedProps: request.forwardedProps, @@ -323,8 +327,15 @@ async function createAgUiDirectStreamResponse( onComplete?: AgUiOnComplete, ): Promise { const threadId = request.threadId ?? crypto.randomUUID(); - const runId = request.runId ?? generateRunId(); - const context = buildStreamContext(request, baseContext, threadId, runId); + const clientRunId = request.runId; + const runId = clientRunId ?? generateRunId(); + const context = buildStreamContext( + request, + baseContext, + threadId, + runId, + clientRunId !== undefined, + ); let messages = normalizeAgUiMessages(request.messages, { providerOwnedToolNames: getProviderToolNames(agent), }); @@ -394,8 +405,15 @@ async function createAgUiInjectedToolsStreamResponse( onComplete?: AgUiOnComplete, ): Promise { const threadId = request.threadId ?? crypto.randomUUID(); - const runId = request.runId ?? generateRunId(); - const context = buildStreamContext(request, baseContext, threadId, runId); + const clientRunId = request.runId; + const runId = clientRunId ?? generateRunId(); + const context = buildStreamContext( + request, + baseContext, + threadId, + runId, + clientRunId !== undefined, + ); let messages = normalizeAgUiMessages(request.messages, { providerOwnedToolNames: getProviderToolNames(agent), }); diff --git a/src/integrations/remote-tools.ts b/src/integrations/remote-tools.ts index 99c0088a21..38faca0faf 100644 --- a/src/integrations/remote-tools.ts +++ b/src/integrations/remote-tools.ts @@ -159,6 +159,12 @@ function snapshotToolExecutionContext( const agentId = includeCallMetadata ? readOwnDataProperty("agentId") : { present: false, value: undefined }; + // A run id minted in-process is not a control-plane run, so it must not be + // presented as a run authorization binding. Strict `=== false` only: + // absent, truthy, or malformed keeps the previous behaviour. + const runIdBinds = includeCallMetadata + ? readOwnDataProperty("runIdBindsToolAuthorization") + : { present: false, value: undefined }; const abortSignal = readOwnDataProperty("abortSignal"); if ( abortSignal.value !== undefined && @@ -171,7 +177,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/tool/types.ts b/src/tool/types.ts index 656dfb135e..53fa412038 100644 --- a/src/tool/types.ts +++ b/src/tool/types.ts @@ -76,6 +76,13 @@ export interface ToolExecutionContext { agentId?: string; /** ID of the current agent run when the runtime is tracking run lifecycles */ runId?: string; + /** + * False when `runId` was minted in-process and is NOT a control-plane run id, + * so it must not be sent as a run authorization binding on integration tool + * calls. Absent means the id came from the platform and may be presented as + * one. Only the local AG-UI handler sets this to false. + */ + 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 */ From aeff67019cdf4f66678fdab3b96eb4094457d1ba Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 19:36:19 +0200 Subject: [PATCH 02/13] Keep AG-UI run ids non-binding in every case Two review findings. An AG-UI run id supplied by the client is an untrusted request field, not proof of a control-plane run. Treating it as binding meant any client that generates its own per-request id still got denied for the same missing-run reason this branch fixes. Hosted durable runs bind through the token claim in createAgUiRuntimeHandler, so createAgUiHandler now marks the id non-binding unconditionally. That also removes the clientRunId branching. beforeStream may return a fresh context object rather than spreading the one it was given. finalContext replaced the whole object and dropped the marker, so snapshotToolExecutionContext read the absent marker as binding and exported the generated id again. The marker is now reapplied to finalContext in both streaming paths. --- src/agent/ag-ui/handler.ts | 44 +++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/agent/ag-ui/handler.ts b/src/agent/ag-ui/handler.ts index 840cd77051..5afbca1613 100644 --- a/src/agent/ag-ui/handler.ts +++ b/src/agent/ag-ui/handler.ts @@ -142,15 +142,15 @@ function buildStreamContext( baseContext: Record, threadId: string, runId: string, - runIdBindsToolAuthorization: boolean, ): Record { return { ...baseContext, threadId, runId, - // Only mark the run id as non-binding. Absence preserves the previous - // behaviour byte for byte, so no other context producer has to change. - ...(runIdBindsToolAuthorization ? {} : { runIdBindsToolAuthorization: false }), + // An AG-UI run id is never a control-plane run, whether the client supplied + // it or we generated it. Hosted durable runs bind through the token claim + // (createAgUiRuntimeHandler), so this handler always marks it non-binding. + runIdBindsToolAuthorization: false, agUi: { context: request.context, forwardedProps: request.forwardedProps, @@ -327,15 +327,8 @@ async function createAgUiDirectStreamResponse( onComplete?: AgUiOnComplete, ): Promise { const threadId = request.threadId ?? crypto.randomUUID(); - const clientRunId = request.runId; - const runId = clientRunId ?? generateRunId(); - const context = buildStreamContext( - request, - baseContext, - threadId, - runId, - clientRunId !== undefined, - ); + const runId = request.runId ?? generateRunId(); + const context = buildStreamContext(request, baseContext, threadId, runId); let messages = normalizeAgUiMessages(request.messages, { providerOwnedToolNames: getProviderToolNames(agent), }); @@ -349,7 +342,12 @@ async function createAgUiDirectStreamResponse( if (isResponseLike(beforeStreamResult)) return beforeStreamResult; messages = applyBeforeStreamResult(messages, beforeStreamResult ?? undefined); - const finalContext = beforeStreamResult?.context ?? context; + // beforeStream may return a fresh context object rather than spreading ours, + // which would drop the marker and re-export the run id as a binding. + const finalContext = { + ...(beforeStreamResult?.context ?? context), + runIdBindsToolAuthorization: false, + }; await agent.clearMemory(); @@ -405,15 +403,8 @@ async function createAgUiInjectedToolsStreamResponse( onComplete?: AgUiOnComplete, ): Promise { const threadId = request.threadId ?? crypto.randomUUID(); - const clientRunId = request.runId; - const runId = clientRunId ?? generateRunId(); - const context = buildStreamContext( - request, - baseContext, - threadId, - runId, - clientRunId !== undefined, - ); + const runId = request.runId ?? generateRunId(); + const context = buildStreamContext(request, baseContext, threadId, runId); let messages = normalizeAgUiMessages(request.messages, { providerOwnedToolNames: getProviderToolNames(agent), }); @@ -427,7 +418,12 @@ async function createAgUiInjectedToolsStreamResponse( if (isResponseLike(beforeStreamResult)) return beforeStreamResult; messages = applyBeforeStreamResult(messages, beforeStreamResult ?? undefined); - const finalContext = beforeStreamResult?.context ?? context; + // beforeStream may return a fresh context object rather than spreading ours, + // which would drop the marker and re-export the run id as a binding. + const finalContext = { + ...(beforeStreamResult?.context ?? context), + runIdBindsToolAuthorization: false, + }; try { sessionManager.startRun({ runId, threadId }); From dd7c1056b62a6f3054b8474c61e871a2f027116a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 19:39:25 +0200 Subject: [PATCH 03/13] Trim comments to what is not obvious from the code --- src/agent/ag-ui/handler.ts | 11 ++++------- src/integrations/remote-tools.ts | 5 ++--- src/tool/types.ts | 8 ++------ 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/agent/ag-ui/handler.ts b/src/agent/ag-ui/handler.ts index 5afbca1613..60f059bb87 100644 --- a/src/agent/ag-ui/handler.ts +++ b/src/agent/ag-ui/handler.ts @@ -147,9 +147,8 @@ function buildStreamContext( ...baseContext, threadId, runId, - // An AG-UI run id is never a control-plane run, whether the client supplied - // it or we generated it. Hosted durable runs bind through the token claim - // (createAgUiRuntimeHandler), so this handler always marks it non-binding. + // Never a control-plane run id, whoever generated it. Hosted durable runs + // bind through the token claim instead. runIdBindsToolAuthorization: false, agUi: { context: request.context, @@ -342,8 +341,7 @@ async function createAgUiDirectStreamResponse( if (isResponseLike(beforeStreamResult)) return beforeStreamResult; messages = applyBeforeStreamResult(messages, beforeStreamResult ?? undefined); - // beforeStream may return a fresh context object rather than spreading ours, - // which would drop the marker and re-export the run id as a binding. + // beforeStream may return a fresh context, dropping the marker. const finalContext = { ...(beforeStreamResult?.context ?? context), runIdBindsToolAuthorization: false, @@ -418,8 +416,7 @@ async function createAgUiInjectedToolsStreamResponse( if (isResponseLike(beforeStreamResult)) return beforeStreamResult; messages = applyBeforeStreamResult(messages, beforeStreamResult ?? undefined); - // beforeStream may return a fresh context object rather than spreading ours, - // which would drop the marker and re-export the run id as a binding. + // beforeStream may return a fresh context, dropping the marker. const finalContext = { ...(beforeStreamResult?.context ?? context), runIdBindsToolAuthorization: false, diff --git a/src/integrations/remote-tools.ts b/src/integrations/remote-tools.ts index 38faca0faf..5d0eb92707 100644 --- a/src/integrations/remote-tools.ts +++ b/src/integrations/remote-tools.ts @@ -159,9 +159,8 @@ function snapshotToolExecutionContext( const agentId = includeCallMetadata ? readOwnDataProperty("agentId") : { present: false, value: undefined }; - // A run id minted in-process is not a control-plane run, so it must not be - // presented as a run authorization binding. Strict `=== false` only: - // absent, truthy, or malformed keeps the previous behaviour. + // 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 }; diff --git a/src/tool/types.ts b/src/tool/types.ts index 53fa412038..a235763839 100644 --- a/src/tool/types.ts +++ b/src/tool/types.ts @@ -76,12 +76,8 @@ export interface ToolExecutionContext { agentId?: string; /** ID of the current agent run when the runtime is tracking run lifecycles */ runId?: string; - /** - * False when `runId` was minted in-process and is NOT a control-plane run id, - * so it must not be sent as a run authorization binding on integration tool - * calls. Absent means the id came from the platform and may be presented as - * one. Only the local AG-UI handler sets this to false. - */ + /** 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; From 0238685d7505ff574030144ef0b4f941bf93f81e Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 19:47:46 +0200 Subject: [PATCH 04/13] Regenerate API reference after the run binding change deno task docs. Line number shifts only, no content changes. --- docs/api-reference/veryfront/agent.md | 6 +++--- docs/api-reference/veryfront/integrations.md | 8 ++++---- docs/api-reference/veryfront/tool.md | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 59863929ff..eb1caf7af0 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#L522) | | `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#L509) | +| `AgUiHandlerOptions` | Options accepted by AG-UI handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L494) | | `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..1108e55d4d 100644 --- a/docs/api-reference/veryfront/tool.md +++ b/docs/api-reference/veryfront/tool.md @@ -172,16 +172,16 @@ Create a typed tool definition. | `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) | | `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 From 40119f7df88195afa7a454aefb3477df7c639709 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 19:50:27 +0200 Subject: [PATCH 05/13] test(agent): cover non-binding AG-UI run IDs --- src/agent/ag-ui/handler.test.ts | 54 +++++++++++++++++++++++++-- src/integrations/remote-tools.test.ts | 32 ++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/agent/ag-ui/handler.test.ts b/src/agent/ag-ui/handler.test.ts index 7a6d54de8f..896e52462b 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,32 @@ 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 non-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, false); + assertStringIncludes(await response.text(), '"runId":"run_client_1"'); }); it("omits provider-owned remote tool history before direct streaming", async () => { @@ -370,6 +397,8 @@ describe("agent/ag-ui-handler", () => { _messages, context, ): Promise> { + assertEquals(context?.runId, "run_data_1"); + assertEquals(context?.runIdBindsToolAuthorization, false); const publishDataEvent = context?.publishDataEvent; if (typeof publishDataEvent === "function") { await publishDataEvent({ @@ -452,7 +481,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 +524,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 +768,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 +847,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 +859,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 +873,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 +885,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/integrations/remote-tools.test.ts b/src/integrations/remote-tools.test.ts index 27a655709c..620299f3f3 100644 --- a/src/integrations/remote-tools.test.ts +++ b/src/integrations/remote-tools.test.ts @@ -730,6 +730,38 @@ describe("integrations/remote-tools", () => { }); }); + 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", From 2670b11b1026239a087623686dae90e753b586aa Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 20:03:19 +0200 Subject: [PATCH 06/13] Apply the run binding rule on the MCP and delegate paths, and cover it Review found the same defect in two more places. remote-mcp.ts buildRunContextMeta exported context.runId as _meta.run_id, which the API reads back into the same callIntegrationTool gate. An integration reached over remote MCP was still denied. It now honours the marker like the REST path. mcp-server-tool-sources.ts copies authToken, runId and agentId from the credential owner into a nested context. The marker was not in that list, so it was dropped when the owner supplied a run id, re-exporting a local id as binding. Added it, and the marker is now cleared when the owner has a real run id and no marker, so a stale nested marker cannot suppress a legitimate binding. Three regression tests: suppression on the marker, run_id still sent when the marker is absent, and suppression only on strict false. --- docs/api-reference/veryfront/tool.md | 6 +++--- src/agent/runtime/mcp-server-tool-sources.ts | 16 ++++++++++++++-- src/tool/remote-mcp.ts | 7 ++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/api-reference/veryfront/tool.md b/docs/api-reference/veryfront/tool.md index 1108e55d4d..0b48a89313 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#L951) | +| `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#L983) | | `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#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) | +| `RemoteMCPToolSourceTransportOptions` | Deployment-owned transport policy for exact, immutable MCP endpoints. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/tool/remote-mcp.ts#L958) | | `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/runtime/mcp-server-tool-sources.ts b/src/agent/runtime/mcp-server-tool-sources.ts index 3e80de331c..914fb3f7d1 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, @@ -145,7 +150,14 @@ function withBoundRemoteToolContext( const mergedContext = { ...(context ?? {}) }; for (const key of keys) { if (boundContext[key] !== undefined) { - mergedContext[key] = boundContext[key]; + (mergedContext as Record)[key] = boundContext[key]; + } else if ( + key === "runIdBindsToolAuthorization" && boundContext.runId !== undefined + ) { + // The marker travels with the run id it describes. An owner supplying a + // real run id and no marker must clear a stale one from the nested + // context, or a legitimate binding is suppressed. + delete mergedContext.runIdBindsToolAuthorization; } } return mergedContext; diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index 5b9dea30e0..5697dcbb91 100644 --- a/src/tool/remote-mcp.ts +++ b/src/tool/remote-mcp.ts @@ -808,7 +808,12 @@ function buildRunContextMeta( context: ToolExecutionContext | undefined, ): Record | undefined { const meta: Record = {}; - if (typeof context?.runId === "string" && context.runId.length > 0) { + // Same authorization gate as the REST integration route, so a run id the + // control plane never issued must not be sent here either. + if ( + context?.runIdBindsToolAuthorization !== false && + typeof context?.runId === "string" && context.runId.length > 0 + ) { meta.run_id = context.runId; } if (typeof context?.agentId === "string" && context.agentId.length > 0) { From 9fc48514e91dc53ebb52d13d1c30105e4bdcb962 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 20:04:50 +0200 Subject: [PATCH 07/13] Pin that run_id suppression requires strict false The marker is fail-open, so a truthy, absent or malformed value must still export the run id. Only an explicit false suppresses it. --- src/integrations/remote-tools.test.ts | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/integrations/remote-tools.test.ts b/src/integrations/remote-tools.test.ts index 620299f3f3..96efcfc267 100644 --- a/src/integrations/remote-tools.test.ts +++ b/src/integrations/remote-tools.test.ts @@ -730,6 +730,35 @@ 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", From 7b8e83358bb8b20acb6f49775c760744beb362e4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 20:15:25 +0200 Subject: [PATCH 08/13] fix(agent): keep run binding markers attached --- .../runtime/mcp-server-tool-sources.test.ts | 27 ++++++++++++++ src/agent/runtime/mcp-server-tool-sources.ts | 16 ++++---- src/tool/remote-mcp.test.ts | 37 +++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/agent/runtime/mcp-server-tool-sources.test.ts b/src/agent/runtime/mcp-server-tool-sources.test.ts index 8b24fbc52f..294a443e99 100644 --- a/src/agent/runtime/mcp-server-tool-sources.test.ts +++ b/src/agent/runtime/mcp-server-tool-sources.test.ts @@ -584,6 +584,33 @@ Deno.test("nested remote source bindings keep the original credential identity", }]); }); +Deno.test("nested remote source bindings keep a run marker with its run id", async () => { + let executeContext: ToolExecutionContext | undefined; + const source: RemoteToolSource = { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool(_toolName, _args, context) { + executeContext = context; + return Promise.resolve({ ok: true }); + }, + }; + const bound = bindRuntimeRemoteToolSourcesToCredentialOwner([source], { + authToken: "owner-token", + runIdBindsToolAuthorization: false, + }); + + await bound?.[0]?.executeTool("get_file", {}, { + runId: "nested-run", + runIdBindsToolAuthorization: true, + }); + + assertEquals(executeContext, { + authToken: "owner-token", + runId: "nested-run", + runIdBindsToolAuthorization: true, + }); +}); + Deno.test("getRuntimeRemoteToolSources skips the implicit source without server identity", () => { const sources = getRuntimeRemoteToolSources( { diff --git a/src/agent/runtime/mcp-server-tool-sources.ts b/src/agent/runtime/mcp-server-tool-sources.ts index 914fb3f7d1..681d6bb1b2 100644 --- a/src/agent/runtime/mcp-server-tool-sources.ts +++ b/src/agent/runtime/mcp-server-tool-sources.ts @@ -149,15 +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 as Record)[key] = boundContext[key]; - } else if ( - key === "runIdBindsToolAuthorization" && boundContext.runId !== undefined - ) { - // The marker travels with the run id it describes. An owner supplying a - // real run id and no marker must clear a stale one from the nested - // context, or a legitimate binding is suppressed. - delete mergedContext.runIdBindsToolAuthorization; } } return mergedContext; diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index ff3910c0aa..de34e0506e 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -294,6 +294,43 @@ describe("tool/remote-mcp", () => { }); }); + it("omits non-binding run ids from MCP call metadata", async () => { + let requestBody: Record | undefined; + const source = createRemoteMCPToolSource({ + id: "veryfront-mcp", + endpoint: "https://93.184.216.34", + }); + + 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("prefers structuredContent for MCP isError tool results", async () => { const source = createRemoteMCPToolSource({ id: "docs", From 11d6582c712d68a1d2d7e8888541bc6b932e72c6 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 20:27:07 +0200 Subject: [PATCH 09/13] Suppress MCP run_id only on the Veryfront transport Review: the marker means the run id must not be used as a Veryfront authorization binding, not that it is secret. Suppressing _meta.run_id for every remote MCP server stripped correlation metadata that third-party servers had always received. Suppression is now scoped to endpoints whose origin matches the configured Veryfront API base URL, which is the only handler that reads _meta.run_id back into the integration authorization gate. The existing non-binding test asserted suppression against an endpoint that was not the control plane, so it now sets VERYFRONT_API_BASE_URL to match. Added the converse: a third-party origin still receives run_id. The third-party endpoint is a public address rather than a TEST-NET one because the egress guard classifies TEST-NET as internal and blocks it. --- docs/api-reference/veryfront/tool.md | 8 +++--- src/tool/remote-mcp.test.ts | 43 ++++++++++++++++++++++++++++ src/tool/remote-mcp.ts | 27 ++++++++++++++--- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/docs/api-reference/veryfront/tool.md b/docs/api-reference/veryfront/tool.md index 0b48a89313..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#L951) | -| `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#L983) | +| `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,8 +169,8 @@ 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#L958) | +| `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#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/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index de34e0506e..c5b9137af2 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -296,6 +296,8 @@ describe("tool/remote-mcp", () => { it("omits non-binding run ids from MCP call metadata", async () => { let requestBody: Record | undefined; + const previousApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); + Deno.env.set("VERYFRONT_API_BASE_URL", "https://93.184.216.34"); const source = createRemoteMCPToolSource({ id: "veryfront-mcp", endpoint: "https://93.184.216.34", @@ -329,6 +331,47 @@ describe("tool/remote-mcp", () => { _meta: { agent_id: "gmail-agent" }, }, }); + + if (previousApiBaseUrl === undefined) Deno.env.delete("VERYFRONT_API_BASE_URL"); + else Deno.env.set("VERYFRONT_API_BASE_URL", previousApiBaseUrl); + }); + + it("keeps run ids for MCP servers that are not the Veryfront control plane", async () => { + let requestBody: Record | undefined; + const previousApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); + Deno.env.set("VERYFRONT_API_BASE_URL", "https://93.184.216.34"); + const source = createRemoteMCPToolSource({ + id: "third-party-mcp", + endpoint: "https://93.184.216.35", + }); + + 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" }, + ); + + if (previousApiBaseUrl === undefined) Deno.env.delete("VERYFRONT_API_BASE_URL"); + else Deno.env.set("VERYFRONT_API_BASE_URL", previousApiBaseUrl); }); it("prefers structuredContent for MCP isError tool results", async () => { diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index 5697dcbb91..7cadce5f9e 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,14 +805,32 @@ 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; + try { + return new URL(endpoint).origin === new URL(apiBaseUrl).origin; + } catch { + return false; + } +} + function buildRunContextMeta( context: ToolExecutionContext | undefined, + endpoint: string, ): Record | undefined { const meta: Record = {}; - // Same authorization gate as the REST integration route, so a run id the - // control plane never issued must not be sent here either. + // 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 ( - context?.runIdBindsToolAuthorization !== false && + !suppressRunId && typeof context?.runId === "string" && context.runId.length > 0 ) { meta.run_id = context.runId; @@ -902,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 { From 0e23d686c55776821dd9d2b623acdbc57adcf76a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 20:36:03 +0200 Subject: [PATCH 10/13] Address review: runtime-neutral env in tests, owner-marker coverage remote-mcp.test.ts used Deno.env, which Node and Bun runners can skip, and restoration did not run on failure. Replaced with setEnv/deleteEnv from compat/process and an afterEach restore. mcp-server-tool-sources.test.ts did not exercise the marker copy or the delete branch, because the owner context omitted runId. Added both: an owner run id carrying a non-binding marker replaces the nested context, and an owner run id without a marker clears a stale nested one. --- .../runtime/mcp-server-tool-sources.test.ts | 55 +++++++++++++++++++ src/tool/remote-mcp.test.ts | 32 ++++++++--- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/agent/runtime/mcp-server-tool-sources.test.ts b/src/agent/runtime/mcp-server-tool-sources.test.ts index 294a443e99..6c562b8e4b 100644 --- a/src/agent/runtime/mcp-server-tool-sources.test.ts +++ b/src/agent/runtime/mcp-server-tool-sources.test.ts @@ -611,6 +611,61 @@ Deno.test("nested remote source bindings keep a run marker with its run id", asy }); }); +Deno.test("owner run id and its non-binding marker both replace the nested run context", async () => { + let executeContext: ToolExecutionContext | undefined; + const source: RemoteToolSource = { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool(_toolName, _args, context) { + executeContext = context; + return Promise.resolve({ ok: true }); + }, + }; + const bound = bindRuntimeRemoteToolSourcesToCredentialOwner([source], { + 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, + }); +}); + +Deno.test("an owner run id without a marker clears a stale nested marker", async () => { + let executeContext: ToolExecutionContext | undefined; + const source: RemoteToolSource = { + id: VERYFRONT_STUDIO_MCP_SOURCE_ID, + listTools: () => Promise.resolve([]), + executeTool(_toolName, _args, context) { + executeContext = context; + return Promise.resolve({ ok: true }); + }, + }; + const bound = bindRuntimeRemoteToolSourcesToCredentialOwner([source], { + authToken: "owner-token", + runId: "owner-run", + }); + + // Without the delete, the nested false would suppress a legitimate binding. + await bound?.[0]?.executeTool("get_file", {}, { + runId: "nested-run", + runIdBindsToolAuthorization: false, + }); + + assertEquals(executeContext, { + authToken: "owner-token", + runId: "owner-run", + }); +}); + Deno.test("getRuntimeRemoteToolSources skips the implicit source without server identity", () => { const sources = getRuntimeRemoteToolSources( { diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index c5b9137af2..d4dfeef32f 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -5,7 +5,9 @@ import { assertRejects, assertThrows, } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; +import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { deleteEnv, getEnv, setEnv } from "#veryfront/compat/process.ts"; +import { refreshEnvironmentConfig } from "#veryfront/config/environment-config.ts"; import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { createRemoteMCPToolSource, @@ -16,6 +18,24 @@ import { MAX_REMOTE_MCP_TOOL_LIST_RESPONSE_BYTES, } from "./remote-mcp.ts"; + +/** + * Runtime-neutral env override for the control-plane origin. Restored by the + * afterEach below, so it is undone even when a test fails. + */ +const originalApiBaseUrl = getEnv("VERYFRONT_API_BASE_URL"); + +function setApiBaseUrl(value: string): void { + setEnv("VERYFRONT_API_BASE_URL", value); + refreshEnvironmentConfig(); +} + +afterEach(() => { + if (originalApiBaseUrl === undefined) deleteEnv("VERYFRONT_API_BASE_URL"); + else setEnv("VERYFRONT_API_BASE_URL", originalApiBaseUrl); + refreshEnvironmentConfig(); +}); + describe("tool/remote-mcp", () => { it("uses host transport only for an exact trusted endpoint", async () => { let transportCalls = 0; @@ -295,9 +315,8 @@ describe("tool/remote-mcp", () => { }); it("omits non-binding run ids from MCP call metadata", async () => { + setApiBaseUrl("https://93.184.216.34"); let requestBody: Record | undefined; - const previousApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); - Deno.env.set("VERYFRONT_API_BASE_URL", "https://93.184.216.34"); const source = createRemoteMCPToolSource({ id: "veryfront-mcp", endpoint: "https://93.184.216.34", @@ -332,14 +351,11 @@ describe("tool/remote-mcp", () => { }, }); - if (previousApiBaseUrl === undefined) Deno.env.delete("VERYFRONT_API_BASE_URL"); - else Deno.env.set("VERYFRONT_API_BASE_URL", previousApiBaseUrl); }); it("keeps run ids for MCP servers that are not the Veryfront control plane", async () => { + setApiBaseUrl("https://93.184.216.34"); let requestBody: Record | undefined; - const previousApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); - Deno.env.set("VERYFRONT_API_BASE_URL", "https://93.184.216.34"); const source = createRemoteMCPToolSource({ id: "third-party-mcp", endpoint: "https://93.184.216.35", @@ -370,8 +386,6 @@ describe("tool/remote-mcp", () => { { run_id: "run-local", agent_id: "gmail-agent" }, ); - if (previousApiBaseUrl === undefined) Deno.env.delete("VERYFRONT_API_BASE_URL"); - else Deno.env.set("VERYFRONT_API_BASE_URL", previousApiBaseUrl); }); it("prefers structuredContent for MCP isError tool results", async () => { From 200a7ab5a10c288d92244bac6d0df84615e5ceef Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 10 Aug 2026 20:40:51 +0200 Subject: [PATCH 11/13] Format test file --- src/tool/remote-mcp.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index d4dfeef32f..754bdeca26 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -18,7 +18,6 @@ import { MAX_REMOTE_MCP_TOOL_LIST_RESPONSE_BYTES, } from "./remote-mcp.ts"; - /** * Runtime-neutral env override for the control-plane origin. Restored by the * afterEach below, so it is undone even when a test fails. @@ -350,7 +349,6 @@ describe("tool/remote-mcp", () => { _meta: { agent_id: "gmail-agent" }, }, }); - }); it("keeps run ids for MCP servers that are not the Veryfront control plane", async () => { @@ -385,7 +383,6 @@ describe("tool/remote-mcp", () => { (requestBody as { params?: { _meta?: Record } }).params?._meta, { run_id: "run-local", agent_id: "gmail-agent" }, ); - }); it("prefers structuredContent for MCP isError tool results", async () => { From c27d3c5e78fad1779bfeaa882d3fac1b48cde443 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 20:40:08 +0200 Subject: [PATCH 12/13] fix(agent): address run binding review --- src/agent/ag-ui/handler.test.ts | 6 +- src/agent/ag-ui/handler.ts | 14 +- ...-server-tool-sources.cross-runtime.test.ts | 64 ++++++++ .../runtime/mcp-server-tool-sources.test.ts | 82 ---------- src/tool/remote-mcp.test.ts | 142 ++++++++---------- 5 files changed, 137 insertions(+), 171 deletions(-) create mode 100644 src/agent/runtime/mcp-server-tool-sources.cross-runtime.test.ts diff --git a/src/agent/ag-ui/handler.test.ts b/src/agent/ag-ui/handler.test.ts index 896e52462b..650e3676a1 100644 --- a/src/agent/ag-ui/handler.test.ts +++ b/src/agent/ag-ui/handler.test.ts @@ -182,7 +182,7 @@ describe("agent/ag-ui-handler", () => { assertStringIncludes(body, `"runId":"${testAgent.capturedContext?.runId}"`); }); - it("keeps a client-supplied direct AG-UI run ID non-binding", async () => { + it("keeps a client-supplied direct AG-UI run ID eligible for binding", async () => { const testAgent = createTestAgent(); const handler = createAgUiHandler({ agent: testAgent.agent }); @@ -203,7 +203,7 @@ describe("agent/ag-ui-handler", () => { assertEquals(response.status, 200); assertEquals(testAgent.capturedContext?.runId, "run_client_1"); - assertEquals(testAgent.capturedContext?.runIdBindsToolAuthorization, false); + assertEquals(testAgent.capturedContext?.runIdBindsToolAuthorization, undefined); assertStringIncludes(await response.text(), '"runId":"run_client_1"'); }); @@ -398,7 +398,7 @@ describe("agent/ag-ui-handler", () => { context, ): Promise> { assertEquals(context?.runId, "run_data_1"); - assertEquals(context?.runIdBindsToolAuthorization, false); + assertEquals(context?.runIdBindsToolAuthorization, undefined); const publishDataEvent = context?.publishDataEvent; if (typeof publishDataEvent === "function") { await publishDataEvent({ diff --git a/src/agent/ag-ui/handler.ts b/src/agent/ag-ui/handler.ts index 60f059bb87..c2fce297a4 100644 --- a/src/agent/ag-ui/handler.ts +++ b/src/agent/ag-ui/handler.ts @@ -147,9 +147,9 @@ function buildStreamContext( ...baseContext, threadId, runId, - // Never a control-plane run id, whoever generated it. Hosted durable runs - // bind through the token claim instead. - runIdBindsToolAuthorization: false, + // Only a locally minted ID is not a control-plane authorization binding. + // Client-supplied IDs remain eligible for the existing binding flow. + runIdBindsToolAuthorization: request.runId === undefined ? false : undefined, agUi: { context: request.context, forwardedProps: request.forwardedProps, @@ -341,10 +341,10 @@ async function createAgUiDirectStreamResponse( if (isResponseLike(beforeStreamResult)) return beforeStreamResult; messages = applyBeforeStreamResult(messages, beforeStreamResult ?? undefined); - // beforeStream may return a fresh context, dropping the marker. + // beforeStream may return a fresh context, dropping the generated-run marker. const finalContext = { ...(beforeStreamResult?.context ?? context), - runIdBindsToolAuthorization: false, + runIdBindsToolAuthorization: context.runIdBindsToolAuthorization, }; await agent.clearMemory(); @@ -416,10 +416,10 @@ async function createAgUiInjectedToolsStreamResponse( if (isResponseLike(beforeStreamResult)) return beforeStreamResult; messages = applyBeforeStreamResult(messages, beforeStreamResult ?? undefined); - // beforeStream may return a fresh context, dropping the marker. + // beforeStream may return a fresh context, dropping the generated-run marker. const finalContext = { ...(beforeStreamResult?.context ?? context), - runIdBindsToolAuthorization: false, + runIdBindsToolAuthorization: context.runIdBindsToolAuthorization, }; try { 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.test.ts b/src/agent/runtime/mcp-server-tool-sources.test.ts index 6c562b8e4b..8b24fbc52f 100644 --- a/src/agent/runtime/mcp-server-tool-sources.test.ts +++ b/src/agent/runtime/mcp-server-tool-sources.test.ts @@ -584,88 +584,6 @@ Deno.test("nested remote source bindings keep the original credential identity", }]); }); -Deno.test("nested remote source bindings keep a run marker with its run id", async () => { - let executeContext: ToolExecutionContext | undefined; - const source: RemoteToolSource = { - id: VERYFRONT_STUDIO_MCP_SOURCE_ID, - listTools: () => Promise.resolve([]), - executeTool(_toolName, _args, context) { - executeContext = context; - return Promise.resolve({ ok: true }); - }, - }; - const bound = bindRuntimeRemoteToolSourcesToCredentialOwner([source], { - authToken: "owner-token", - runIdBindsToolAuthorization: false, - }); - - await bound?.[0]?.executeTool("get_file", {}, { - runId: "nested-run", - runIdBindsToolAuthorization: true, - }); - - assertEquals(executeContext, { - authToken: "owner-token", - runId: "nested-run", - runIdBindsToolAuthorization: true, - }); -}); - -Deno.test("owner run id and its non-binding marker both replace the nested run context", async () => { - let executeContext: ToolExecutionContext | undefined; - const source: RemoteToolSource = { - id: VERYFRONT_STUDIO_MCP_SOURCE_ID, - listTools: () => Promise.resolve([]), - executeTool(_toolName, _args, context) { - executeContext = context; - return Promise.resolve({ ok: true }); - }, - }; - const bound = bindRuntimeRemoteToolSourcesToCredentialOwner([source], { - 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, - }); -}); - -Deno.test("an owner run id without a marker clears a stale nested marker", async () => { - let executeContext: ToolExecutionContext | undefined; - const source: RemoteToolSource = { - id: VERYFRONT_STUDIO_MCP_SOURCE_ID, - listTools: () => Promise.resolve([]), - executeTool(_toolName, _args, context) { - executeContext = context; - return Promise.resolve({ ok: true }); - }, - }; - const bound = bindRuntimeRemoteToolSourcesToCredentialOwner([source], { - authToken: "owner-token", - runId: "owner-run", - }); - - // Without the delete, the nested false would suppress a legitimate binding. - await bound?.[0]?.executeTool("get_file", {}, { - runId: "nested-run", - runIdBindsToolAuthorization: false, - }); - - assertEquals(executeContext, { - authToken: "owner-token", - runId: "owner-run", - }); -}); - Deno.test("getRuntimeRemoteToolSources skips the implicit source without server identity", () => { const sources = getRuntimeRemoteToolSources( { diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index 754bdeca26..f121e72987 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -5,9 +5,8 @@ import { assertRejects, assertThrows, } from "#veryfront/testing/assert.ts"; -import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; -import { deleteEnv, getEnv, setEnv } from "#veryfront/compat/process.ts"; -import { refreshEnvironmentConfig } from "#veryfront/config/environment-config.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, @@ -18,23 +17,6 @@ import { MAX_REMOTE_MCP_TOOL_LIST_RESPONSE_BYTES, } from "./remote-mcp.ts"; -/** - * Runtime-neutral env override for the control-plane origin. Restored by the - * afterEach below, so it is undone even when a test fails. - */ -const originalApiBaseUrl = getEnv("VERYFRONT_API_BASE_URL"); - -function setApiBaseUrl(value: string): void { - setEnv("VERYFRONT_API_BASE_URL", value); - refreshEnvironmentConfig(); -} - -afterEach(() => { - if (originalApiBaseUrl === undefined) deleteEnv("VERYFRONT_API_BASE_URL"); - else setEnv("VERYFRONT_API_BASE_URL", originalApiBaseUrl); - refreshEnvironmentConfig(); -}); - describe("tool/remote-mcp", () => { it("uses host transport only for an exact trusted endpoint", async () => { let transportCalls = 0; @@ -314,75 +296,77 @@ describe("tool/remote-mcp", () => { }); it("omits non-binding run ids from MCP call metadata", async () => { - setApiBaseUrl("https://93.184.216.34"); let requestBody: Record | undefined; - const source = createRemoteMCPToolSource({ - id: "veryfront-mcp", - endpoint: "https://93.184.216.34", - }); + await withEnv({ VERYFRONT_API_BASE_URL: "https://93.184.216.34" }, async () => { + const source = createRemoteMCPToolSource({ + id: "veryfront-mcp", + endpoint: "https://93.184.216.34", + }); - 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", - }), - ); + 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" }, - }, + 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 MCP servers that are not the Veryfront control plane", async () => { - setApiBaseUrl("https://93.184.216.34"); let requestBody: Record | undefined; - const source = createRemoteMCPToolSource({ - id: "third-party-mcp", - endpoint: "https://93.184.216.35", - }); + await withEnv({ VERYFRONT_API_BASE_URL: "https://93.184.216.34" }, async () => { + const source = createRemoteMCPToolSource({ + id: "third-party-mcp", + endpoint: "https://93.184.216.35", + }); - 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", - }), - ); + 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" }, - ); + // 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 () => { From 3774dd15b18feafdf39b971526f9b99945f91ee1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 10 Aug 2026 20:52:15 +0200 Subject: [PATCH 13/13] fix(agent): preserve local eval run semantics --- docs/api-reference/veryfront/agent.md | 6 ++--- src/agent/ag-ui/handler.test.ts | 27 +++++++++++++++++++ src/agent/ag-ui/handler.ts | 11 +++++--- .../project-run-execute.handler.test.ts | 6 ++++- .../request/project-run-execute.handler.ts | 5 +++- src/tool/remote-mcp.test.ts | 6 ++--- src/tool/remote-mcp.ts | 10 +++---- 7 files changed, 55 insertions(+), 16 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index eb1caf7af0..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#L522) | +| `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#L509) | -| `AgUiHandlerOptions` | Options accepted by AG-UI handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/handler.ts#L494) | +| `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/src/agent/ag-ui/handler.test.ts b/src/agent/ag-ui/handler.test.ts index 650e3676a1..d84777a60f 100644 --- a/src/agent/ag-ui/handler.test.ts +++ b/src/agent/ag-ui/handler.test.ts @@ -207,6 +207,33 @@ describe("agent/ag-ui-handler", () => { 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 () => { const testAgent = createTestAgent(); testAgent.agent.config.providerTools = ["web_search"]; diff --git a/src/agent/ag-ui/handler.ts b/src/agent/ag-ui/handler.ts index c2fce297a4..57bc26a098 100644 --- a/src/agent/ag-ui/handler.ts +++ b/src/agent/ag-ui/handler.ts @@ -143,13 +143,18 @@ function buildStreamContext( threadId: string, runId: string, ): Record { + const configuredRunIdBinding = baseContext.runIdBindsToolAuthorization; return { ...baseContext, threadId, runId, - // Only a locally minted ID is not a control-plane authorization binding. - // Client-supplied IDs remain eligible for the existing binding flow. - runIdBindsToolAuthorization: request.runId === undefined ? false : undefined, + // 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, 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 f121e72987..443efbbca2 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -300,7 +300,7 @@ describe("tool/remote-mcp", () => { await withEnv({ VERYFRONT_API_BASE_URL: "https://93.184.216.34" }, async () => { const source = createRemoteMCPToolSource({ id: "veryfront-mcp", - endpoint: "https://93.184.216.34", + endpoint: "https://93.184.216.34/mcp", }); await withMockFetch( @@ -334,12 +334,12 @@ describe("tool/remote-mcp", () => { }); }); - it("keeps run ids for MCP servers that are not the Veryfront control plane", async () => { + 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.35", + endpoint: "https://93.184.216.34/custom-mcp", }); await withMockFetch( diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index 7cadce5f9e..40ce252de4 100644 --- a/src/tool/remote-mcp.ts +++ b/src/tool/remote-mcp.ts @@ -813,11 +813,11 @@ function normalizeCallToolResult(input: { function endpointBindsToolAuthorization(endpoint: string): boolean { const apiBaseUrl = getApiBaseUrlEnv(); if (typeof apiBaseUrl !== "string" || apiBaseUrl.length === 0) return false; - try { - return new URL(endpoint).origin === new URL(apiBaseUrl).origin; - } catch { - return false; - } + const normalizedEndpoint = normalizeTrustedEndpoint(endpoint); + const controlPlaneEndpoint = normalizeTrustedEndpoint( + `${apiBaseUrl.replace(/\/+$/, "")}/mcp`, + ); + return normalizedEndpoint !== undefined && normalizedEndpoint === controlPlaneEndpoint; } function buildRunContextMeta(