diff --git a/.changeset/toolresultfrom-same-host-connections.md b/.changeset/toolresultfrom-same-host-connections.md new file mode 100644 index 000000000..1774e6d50 --- /dev/null +++ b/.changeset/toolresultfrom-same-host-connections.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +`toolResultFrom` now narrows results from OpenAPI connections and gives connections on the same host distinct identities, so two `defineOpenAPIConnection` definitions sharing a `baseUrl` (e.g. Jira and Confluence on one Atlassian domain) each match through their imported definition object instead of colliding on a shared `connection:` identity. diff --git a/docs/guides/hooks.md b/docs/guides/hooks.md index 6a8132b91..c1e13db5d 100644 --- a/docs/guides/hooks.md +++ b/docs/guides/hooks.md @@ -42,7 +42,7 @@ interface HookContext { ### Narrowing tool results -`toolResultFrom` narrows an `action.result` event to a specific authored tool or MCP connection and returns typed output. Import it from `eve/tools`: +`toolResultFrom` narrows an `action.result` event to a specific authored tool or connection (MCP or OpenAPI) and returns typed output. Import it from `eve/tools`: ```ts import { defineHook } from "eve/hooks"; @@ -59,7 +59,7 @@ export default defineHook({ console.log(weather.output.temperature); } - // MCP connection: output is unknown, toolName is qualified + // Connection (MCP or OpenAPI): output is unknown, toolName is qualified const linearResult = toolResultFrom(event.data.result, linear); if (linearResult) { console.log(linearResult.connectionToolName, linearResult.output); diff --git a/packages/eve/src/public/definitions/connections/mcp.ts b/packages/eve/src/public/definitions/connections/mcp.ts index e4e586c8b..8cc819f36 100644 --- a/packages/eve/src/public/definitions/connections/mcp.ts +++ b/packages/eve/src/public/definitions/connections/mcp.ts @@ -6,7 +6,7 @@ import type { import { normalizeAuthorizationSpec } from "#runtime/connections/validate-authorization.js"; import { stampConnectionProtocol } from "#public/definitions/connections/protocol.js"; import type { Approval } from "#public/definitions/approval.js"; -import { stampDefinitionKey } from "#public/tool-result-narrowing.js"; +import { connectionDefinitionKey, stampDefinitionKey } from "#public/tool-result-narrowing.js"; /** * Public definition for an MCP client connection authored in @@ -96,7 +96,7 @@ export function defineMcpClientConnection( if (definition.auth !== undefined && typeof definition.auth !== "function") { definition.auth = normalizeAuthorizationSpec(definition.auth, "defineMcpClientConnection:"); } - stampDefinitionKey(definition, `connection:${definition.url}`); + stampDefinitionKey(definition, connectionDefinitionKey(definition.url, definition.description)); stampConnectionProtocol(definition, "mcp"); return definition; } diff --git a/packages/eve/src/public/definitions/connections/openapi.ts b/packages/eve/src/public/definitions/connections/openapi.ts index a090ac695..872b68ecb 100644 --- a/packages/eve/src/public/definitions/connections/openapi.ts +++ b/packages/eve/src/public/definitions/connections/openapi.ts @@ -6,7 +6,7 @@ import type { import { normalizeAuthorizationSpec } from "#runtime/connections/validate-authorization.js"; import { stampConnectionProtocol } from "#public/definitions/connections/protocol.js"; import type { Approval } from "#public/definitions/approval.js"; -import { stampDefinitionKey } from "#public/tool-result-narrowing.js"; +import { connectionDefinitionKey, stampDefinitionKey } from "#public/tool-result-narrowing.js"; /** * The OpenAPI document backing the connection: either an HTTPS URL the @@ -116,10 +116,10 @@ export function defineOpenAPIConnection( if (definition.auth !== undefined && typeof definition.auth !== "function") { definition.auth = normalizeAuthorizationSpec(definition.auth, "defineOpenAPIConnection:"); } - const definitionKey = - definition.baseUrl ?? - (typeof definition.spec === "string" ? definition.spec : definition.description); - stampDefinitionKey(definition, `connection:${definitionKey}`); + stampDefinitionKey( + definition, + connectionDefinitionKey(definition.baseUrl ?? "", definition.description), + ); stampConnectionProtocol(definition, "openapi"); return definition; } diff --git a/packages/eve/src/public/tool-result-narrowing.test.ts b/packages/eve/src/public/tool-result-narrowing.test.ts index 75bd55b55..cdfad5add 100644 --- a/packages/eve/src/public/tool-result-narrowing.test.ts +++ b/packages/eve/src/public/tool-result-narrowing.test.ts @@ -3,8 +3,10 @@ import { z } from "#compiled/zod/index.js"; import type { RuntimeActionResult } from "#runtime/actions/types.js"; import { defineMcpClientConnection } from "#public/definitions/connections/mcp.js"; +import { defineOpenAPIConnection } from "#public/definitions/connections/openapi.js"; import { defineTool } from "#public/definitions/tool.js"; import { + connectionDefinitionKey, toolResultFrom, registerDefinitionSource, stampDefinitionKey, @@ -33,6 +35,16 @@ function subagentResult(): RuntimeActionResult { return { callId: "call_2", kind: "subagent-result", output: "done", subagentName: "sub" }; } +const DEFINITION_KEY = Symbol.for("eve.definition-source-key"); + +function readStampedKey(definition: object): string { + const key = (definition as Record)[DEFINITION_KEY]; + if (key === undefined) { + throw new Error("definition was not stamped with an identity key"); + } + return key; +} + describe("toolResultFrom", () => { const weatherTool = defineTool({ description: "Get the current weather for a city.", @@ -49,7 +61,7 @@ describe("toolResultFrom", () => { kind: "tool", name: "get_weather", }); - registerDefinitionSource("connection:https://mcp.linear.app", { + registerDefinitionSource(readStampedKey(linearConnection), { kind: "connection", name: "linear", }); @@ -272,4 +284,99 @@ describe("toolResultFrom", () => { }); expect(toolResultFrom(toolResult("first__search", []), second)).toBeUndefined(); }); + + it("matches through same-host OpenAPI connections passed as module exports", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const jira = defineOpenAPIConnection({ + spec: { openapi: "3.0.0", info: { title: "Jira", version: "1" }, paths: {} }, + baseUrl: "https://example.atlassian.net", + description: "Jira issues API", + }); + const confluence = defineOpenAPIConnection({ + spec: { openapi: "3.0.0", info: { title: "Confluence", version: "1" }, paths: {} }, + baseUrl: "https://example.atlassian.net", + description: "Confluence pages API", + }); + + // Mirror resolveConnectionDefinition, which registers the fallback + // identity that equals the authoring-time key each `define*` factory + // stamps. The authored module exports the caller passes to + // `toolResultFrom` carry only that fallback identity, so two same-host + // connections must not collide on it. + registerDefinitionSource(readStampedKey(jira), { + kind: "connection", + logicalPath: "connections/jira.ts", + name: "jira", + }); + registerDefinitionSource(readStampedKey(confluence), { + kind: "connection", + logicalPath: "connections/confluence.ts", + name: "confluence", + }); + + expect(warn).not.toHaveBeenCalled(); + + expect(toolResultFrom(toolResult("jira__getIssue", { id: 1 }), jira)).toEqual({ + callId: "call_1", + connectionToolName: "getIssue", + output: { id: 1 }, + toolName: "jira__getIssue", + }); + expect(toolResultFrom(toolResult("confluence__getPage", { id: 2 }), confluence)).toEqual({ + callId: "call_1", + connectionToolName: "getPage", + output: { id: 2 }, + toolName: "confluence__getPage", + }); + // A Confluence result must not narrow through the Jira definition. + expect(toolResultFrom(toolResult("confluence__getPage", { id: 2 }), jira)).toBeUndefined(); + }); + + it("matches through baseUrl-less OpenAPI connections passed as module exports", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const petstore = defineOpenAPIConnection({ + spec: { openapi: "3.0.0", info: { title: "Petstore", version: "1" }, paths: {} }, + description: "Petstore public API", + }); + const weather = defineOpenAPIConnection({ + spec: { openapi: "3.0.0", info: { title: "Weather", version: "1" }, paths: {} }, + description: "Weather public API", + }); + + // The compiler stores `url = baseUrl ?? ""` and the resolver registers + // `connectionDefinitionKey(url, description)`, so a baseUrl-less + // connection's authoring-time key must equal `connection:` + // for the module export the caller passes to narrow. + expect(readStampedKey(petstore)).toBe(connectionDefinitionKey("", "Petstore public API")); + expect(readStampedKey(weather)).toBe(connectionDefinitionKey("", "Weather public API")); + registerDefinitionSource(connectionDefinitionKey("", "Petstore public API"), { + kind: "connection", + logicalPath: "connections/petstore.ts", + name: "petstore", + }); + registerDefinitionSource(connectionDefinitionKey("", "Weather public API"), { + kind: "connection", + logicalPath: "connections/weather.ts", + name: "weather", + }); + + expect(warn).not.toHaveBeenCalled(); + + expect(toolResultFrom(toolResult("petstore__listPets", [{ id: 1 }]), petstore)).toEqual({ + callId: "call_1", + connectionToolName: "listPets", + output: [{ id: 1 }], + toolName: "petstore__listPets", + }); + expect(toolResultFrom(toolResult("weather__getForecast", { tempF: 72 }), weather)).toEqual({ + callId: "call_1", + connectionToolName: "getForecast", + output: { tempF: 72 }, + toolName: "weather__getForecast", + }); + // A Weather result must not narrow through the Petstore definition. + expect( + toolResultFrom(toolResult("weather__getForecast", { tempF: 72 }), petstore), + ).toBeUndefined(); + }); }); diff --git a/packages/eve/src/public/tool-result-narrowing.ts b/packages/eve/src/public/tool-result-narrowing.ts index af72d5422..701184171 100644 --- a/packages/eve/src/public/tool-result-narrowing.ts +++ b/packages/eve/src/public/tool-result-narrowing.ts @@ -1,7 +1,17 @@ import type { RuntimeActionResult } from "#runtime/actions/types.js"; import type { McpClientConnectionDefinition } from "#public/definitions/connections/mcp.js"; +import type { OpenAPIConnectionDefinition } from "#public/definitions/connections/openapi.js"; import type { ToolDefinition } from "#public/definitions/tool.js"; +/** + * Connection definitions {@link toolResultFrom} can narrow against. Both + * wire protocols match by the qualified tool name (`__`), + * so narrowing is protocol-agnostic. + */ +export type NarrowableConnectionDefinition = + | McpClientConnectionDefinition + | OpenAPIConnectionDefinition; + /** * Narrowed tool result returned by {@link toolResultFrom} when the * action result matches an authored {@link ToolDefinition}. @@ -16,12 +26,12 @@ export interface MatchedToolResult { /** * Narrowed tool result returned by {@link toolResultFrom} when the - * action result matches an MCP connection. + * action result matches an MCP or OpenAPI connection. * - * `output` stays `unknown` because MCP tool schemas are remote. - * `connectionToolName` is the unqualified MCP tool name (e.g. - * `"list_issues"`) while `toolName` is the full qualified name - * (e.g. `"linear__list_issues"`). + * `output` stays `unknown` because connection tool/operation schemas are + * not known statically. `connectionToolName` is the unqualified tool or + * operation name (e.g. `"list_issues"`) while `toolName` is the full + * qualified name (e.g. `"linear__list_issues"`). */ export interface MatchedConnectionResult { readonly callId: string; @@ -80,6 +90,24 @@ export function stampDefinitionKey(definition: object, key: string): void { Object.defineProperty(definition, DEFINITION_KEY, { configurable: true, value: key }); } +/** + * Builds the authoring-time fallback identity for a connection. + * + * The `define*` factory stamps this on the authored object and the + * resolver registers the same string, so `toolResultFrom` can match a + * connection passed as a module export even across module instances, + * where the source-derived key never reached the caller's copy. + * + * The `description` is folded in so two connections on the same host + * (e.g. Jira and Confluence on one Atlassian domain) get distinct + * fallback identities instead of colliding on `connection:`. The + * NUL separator keeps `(url, description)` unambiguous. Two connections + * identical in both url and description remain genuinely ambiguous. + */ +export function connectionDefinitionKey(url: string, description: string): string { + return `connection:${url}\u0000${description}`; +} + /** * Reads the stable key from a definition, or `undefined` if unstamped. */ @@ -142,7 +170,7 @@ export interface ToolResultFromFn { ( result: RuntimeActionResult, - connection: McpClientConnectionDefinition, + connection: NarrowableConnectionDefinition, ): MatchedConnectionResult | undefined; } @@ -150,9 +178,9 @@ export interface ToolResultFromFn { * Narrows a {@link RuntimeActionResult} to a typed tool or connection * result by matching against an authored definition object. * - * Pass a `ToolDefinition` to get a typed `output`; pass a - * `McpClientConnectionDefinition` to match any tool from that - * connection (`output` stays `unknown`). + * Pass a `ToolDefinition` to get a typed `output`; pass an MCP or + * OpenAPI connection definition to match any tool from that connection + * (`output` stays `unknown`). * * Returns `undefined` when the result doesn't match, or when * `isError` is `true`. @@ -165,11 +193,11 @@ function toolResultFromImpl( ): MatchedToolResult | undefined; function toolResultFromImpl( result: RuntimeActionResult, - connection: McpClientConnectionDefinition, + connection: NarrowableConnectionDefinition, ): MatchedConnectionResult | undefined; function toolResultFromImpl( result: RuntimeActionResult, - source: ToolDefinition | McpClientConnectionDefinition, + source: ToolDefinition | NarrowableConnectionDefinition, ): MatchedToolResult | MatchedConnectionResult | undefined { if (result.kind !== "tool-result") return undefined; if (result.isError === true) return undefined; diff --git a/packages/eve/src/runtime/resolve-connection.ts b/packages/eve/src/runtime/resolve-connection.ts index bb43b2631..faff5f3dc 100644 --- a/packages/eve/src/runtime/resolve-connection.ts +++ b/packages/eve/src/runtime/resolve-connection.ts @@ -1,7 +1,11 @@ import type { CompiledConnectionDefinition } from "#compiler/manifest.js"; import type { CompiledModuleMap } from "#compiler/module-map.js"; import { expectObjectRecord } from "#internal/authored-module.js"; -import { registerDefinitionSource, stampDefinitionKey } from "#public/tool-result-narrowing.js"; +import { + connectionDefinitionKey, + registerDefinitionSource, + stampDefinitionKey, +} from "#public/tool-result-narrowing.js"; import { toErrorMessage } from "#shared/errors.js"; import type { ConnectionAuthResolver, @@ -49,11 +53,15 @@ export async function resolveConnectionDefinition( const sourceKey = `connection-source:${definition.sourceId}`; stampDefinitionKey(resolvedRecord, sourceKey); registerDefinitionSource(sourceKey, sourceEntry); - // Use the compiled `url` (the MCP endpoint or OpenAPI base URL) as - // the secondary key so it matches the authoring-time key stamped by - // the `define*` factory for both protocols. The live record only - // carries `url` for MCP connections. - registerDefinitionSource(`connection:${definition.url}`, sourceEntry); + // Register the same fallback identity the `define*` factory stamps + // (compiled `url` plus `description` for both protocols), so + // `toolResultFrom` matches a connection passed as a module export. + // Folding in `description` keeps same-host connections distinct + // instead of colliding on `connection:`. + registerDefinitionSource( + connectionDefinitionKey(definition.url, definition.description), + sourceEntry, + ); const hasAuth = resolvedRecord.auth !== undefined; const hasHeaders = resolvedRecord.headers !== undefined;