diff --git a/evals/README.md b/evals/README.md
index b43854574..03a149a20 100644
--- a/evals/README.md
+++ b/evals/README.md
@@ -243,7 +243,7 @@ rationale in `runner/run.ts`). `requestedTrials = requestedIds.length * iteratio
- A calibrated suite gates on an aggregate rate instead of one flaky item blocking every PR; `--pass-threshold 1.0` restores strict all-pass
- The trial count matters as much as the scores: the Langfuse SDK drops an item whose task throws, so gating on the results it returns would report `7/7 passed` on a run where three trials never executed
-Harness failures (MCP spawn, OpenRouter, judge) are therefore left to throw rather than being converted into a `FAIL` verdict. A broken harness shows up as a shortfall, not as a failing eval.
+Harness failures (MCP spawn, an MCP server not connected at init, OpenRouter, judge) are therefore left to throw rather than being converted into a `FAIL` verdict. A broken harness shows up as a shortfall, not as a failing eval.
**Location:** `runner/experiment.ts` (`buildRunSummary`, `resolveExitCode`)
@@ -421,13 +421,10 @@ above). The snapshot holds the same fields flattened, one object per case, in th
**Symptom:** Test 2 fails after Test 1, passes alone.
**Solution:** ✅ Isolated agent + MCP instance per test.
-### Agent claims the Apify MCP server is "still connecting" (remote/sandboxed environments)
-**Symptom:** A transcript shows the agent reading a system notice that MCP servers are still
-connecting, concluding the Apify tools are unavailable, and falling back to a built-in tool.
-**Cause:** In sandboxed remote Claude Code environments the MCP handshake can race the first
-turn; a fast model sometimes doesn't wait or retry. On a local machine the CLI completes the
-handshake before the first turn, so this doesn't reproduce there.
-**Solution:** Retry the item once; a repeat failure on a local machine is real.
+### Apify MCP server not connected at init
+**Symptom:** `MCP server "apify" not connected at init (status: pending)`, then `retrying once`; a failed retry shows `🔥` and counts as a failed trial.
+**Cause:** The CLI waits 5 s (`MCP_CONNECT_TIMEOUT_MS`) for the server, then emits `init` with it `pending` and runs the first turn without its tools. Common on a loaded machine (high `--concurrency`, few CPUs).
+**Solution:** Automatic: the run aborts at `init`; `pending`/`failed` retry once, other statuses fail at once. If it recurs, lower `--concurrency`.
### Agent answers from memory or shells out instead of using our tools
**Symptom:** The judge reports the agent used `Bash`, `WebSearch`, or its own knowledge.
@@ -459,7 +456,7 @@ Fork PRs cannot run evals because GitHub withholds repository secrets. Push the
Both tiers need `ANTHROPIC_API_KEY`, the three `LANGFUSE_*` keys, and `APIFY_TOKEN` (mapped from the `APIFY_TEST_USER_API_TOKEN` repository secret). `OPENROUTER_API_KEY` is required only by the merge judge. The three publish cases also need write access to `apify/normal-mode-test-actor`.
-The pr tier has run on a hosted runner in 4m31s, inside its ten-minute target; the merge tier's threshold and 90-minute timeout are still provisional. Transient network and provider failures retry once; the handshake race above does not, because it produces a wrong answer rather than an error.
+The pr tier has run on a hosted runner in 4m31s, inside its ten-minute target; the merge tier's threshold and 90-minute timeout are still provisional. Transient network and provider failures retry once, as does an MCP server `pending` or `failed` at init (see Common issues).
## Writing tool descriptions
diff --git a/evals/agent/claude_agent.ts b/evals/agent/claude_agent.ts
index 5cc086978..7f9079054 100644
--- a/evals/agent/claude_agent.ts
+++ b/evals/agent/claude_agent.ts
@@ -172,6 +172,14 @@ export async function runAgentConversation(options: AgentRunOptions): Promise server.name === MCP_SERVER_NAME)?.status ?? 'missing';
+ if (status !== 'connected') {
+ throw new Error(`MCP server "${MCP_SERVER_NAME}" not connected at init (status: ${status})`);
+ }
+ }
messages.push(message);
receivedAt.push(Date.now());
}
diff --git a/evals/runner/experiment.ts b/evals/runner/experiment.ts
index f2d9815aa..86cdc35b7 100644
--- a/evals/runner/experiment.ts
+++ b/evals/runner/experiment.ts
@@ -351,13 +351,14 @@ export function formatTeardownHint(runId: string): string[] {
}
/**
- * Messages of failures worth replaying the agent run for: the network or the provider
- * dropped the request, not the agent's own doing.
+ * Messages of failures worth replaying the agent run for: the network, the provider or the
+ * MCP handshake failed, not the agent's own doing.
*
* Matched on the message because the SDK surfaces them as plain `Error`s with no code or
* status. Deliberately narrow: retrying a deterministic failure (a bad prompt, a missing
* binary, a tool timeout) only doubles the spend and, for a case with fixed-name fixtures,
* turns the replay itself into a name collision that fails the zero-tool-error gate.
+ * An MCP server `pending` or `failed` at init is replayed once; other statuses are configuration errors.
*/
const TRANSIENT_AGENT_ERROR_PATTERNS = [
/connection error/i,
@@ -368,6 +369,7 @@ const TRANSIENT_AGENT_ERROR_PATTERNS = [
/\b(408|429|500|502|503|504)\b/,
/overloaded/i,
/rate.?limit/i,
+ /MCP server ".+" not connected at init \(status: (pending|failed)\)/,
];
/** Whether an agent-run failure is transient, so replaying the prompt could recover it. */
diff --git a/tests/unit/evals.claude_agent.test.ts b/tests/unit/evals.claude_agent.test.ts
index 106704314..3440780fe 100644
--- a/tests/unit/evals.claude_agent.test.ts
+++ b/tests/unit/evals.claude_agent.test.ts
@@ -2,6 +2,7 @@ import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk';
import type { Options, PreToolUseHookInput, SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { isTransientAgentError } from '../../evals/runner/experiment.js';
import { TOOL_CALL_DENY_REASON, TOOL_CALL_MAX_TURNS } from '../../evals/runner/tool_call_mode.js';
import { REPORT_PROBLEM_NUDGE } from '../../src/tools/dev/report_problem.js';
@@ -40,6 +41,24 @@ function resultMessage(overrides: Record = {}): SDKMessage {
} as unknown as SDKMessage;
}
+/** The SDK `init` message; only the fields the run and the adapter read. */
+function initMessage(mcpServers: { name: string; status: string }[]): SDKMessage {
+ return {
+ type: 'system',
+ subtype: 'init',
+ claude_code_version: '2.1.206',
+ mcp_servers: mcpServers,
+ } as unknown as SDKMessage;
+}
+
+function assistantMessage(text: string): SDKMessage {
+ return {
+ type: 'assistant',
+ message: { id: 'msg-1', content: [{ type: 'text', text }] },
+ parent_tool_use_id: null,
+ } as unknown as SDKMessage;
+}
+
/** A minimal SDK stream a mocked `query()` can yield to let the adapter succeed. */
async function* fakeStream(messages: SDKMessage[]): AsyncGenerator {
for (const message of messages) yield message;
@@ -195,4 +214,69 @@ describe('runAgentConversation()', () => {
await expect(runAgentConversation(baseOptions())).rejects.toBe(originalError);
});
+
+ describe('with an init message', () => {
+ let isPastInit: boolean;
+
+ /** Yields init with the given servers, then records whether the run read any further. */
+ function mockInitStream(mcpServers: { name: string; status: string }[]) {
+ isPastInit = false;
+ mocks.query.mockImplementation(({ options }: { options: Options }) => {
+ capturedOptions = options;
+ return (async function* () {
+ yield initMessage(mcpServers);
+ isPastInit = true;
+ yield assistantMessage('done');
+ yield resultMessage();
+ })();
+ });
+ }
+
+ it('throws when the MCP server is not connected at init', async () => {
+ mockInitStream([{ name: 'apify', status: 'pending' }]);
+
+ await expect(runAgentConversation(baseOptions())).rejects.toThrow(
+ 'MCP server "apify" not connected at init (status: pending)',
+ );
+ expect(isPastInit).toBe(false);
+ expect(capturedOptions?.abortController?.signal.aborted).toBe(true);
+ });
+
+ it('throws when the MCP server is missing from init', async () => {
+ mockInitStream([]);
+
+ await expect(runAgentConversation(baseOptions())).rejects.toThrow(
+ 'MCP server "apify" not connected at init (status: missing)',
+ );
+ expect(isPastInit).toBe(false);
+ });
+
+ it('runs to completion when the MCP server is connected at init', async () => {
+ mockInitStream([{ name: 'apify', status: 'connected' }]);
+
+ const run = await runAgentConversation(baseOptions());
+
+ expect(isPastInit).toBe(true);
+ expect(run.claudeCodeVersion).toBe('2.1.206');
+ expect(run.conversation.turns.at(-1)?.finalResponse).toBe('done');
+ });
+
+ it('keeps the not-connected error transient when stderr is appended', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mocks.query.mockImplementation(({ options }: { options: Options }) => {
+ return (async function* () {
+ options.stderr?.('some line\n');
+ yield initMessage([{ name: 'apify', status: 'pending' }]);
+ })();
+ });
+
+ const error = await runAgentConversation(baseOptions()).catch((caught: unknown) => caught);
+
+ const message = error instanceof Error ? error.message : String(error);
+ expect(message).toContain('MCP server "apify" not connected at init (status: pending)');
+ expect(message).toContain('\n [claude-stderr] some line');
+ expect(isTransientAgentError(error)).toBe(true);
+ errorSpy.mockRestore();
+ });
+ });
});
diff --git a/tests/unit/evals.experiment.test.ts b/tests/unit/evals.experiment.test.ts
index b63dfe175..525f01fa2 100644
--- a/tests/unit/evals.experiment.test.ts
+++ b/tests/unit/evals.experiment.test.ts
@@ -181,16 +181,21 @@ describe('isTransientAgentError()', () => {
'read ECONNRESET',
'API error: 529 overloaded_error',
'Request failed with status 503',
+ 'MCP server "apify" not connected at init (status: pending)',
+ 'MCP server "apify" not connected at init (status: failed)',
])('treats "%s" as transient', (message) => {
expect(isTransientAgentError(new Error(message))).toBe(true);
});
- it.each(['spawn ENOENT', 'Prompt is too long', 'stdio binary not found', 'Invalid model name'])(
- 'treats "%s" as permanent',
- (message) => {
- expect(isTransientAgentError(new Error(message))).toBe(false);
- },
- );
+ it.each([
+ 'spawn ENOENT',
+ 'Prompt is too long',
+ 'stdio binary not found',
+ 'Invalid model name',
+ 'MCP server "apify" not connected at init (status: disabled)',
+ ])('treats "%s" as permanent', (message) => {
+ expect(isTransientAgentError(new Error(message))).toBe(false);
+ });
});
describe('createExperimentTask()', () => {
@@ -250,6 +255,19 @@ describe('createExperimentTask()', () => {
expect(mocks.runAgentConversation).toHaveBeenCalledTimes(2);
});
+ it('retries once and never judges when the MCP server stays unconnected', async () => {
+ mocks.runAgentConversation.mockRejectedValue(
+ new Error('MCP server "apify" not connected at init (status: pending)'),
+ );
+
+ await expect(makeMcpAgentTask()(makeItem())).rejects.toThrow(
+ 'Item "search-001": MCP server "apify" not connected at init (status: pending)',
+ );
+ expect(mocks.runAgentConversation).toHaveBeenCalledTimes(2);
+ expect(mocks.evaluateConversation).not.toHaveBeenCalled();
+ expect(mocks.emitObservations).not.toHaveBeenCalled();
+ });
+
it('still scores the item when emitting the agent trace throws', async () => {
mocks.runAgentConversation.mockResolvedValue(makeAgentRun());
mocks.emitObservations.mockImplementation(() => {