Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Give your AI coding agents full visibility into your CI test results. The Curren
| `currents-get-errors-explorer` | Get aggregated error metrics for a project within a date range. |
| `currents-get-test-evidence` | Collect evidence artifacts (screenshots, videos, traces, attachments) produced by tests in a CI run, with signed download URLs grouped per test. |
| `currents-create-trace-link` | Create a shareable link that serves a test attempt's Playwright trace: a markdown digest of what the attempt did and what failed, a filmstrip, an animated screencast, DOM snapshots, network requests and attachments. |
| `currents-create-session` | Record a browser session you drove as a Currents run, so its evidence can be read and shared like a CI run's. |
| `currents-list-webhooks` | List all webhooks for a project. |
| `currents-create-webhook` | Create a new webhook for a project. |
| `currents-get-webhook` | Get a single webhook by ID. |
Expand Down
2 changes: 1 addition & 1 deletion mcp-server/.synced-from
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7ee9a90ba1c844bacebe1159f2b3228a7c488f6a
2fbc58bf3d70f9b56d624f54c61e4c0ec79a5c65
40 changes: 40 additions & 0 deletions mcp-server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AddressInfo } from 'node:net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { handleMcpRequest } from './http';
import { ApiDispatch, RequestContext } from './lib/context';
import { getSkills } from './skills';

