@lightpath-dev/mcp 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/autostart.d.ts +33 -0
- package/dist/autostart.js +79 -0
- package/dist/budget.d.ts +39 -0
- package/dist/budget.js +77 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +126 -0
- package/dist/format.d.ts +33 -0
- package/dist/format.js +118 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/redact.d.ts +15 -0
- package/dist/redact.js +41 -0
- package/dist/request.d.ts +64 -0
- package/dist/request.js +165 -0
- package/dist/server.d.ts +32 -0
- package/dist/server.js +341 -0
- package/dist/slices.d.ts +21 -0
- package/dist/slices.js +216 -0
- package/dist/summary.d.ts +30 -0
- package/dist/summary.js +185 -0
- package/dist/texts.d.ts +10 -0
- package/dist/texts.js +13 -0
- package/package.json +47 -3
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Trace, type TraceId } from '@lightpath-dev/protocol';
|
|
2
|
+
/** Longest wait after the response for the backend trace (design section 4A, D-125). */
|
|
3
|
+
export declare const REQUEST_TRACE_WAIT_MS = 1500;
|
|
4
|
+
export declare const REQUEST_METHODS: readonly ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
5
|
+
export type RequestMethod = (typeof REQUEST_METHODS)[number];
|
|
6
|
+
export interface RunRequestInput {
|
|
7
|
+
method?: RequestMethod | undefined;
|
|
8
|
+
/** A path (`/api/orders`), resolved against the app's URL, or a full loopback URL. */
|
|
9
|
+
url: string;
|
|
10
|
+
body?: string | undefined;
|
|
11
|
+
contentType?: string | undefined;
|
|
12
|
+
timeoutMs?: number | undefined;
|
|
13
|
+
}
|
|
14
|
+
export type Resolver = (host: string) => Promise<{
|
|
15
|
+
address: string;
|
|
16
|
+
family: number;
|
|
17
|
+
}[]>;
|
|
18
|
+
/** True for 127.0.0.0/8 and ::1 (and ::ffff:127.x), the only addresses a request may go to. */
|
|
19
|
+
export declare function isLoopbackAddress(address: string): boolean;
|
|
20
|
+
export type Target = {
|
|
21
|
+
ok: true;
|
|
22
|
+
url: URL;
|
|
23
|
+
address: string;
|
|
24
|
+
family: number;
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
code: 'not_loopback' | 'no_app_url';
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Checks where a request would go, before any network call (design section 4A): http or https,
|
|
32
|
+
* host `localhost`, `127.0.0.1` or `[::1]`, and every address `localhost` resolves to on loopback.
|
|
33
|
+
* Returns the vetted address, so the request connects there and can't be sent elsewhere by a
|
|
34
|
+
* second lookup.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveTarget(raw: string, appUrl: string | undefined, resolve?: Resolver): Promise<Target>;
|
|
37
|
+
/** `/api/orders?token=x` → `/api/orders`: queries never reach the output (they can hold secrets). */
|
|
38
|
+
export declare const pathOnly: (pathOrUrl: string) => string;
|
|
39
|
+
/** `t_` + 12 random base36 characters, the TraceId format (packages/protocol/src/model.ts). */
|
|
40
|
+
export declare function newRequestTraceId(): TraceId;
|
|
41
|
+
export type Sent = {
|
|
42
|
+
ok: true;
|
|
43
|
+
status: number;
|
|
44
|
+
bytes: number;
|
|
45
|
+
durationMs: number;
|
|
46
|
+
location?: string | undefined;
|
|
47
|
+
} | {
|
|
48
|
+
ok: false;
|
|
49
|
+
message: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Sends one request to a vetted loopback address with Lightpath's trace header. Uses node:http
|
|
53
|
+
* with its own agent: no proxy (HTTP_PROXY, HTTPS_PROXY and NODE_USE_ENV_PROXY are ignored), no
|
|
54
|
+
* redirect followed, and the connection goes to the address `resolveTarget` checked. The response
|
|
55
|
+
* body is read for its size only and never kept.
|
|
56
|
+
*/
|
|
57
|
+
export declare function sendRequest(target: Extract<Target, {
|
|
58
|
+
ok: true;
|
|
59
|
+
}>, input: RunRequestInput, traceId: TraceId): Promise<Sent>;
|
|
60
|
+
/** Where a redirect points, when that is loopback too: its origin only. Undefined otherwise. */
|
|
61
|
+
export declare function loopbackLocation(location: string | undefined, base: URL): string | undefined;
|
|
62
|
+
/** True once the trace holds the backend side of the request (a non-browser request entry). */
|
|
63
|
+
export declare const hasBackend: (t: Trace | null | undefined) => boolean;
|
|
64
|
+
//# sourceMappingURL=request.d.ts.map
|
package/dist/request.js
ADDED
|
@@ -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
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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;
|
|
10
|
+
export interface McpOptions {
|
|
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;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Builds the MCP server (docs/ARCHITECTURE.md section 9.3). Every call reads the discovery file
|
|
23
|
+
* and connects fresh, so the server works whether `lightpath dev` started before or after it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createMcpServer(options: McpOptions): McpServer;
|
|
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;
|
|
32
|
+
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
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
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
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
|
+
}
|
|
95
|
+
/** This package's version, read from package.json next to dist/ so it can't drift. */
|
|
96
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
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}$/);
|
|
105
|
+
/**
|
|
106
|
+
* Builds the MCP server (docs/ARCHITECTURE.md section 9.3). Every call reads the discovery file
|
|
107
|
+
* and connects fresh, so the server works whether `lightpath dev` started before or after it.
|
|
108
|
+
*/
|
|
109
|
+
export function createMcpServer(options) {
|
|
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
|
+
};
|
|
144
|
+
const withClient = async (fn) => {
|
|
145
|
+
const { info } = await ensure();
|
|
146
|
+
const client = await DaemonClient.connect(info.port, info.token);
|
|
147
|
+
try {
|
|
148
|
+
return await fn(client);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
client.close();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
server.registerTool('run_action', {
|
|
155
|
+
description: RUN_ACTION_DESCRIPTION,
|
|
156
|
+
inputSchema: {
|
|
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'),
|
|
170
|
+
},
|
|
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));
|
|
221
|
+
});
|
|
222
|
+
server.registerTool('find_code_for_action', {
|
|
223
|
+
description: DESCRIPTIONS.find_code_for_action,
|
|
224
|
+
inputSchema: { action: z.string().min(1).describe('Words from the button or link label') },
|
|
225
|
+
}, async ({ action }) => {
|
|
226
|
+
const trace = await withClient(async (c) => {
|
|
227
|
+
const [match] = await c.listTraces({ label: action, limit: 1 });
|
|
228
|
+
return match ? c.getTrace(match.id) : null;
|
|
229
|
+
});
|
|
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.`);
|
|
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 } : {}) }))))));
|
|
260
|
+
server.registerTool('diff_traces', {
|
|
261
|
+
description: DESCRIPTIONS.diff_traces,
|
|
262
|
+
inputSchema: { a: traceIdSchema, b: traceIdSchema },
|
|
263
|
+
}, async ({ a, b }) => {
|
|
264
|
+
const [ta, tb] = await withClient((c) => Promise.all([c.getTrace(a), c.getTrace(b)]));
|
|
265
|
+
if (!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)));
|
|
271
|
+
});
|
|
272
|
+
return server;
|
|
273
|
+
}
|
|
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
|
+
}
|
|
296
|
+
export async function runMcpServer(options) {
|
|
297
|
+
await createMcpServer(options).connect(new StdioServerTransport());
|
|
298
|
+
}
|
|
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) {
|
|
325
|
+
// The nearest folder at or above the root with a discovery file: editors don't always start
|
|
326
|
+
// MCP servers in the project folder itself.
|
|
327
|
+
let raw;
|
|
328
|
+
let dir = resolve(workspaceRoot);
|
|
329
|
+
for (;;) {
|
|
330
|
+
raw = await readFile(join(dir, DISCOVERY_FILE), 'utf8').catch(() => undefined);
|
|
331
|
+
const parent = dirname(dir);
|
|
332
|
+
if (raw !== undefined || parent === dir)
|
|
333
|
+
break;
|
|
334
|
+
dir = parent;
|
|
335
|
+
}
|
|
336
|
+
if (raw === undefined) {
|
|
337
|
+
throw new Error(`Lightpath isn't running in ${workspaceRoot}. Start it with: lightpath dev -- <your dev command>`);
|
|
338
|
+
}
|
|
339
|
+
return { info: DaemonDiscovery.parse(JSON.parse(raw)), root: dir };
|
|
340
|
+
}
|
|
341
|
+
//# sourceMappingURL=server.js.map
|
package/dist/slices.d.ts
ADDED
|
@@ -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
|