Skip to content

Commit 0cda95d

Browse files
fix(mcp): open a session without the optional ai package (tester-army#759)
* fix(mcp): open a session without the optional ai package * docs(changeset): word the mcp entry for what changed * docs(mcp): JSDoc on the Standard Schema helpers * docs(mcp): the server needs no model package --------- Co-authored-by: oskar <[email protected]>
1 parent b3c9832 commit 0cda95d

7 files changed

Lines changed: 116 additions & 16 deletions

File tree

‎.changeset/mcp-without-ai.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'e2e': patch
3+
---
4+
5+
`e2e mcp` opens a session in a project without the optional `ai` package. Without the AI SDK, the catalog and the argument checks read each tool's Standard Schema (zod's), so a deterministic-only project (no `agents`, `e2e init` with no model gateway) can `open_session`, `observe`, `locate`, and drive the page.

‎docs/reference/mcp.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ For clients that use a `mcpServers` config, add:
5353
}
5454
```
5555

56+
The server needs no model: a project without the optional `ai` package
57+
opens sessions and drives the app with the deterministic tools.
58+
5659
Start the server in the project directory. Config discovery works like
5760
`e2e run`. Set `--config` for another location, or pass `config` to
5861
`open_session` for one session.

‎packages/e2e/src/agent/ai-sdk.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ export async function loadAiSdk(): Promise<AiSdk> {
6666
}
6767
}
6868

69+
/**
70+
* The SDK when the optional peer is installed, else undefined: for the seams
71+
* that have a way to do their job without it.
72+
*/
73+
export async function loadAiSdkIfInstalled(): Promise<AiSdk | undefined> {
74+
return loadAiSdk().catch(() => undefined);
75+
}
76+
77+
/** The SDK when an earlier call loaded it, else undefined; never loads it. */
78+
export function loadedAiSdk(): AiSdk | undefined {
79+
return cache().loaded;
80+
}
81+
6982
/**
7083
* The already-loaded SDK, for the few synchronous seams (error-class checks)
7184
* that run strictly after an async caller primed the cache.

‎packages/e2e/src/mcp/session.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
*/
1414

1515
import { z } from 'zod';
16-
import { loadAiSdk } from '../agent/ai-sdk.ts';
16+
import { loadAiSdkIfInstalled } from '../agent/ai-sdk.ts';
1717
import { openInteractiveStep, type InteractiveStep } from '../agent/interactive-step.ts';
1818
import { ScreenPresenter } from '../agent/screen-update.ts';
1919
import type { ResolvedConfig, ResolvedTarget } from '../config/resolve.ts';
@@ -187,10 +187,10 @@ export class SessionHost {
187187
}
188188

189189
private async openSession(id: string, options: OpenSessionOptions, request: AbortSignal | undefined): Promise<string> {
190-
// The catalog reads the tools' schemas through the AI SDK, synchronously
191-
// and on every render, so the optional SDK is loaded once here: a project
192-
// without it learns so before an attempt opens a browser.
193-
await loadAiSdk();
190+
// The catalog renders synchronously, so the optional SDK is loaded once
191+
// here when installed. Without it the catalog reads the tools' Standard
192+
// Schemas, and only a model-backed call needs the package.
193+
await loadAiSdkIfInstalled();
194194
// The config is claimed before it evaluates: its top-level code resolves
195195
// secrets against the registry an open session installed, which only
196196
// knows that session's config.

‎packages/e2e/src/mcp/tools.ts‎

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@
88
* validates the arguments against the schema, runs the tool, and renders its
99
* output in the MCP result envelope.
1010
*
11-
* The schemas are read through the AI SDK's `asSchema`, and the SDK is an
12-
* optional peer dependency: this module reaches it only through the lazy
13-
* loader, never a static import, since the CLI loads this module for every
14-
* command and `e2e init` runs before `ai` is installed.
11+
* The schemas are read through the AI SDK's `asSchema` when it is installed,
12+
* and through the schema's own Standard Schema (zod's, among others) when it
13+
* is not. The SDK is an optional peer dependency: this module reaches it only
14+
* through the lazy loader, never a static import, since the CLI loads this
15+
* module for every command and `e2e init` runs before `ai` is installed.
1516
*/
1617

1718
import type { JSONSchema7, Tool, ToolExecutionOptions, ToolSet } from 'ai';
1819
import type { z } from 'zod';
19-
import { aiSdk, loadAiSdk } from '../agent/ai-sdk.ts';
20+
import { loadAiSdk, loadAiSdkIfInstalled, loadedAiSdk } from '../agent/ai-sdk.ts';
2021
import { isFailedResult } from '../agent/loop-guards.ts';
2122
import { codedMessage, ConfigurationError, errorMessage } from '../internal/errors.ts';
23+
import { describeIssue } from '../internal/standard-schema.ts';
24+
import type { StandardSchemaV1 } from '../types.ts';
2225

2326
/** One MCP content part this server emits. */
2427
export type McpContent =
@@ -92,15 +95,35 @@ const CATALOG_SENTENCE_MAX = 160;
9295
* never need. A zod schema converts synchronously; a schema that only
9396
* resolves lazily is shown as an open object rather than awaited, since the
9497
* catalog is rendered inline. Synchronous, so the session that renders the
95-
* catalog has loaded the SDK first.
98+
* catalog has loaded the SDK first when it is installed; without it, the
99+
* schema's Standard JSON Schema converter is read instead.
96100
*/
97101
export function toolJsonSchema(tool: ToolSet[string]): JSONSchema7 {
98-
const raw = aiSdk().asSchema(tool.inputSchema).jsonSchema;
99-
if (typeof (raw as PromiseLike<JSONSchema7>).then === 'function') return { type: 'object' };
102+
const sdk = loadedAiSdk();
103+
const raw = sdk === undefined ? standardJsonSchema(tool.inputSchema) : sdk.asSchema(tool.inputSchema).jsonSchema;
104+
if (raw === undefined || typeof (raw as PromiseLike<JSONSchema7>).then === 'function') return { type: 'object' };
100105
const { $schema: _draft, ...schema } = raw as JSONSchema7;
101106
return schema;
102107
}
103108

109+
/** A Standard Schema that may also carry the Standard JSON Schema converter. */
110+
interface StandardToolSchema extends StandardSchemaV1 {
111+
readonly '~standard': StandardSchemaV1['~standard'] & {
112+
readonly jsonSchema?: { readonly input: (options: { readonly target: string }) => unknown };
113+
};
114+
}
115+
116+
/** The tool schema as a Standard Schema v1, or undefined for a schema the AI SDK built. */
117+
function standardSchemaOf(schema: unknown): StandardToolSchema | undefined {
118+
const props = (schema as Partial<StandardToolSchema> | undefined)?.['~standard'];
119+
return typeof props === 'object' && props !== null && typeof props.validate === 'function' ? (schema as StandardToolSchema) : undefined;
120+
}
121+
122+
/** The draft-07 JSON Schema a Standard Schema's own converter produces, or undefined without one. */
123+
function standardJsonSchema(schema: unknown): unknown {
124+
return standardSchemaOf(schema)?.['~standard'].jsonSchema?.input({ target: 'draft-07' });
125+
}
126+
104127
/**
105128
* One catalog line: the name, the argument names (`?` marks an optional
106129
* one), the first sentence of the description, and the read-only mark.
@@ -167,14 +190,27 @@ export async function invokeTool(
167190
}
168191

169192
async function validateArgs(name: string, tool: ToolSet[string], args: Record<string, unknown>): Promise<unknown> {
170-
const { asSchema } = await loadAiSdk();
193+
const sdk = await loadAiSdkIfInstalled();
194+
const standard = standardSchemaOf(tool.inputSchema);
195+
if (sdk === undefined && standard !== undefined) {
196+
const result = await standard['~standard'].validate(args);
197+
if (result.issues === undefined) return result.value;
198+
throw invalidArgs(name, result.issues.map(describeIssue).join('; '));
199+
}
200+
// A schema the SDK built (`jsonSchema()`, a lazy schema) only exists with the SDK installed.
201+
const { asSchema } = sdk ?? (await loadAiSdk());
171202
const schema = asSchema(tool.inputSchema);
172203
if (schema.validate === undefined) return args;
173204
const result = await schema.validate(args);
174205
if (result.success) return result.value;
175-
throw new ConfigurationError(
206+
throw invalidArgs(name, describeValidationError(result.error));
207+
}
208+
209+
/** The failure a `call` with arguments its tool's schema rejects gets. */
210+
function invalidArgs(name: string, issues: string): ConfigurationError {
211+
return new ConfigurationError(
176212
'INVALID_ARGUMENT',
177-
`call ${name}: ${describeValidationError(result.error)}; tools {tool: ${JSON.stringify(name)}} shows its arguments`,
213+
`call ${name}: ${issues}; tools {tool: ${JSON.stringify(name)}} shows its arguments`,
178214
);
179215
}
180216

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { ToolSet } from 'ai';
2+
import { z } from 'zod';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { loadAiSdk } from '../../src/agent/ai-sdk.ts';
5+
import { catalogLine, invokeTool, toolJsonSchema } from '../../src/mcp/tools.ts';
6+
7+
// A project that never installed the optional `ai` peer.
8+
vi.mock('ai', () => {
9+
throw new Error("Cannot find package 'ai'");
10+
});
11+
12+
const extra = { signal: new AbortController().signal };
13+
14+
const tap: ToolSet[string] = {
15+
description: 'Tap or click one node. The result waits for the effect and reports what changed.',
16+
inputSchema: z.object({ target: z.string().min(1).describe('Node id'), times: z.number().int().optional() }),
17+
execute: async (input: { target: string }) => `Tapped #${input.target}.`,
18+
};
19+
20+
describe('the MCP catalog without the ai package', () => {
21+
it('is the precondition: the SDK cannot load', async () => {
22+
await expect(loadAiSdk()).rejects.toMatchObject({ code: 'MODEL_UNAVAILABLE' });
23+
});
24+
25+
it('renders a zod tool from its Standard Schema', () => {
26+
expect(catalogLine('tap', tap, false)).toBe('- tap {target, times?}: Tap or click one node.');
27+
expect(toolJsonSchema(tap)).toMatchObject({
28+
type: 'object',
29+
required: ['target'],
30+
properties: { target: { type: 'string', description: 'Node id' }, times: { type: 'integer' } },
31+
});
32+
expect(toolJsonSchema(tap)).not.toHaveProperty('$schema');
33+
});
34+
35+
it('validates and runs a zod tool', async () => {
36+
const result = await invokeTool('tap', tap, { target: 'n1' }, extra);
37+
expect(result.content).toEqual([{ type: 'text', text: 'Tapped #n1.' }]);
38+
await expect(invokeTool('tap', tap, { target: '' }, extra)).rejects.toThrow(/call tap: target: .+tools \{tool: "tap"\}/);
39+
});
40+
});

‎skills/e2e/references/mcp.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Or declare it in the client's project config (`.mcp.json` for Claude Code,
2020
{ "mcpServers": { "e2e": { "command": "npx", "args": ["e2e", "mcp"] } } }
2121
```
2222

23+
No model is needed: without the optional `ai` package, sessions open and the
24+
deterministic tools work.
25+
2326
Flags: `--config <path>` names the default config file, `--target <name>`
2427
fixes the target every session opens on, `--headless` hides the browser or
2528
simulator (sessions are headed by default outside CI), `--max-sessions <n>`

0 commit comments

Comments
 (0)