With a caller AbortSignal, archive requests ignore their configured timeout. The MCP adapter always supplies a signal, so a slow provider can outlive the requested timeout and return success. The same library request without an external signal times out correctly.
Reproduced on @agntn/archives 0.5.4, commit 37d69db, Node 26.8.1, ofetch 1.5.1 and MCP SDK 1.30.0, using the existing dist exports.
Save the script below as archive-timeout.mts and run node archive-timeout.mts /path/to/archives in a checkout with dependencies and dist installed. It replaces fetch with synthetic responses and connects the real MCP server through the SDK InMemoryTransport. No network or model is used.
The positive control reads ARCHIVE_CONTROL. With timeout=30 ms and retries=0, the delayed CDX library request aborts after about 31 ms. The MCP request waits about 161 ms and returns ARCHIVE_CONTROL without isError. The script asserts the defect, so its successful exit is a reproduction result, not a fix verification.
Complete deterministic reproduction
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
// All responses are synthetic. This probe makes no network requests.
const packageRoot = resolve(process.argv[2] ?? process.cwd());
const require = createRequire(resolve(packageRoot, 'package.json'));
const originalFetch = globalThis.fetch;
const requestedTimeoutMs = 30;
const responseDelayMs = 160;
let phase = 'warmup';
const requests: Array<{
phase: string;
endpoint: string;
signalSupplied: boolean;
aborted: boolean;
elapsedMs: number;
}> = [];
globalThis.fetch = async (input, options = {}) => {
const url = String(input instanceof Request ? input.url : input);
assert.ok(url.startsWith('https://web.archive.org/'), 'Unexpected synthetic endpoint');
const isIndex = url.includes('/cdx/search/cdx?');
assert.ok(isIndex || url.includes('/web/20200101000000id_/'));
const started = performance.now();
const row = { phase, endpoint: isIndex ? 'cdx' : 'playback', signalSupplied: Boolean(options.signal), aborted: false, elapsedMs: 0 };
requests.push(row);
if (isIndex && phase !== 'warmup') {
await new Promise<void>((resolveWait, rejectWait) => {
const signal = options.signal;
const finish = () => {
signal?.removeEventListener('abort', abort);
row.elapsedMs = performance.now() - started;
resolveWait();
};
const timer = setTimeout(finish, responseDelayMs);
const abort = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
row.aborted = true;
row.elapsedMs = performance.now() - started;
rejectWait(signal?.reason ?? new Error('Aborted'));
};
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
});
}
return isIndex
? new Response(JSON.stringify([
['original', 'timestamp', 'statuscode'],
['https://example.com/', '20200101000000', '200'],
]), { headers: { 'content-type': 'application/json' } })
: new Response('<html><body>ARCHIVE_CONTROL</body></html>', {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
};
let client: { close(): Promise<void> } | undefined;
let server: { close(): Promise<void> } | undefined;
try {
const { createArchive, WaybackProvider } = await import(pathToFileURL(require.resolve('@agntn/archives')).href);
const { createMcpServer } = await import(pathToFileURL(require.resolve('@agntn/archives/mcp')).href);
const { Client } = await import(pathToFileURL(require.resolve('@modelcontextprotocol/sdk/client/index.js')).href);
const { InMemoryTransport } = await import(pathToFileURL(require.resolve('@modelcontextprotocol/sdk/inMemory.js')).href);
const args = { cache: false, retries: 0, timeout: requestedTimeoutMs, timestamp: '20200101000000' };
const archive = createArchive(new WaybackProvider());
const warmup = await archive.content('https://example.com/', args);
assert.equal(warmup.success, true);
phase = 'library';
const direct = await archive.content('https://example.com/', args);
assert.equal(direct.success, false, 'The control must enforce its timeout');
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const activeClient = new Client({ name: 'archive-timeout-probe', version: '1.0.0' });
const activeServer = createMcpServer();
client = activeClient;
server = activeServer;
await Promise.all([activeServer.connect(serverTransport), activeClient.connect(clientTransport)]);
phase = 'mcp';
const mcp = await activeClient.callTool({
name: 'archives_content',
arguments: { target: 'https://example.com/', provider: 'wayback', ...args },
});
const directRequest = requests.find(row => row.phase === 'library' && row.endpoint === 'cdx');
const mcpRequest = requests.find(row => row.phase === 'mcp' && row.endpoint === 'cdx');
assert.ok(directRequest?.aborted);
assert.ok(mcpRequest && !mcpRequest.aborted);
assert.ok(mcpRequest.elapsedMs >= responseDelayMs - 10);
const defectReproduced = mcp.isError !== true && JSON.stringify(mcp).includes('ARCHIVE_CONTROL');
assert.equal(defectReproduced, true, 'This probe expects the recorded defect on the affected build');
console.log(JSON.stringify({ node: process.version, requestedTimeoutMs, responseDelayMs, defectReproduced, librarySuccess: direct.success, mcpIsError: mcp.isError ?? false, requests }, null, 2));
} finally {
await client?.close();
await server?.close();
globalThis.fetch = originalFetch;
}
The source path is MCP extra.signal -> shared executor -> Wayback createFetchOptions -> ofetch. src/utils/_utils.ts returns both timeout and signal. In ofetch 1.5.1, the timeout controller is created only when no signal was supplied. Forwarding cancellation alone therefore disables the timeout. PR #30 introduced signal forwarding; it did not compose cancellation with the deadline.
Expected: preserve both explicit caller cancellation and the per-request timeout, including through MCP. Keep retries=0 in the regression so elapsed time has one clear bound. Cover a slow synthetic fetch through the real adapter and an explicit cancellation control.
The existing MCP test named "asks for a read timeout a page transfer can meet, unless told otherwise" mocks provider.content and checks only the forwarded option. It cannot detect the missing timer.
Checked all open and closed issues, open PRs, and relevant closed PRs and source history. #75 concerns Pi failure-status translation; closed #3 concerned hardcoded Archive.today options. Neither covers this timeout/cancellation interaction. No production fix is included.
With a caller AbortSignal, archive requests ignore their configured timeout. The MCP adapter always supplies a signal, so a slow provider can outlive the requested timeout and return success. The same library request without an external signal times out correctly.
Reproduced on @agntn/archives 0.5.4, commit 37d69db, Node 26.8.1, ofetch 1.5.1 and MCP SDK 1.30.0, using the existing dist exports.
Save the script below as archive-timeout.mts and run
node archive-timeout.mts /path/to/archivesin a checkout with dependencies and dist installed. It replaces fetch with synthetic responses and connects the real MCP server through the SDK InMemoryTransport. No network or model is used.The positive control reads ARCHIVE_CONTROL. With timeout=30 ms and retries=0, the delayed CDX library request aborts after about 31 ms. The MCP request waits about 161 ms and returns ARCHIVE_CONTROL without isError. The script asserts the defect, so its successful exit is a reproduction result, not a fix verification.
Complete deterministic reproduction
The source path is MCP extra.signal -> shared executor -> Wayback createFetchOptions -> ofetch.
src/utils/_utils.tsreturns both timeout and signal. In ofetch 1.5.1, the timeout controller is created only when no signal was supplied. Forwarding cancellation alone therefore disables the timeout. PR #30 introduced signal forwarding; it did not compose cancellation with the deadline.Expected: preserve both explicit caller cancellation and the per-request timeout, including through MCP. Keep retries=0 in the regression so elapsed time has one clear bound. Cover a slow synthetic fetch through the real adapter and an explicit cancellation control.
The existing MCP test named "asks for a read timeout a page transfer can meet, unless told otherwise" mocks provider.content and checks only the forwarded option. It cannot detect the missing timer.
Checked all open and closed issues, open PRs, and relevant closed PRs and source history. #75 concerns Pi failure-status translation; closed #3 concerned hardcoded Archive.today options. Neither covers this timeout/cancellation interaction. No production fix is included.