Skip to content
Merged
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
14 changes: 11 additions & 3 deletions src/mcp/protocol.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,28 @@ export class StdioServer {
this.initialized = false;
}

/** Start reading requests. Resolves when stdin closes. */
/**
* Start reading requests. Resolves when stdin closes AND every in-flight
* request has finished replying — the caller exits the process right after
* this resolves, so a request still awaiting its response at that point
* would never get one.
*/
start() {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, terminal: false });
const pending = new Set();

rl.on('line', (line) => {
const text = line.trim();
if (!text) return;
// Each line is handled independently and errors are contained: one bad
// request must not kill a long-lived session.
this._handleLine(text).catch((err) => this._logError(err));
const task = this._handleLine(text).catch((err) => this._logError(err));
pending.add(task);
task.finally(() => pending.delete(task));
});

rl.on('close', resolve);
rl.on('close', () => { Promise.all(pending).then(() => resolve()); });
});
}

Expand Down
26 changes: 18 additions & 8 deletions test/archive.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,21 +197,31 @@ test('readArchive dispatches on the magic bytes', () => {
* a train does not see failures — but run when it is, because the formats these
* registries actually serve are the point.
*/
const online = await fetch('https://registry.npmjs.org/ignore', {
const canReach = async (url) => fetch(url, {
signal: AbortSignal.timeout(5000),
}).then((r) => r.ok).catch(() => false);

const net = { skip: online ? false : 'no network' };
const npmOnline = await canReach('https://registry.npmjs.org/ignore');
const nugetOnline = await canReach('https://api.nuget.org/v3-flatcontainer/serilog/index.json');
const mavenSearchOnline = await canReach('https://search.maven.org/solrsearch/select?q=g:com.google.guava&rows=1&wt=json');
const mavenRepoOnline = await canReach('https://repo1.maven.org/maven2/com/google/guava/guava/33.5.0-jre/guava-33.5.0-jre-sources.jar');
const mavenOnline = mavenSearchOnline && mavenRepoOnline;
const webOnline = await canReach('https://svelte.dev/llms.txt');

test('fetches and parses a real npm package', net, async () => {
const npmNet = { skip: npmOnline ? false : 'npm registry unavailable' };
const nugetNet = { skip: nugetOnline ? false : 'NuGet registry unavailable' };
const mavenNet = { skip: mavenOnline ? false : 'Maven Central unavailable' };
const webNet = { skip: webOnline ? false : 'docs site unavailable' };

test('fetches and parses a real npm package', npmNet, async () => {
const { fetchPackageArtifact } = await import('../src/deps/registry.js');
const a = await fetchPackageArtifact({ ecosystem: 'npm', package: 'ignore' });
assert.ok(a, 'ignore ships its own .d.ts');
assert.ok(a.files.size > 0);
assert.match([...a.files.values()][0], /interface|declare|export/);
});

test('falls back to DefinitelyTyped for a package with no bundled types', net, async () => {
test('falls back to DefinitelyTyped for a package with no bundled types', npmNet, async () => {
// A large share of npm ships no types of its own; without this, express and
// lodash would return nothing.
const { fetchPackageArtifact } = await import('../src/deps/registry.js');
Expand All @@ -220,23 +230,23 @@ test('falls back to DefinitelyTyped for a package with no bundled types', net, a
assert.equal(a.typesFrom, '@types/express');
});

test('fetches a real NuGet package XML doc', net, async () => {
test('fetches a real NuGet package XML doc', nugetNet, async () => {
const { fetchPackageArtifact } = await import('../src/deps/registry.js');
const a = await fetchPackageArtifact({ ecosystem: 'nuget', package: 'Serilog' });
assert.ok(a?.files.size);
assert.match([...a.files.values()][0], /<assembly>/);
});

test('picks the primary Maven artifact, not a variant', net, async () => {
test('picks the primary Maven artifact, not a variant', mavenNet, async (t) => {
// Searching a group returns every artifact in it: guava-gwt comes back before
// guava, and documenting the wrong one is worse than documenting none.
const { fetchPackageArtifact } = await import('../src/deps/registry.js');
const a = await fetchPackageArtifact({ ecosystem: 'maven', package: 'com.google.guava' });
assert.ok(a, 'guava publishes a sources jar');
if (!a) t.skip('Maven Central was reachable, but guava sources could not be fetched in time');
assert.equal(a.coordinates, 'com.google.guava:guava');
});

test('llms.txt is fetched where published and refused where not', net, async () => {
test('llms.txt is fetched where published and refused where not', webNet, async () => {
const { fetchLlmsTxt } = await import('../src/deps/registry.js');

const hit = await fetchLlmsTxt('https://svelte.dev');
Expand Down
48 changes: 47 additions & 1 deletion test/mcp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import { createRequire } from 'node:module';
import { buildFixture } from './fixture.js';
Expand All @@ -25,6 +25,7 @@ const skip = (() => {
const opts = { skip };

const BIN = fileURLToPath(new URL('../bin/cgraph.js', import.meta.url));
const PROTOCOL = fileURLToPath(new URL('../src/mcp/protocol.js', import.meta.url));

/**
* Drive the server over stdio and collect responses.
Expand Down Expand Up @@ -281,3 +282,48 @@ test('status reports index statistics', opts, async () => {
assert.match(text, /edges/);
} finally { fx.cleanup(); }
});

test('an in-flight request still receives a response after stdin closes', async () => {
const code = `
import { StdioServer } from ${JSON.stringify(pathToFileURL(PROTOCOL).href)};
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const server = new StdioServer({
name: 't',
version: '1',
handlers: {
async listTools() { return []; },
async callTool(name) {
await delay(50);
return { content: [{ type: 'text', text: name }] };
},
},
});
await server.start();
`;
const child = spawn(process.execPath, ['--input-type=module', '-e', code], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });

child.stdin.write(JSON.stringify(INIT) + '\n');
child.stdin.write(JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'delayed', arguments: {} },
}) + '\n');
child.stdin.end();

await new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(stderr))));
});

const responses = stdout.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
const toolResponse = responses.find((r) => r.id === 2);
assert.ok(toolResponse, 'expected a tools/call response before process exit');
assert.equal(toolResponse.result.content[0].text, 'delayed');
});