@sdods/mcp 0.2.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 +17 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli.d.ts +50 -0
- package/dist/cli.js +171 -0
- package/dist/fs.d.ts +45 -0
- package/dist/fs.js +184 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/install/index.d.ts +46 -0
- package/dist/install/index.js +146 -0
- package/dist/prompts/index.d.ts +33 -0
- package/dist/prompts/index.js +151 -0
- package/dist/proposals.d.ts +71 -0
- package/dist/proposals.js +188 -0
- package/dist/registry/adapters.d.ts +46 -0
- package/dist/registry/adapters.js +76 -0
- package/dist/registry/registry.d.ts +96 -0
- package/dist/registry/registry.js +141 -0
- package/dist/resources.d.ts +5 -0
- package/dist/resources.js +130 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.js +143 -0
- package/dist/tools/analyze.d.ts +87 -0
- package/dist/tools/analyze.js +820 -0
- package/dist/tools/data.d.ts +18 -0
- package/dist/tools/data.js +153 -0
- package/dist/tools/feature.d.ts +53 -0
- package/dist/tools/feature.js +340 -0
- package/dist/tools/index.d.ts +12 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/issue-schedule.d.ts +22 -0
- package/dist/tools/issue-schedule.js +193 -0
- package/dist/tools/project.d.ts +11 -0
- package/dist/tools/project.js +144 -0
- package/dist/tools/proposal.d.ts +19 -0
- package/dist/tools/proposal.js +89 -0
- package/dist/tools/run.d.ts +49 -0
- package/dist/tools/run.js +387 -0
- package/dist/transports/http.d.ts +40 -0
- package/dist/transports/http.js +151 -0
- package/dist/transports/stdio.d.ts +6 -0
- package/dist/transports/stdio.js +15 -0
- package/package.json +57 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { runFiles } from '@sdods/contracts';
|
|
5
|
+
import { SdodsCliError, sdodsCli } from '../cli.js';
|
|
6
|
+
import { getRun, lastRun, listRuns, listScenarioDirs, readScenarioDir, screenshotUri, } from '../fs.js';
|
|
7
|
+
import { defineTool, summarize } from '../registry/registry.js';
|
|
8
|
+
const runArgs = {
|
|
9
|
+
project: z.string(),
|
|
10
|
+
env: z.string().optional(),
|
|
11
|
+
tags: z.string().optional().describe('Cucumber tag expression, e.g. "@smoke and not @mock"'),
|
|
12
|
+
layers: z.array(z.enum(['ui', 'api', 'hybrid', 'recorded'])).optional(),
|
|
13
|
+
browsers: z
|
|
14
|
+
.array(z.enum(['chromium', 'firefox', 'webkit', 'mobile-chrome', 'mobile-safari']))
|
|
15
|
+
.optional(),
|
|
16
|
+
process: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('named process (pr-check, nightly-regression, release-gate …)'),
|
|
20
|
+
modules: z.array(z.string()).optional(),
|
|
21
|
+
harMode: z.enum(['off', 'update', 'replay']).optional(),
|
|
22
|
+
workers: z.number().int().positive().optional(),
|
|
23
|
+
retries: z.number().int().min(0).optional(),
|
|
24
|
+
grep: z.string().optional(),
|
|
25
|
+
headed: z.boolean().optional(),
|
|
26
|
+
strict: z.boolean().optional().describe('with harMode replay: abort on any un-recorded request'),
|
|
27
|
+
runId: z.string().optional(),
|
|
28
|
+
};
|
|
29
|
+
function buildRunCli(a) {
|
|
30
|
+
const cli = ['run', '-p', a.project];
|
|
31
|
+
if (a.env)
|
|
32
|
+
cli.push('-e', a.env);
|
|
33
|
+
if (a.tags)
|
|
34
|
+
cli.push('-t', a.tags);
|
|
35
|
+
for (const l of a.layers ?? [])
|
|
36
|
+
cli.push('-l', l);
|
|
37
|
+
for (const b of a.browsers ?? [])
|
|
38
|
+
cli.push('-b', b);
|
|
39
|
+
if (a.process)
|
|
40
|
+
cli.push('--process', a.process);
|
|
41
|
+
for (const m of a.modules ?? [])
|
|
42
|
+
cli.push('--module', m);
|
|
43
|
+
if (a.harMode === 'replay')
|
|
44
|
+
cli.push('--har-replay');
|
|
45
|
+
if (a.harMode === 'update')
|
|
46
|
+
cli.push('--har-update');
|
|
47
|
+
if (a.strict)
|
|
48
|
+
cli.push('--strict');
|
|
49
|
+
if (a.workers)
|
|
50
|
+
cli.push('-w', String(a.workers));
|
|
51
|
+
if (a.retries !== undefined)
|
|
52
|
+
cli.push('--retries', String(a.retries));
|
|
53
|
+
if (a.grep)
|
|
54
|
+
cli.push('--grep', a.grep);
|
|
55
|
+
if (a.headed)
|
|
56
|
+
cli.push('--headed');
|
|
57
|
+
if (a.runId)
|
|
58
|
+
cli.push('--run-id', a.runId);
|
|
59
|
+
return cli;
|
|
60
|
+
}
|
|
61
|
+
function scenarioView(runId, s) {
|
|
62
|
+
return {
|
|
63
|
+
fingerprint: s.fingerprint,
|
|
64
|
+
retry: s.retry,
|
|
65
|
+
meta: s.meta,
|
|
66
|
+
screenshots: s.screenshots.map((sh) => ({
|
|
67
|
+
...sh,
|
|
68
|
+
uri: screenshotUri(runId, s.fingerprint, s.retry, sh.file),
|
|
69
|
+
})),
|
|
70
|
+
apiSnapshots: s.apiSnapshots,
|
|
71
|
+
healEvents: s.healEvents,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function summaryText(runId, summary, exitCode) {
|
|
75
|
+
const s = summary;
|
|
76
|
+
const t = s?.totals;
|
|
77
|
+
const head = t
|
|
78
|
+
? `Run ${runId}: ${t.passed ?? 0} passed, ${t.failed ?? 0} failed, ${t.skipped ?? 0} skipped, ${t.flaky ?? 0} flaky (exit ${exitCode})`
|
|
79
|
+
: `Run ${runId} finished with exit code ${exitCode}`;
|
|
80
|
+
return summarize(head, summary ?? { runId, exitCode });
|
|
81
|
+
}
|
|
82
|
+
export const runTools = [
|
|
83
|
+
defineTool({
|
|
84
|
+
name: 'run_tests',
|
|
85
|
+
title: 'Run tests',
|
|
86
|
+
description: 'Run a project slice through `sdods run` (lint → generate specs → run suite). Streams progress lines; returns the run id, totals, failed scenarios and report paths. Test failures are returned as data, not as a tool error.',
|
|
87
|
+
shape: runArgs,
|
|
88
|
+
access: 'run',
|
|
89
|
+
domain: 'runs',
|
|
90
|
+
capability: 'run',
|
|
91
|
+
annotations: { openWorldHint: true },
|
|
92
|
+
docsPath: '/docs/reference/cli-commands/run',
|
|
93
|
+
handler: async (args, ctx) => {
|
|
94
|
+
const cli = buildRunCli(args);
|
|
95
|
+
let runId = args.runId;
|
|
96
|
+
const onLine = (line) => {
|
|
97
|
+
const m = /run[- ]?id[:=]\s*([0-9a-f-]{20,})/i.exec(line);
|
|
98
|
+
if (m && !runId)
|
|
99
|
+
runId = m[1];
|
|
100
|
+
const p = /\[(\d+)\/(\d+)\]/.exec(line);
|
|
101
|
+
ctx.onProgress?.({
|
|
102
|
+
message: line.slice(0, 200),
|
|
103
|
+
current: p ? Number(p[1]) : undefined,
|
|
104
|
+
total: p ? Number(p[2]) : undefined,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
let exitCode = 0;
|
|
108
|
+
let json;
|
|
109
|
+
try {
|
|
110
|
+
const r = await sdodsCli(cli, {
|
|
111
|
+
cwd: ctx.rootDir,
|
|
112
|
+
signal: ctx.signal,
|
|
113
|
+
onLine,
|
|
114
|
+
timeoutMs: 60 * 60_000,
|
|
115
|
+
});
|
|
116
|
+
json = r.json;
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
if (!(e instanceof SdodsCliError))
|
|
120
|
+
throw e;
|
|
121
|
+
if (e.notSupported)
|
|
122
|
+
return {
|
|
123
|
+
text: 'sdods run is not available in this build.',
|
|
124
|
+
data: { note: 'command not available in this build' },
|
|
125
|
+
};
|
|
126
|
+
if (e.exitCode !== 1)
|
|
127
|
+
throw e; // config/lint errors are real errors
|
|
128
|
+
exitCode = 1;
|
|
129
|
+
json = safeJson(e.stderr) ?? safeJson(e.message);
|
|
130
|
+
}
|
|
131
|
+
const summaryObj = json ?? {};
|
|
132
|
+
runId = runId ?? summaryObj.runId ?? lastRun(ctx.rootDir)?.runId;
|
|
133
|
+
const run = runId ? getRun(ctx.rootDir, runId) : undefined;
|
|
134
|
+
const summary = run?.summary ?? json;
|
|
135
|
+
return {
|
|
136
|
+
text: summaryText(runId ?? '?', summary, exitCode),
|
|
137
|
+
data: { runId, exitCode, summary, manifest: run?.manifest },
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
defineTool({
|
|
142
|
+
name: 'run_tests_async',
|
|
143
|
+
title: 'Start a run (async)',
|
|
144
|
+
description: 'Start `sdods run` in the background and return immediately with the run id; poll with run_get.',
|
|
145
|
+
shape: runArgs,
|
|
146
|
+
access: 'run',
|
|
147
|
+
domain: 'runs',
|
|
148
|
+
capability: 'run',
|
|
149
|
+
annotations: { openWorldHint: true },
|
|
150
|
+
handler: async (args, ctx) => {
|
|
151
|
+
const { newRunId } = await import('@sdods/contracts');
|
|
152
|
+
const runId = args.runId ?? newRunId();
|
|
153
|
+
const cli = buildRunCli({ ...args, runId });
|
|
154
|
+
void sdodsCli(cli, { cwd: ctx.rootDir, timeoutMs: 60 * 60_000 }).catch(() => undefined);
|
|
155
|
+
return {
|
|
156
|
+
text: `Run ${runId} started. Poll with run_get { runId: "${runId}" }.`,
|
|
157
|
+
data: { runId, status: 'running' },
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
defineTool({
|
|
162
|
+
name: 'run_list',
|
|
163
|
+
title: 'List runs',
|
|
164
|
+
description: 'Recent runs from the artifacts directory (newest first) with manifest and totals.',
|
|
165
|
+
shape: {
|
|
166
|
+
limit: z.number().int().positive().max(200).optional(),
|
|
167
|
+
project: z.string().optional(),
|
|
168
|
+
},
|
|
169
|
+
access: 'read',
|
|
170
|
+
domain: 'runs',
|
|
171
|
+
capability: 'run',
|
|
172
|
+
handler: async (args, ctx) => {
|
|
173
|
+
const runs = listRuns(ctx.rootDir, args.limit ?? 20)
|
|
174
|
+
.filter((r) => (args.project ? r.manifest?.projectSlug === args.project : true))
|
|
175
|
+
.map((r) => ({
|
|
176
|
+
runId: r.runId,
|
|
177
|
+
project: r.manifest?.projectSlug,
|
|
178
|
+
env: r.manifest?.env,
|
|
179
|
+
startedAt: r.manifest?.startedAt,
|
|
180
|
+
status: r.summary?.status ?? (r.summary ? 'finished' : 'running'),
|
|
181
|
+
totals: r.summary?.totals,
|
|
182
|
+
process: r.manifest?.process,
|
|
183
|
+
}));
|
|
184
|
+
return { text: summarize(`Runs (${runs.length})`, runs), data: runs };
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
defineTool({
|
|
188
|
+
name: 'run_get',
|
|
189
|
+
title: 'Get run',
|
|
190
|
+
description: 'Manifest, summary, report paths and the scenario list of a run (use "last" for the latest).',
|
|
191
|
+
shape: { runId: z.string() },
|
|
192
|
+
access: 'read',
|
|
193
|
+
domain: 'runs',
|
|
194
|
+
capability: 'run',
|
|
195
|
+
handler: async (args, ctx) => {
|
|
196
|
+
const run = args.runId === 'last' ? lastRun(ctx.rootDir) : getRun(ctx.rootDir, args.runId);
|
|
197
|
+
if (!run)
|
|
198
|
+
throw Object.assign(new Error(`Unknown run ${args.runId}`), {
|
|
199
|
+
error: { code: 'NOT_FOUND' },
|
|
200
|
+
});
|
|
201
|
+
const scenarios = listScenarioDirs(run.dir, run.manifest?.projectSlug).map((s) => ({
|
|
202
|
+
fingerprint: s.fingerprint,
|
|
203
|
+
retry: s.retry,
|
|
204
|
+
name: s.meta?.scenarioName,
|
|
205
|
+
feature: s.meta?.featureUri,
|
|
206
|
+
status: s.meta?.status,
|
|
207
|
+
durationMs: s.meta?.durationMs,
|
|
208
|
+
screenshots: s.screenshots.length,
|
|
209
|
+
apiCalls: s.apiSnapshots.length / 2,
|
|
210
|
+
heals: s.healEvents.length,
|
|
211
|
+
}));
|
|
212
|
+
const data = {
|
|
213
|
+
runId: run.runId,
|
|
214
|
+
dir: run.dir,
|
|
215
|
+
status: run.summary?.status ??
|
|
216
|
+
(existsSync(join(run.dir, runFiles.summary)) ? 'finished' : 'running'),
|
|
217
|
+
manifest: run.manifest,
|
|
218
|
+
summary: run.summary,
|
|
219
|
+
reports: {
|
|
220
|
+
html: join(run.dir, runFiles.htmlReport),
|
|
221
|
+
dashboard: join(run.dir, runFiles.dashboard),
|
|
222
|
+
messages: join(run.dir, runFiles.messages),
|
|
223
|
+
},
|
|
224
|
+
scenarios,
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
text: summarize(`Run ${run.runId}`, { ...data, scenarios: scenarios.slice(0, 50) }),
|
|
228
|
+
data,
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
defineTool({
|
|
233
|
+
name: 'run_get_scenario',
|
|
234
|
+
title: 'Get scenario result',
|
|
235
|
+
description: 'One scenario of a run: meta, steps, before/after screenshot URIs (sdods://screenshot/…), API request/response snapshots and heal events.',
|
|
236
|
+
shape: {
|
|
237
|
+
runId: z.string(),
|
|
238
|
+
fingerprint: z.string(),
|
|
239
|
+
retry: z.number().int().min(0).optional(),
|
|
240
|
+
},
|
|
241
|
+
access: 'read',
|
|
242
|
+
domain: 'runs',
|
|
243
|
+
capability: 'run',
|
|
244
|
+
handler: async (args, ctx) => {
|
|
245
|
+
const run = args.runId === 'last' ? lastRun(ctx.rootDir) : getRun(ctx.rootDir, args.runId);
|
|
246
|
+
if (!run)
|
|
247
|
+
throw Object.assign(new Error(`Unknown run ${args.runId}`), {
|
|
248
|
+
error: { code: 'NOT_FOUND' },
|
|
249
|
+
});
|
|
250
|
+
const all = listScenarioDirs(run.dir, run.manifest?.projectSlug).filter((s) => s.fingerprint === args.fingerprint || s.meta?.scenarioName === args.fingerprint);
|
|
251
|
+
if (!all.length)
|
|
252
|
+
throw Object.assign(new Error(`Scenario ${args.fingerprint} not found in run ${run.runId}`), { error: { code: 'NOT_FOUND' } });
|
|
253
|
+
const pick = args.retry !== undefined
|
|
254
|
+
? all.find((s) => s.retry === args.retry)
|
|
255
|
+
: all.sort((a, b) => b.retry - a.retry)[0];
|
|
256
|
+
const s = pick ?? all[0];
|
|
257
|
+
const data = {
|
|
258
|
+
runId: run.runId,
|
|
259
|
+
...scenarioView(run.runId, readScenarioDir(s.dir, s.fingerprint, s.retry)),
|
|
260
|
+
attempts: all.map((a) => a.retry),
|
|
261
|
+
};
|
|
262
|
+
return {
|
|
263
|
+
text: summarize(`Scenario ${s.meta?.scenarioName ?? s.fingerprint} (retry ${s.retry})`, data),
|
|
264
|
+
data,
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
}),
|
|
268
|
+
defineTool({
|
|
269
|
+
name: 'run_last_failed',
|
|
270
|
+
title: 'Last failed scenarios',
|
|
271
|
+
description: 'Failed scenarios of the latest run (or a given run) with error messages and failure screenshots.',
|
|
272
|
+
shape: { runId: z.string().optional(), project: z.string().optional() },
|
|
273
|
+
access: 'read',
|
|
274
|
+
domain: 'runs',
|
|
275
|
+
capability: 'run',
|
|
276
|
+
handler: async (args, ctx) => {
|
|
277
|
+
const run = args.runId
|
|
278
|
+
? getRun(ctx.rootDir, args.runId)
|
|
279
|
+
: listRuns(ctx.rootDir, 50).find((r) => args.project ? r.manifest?.projectSlug === args.project : true);
|
|
280
|
+
if (!run)
|
|
281
|
+
return { text: 'No runs found.', data: { failed: [] } };
|
|
282
|
+
const failed = listScenarioDirs(run.dir, run.manifest?.projectSlug)
|
|
283
|
+
.filter((s) => s.meta?.status && s.meta.status !== 'passed' && s.meta.status !== 'skipped')
|
|
284
|
+
.map((s) => ({
|
|
285
|
+
fingerprint: s.fingerprint,
|
|
286
|
+
retry: s.retry,
|
|
287
|
+
name: s.meta?.scenarioName,
|
|
288
|
+
feature: s.meta?.featureUri,
|
|
289
|
+
status: s.meta?.status,
|
|
290
|
+
error: s.meta?.errorMessage,
|
|
291
|
+
failureShot: s.screenshots.find((x) => x.phase === 'failure')
|
|
292
|
+
? screenshotUri(run.runId, s.fingerprint, s.retry, 'scenario-failure.png')
|
|
293
|
+
: undefined,
|
|
294
|
+
}));
|
|
295
|
+
const fromSummary = run.summary?.failed ?? [];
|
|
296
|
+
return {
|
|
297
|
+
text: summarize(`Failed in ${run.runId}: ${failed.length || fromSummary.length}`, failed.length ? failed : fromSummary),
|
|
298
|
+
data: { runId: run.runId, failed: failed.length ? failed : fromSummary },
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
}),
|
|
302
|
+
defineTool({
|
|
303
|
+
name: 'heal_events',
|
|
304
|
+
title: 'Heal events',
|
|
305
|
+
description: 'Self-healing locator events of a run (or the last run): original selector, chosen strategy, candidates and scores.',
|
|
306
|
+
shape: { runId: z.string().optional(), project: z.string().optional() },
|
|
307
|
+
access: 'read',
|
|
308
|
+
domain: 'runs',
|
|
309
|
+
capability: 'run',
|
|
310
|
+
handler: async (args, ctx) => {
|
|
311
|
+
const run = args.runId
|
|
312
|
+
? getRun(ctx.rootDir, args.runId)
|
|
313
|
+
: listRuns(ctx.rootDir, 50).find((r) => args.project ? r.manifest?.projectSlug === args.project : true);
|
|
314
|
+
if (!run)
|
|
315
|
+
return { text: 'No runs found.', data: { events: [] } };
|
|
316
|
+
const events = listScenarioDirs(run.dir, run.manifest?.projectSlug).flatMap((s) => s.healEvents.map((e) => ({
|
|
317
|
+
fingerprint: s.fingerprint,
|
|
318
|
+
retry: s.retry,
|
|
319
|
+
scenario: s.meta?.scenarioName,
|
|
320
|
+
...e,
|
|
321
|
+
})));
|
|
322
|
+
return {
|
|
323
|
+
text: summarize(`Heal events in ${run.runId}: ${events.length}`, events),
|
|
324
|
+
data: { runId: run.runId, events },
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
defineTool({
|
|
329
|
+
name: 'heal_locator_stats',
|
|
330
|
+
title: 'Locator stats',
|
|
331
|
+
description: 'Aggregate heal events across recent runs per original selector: occurrences, winning strategies, suggested selector.',
|
|
332
|
+
shape: {
|
|
333
|
+
project: z.string().optional(),
|
|
334
|
+
runs: z.number().int().positive().max(200).optional(),
|
|
335
|
+
},
|
|
336
|
+
access: 'read',
|
|
337
|
+
domain: 'runs',
|
|
338
|
+
capability: 'run',
|
|
339
|
+
handler: async (args, ctx) => {
|
|
340
|
+
const stats = new Map();
|
|
341
|
+
for (const run of listRuns(ctx.rootDir, args.runs ?? 30).filter((r) => args.project ? r.manifest?.projectSlug === args.project : true)) {
|
|
342
|
+
for (const s of listScenarioDirs(run.dir, run.manifest?.projectSlug)) {
|
|
343
|
+
for (const raw of s.healEvents) {
|
|
344
|
+
const key = String(raw.originalSelector ?? raw.original ?? '?');
|
|
345
|
+
const entry = stats.get(key) ?? {
|
|
346
|
+
selector: key,
|
|
347
|
+
description: raw.description,
|
|
348
|
+
occurrences: 0,
|
|
349
|
+
strategies: {},
|
|
350
|
+
lastRun: run.runId,
|
|
351
|
+
};
|
|
352
|
+
entry.occurrences++;
|
|
353
|
+
const strategy = String(raw.strategyUsed ??
|
|
354
|
+
raw.chosen?.strategy ??
|
|
355
|
+
'none');
|
|
356
|
+
entry.strategies[strategy] = (entry.strategies[strategy] ?? 0) + 1;
|
|
357
|
+
entry.suggested =
|
|
358
|
+
raw.healedSelector ??
|
|
359
|
+
raw.chosen?.selector ??
|
|
360
|
+
entry.suggested;
|
|
361
|
+
stats.set(key, entry);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const rows = [...stats.values()].sort((a, b) => b.occurrences - a.occurrences);
|
|
366
|
+
return { text: summarize(`Locator stats (${rows.length} selectors)`, rows), data: rows };
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
];
|
|
370
|
+
function safeJson(s) {
|
|
371
|
+
if (!s)
|
|
372
|
+
return undefined;
|
|
373
|
+
const m = /\{[\s\S]*\}$/m.exec(s.trim());
|
|
374
|
+
if (!m)
|
|
375
|
+
return undefined;
|
|
376
|
+
try {
|
|
377
|
+
return JSON.parse(m[0]);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
export function readRunLog(dir) {
|
|
384
|
+
const f = join(dir, runFiles.log);
|
|
385
|
+
return existsSync(f) ? readFileSync(f, 'utf8') : undefined;
|
|
386
|
+
}
|
|
387
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
2
|
+
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
|
3
|
+
import { type BuildServerOptions } from '../server.js';
|
|
4
|
+
import type { Principal } from '../registry/registry.js';
|
|
5
|
+
export type Authenticate = (token: string | undefined, req: IncomingMessage) => Promise<Principal | null> | Principal | null;
|
|
6
|
+
export interface HttpTransportOptions extends Omit<BuildServerOptions, 'principal'> {
|
|
7
|
+
/** Resolve a bearer token to a principal. Default denies everything. */
|
|
8
|
+
authenticate?: Authenticate;
|
|
9
|
+
/** idle session eviction (ms) */
|
|
10
|
+
sessionIdleMs?: number;
|
|
11
|
+
/** allowed Host header values (defaults to localhost variants); '*' disables the check */
|
|
12
|
+
allowedHosts?: string[];
|
|
13
|
+
path?: string;
|
|
14
|
+
}
|
|
15
|
+
interface Session {
|
|
16
|
+
transport: NodeStreamableHTTPServerTransport;
|
|
17
|
+
principalKey: string;
|
|
18
|
+
lastSeen: number;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Node request handler for the streamable-HTTP MCP endpoint. Mount it on any http server
|
|
23
|
+
* (Fastify can pass raw req/res). Sessions are token-bound and evicted when idle.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createMcpHttpHandler(opts?: HttpTransportOptions): {
|
|
26
|
+
handler: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise<void>;
|
|
27
|
+
sessions: Map<string, Session>;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
/** Standalone HTTP server (`sdods mcp --http --port 4001`). */
|
|
31
|
+
export declare function serveSdodsHttp(opts?: HttpTransportOptions & {
|
|
32
|
+
port?: number;
|
|
33
|
+
host?: string;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
server: Server;
|
|
36
|
+
port: number;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}>;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
|
4
|
+
import { buildSdodsMcpServer } from '../server.js';
|
|
5
|
+
/**
|
|
6
|
+
* Node request handler for the streamable-HTTP MCP endpoint. Mount it on any http server
|
|
7
|
+
* (Fastify can pass raw req/res). Sessions are token-bound and evicted when idle.
|
|
8
|
+
*/
|
|
9
|
+
export function createMcpHttpHandler(opts = {}) {
|
|
10
|
+
const sessions = new Map();
|
|
11
|
+
const authenticate = opts.authenticate ?? (() => null);
|
|
12
|
+
const idleMs = opts.sessionIdleMs ?? 30 * 60_000;
|
|
13
|
+
const evict = setInterval(() => {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
for (const [id, s] of sessions)
|
|
16
|
+
if (now - s.lastSeen > idleMs)
|
|
17
|
+
void s.close().finally(() => sessions.delete(id));
|
|
18
|
+
}, 60_000);
|
|
19
|
+
evict.unref();
|
|
20
|
+
const handler = async (req, res, parsedBody) => {
|
|
21
|
+
const header = req.headers.authorization;
|
|
22
|
+
const token = header?.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : undefined;
|
|
23
|
+
const principal = await authenticate(token, req);
|
|
24
|
+
if (!principal) {
|
|
25
|
+
res.writeHead(401, {
|
|
26
|
+
'content-type': 'application/json',
|
|
27
|
+
'www-authenticate': 'Bearer realm="sdods"',
|
|
28
|
+
});
|
|
29
|
+
res.end(JSON.stringify({
|
|
30
|
+
error: {
|
|
31
|
+
code: 'AUTH_FAILED',
|
|
32
|
+
message: 'A valid bearer token is required.',
|
|
33
|
+
hint: 'Create one with `sdods tokens create` and pass Authorization: Bearer <token>.',
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const principalKey = principal.userId ?? principal.name ?? token ?? 'anonymous';
|
|
39
|
+
const sid = req.headers['mcp-session-id'];
|
|
40
|
+
const sessionId = Array.isArray(sid) ? sid[0] : sid;
|
|
41
|
+
let session = sessionId ? sessions.get(sessionId) : undefined;
|
|
42
|
+
if (session && session.principalKey !== principalKey) {
|
|
43
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
44
|
+
res.end(JSON.stringify({
|
|
45
|
+
error: { code: 'AUTH_FAILED', message: 'Session belongs to another principal.' },
|
|
46
|
+
}));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!session) {
|
|
50
|
+
if (req.method !== 'POST') {
|
|
51
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
52
|
+
res.end(JSON.stringify({
|
|
53
|
+
error: {
|
|
54
|
+
code: 'INVALID_REQUEST',
|
|
55
|
+
message: 'No session; send an initialize request first.',
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const built = buildSdodsMcpServer({ ...opts, principal: { ...principal, via: 'http' } });
|
|
61
|
+
const transport = new NodeStreamableHTTPServerTransport({
|
|
62
|
+
sessionIdGenerator: () => randomUUID(),
|
|
63
|
+
onsessioninitialized: (id) => {
|
|
64
|
+
sessions.set(id, {
|
|
65
|
+
transport,
|
|
66
|
+
principalKey,
|
|
67
|
+
lastSeen: Date.now(),
|
|
68
|
+
close: () => built.server.close(),
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
transport.onclose = () => {
|
|
73
|
+
if (transport.sessionId)
|
|
74
|
+
sessions.delete(transport.sessionId);
|
|
75
|
+
};
|
|
76
|
+
await built.server.connect(transport);
|
|
77
|
+
session = {
|
|
78
|
+
transport,
|
|
79
|
+
principalKey,
|
|
80
|
+
lastSeen: Date.now(),
|
|
81
|
+
close: () => built.server.close(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
session.lastSeen = Date.now();
|
|
85
|
+
await session.transport.handleRequest(req, res, parsedBody);
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
handler,
|
|
89
|
+
sessions,
|
|
90
|
+
async close() {
|
|
91
|
+
clearInterval(evict);
|
|
92
|
+
for (const s of sessions.values())
|
|
93
|
+
await s.close().catch(() => undefined);
|
|
94
|
+
sessions.clear();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Standalone HTTP server (`sdods mcp --http --port 4001`). */
|
|
99
|
+
export function serveSdodsHttp(opts = {}) {
|
|
100
|
+
const path = opts.path ?? '/mcp';
|
|
101
|
+
const mcp = createMcpHttpHandler(opts);
|
|
102
|
+
const server = createServer((req, res) => {
|
|
103
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
104
|
+
if (url.pathname === '/healthz') {
|
|
105
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
106
|
+
res.end(JSON.stringify({ ok: true, sessions: mcp.sessions.size }));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (url.pathname !== path) {
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const chunks = [];
|
|
115
|
+
req.on('data', (c) => chunks.push(c));
|
|
116
|
+
req.on('end', () => {
|
|
117
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
118
|
+
let body;
|
|
119
|
+
if (raw) {
|
|
120
|
+
try {
|
|
121
|
+
body = JSON.parse(raw);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
125
|
+
res.end(JSON.stringify({ error: { code: 'INVALID_REQUEST', message: 'Body is not JSON' } }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
mcp.handler(req, res, body).catch((e) => {
|
|
130
|
+
if (!res.headersSent)
|
|
131
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ error: { code: 'INTERNAL', message: e.message } }));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
server.listen(opts.port ?? 4001, opts.host ?? '127.0.0.1', () => {
|
|
138
|
+
const addr = server.address();
|
|
139
|
+
const port = typeof addr === 'object' && addr ? addr.port : (opts.port ?? 4001);
|
|
140
|
+
resolve({
|
|
141
|
+
server,
|
|
142
|
+
port,
|
|
143
|
+
close: async () => {
|
|
144
|
+
await mcp.close();
|
|
145
|
+
await new Promise((r) => server.close(() => r()));
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type BuildServerOptions } from '../server.js';
|
|
2
|
+
/** Serve SDODS over stdio (used by `sdods mcp`). Logs go to stderr only. */
|
|
3
|
+
export declare function serveSdodsStdio(opts?: BuildServerOptions): {
|
|
4
|
+
close(): Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
//# sourceMappingURL=stdio.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
2
|
+
import { buildSdodsMcpServer } from '../server.js';
|
|
3
|
+
/** Serve SDODS over stdio (used by `sdods mcp`). Logs go to stderr only. */
|
|
4
|
+
export function serveSdodsStdio(opts = {}) {
|
|
5
|
+
const built = buildSdodsMcpServer({
|
|
6
|
+
...opts,
|
|
7
|
+
principal: opts.principal ?? { name: 'local', scopes: ['*'], via: 'stdio' },
|
|
8
|
+
});
|
|
9
|
+
built.ctx.logger.info(`serving ${built.toolCount} tools over stdio (caps: ${[...built.ctx.caps].join(',')})`);
|
|
10
|
+
const handle = serveStdio(() => built.server, {
|
|
11
|
+
onerror: (e) => built.ctx.logger.warn('stdio error', { message: e?.message ?? String(e) }),
|
|
12
|
+
});
|
|
13
|
+
return { close: () => handle.close() };
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=stdio.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sdods/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "SDODS MCP server: project analysis, runs, results and proposals as tools for AI clients.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "SDODS <admin@sdods.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./registry": "./dist/registry/registry.js",
|
|
11
|
+
"./server": "./dist/server.js",
|
|
12
|
+
"./cli": "./dist/cli.js",
|
|
13
|
+
"./proposals": "./dist/proposals.js",
|
|
14
|
+
"./prompts": "./dist/prompts/index.js",
|
|
15
|
+
"./install": "./dist/install/index.js",
|
|
16
|
+
"./transports/stdio": "./dist/transports/stdio.js",
|
|
17
|
+
"./transports/http": "./dist/transports/http.js"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@sdods/contracts": "0.2.0",
|
|
30
|
+
"@cucumber/gherkin": "^42.0.1",
|
|
31
|
+
"@cucumber/messages": "^34.2.1",
|
|
32
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
33
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
34
|
+
"csv-parse": "^7.0.2",
|
|
35
|
+
"yaml": "^2.9.0",
|
|
36
|
+
"zod": "^4.5.4",
|
|
37
|
+
"smol-toml": "^1.8.0"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://sdods.com",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/siri1410/SDODS/issues"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/siri1410/SDODS.git",
|
|
46
|
+
"directory": "packages/mcp"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"sdods",
|
|
50
|
+
"automation",
|
|
51
|
+
"orchestration",
|
|
52
|
+
"testing",
|
|
53
|
+
"bdd",
|
|
54
|
+
"ai-agents",
|
|
55
|
+
"mcp"
|
|
56
|
+
]
|
|
57
|
+
}
|