vi.mock('./lib/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
Expand Down Expand Up @@ -88,6 +89,45 @@ describe('handleMcpRequest', () => {
expect(JSON.stringify(result.content)).toContain('run-1');
});

// Both halves over the transport, which is where a host meets them:
// `instructions` comes back from `initialize`, and `prompts/list` needs the
// capability the registration declares.
describe('skills', () => {
it('names them in the instructions the handshake returns', () => {
expect(client.getInstructions()).toContain('collect-evidence');
});

it('lists one prompt per skill', async () => {
const expected = getSkills().map((skill) => skill.name);
// Both sides are empty if no skill shipped, which would pass without
// serving anything.
expect(expected.length).toBeGreaterThan(0);

const { prompts } = await client.listPrompts();

expect(prompts.map((prompt) => prompt.name)).toEqual(expected);
});

// The references are the half a second fetch would lose. `skills.test.ts`
// covers the ordering against a fixture, which this cannot: the order
// `getSkills` returns depends on the collation of the machine it runs on.
it('carries the whole skill in the prompt, entry point first', async () => {
const skill = getSkills()[0];
const { messages } = await client.getPrompt({ name: skill.name });
const text = messages
.map((message) =>
message.content.type === 'text' ? message.content.text : ''
)
.join('');

for (const file of skill.files) {
expect(text).toContain(`<file path="${file.path}">`);
expect(text).toContain(file.content);
}
expect(text.startsWith('<file path="SKILL.md">')).toBe(true);
});
});

// Each exchange gets a server and a transport of its own, so nothing may
// depend on the one before it.
it('serves a second exchange with no session to carry', async () => {
Expand Down
95 changes: 89 additions & 6 deletions mcp-server/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { RequestContext, requestContext } from './lib/context';
import { logger } from './lib/logger';
Expand All @@ -18,17 +18,26 @@ import { createMcpServer } from './server';
* call, because the tool handlers are reached from inside `handleRequest` and
* `AsyncLocalStorage` is how they read it (`lib/context.ts`).
*
* The caller has already parsed the body: the transport takes it as an argument
* rather than reading the stream, so the host's `express.json()` and this agree
* on one parse.
* The web-standard transport rather than the node one, which is a wrapper over
* this same class that converts the node request with `@hono/node-server`. That
* conversion needs a request backed by a real socket, and the api Lambda has
* none: `@vendia/serverless-express` builds a stand-in, and the conversion
* answered `400` with an empty body for every call while the identical chain on
* a socket answered `200`. Going through the web types directly is what the SDK
* documents for a host that is not a node HTTP server, and it removes the
* difference between the two runtimes rather than working around it.
*
* The request is rebuilt rather than forwarded because nothing here reads its
* stream: the host has already parsed the body, and `parsedBody` is what the
* transport reads, so `express.json()` and this agree on one parse.
*/
export async function handleMcpRequest(
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
context: RequestContext
): Promise<void> {
const server = createMcpServer(context);
const transport = new StreamableHTTPServerTransport({
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
Expand All @@ -44,6 +53,80 @@ export async function handleMcpRequest(

await requestContext.run(context, async () => {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
const response = await transport.handleRequest(toWebRequest(req), {
parsedBody: req.body,
});
await writeNodeResponse(res, response);
});
}

/**
* The node request as the web `Request` the transport takes.
*
* No body: the transport reads `parsedBody` instead, and attaching one would
* mean reading a stream `express.json()` has already consumed. `duplex` is
* therefore not needed either.
*
* The URL is absolute because `Request` requires one. Only its path and query
* are read — the transport matches the method and the headers — so the origin
* is reconstructed from the `host` header and a placeholder when a caller sent
* none, rather than being carried through to anything a client sees.
*/
function toWebRequest(req: IncomingMessage): Request {
const host = req.headers.host ?? 'mcp.invalid';
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (value === undefined) {
continue;
}
// A header node parsed as a list arrives as an array; `set-cookie` is the
// only one it always does that for, and a request carries none.
for (const one of Array.isArray(value) ? value : [value]) {
headers.append(name, one);
}
}
return new Request(new URL(req.url ?? '/', `https://${host}`), {
method: req.method ?? 'POST',
headers,
});
}

/**
* The transport's web `Response`, written to the node response the host gave us.
*
* Read in full and handed to `end` in one call, rather than written chunk by
* chunk. `enableJsonResponse` makes every exchange a single JSON body, so there
* is no stream to preserve, and the two ways of pacing chunks against a
* consumer are each unavailable somewhere this runs:
*
* - `drain` is never emitted under `@vendia/serverless-express`, whose response
* carries a socket stand-in with `on` set to `Function.prototype`.
* - the `write` callback is dropped by `compression`, which replaces `write`
* with a two-parameter version that forwards to a zlib stream and returns.
*
* Either one silently never resolves, which on the api Lambda is a hung
* invocation and a 502 rather than an answer. Waiting on neither is what makes
* this behave the same under a socket, a compression middleware, and the
* Lambda's stand-in.
*
* If a later change turns `enableJsonResponse` off to stream notifications,
* this has to write through as chunks arrive — and `compression` on the same
* route has to learn about SSE at the same time, for the same reason.
*/
async function writeNodeResponse(
res: ServerResponse,
response: Response
): Promise<void> {
res.statusCode = response.status;
response.headers.forEach((value, name) => {
res.setHeader(name, value);
});

if (!response.body) {
res.end();
return;
}

const body = Buffer.from(await response.arrayBuffer());
res.end(body.length > 0 ? body : undefined);
}
3 changes: 3 additions & 0 deletions mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export {
type RequestContext,
type ToolCallReport,
} from './lib/context';
// Part of the same contract: the host marks a dispatched read its own deadline
// stopped, and the retry loop here reads the mark.
export { DEADLINE_EXCEEDED_HEADER } from './lib/request';
export { setLogger, type LogSink } from './lib/logger';
export { handleMcpRequest } from './http';
export { isToolGranted, type McpTool, type ToolScope } from './lib/tool';
Expand Down
119 changes: 119 additions & 0 deletions mcp-server/src/lib/concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import { mapWithConcurrency } from './concurrency';

/** Records how many calls overlapped, so a limit can be asserted on. */
const tracking = () => {
const state = { running: 0, peak: 0, order: [] as number[] };
const run = async (item: number) => {
state.running += 1;
state.peak = Math.max(state.peak, state.running);
state.order.push(item);
await new Promise((resolve) => setTimeout(resolve, 1));
state.running -= 1;
return item * 2;
};
return { state, run };
};

describe('mapWithConcurrency', () => {
it('answers in the order the items were given', async () => {
const { run } = tracking();
await expect(mapWithConcurrency([1, 2, 3, 4, 5], 2, run)).resolves.toEqual([
2, 4, 6, 8, 10,
]);
});

it('runs no more than the limit at once', async () => {
const { state, run } = tracking();
await mapWithConcurrency([1, 2, 3, 4, 5, 6, 7, 8], 3, run);
expect(state.peak).toBe(3);
});

it('starts every item', async () => {
const { state, run } = tracking();
await mapWithConcurrency([1, 2, 3, 4, 5, 6, 7], 2, run);
expect(state.order.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6, 7]);
});

it('runs one at a time for a limit below one', async () => {
const { state, run } = tracking();
await mapWithConcurrency([1, 2, 3], 0, run);
expect(state.peak).toBe(1);
});

it('answers nothing for no items', async () => {
const { state, run } = tracking();
await expect(mapWithConcurrency([], 4, run)).resolves.toEqual([]);
expect(state.peak).toBe(0);
});

it('rejects with what an item threw', async () => {
await expect(
mapWithConcurrency([1, 2, 3], 2, async (item) => {
if (item === 2) {
throw new Error('boom');
}
return item;
})
).rejects.toThrow('boom');
});

// Anything still running once this has answered is outside the limit, which
// is the whole of what the caller asked for.
it('leaves nothing running once it has answered', async () => {
let running = 0;
let settled = false;
let ranAfterSettling = 0;

const pending = mapWithConcurrency(
[1, 2, 3, 4, 5, 6, 7, 8],
3,
async (item) => {
running += 1;
await new Promise((resolve) => setTimeout(resolve, 5));
if (settled) {
ranAfterSettling += 1;
}
running -= 1;
if (item === 2) {
throw new Error('boom');
}
return item;
}
);

await expect(pending).rejects.toThrow('boom');
settled = true;
expect(running).toBe(0);

await new Promise((resolve) => setTimeout(resolve, 50));
expect(ranAfterSettling).toBe(0);
});

it('stops claiming items once one has failed', async () => {
const started: number[] = [];

await expect(
mapWithConcurrency([1, 2, 3, 4, 5, 6, 7, 8], 2, async (item) => {
started.push(item);
await new Promise((resolve) => setTimeout(resolve, 1));
if (item === 1) {
throw new Error('boom');
}
return item;
})
).rejects.toThrow('boom');

// The two in the first wave, and nothing claimed after the failure.
expect(started).toEqual([1, 2]);
});

it('throws the first rejection, not a later one', async () => {
await expect(
mapWithConcurrency([1, 2, 3, 4], 4, async (item) => {
await new Promise((resolve) => setTimeout(resolve, item));
throw new Error(`boom ${item}`);
})
).rejects.toThrow('boom 1');
});
});
49 changes: 49 additions & 0 deletions mcp-server/src/lib/concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* `items.map(run)` with at most `limit` running at once, answering the results
* in the order the items were given.
*
* Its own few lines rather than a dependency: this package is published to
* npm, where every dependency is one a consumer installs, and the four it has
* today are the SDK, zod and two workspace packages.
*
* On a rejection it stops claiming items and then waits for the ones already
* running, rather than rejecting the moment the first one fails. A bare
* `Promise.all` answers the caller while its siblings are still going, so the
* limit stops meaning anything the moment one item fails — the calls it was
* holding back carry on outside it, against a host that has already been told
* the work is over. The first rejection is what it throws; a later one is
* dropped, the way `Promise.all` drops it.
*/
export async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
run: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
// A container rather than a `let`: the workers assign it and the check below
// reads it after awaiting them, which narrowing on a local would not see.
const first: { failure?: { error: unknown } } = {};
let next = 0;

const worker = async () => {
for (let index = next++; index < items.length; index = next++) {
if (first.failure) {
return;
}
try {
results[index] = await run(items[index], index);
} catch (error) {
first.failure ??= { error };
return;
}
}
};

await Promise.all(
Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker)
);
if (first.failure) {
throw first.failure.error;
}
return results;
}
22 changes: 22 additions & 0 deletions mcp-server/src/lib/instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,25 @@ describe('scopes that reach no tool', () => {
expect(SCOPES_WITHOUT_TOOLS).not.toContain('results:read');
});
});

describe('the skills line', () => {
it('names every skill and warns that a step may be out of reach', () => {
const text = buildServerInstructions({ apiKeyScope: 'write' }, [
{ name: 'collect-evidence' },
{ name: 'browser-evidence' },
]);

expect(text).toContain('collect-evidence, browser-evidence');
expect(text).toContain('does not reach');
});

// A deployment whose skills did not ship serves every tool and no skill
// (`host/assets.ts`), and must not answer with a sentence naming none.
it('is left out when no skill shipped', () => {
const text = buildServerInstructions({ apiKeyScope: 'write' }, []);

expect(text).not.toContain('prompts');
expect(text).toBe(text.trimEnd());
expect(text).toBe(buildServerInstructions({ apiKeyScope: 'write' }));
});
});
Loading