@lightpath-dev/mcp 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { lookup as dnsLookup } from 'node:dns/promises';
3
+ import http from 'node:http';
4
+ import https from 'node:https';
5
+ import { TRACE_HEADER } from '@lightpath-dev/protocol';
6
+ /** Longest wait after the response for the backend trace (design section 4A, D-125). */
7
+ export const REQUEST_TRACE_WAIT_MS = 1500;
8
+ export const REQUEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
9
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
10
+ /** True for 127.0.0.0/8 and ::1 (and ::ffff:127.x), the only addresses a request may go to. */
11
+ export function isLoopbackAddress(address) {
12
+ const v4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address;
13
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v4))
14
+ return true;
15
+ return address === '::1' || address === '0:0:0:0:0:0:0:1';
16
+ }
17
+ /**
18
+ * Checks where a request would go, before any network call (design section 4A): http or https,
19
+ * host `localhost`, `127.0.0.1` or `[::1]`, and every address `localhost` resolves to on loopback.
20
+ * Returns the vetted address, so the request connects there and can't be sent elsewhere by a
21
+ * second lookup.
22
+ */
23
+ export async function resolveTarget(raw, appUrl, resolve = (host) => dnsLookup(host, { all: true, verbatim: true })) {
24
+ let url;
25
+ if (raw.startsWith('/')) {
26
+ if (!appUrl) {
27
+ return {
28
+ ok: false,
29
+ code: 'no_app_url',
30
+ message: `"${pathOnly(raw)}" is a path, but no app URL is set. Pass a full loopback URL like http://localhost:3001${pathOnly(raw)}, or set "url" in lightpath.config.json.`,
31
+ };
32
+ }
33
+ try {
34
+ url = new URL(raw, appUrl);
35
+ }
36
+ catch {
37
+ return { ok: false, code: 'not_loopback', message: `"url" in lightpath.config.json isn't a URL.` };
38
+ }
39
+ }
40
+ else {
41
+ try {
42
+ url = new URL(raw);
43
+ }
44
+ catch {
45
+ return {
46
+ ok: false,
47
+ code: 'not_loopback',
48
+ message: 'Not a URL. Pass a path like /api/orders or http://localhost:3001/api/orders.',
49
+ };
50
+ }
51
+ }
52
+ const refuse = (why) => ({
53
+ ok: false,
54
+ code: 'not_loopback',
55
+ message: `Refused before sending: ${why}. run_request only sends to localhost, 127.0.0.1 or [::1], so nothing leaves this machine.`,
56
+ });
57
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
58
+ return refuse(`the scheme is ${url.protocol.replace(':', '')}`);
59
+ if (url.username || url.password)
60
+ return refuse('the URL has a user name or password');
61
+ if (!LOOPBACK_HOSTS.has(url.hostname))
62
+ return refuse(`the host is ${url.hostname}`);
63
+ if (url.hostname === '127.0.0.1')
64
+ return { ok: true, url, address: '127.0.0.1', family: 4 };
65
+ if (url.hostname === '[::1]')
66
+ return { ok: true, url, address: '::1', family: 6 };
67
+ let addresses;
68
+ try {
69
+ addresses = await resolve('localhost');
70
+ }
71
+ catch {
72
+ return refuse('localhost did not resolve');
73
+ }
74
+ if (addresses.length === 0 || !addresses.every((a) => isLoopbackAddress(a.address))) {
75
+ return refuse(`localhost resolves to ${addresses.map((a) => a.address).join(', ') || 'nothing'}, not only loopback`);
76
+ }
77
+ // Prefer IPv4, as dev servers bound to 127.0.0.1 are the common case; fall back to the rest.
78
+ const first = addresses.find((a) => a.family === 4) ?? addresses[0];
79
+ return { ok: true, url, address: first.address, family: first.family };
80
+ }
81
+ /** `/api/orders?token=x` → `/api/orders`: queries never reach the output (they can hold secrets). */
82
+ export const pathOnly = (pathOrUrl) => pathOrUrl.split(/[?#]/)[0] ?? pathOrUrl;
83
+ /** `t_` + 12 random base36 characters, the TraceId format (packages/protocol/src/model.ts). */
84
+ export function newRequestTraceId() {
85
+ const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
86
+ const bytes = randomBytes(12);
87
+ let id = 't_';
88
+ for (const b of bytes)
89
+ id += alphabet[b % 36];
90
+ return id;
91
+ }
92
+ /**
93
+ * Sends one request to a vetted loopback address with Lightpath's trace header. Uses node:http
94
+ * with its own agent: no proxy (HTTP_PROXY, HTTPS_PROXY and NODE_USE_ENV_PROXY are ignored), no
95
+ * redirect followed, and the connection goes to the address `resolveTarget` checked. The response
96
+ * body is read for its size only and never kept.
97
+ */
98
+ export function sendRequest(target, input, traceId) {
99
+ const { url } = target;
100
+ const lib = url.protocol === 'https:' ? https : http;
101
+ const agent = new lib.Agent({ keepAlive: false });
102
+ const lookup = (_host, options, callback) => {
103
+ const all = typeof options === 'object' && options !== null && 'all' in options && options.all;
104
+ if (all)
105
+ callback(null, [
106
+ { address: target.address, family: target.family },
107
+ ]);
108
+ else
109
+ callback(null, target.address, target.family);
110
+ };
111
+ const headers = { [TRACE_HEADER]: traceId };
112
+ if (input.body !== undefined) {
113
+ headers['content-type'] = input.contentType ?? 'application/json';
114
+ headers['content-length'] = String(Buffer.byteLength(input.body));
115
+ }
116
+ else if (input.contentType)
117
+ headers['content-type'] = input.contentType;
118
+ const started = performance.now();
119
+ const timeoutMs = input.timeoutMs ?? 10_000;
120
+ return new Promise((resolve) => {
121
+ const req = lib.request(url, { method: input.method ?? 'GET', headers, agent, lookup, timeout: timeoutMs }, (res) => {
122
+ let bytes = 0;
123
+ res.on('data', (chunk) => {
124
+ bytes += chunk.length;
125
+ });
126
+ res.on('end', () => {
127
+ agent.destroy();
128
+ resolve({
129
+ ok: true,
130
+ status: res.statusCode ?? 0,
131
+ bytes,
132
+ durationMs: performance.now() - started,
133
+ location: typeof res.headers.location === 'string' ? res.headers.location : undefined,
134
+ });
135
+ });
136
+ res.on('error', (err) => {
137
+ agent.destroy();
138
+ resolve({ ok: false, message: err.message });
139
+ });
140
+ });
141
+ req.on('timeout', () => req.destroy(new Error(`no response within ${timeoutMs} ms`)));
142
+ req.on('error', (err) => {
143
+ agent.destroy();
144
+ resolve({ ok: false, message: err.code ?? err.message });
145
+ });
146
+ if (input.body !== undefined)
147
+ req.write(input.body);
148
+ req.end();
149
+ });
150
+ }
151
+ /** Where a redirect points, when that is loopback too: its origin only. Undefined otherwise. */
152
+ export function loopbackLocation(location, base) {
153
+ if (!location)
154
+ return undefined;
155
+ try {
156
+ const to = new URL(location, base);
157
+ return LOOPBACK_HOSTS.has(to.hostname) ? `${to.origin}${pathOnly(to.pathname)}` : undefined;
158
+ }
159
+ catch {
160
+ return undefined;
161
+ }
162
+ }
163
+ /** True once the trace holds the backend side of the request (a non-browser request entry). */
164
+ export const hasBackend = (t) => Boolean(t?.requests.some((r) => r.collector !== 'browser'));
165
+ //# sourceMappingURL=request.js.map
package/dist/server.d.ts CHANGED
@@ -1,12 +1,32 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type AutoStartOptions } from './autostart.js';
3
+ import { type Resolver } from './request.js';
4
+ import { type ReadSource } from './slices.js';
5
+ /**
6
+ * The run_action description (docs/design/agent-loop.md section 3, D-119; bug-report trigger and
7
+ * one step per call, D-134): under 300 characters.
8
+ */
9
+ export declare const RUN_ACTION_DESCRIPTION: string;
2
10
  export interface McpOptions {
3
11
  workspaceRoot: string;
12
+ /** Resolves `localhost` for run_request's loopback check (tests pass a fake). */
13
+ resolver?: Resolver;
14
+ /** How long run_request waits for the backend trace after the response (default 1,500 ms). */
15
+ requestTraceWaitMs?: number;
16
+ /** Starting Lightpath when it isn't running (D-122): set by `lightpath mcp` from the config. */
17
+ autoStart?: AutoStartOptions;
18
+ /** How long to wait for an auto-started daemon and its browser (default 60 s). */
19
+ autoStartWaitMs?: number;
4
20
  }
5
21
  /**
6
22
  * Builds the MCP server (docs/ARCHITECTURE.md section 9.3). Every call reads the discovery file
7
23
  * and connects fresh, so the server works whether `lightpath dev` started before or after it.
8
24
  */
9
25
  export declare function createMcpServer(options: McpOptions): McpServer;
10
- /** Runs the MCP server over stdio. stdout belongs to the protocol, so nothing else may print to it. */
11
26
  export declare function runMcpServer(options: McpOptions): Promise<void>;
27
+ /**
28
+ * Reads trace files for slices (D-117): only a workspace-relative path whose real path is inside
29
+ * one of the daemon's workspace roots. The daemon never reads files for clients (ARCHITECTURE 7.2).
30
+ */
31
+ export declare function workspaceReader(roots: readonly string[]): ReadSource;
12
32
  //# sourceMappingURL=server.d.ts.map
package/dist/server.js CHANGED
@@ -1,26 +1,149 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { readFile } from 'node:fs/promises';
3
- import { dirname, join, resolve } from 'node:path';
4
- import { DaemonClient, DaemonDiscovery, DISCOVERY_FILE } from '@lightpath-dev/protocol';
2
+ import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { DaemonClient, DaemonDiscovery, DISCOVERY_FILE, PROTOCOL_VERSION, postAction, } from '@lightpath-dev/protocol';
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { z } from 'zod';
8
- import { describeSummaries, describeTrace, diffTraces } from './format.js';
8
+ import { AUTO_START_WAIT_MS, AutoStarter, DEV_LOG } from './autostart.js';
9
+ import { fitText } from './budget.js';
10
+ import { diffFunctions, hasFunctions } from './diff.js';
11
+ import { describeActionResult, describeRequestResult, describeSummaries, diffTraces, redirectNote, } from './format.js';
12
+ import { redact, redactFillValues } from './redact.js';
13
+ import { hasBackend, loopbackLocation, newRequestTraceId, pathOnly, REQUEST_METHODS, REQUEST_TRACE_WAIT_MS, resolveTarget as resolveRequestTarget, sendRequest, } from './request.js';
14
+ import { sliceTrace } from './slices.js';
15
+ import { summarizeTrace } from './summary.js';
16
+ import { DESCRIPTIONS, INSTRUCTIONS } from './texts.js';
17
+ /**
18
+ * The run_action description (docs/design/agent-loop.md section 3, D-119; bug-report trigger and
19
+ * one step per call, D-134): under 300 characters.
20
+ */
21
+ export const RUN_ACTION_DESCRIPTION = "Do one step in the user's running app (click a button or link, or press Enter or Space, after filling " +
22
+ 'fields) and return the code it ran, browser and server. Page, sign-in and data stay between calls. ' +
23
+ 'Use it first to reproduce a reported bug step by step, and again after the fix.';
24
+ const targetSchema = z.string().min(1).max(500).describe('button "Save", text, or css=<selector>');
25
+ const withinSchema = z.string().min(1).max(200).describe('Text of its row or section');
26
+ /**
27
+ * The app URL that run_request resolves a path against: `url` in `lightpath.config.json`, in the
28
+ * folder that holds the daemon's discovery file, or else in the workspace root.
29
+ */
30
+ async function appUrl(dirs) {
31
+ for (const dir of dirs) {
32
+ try {
33
+ const raw = await readFile(join(dir, 'lightpath.config.json'), 'utf8');
34
+ const url = JSON.parse(raw.replace(/^\uFEFF/, '')).url;
35
+ if (typeof url === 'string')
36
+ return url;
37
+ }
38
+ catch {
39
+ // No config here, or not JSON.
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+ /** run_request (design section 4A, D-125): one request on loopback, then its backend trace. */
45
+ async function runRequest(options, found, input) {
46
+ const method = input.method ?? 'GET';
47
+ const target = await resolveRequestTarget(input.url, await appUrl([found.root, options.workspaceRoot]), options.resolver);
48
+ if (!target.ok)
49
+ return `run_request did not send anything: ${target.code}. ${target.message}`;
50
+ const path = pathOnly(target.url.pathname);
51
+ const traceId = newRequestTraceId();
52
+ const client = await DaemonClient.connect(found.info.port, found.info.token);
53
+ try {
54
+ // Listen before sending: the backend delta can arrive before the response does.
55
+ let latest;
56
+ let wake = () => { };
57
+ const off = client.onTrace((t) => {
58
+ if (t.id !== traceId)
59
+ return;
60
+ latest = t;
61
+ if (hasBackend(t))
62
+ wake();
63
+ });
64
+ try {
65
+ const sent = await sendRequest(target, input, traceId);
66
+ if (!sent.ok) {
67
+ return `run_request got no response: no_response. ${method} ${target.url.origin}${path} failed (${sent.message}). Is your server running under lightpath dev?`;
68
+ }
69
+ if (!hasBackend(latest)) {
70
+ await new Promise((resolve) => {
71
+ const timer = setTimeout(resolve, options.requestTraceWaitMs ?? REQUEST_TRACE_WAIT_MS);
72
+ wake = () => {
73
+ clearTimeout(timer);
74
+ resolve();
75
+ };
76
+ });
77
+ }
78
+ const trace = hasBackend(latest) ? latest : await client.getTrace(traceId);
79
+ if (!trace || !hasBackend(trace)) {
80
+ const note = redirectNote(sent.status, loopbackLocation(sent.location, target.url));
81
+ return (`run_request got ${method} ${path} → ${sent.status} (${sent.bytes.toLocaleString('en-US')} bytes, ${Math.round(sent.durationMs)} ms), but no trace: no_trace. ` +
82
+ "No backend lines arrived: the server isn't running under lightpath dev, or Lightpath skips this process." +
83
+ (note ? `\n${note}` : ''));
84
+ }
85
+ return describeRequestResult(method, path, { ...sent, redirect: loopbackLocation(sent.location, target.url) }, trace);
86
+ }
87
+ finally {
88
+ off();
89
+ }
90
+ }
91
+ finally {
92
+ client.close();
93
+ }
94
+ }
9
95
  /** This package's version, read from package.json next to dist/ so it can't drift. */
10
96
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
11
- const text = (t) => ({ content: [{ type: 'text', text: t }] });
12
- const traceIdSchema = z
13
- .string()
14
- .regex(/^t_[a-z0-9]{12}$/)
15
- .describe('Trace id, like t_k3x9q0m2a7bz');
97
+ /**
98
+ * Every tool result goes out through here: redaction rules 2 to 5 (D-123), then the 16,000-character
99
+ * cap again, since a `[redacted]` can be longer than what it hides.
100
+ */
101
+ const text = (t) => ({ content: [{ type: 'text', text: fitText(redact(t)) }] });
102
+ /** Source slices only: the user's own files are never redacted (design section 9). */
103
+ const sourceText = (t) => ({ content: [{ type: 'text', text: t }] });
104
+ const traceIdSchema = z.string().regex(/^t_[a-z0-9]{12}$/);
16
105
  /**
17
106
  * Builds the MCP server (docs/ARCHITECTURE.md section 9.3). Every call reads the discovery file
18
107
  * and connects fresh, so the server works whether `lightpath dev` started before or after it.
19
108
  */
20
109
  export function createMcpServer(options) {
21
- const server = new McpServer({ name: 'lightpath', version: VERSION });
110
+ const server = new McpServer({ name: 'lightpath', version: VERSION }, { instructions: INSTRUCTIONS });
111
+ const starter = new AutoStarter(resolve(options.workspaceRoot), options.autoStart);
112
+ /** The auto-start line, printed at the start of the next tool result (D-122). */
113
+ let note;
114
+ const ensure = async () => {
115
+ const live = await liveDaemon(options.workspaceRoot);
116
+ if (live)
117
+ return live;
118
+ if (!starter.enabled)
119
+ throw new Error(redact(starter.notRunning()));
120
+ note = starter.start();
121
+ const deadline = Date.now() + (options.autoStartWaitMs ?? AUTO_START_WAIT_MS);
122
+ while (Date.now() < deadline) {
123
+ await sleep(250);
124
+ const up = await liveDaemon(options.workspaceRoot);
125
+ if (up)
126
+ return { ...up, started: true };
127
+ if (starter.pid === undefined)
128
+ break;
129
+ }
130
+ throw new Error(redact(`${note}, but it didn't start within 60 s, or it exited. See ${DEV_LOG}.`));
131
+ };
132
+ /** Every tool result: the auto-start line first, when this call started Lightpath. */
133
+ const reply = (t) => {
134
+ const n = note;
135
+ note = undefined;
136
+ return text(n ? `${n}\n${t}` : t);
137
+ };
138
+ /** A source slice: the start line, redacted like any other, then the user's lines as they are. */
139
+ const sourceReply = (t) => {
140
+ const n = note;
141
+ note = undefined;
142
+ return sourceText(n ? `${redact(n)}\n${t}` : t);
143
+ };
22
144
  const withClient = async (fn) => {
23
- const client = await connect(options.workspaceRoot);
145
+ const { info } = await ensure();
146
+ const client = await DaemonClient.connect(info.port, info.token);
24
147
  try {
25
148
  return await fn(client);
26
149
  }
@@ -28,53 +151,182 @@ export function createMcpServer(options) {
28
151
  client.close();
29
152
  }
30
153
  };
31
- server.registerTool('list_traces', {
32
- description: 'List recent user actions (clicks and requests) recorded by Lightpath, newest first.',
154
+ server.registerTool('run_action', {
155
+ description: RUN_ACTION_DESCRIPTION,
33
156
  inputSchema: {
34
- label: z.string().optional().describe('Only actions whose label contains this text, e.g. "Checkout"'),
35
- limit: z.number().int().min(1).max(100).optional(),
157
+ target: targetSchema,
158
+ within: withinSchema.optional(),
159
+ action: z.enum(['click', 'press']).optional().describe('Default click'),
160
+ key: z.enum(['Enter', 'Space']).optional().describe('With press; default Enter'),
161
+ timeoutMs: z.number().int().min(500).max(30_000).optional().describe('Settle cap, default 5000'),
162
+ fill: z
163
+ .array(
164
+ // Same forms as target and within. Bounds and descriptions aren't repeated here, to keep
165
+ // tools/list under 4,000 characters (D-129); the daemon checks them (400).
166
+ z.object({ target: z.string(), within: z.string().optional(), value: z.string() }))
167
+ .max(20)
168
+ .optional()
169
+ .describe('Filled first, in order; values never shown'),
36
170
  },
37
- }, async ({ label, limit }) => text(describeSummaries(await withClient((c) => c.listTraces({ limit: limit ?? 20, ...(label ? { label } : {}) })))));
38
- server.registerTool('get_trace', {
39
- description: 'Show every source file and line that ran for one recorded action, frontend and backend.',
40
- inputSchema: { traceId: traceIdSchema },
41
- }, async ({ traceId }) => {
42
- const trace = await withClient((c) => c.getTrace(traceId));
43
- return text(trace ? describeTrace(trace) : `No trace ${traceId}; it may have been evicted.`);
171
+ }, async (input) => {
172
+ const request = { protocolVersion: PROTOCOL_VERSION, ...input };
173
+ const values = (input.fill ?? []).map((f) => f.value);
174
+ try {
175
+ const { info, started } = await ensure();
176
+ // The daemon answers after the settle cap, the backend wait (1.5 s) and the press wait (2 s).
177
+ const send = () => postAction(info.port, info.token, request, (input.timeoutMs ?? 5000) + 30_000);
178
+ let result = await send();
179
+ // A daemon this server just started opens its browser a little later (design section 11),
180
+ // and the app may still be rendering its first data then (seen in CI: the shop's product
181
+ // rows weren't there yet). So right after a start, `no_browser` is retried up to the
182
+ // start wait, and `not_found` for up to 10 s more.
183
+ const deadline = Date.now() + (options.autoStartWaitMs ?? AUTO_START_WAIT_MS);
184
+ let renderDeadline = Number.POSITIVE_INFINITY;
185
+ while (started && !result.ok && Date.now() < deadline) {
186
+ if (result.error.code === 'not_found') {
187
+ if (renderDeadline === Number.POSITIVE_INFINITY)
188
+ renderDeadline = Date.now() + STARTED_RENDER_WAIT_MS;
189
+ if (Date.now() >= renderDeadline)
190
+ break;
191
+ }
192
+ else if (result.error.code !== 'no_browser')
193
+ break;
194
+ await sleep(500);
195
+ result = await send();
196
+ }
197
+ return { ...reply(describeActionResult(input, result)), ...(result.ok ? {} : { isError: true }) };
198
+ }
199
+ catch (err) {
200
+ return { ...reply(redactFillValues(err.message, values)), isError: true };
201
+ }
202
+ });
203
+ server.registerTool('run_request', {
204
+ description: DESCRIPTIONS.run_request,
205
+ inputSchema: {
206
+ method: z.enum(REQUEST_METHODS).optional().describe('Default GET'),
207
+ url: z.string().min(1).max(2000).describe('Path or loopback URL'),
208
+ body: z.string().max(100_000).optional().describe('Never shown back'),
209
+ contentType: z.string().min(1).optional(),
210
+ timeoutMs: z.number().int().min(500).max(30_000).optional(),
211
+ },
212
+ }, async (input) => {
213
+ let live;
214
+ try {
215
+ live = await ensure();
216
+ }
217
+ catch (err) {
218
+ return reply(`${err.message} Nothing was sent.`);
219
+ }
220
+ return reply(await runRequest(options, live, input));
44
221
  });
45
222
  server.registerTool('find_code_for_action', {
46
- description: 'Find the code that runs when the user does something in the app, e.g. "Checkout" or "Add to cart". ' +
47
- 'Uses real runtime data from the most recent matching action, not static analysis.',
223
+ description: DESCRIPTIONS.find_code_for_action,
48
224
  inputSchema: { action: z.string().min(1).describe('Words from the button or link label') },
49
225
  }, async ({ action }) => {
50
226
  const trace = await withClient(async (c) => {
51
227
  const [match] = await c.listTraces({ label: action, limit: 1 });
52
228
  return match ? c.getTrace(match.id) : null;
53
229
  });
54
- return text(trace
55
- ? describeTrace(trace)
56
- : `No recorded action matches "${action}". Ask the user to click it in the app opened by \`lightpath dev\`, then try again.`);
230
+ return reply(trace
231
+ ? summarizeTrace(trace)
232
+ : `No recorded action matches "${action}". Do it with run_action, or ask the user to click it in the app opened by \`lightpath dev\`, then try again.`);
57
233
  });
234
+ server.registerTool('get_trace', {
235
+ description: DESCRIPTIONS.get_trace,
236
+ inputSchema: {
237
+ traceId: traceIdSchema,
238
+ file: z.string().min(1).optional(),
239
+ function: z.string().min(1).optional(),
240
+ page: z.number().int().min(1).optional(),
241
+ },
242
+ }, async ({ traceId, file, function: fn, page }) => {
243
+ const found = await withClient(async (c) => ({
244
+ trace: await c.getTrace(traceId),
245
+ roots: c.workspaceRoots,
246
+ }));
247
+ if (!found.trace)
248
+ return reply(`No trace ${traceId}; it may have been evicted.`);
249
+ if (file === undefined && fn === undefined)
250
+ return reply(summarizeTrace(found.trace));
251
+ return sourceReply(await sliceTrace(found.trace, { file, function: fn, page }, workspaceReader(found.roots)));
252
+ });
253
+ server.registerTool('list_traces', {
254
+ description: DESCRIPTIONS.list_traces,
255
+ inputSchema: {
256
+ label: z.string().optional(),
257
+ limit: z.number().int().min(1).max(100).optional(),
258
+ },
259
+ }, async ({ label, limit }) => reply(fitText(describeSummaries(await withClient((c) => c.listTraces({ limit: limit ?? 20, ...(label ? { label } : {}) }))))));
58
260
  server.registerTool('diff_traces', {
59
- description: 'Compare two recorded actions and list the lines that ran in only one of them.',
261
+ description: DESCRIPTIONS.diff_traces,
60
262
  inputSchema: { a: traceIdSchema, b: traceIdSchema },
61
263
  }, async ({ a, b }) => {
62
264
  const [ta, tb] = await withClient((c) => Promise.all([c.getTrace(a), c.getTrace(b)]));
63
265
  if (!ta || !tb)
64
- return text(`Trace not found: ${[!ta && a, !tb && b].filter(Boolean).join(', ')}`);
65
- return text(diffTraces(ta, tb));
266
+ return reply(`Trace not found: ${[!ta && a, !tb && b].filter(Boolean).join(', ')}`);
267
+ // Traces from a daemon before build 2 have no function list: compare lines.
268
+ return reply(hasFunctions(ta) && hasFunctions(tb)
269
+ ? diffFunctions(ta, tb)
270
+ : fitText(diffTraces(ta, tb)));
66
271
  });
67
272
  return server;
68
273
  }
69
- /** Runs the MCP server over stdio. stdout belongs to the protocol, so nothing else may print to it. */
274
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
275
+ /** After an auto-start, how long run_action retries a target that isn't on the page yet. */
276
+ const STARTED_RENDER_WAIT_MS = 10_000;
277
+ /** The discovery file's daemon, if it is up: a stale file (a hard-killed daemon) counts as none. */
278
+ async function liveDaemon(workspaceRoot) {
279
+ let found;
280
+ try {
281
+ found = await findDaemonWithRoot(workspaceRoot);
282
+ }
283
+ catch {
284
+ return undefined;
285
+ }
286
+ try {
287
+ const res = await fetch(`http://127.0.0.1:${found.info.port}/v1/health`, {
288
+ signal: AbortSignal.timeout(1500),
289
+ });
290
+ return res.ok ? found : undefined;
291
+ }
292
+ catch {
293
+ return undefined;
294
+ }
295
+ }
70
296
  export async function runMcpServer(options) {
71
297
  await createMcpServer(options).connect(new StdioServerTransport());
72
298
  }
73
- async function connect(workspaceRoot) {
299
+ /**
300
+ * Reads trace files for slices (D-117): only a workspace-relative path whose real path is inside
301
+ * one of the daemon's workspace roots. The daemon never reads files for clients (ARCHITECTURE 7.2).
302
+ */
303
+ export function workspaceReader(roots) {
304
+ return async (path) => {
305
+ if (isAbsolute(path) || path.split(/[\\/]/).includes('..'))
306
+ return undefined;
307
+ for (const root of roots) {
308
+ try {
309
+ const realRoot = await realpath(root);
310
+ const real = await realpath(join(root, path));
311
+ const rel = relative(realRoot, real);
312
+ if (!rel || rel.startsWith('..') || isAbsolute(rel))
313
+ continue;
314
+ const [text, info] = await Promise.all([readFile(real, 'utf8'), stat(real)]);
315
+ return { text, mtimeMs: info.mtimeMs };
316
+ }
317
+ catch {
318
+ // Not in this root.
319
+ }
320
+ }
321
+ return undefined;
322
+ };
323
+ }
324
+ async function findDaemonWithRoot(workspaceRoot) {
74
325
  // The nearest folder at or above the root with a discovery file: editors don't always start
75
326
  // MCP servers in the project folder itself.
76
327
  let raw;
77
- for (let dir = resolve(workspaceRoot);;) {
328
+ let dir = resolve(workspaceRoot);
329
+ for (;;) {
78
330
  raw = await readFile(join(dir, DISCOVERY_FILE), 'utf8').catch(() => undefined);
79
331
  const parent = dirname(dir);
80
332
  if (raw !== undefined || parent === dir)
@@ -84,7 +336,6 @@ async function connect(workspaceRoot) {
84
336
  if (raw === undefined) {
85
337
  throw new Error(`Lightpath isn't running in ${workspaceRoot}. Start it with: lightpath dev -- <your dev command>`);
86
338
  }
87
- const info = DaemonDiscovery.parse(JSON.parse(raw));
88
- return DaemonClient.connect(info.port, info.token);
339
+ return { info: DaemonDiscovery.parse(JSON.parse(raw)), root: dir };
89
340
  }
90
341
  //# sourceMappingURL=server.js.map
@@ -0,0 +1,21 @@
1
+ import type { Trace } from '@lightpath-dev/protocol';
2
+ export interface SourceFile {
3
+ text: string;
4
+ mtimeMs: number;
5
+ }
6
+ /** Reads a trace path from the workspace, or undefined (not inside a root, missing, unreadable). */
7
+ export type ReadSource = (path: string) => Promise<SourceFile | undefined>;
8
+ export interface SliceQuery {
9
+ file?: string | undefined;
10
+ function?: string | undefined;
11
+ /** 1-based. */
12
+ page?: number | undefined;
13
+ }
14
+ /**
15
+ * `get_trace` with `file` or `function` (design section 6.3, D-117): the executed lines of each
16
+ * outermost function that ran, with numbers, merged over every phase. A run of not-run lines folds
17
+ * into one marker, an untaken block inside a line that ran is named after the line, lines with no
18
+ * coverage (blank, comments, types) are left out. Paged under 16,000 characters.
19
+ */
20
+ export declare function sliceTrace(trace: Trace, query: SliceQuery, read: ReadSource): Promise<string>;
21
+ //# sourceMappingURL=slices.d.ts.map