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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/toolresultfrom-same-host-connections.md
Original file line number Diff line number Diff line change
@@ -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:<host>` identity.
4 changes: 2 additions & 2 deletions docs/guides/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/public/definitions/connections/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
10 changes: 5 additions & 5 deletions packages/eve/src/public/definitions/connections/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
109 changes: 108 additions & 1 deletion packages/eve/src/public/tool-result-narrowing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<symbol, string | undefined>)[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.",
Expand All @@ -49,7 +61,7 @@ describe("toolResultFrom", () => {
kind: "tool",
name: "get_weather",
});
registerDefinitionSource("connection:https://mcp.linear.app", {
registerDefinitionSource(readStampedKey(linearConnection), {
kind: "connection",
name: "linear",
});
Expand Down Expand Up @@ -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:<desc>`
// 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();
});
});
50 changes: 39 additions & 11 deletions packages/eve/src/public/tool-result-narrowing.ts
Original file line number Diff line number Diff line change
@@ -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 (`<connection>__<op>`),
* 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}.
Expand All @@ -16,12 +26,12 @@ export interface MatchedToolResult<TOutput> {

/**
* 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;
Expand Down Expand Up @@ -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:<host>`. 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.
*/
Expand Down Expand Up @@ -142,17 +170,17 @@ export interface ToolResultFromFn {

(
result: RuntimeActionResult,
connection: McpClientConnectionDefinition,
connection: NarrowableConnectionDefinition,
): MatchedConnectionResult | undefined;
}

/**
* 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`.
Expand All @@ -165,11 +193,11 @@ function toolResultFromImpl<TInput, TOutput>(
): MatchedToolResult<TOutput> | undefined;
function toolResultFromImpl(
result: RuntimeActionResult,
connection: McpClientConnectionDefinition,
connection: NarrowableConnectionDefinition,
): MatchedConnectionResult | undefined;
function toolResultFromImpl(
result: RuntimeActionResult,
source: ToolDefinition<unknown, unknown> | McpClientConnectionDefinition,
source: ToolDefinition<unknown, unknown> | NarrowableConnectionDefinition,
): MatchedToolResult<unknown> | MatchedConnectionResult | undefined {
if (result.kind !== "tool-result") return undefined;
if (result.isError === true) return undefined;
Expand Down
20 changes: 14 additions & 6 deletions packages/eve/src/runtime/resolve-connection.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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:<host>`.
registerDefinitionSource(
connectionDefinitionKey(definition.url, definition.description),
sourceEntry,
);

const hasAuth = resolvedRecord.auth !== undefined;
const hasHeaders = resolvedRecord.headers !== undefined;
Expand Down