@selfcure/mcp 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/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @selfcure/mcp
2
+
3
+ > Model Context Protocol server that publishes **selfcure**'s testability analysis to any MCP client.
4
+
5
+ Exposes selfcure's crawler + analyzer findings (component scores, ambiguous locators, suggested `data-testid` patches) over MCP, so an AI agent in Claude Desktop, Cursor, VS Code + GitHub Copilot, Claude Code, or Windsurf can read what the static frontend source makes — or doesn't make — testable.
6
+
7
+ This is the **preventive** companion to the Playwright Test Agents (Planner / Generator / Healer): it surfaces what those runtime agents can't see — the static source, the testability score, and the ambiguity between sibling elements.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g @selfcure/mcp
13
+ # or run on demand:
14
+ npx @selfcure/mcp
15
+ ```
16
+
17
+ ## Use as an MCP server
18
+
19
+ Add to your MCP client config (example for a generic `mcp.json`):
20
+
21
+ ```json
22
+ {
23
+ "servers": {
24
+ "selfcure": { "command": "npx", "args": ["-y", "@selfcure/mcp"] }
25
+ }
26
+ }
27
+ ```
28
+
29
+ Then ask your agent which components are blocking test generation, where selectors are ambiguous, and which `data-testid`s to add.
30
+
31
+ ## Docs
32
+
33
+ Full documentation: https://github.com/ricardofrancocustodio/selfcure#readme
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,665 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import {
7
+ CallToolRequestSchema,
8
+ ErrorCode,
9
+ GetPromptRequestSchema,
10
+ ListPromptsRequestSchema,
11
+ ListResourcesRequestSchema,
12
+ ListToolsRequestSchema,
13
+ McpError,
14
+ ReadResourceRequestSchema
15
+ } from "@modelcontextprotocol/sdk/types.js";
16
+ import { crawl, discoverProject } from "@selfcure/crawler";
17
+ import {
18
+ analyze,
19
+ enrichTmlWithInventory,
20
+ loadInventory,
21
+ TML_LABELS
22
+ } from "@selfcure/analyzer";
23
+ import path from "path";
24
+ import fs from "fs";
25
+ import { pathToFileURL } from "url";
26
+ function resolveConfigPath(cwd, provided) {
27
+ if (provided) return path.resolve(cwd, provided);
28
+ const mjs = path.resolve(cwd, "selfcure.config.mjs");
29
+ if (fs.existsSync(mjs)) return mjs;
30
+ return path.resolve(cwd, "selfcure.config.js");
31
+ }
32
+ async function loadConfig(cwd, configPath) {
33
+ const absPath = resolveConfigPath(cwd, configPath);
34
+ if (!fs.existsSync(absPath)) {
35
+ throw new McpError(
36
+ ErrorCode.InvalidRequest,
37
+ `Config not found: ${absPath}. Run \`selfcure init\` or pass configPath explicitly.`
38
+ );
39
+ }
40
+ const url = `${pathToFileURL(absPath).href}?t=${Date.now()}`;
41
+ const mod = await import(url);
42
+ return { config: mod.default, absPath };
43
+ }
44
+ function toKebab(s) {
45
+ return s.trim().replace(/([A-Z])/g, "-$1").replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
46
+ }
47
+ function suggestTestId(el, index) {
48
+ if (el.label) return toKebab(el.label);
49
+ if (el.selectors?.id) return toKebab(el.selectors.id.replace(/^#/, ""));
50
+ if (el.selectors?.name) {
51
+ const m = el.selectors.name.match(/\[name=["']?([^"'\]]+)["']?\]/);
52
+ if (m) return toKebab(m[1]);
53
+ }
54
+ if (el.selectors?.ariaLabel) {
55
+ const m = el.selectors.ariaLabel.match(/\[aria-label=["']?([^"'\]]+)["']?\]/);
56
+ if (m) return toKebab(m[1]);
57
+ }
58
+ return `${el.type}-${index + 1}`;
59
+ }
60
+ async function runFullAnalysis(cwd, configPath) {
61
+ const { config } = await loadConfig(cwd, configPath);
62
+ const rootDir = path.resolve(cwd, config.rootDir);
63
+ const components = await crawl({
64
+ rootDir,
65
+ include: config.include,
66
+ exclude: config.exclude,
67
+ framework: config.framework
68
+ });
69
+ const results = await analyze(components);
70
+ return { config, results };
71
+ }
72
+ async function runTmlAnalysis(cwd, configPath, inventoryPath) {
73
+ const { results } = await runFullAnalysis(cwd, configPath);
74
+ const invFile = inventoryPath ? path.resolve(cwd, inventoryPath) : path.join(cwd, ".selfcure", "testid-inventory.json");
75
+ const invResult = await loadInventory(invFile).catch(() => null);
76
+ if (invResult?.ok) enrichTmlWithInventory(results, invResult.inventory);
77
+ return results;
78
+ }
79
+ function collectIssues(results, threshold) {
80
+ const raw = [];
81
+ for (const r of results) {
82
+ r.interactiveElements.forEach((el, i) => {
83
+ const isAmbiguous = el.ambiguous;
84
+ const isLowScore = el.testabilityScore < threshold;
85
+ if (!isAmbiguous && !isLowScore) return;
86
+ raw.push({
87
+ filePath: r.component.filePath,
88
+ componentName: r.component.componentName,
89
+ type: el.type,
90
+ selector: el.selector,
91
+ label: el.label,
92
+ testabilityScore: el.testabilityScore,
93
+ kind: isAmbiguous ? "ambiguous" : "low-score",
94
+ ambiguityReason: el.ambiguityReason,
95
+ suggestedTestId: suggestTestId(el, i),
96
+ _filePath: r.component.filePath
97
+ });
98
+ });
99
+ }
100
+ const used = /* @__PURE__ */ new Map();
101
+ for (const issue of raw) {
102
+ if (!used.has(issue._filePath)) used.set(issue._filePath, /* @__PURE__ */ new Map());
103
+ const m = used.get(issue._filePath);
104
+ const n = (m.get(issue.suggestedTestId) ?? 0) + 1;
105
+ m.set(issue.suggestedTestId, n);
106
+ if (n > 1) issue.suggestedTestId = `${issue.suggestedTestId}-${n}`;
107
+ }
108
+ return raw.map(({ _filePath, ...rest }) => rest);
109
+ }
110
+ var TOOLS = [
111
+ // ── Discovery tools (Phase 6) ─────────────────────────────────────────────
112
+ {
113
+ name: "selfcure_discover_project",
114
+ description: 'Run static project discovery: detect framework, package manager, dev scripts, and route candidates from source files \u2014 no running app required. Returns a compact ProjectMap. Call this first when a user asks "what routes does this app have?" or "what framework is this project using?". Larger artifacts (DOM snapshots, screenshots) are written to .selfcure/ and referenced by path.',
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ projectRoot: { type: "string", description: "Root directory to analyse. Defaults to cwd." },
119
+ outDir: { type: "string", description: "Where to write project-map.json. Defaults to .selfcure." }
120
+ }
121
+ }
122
+ },
123
+ {
124
+ name: "selfcure_get_project_map",
125
+ description: "Read the project-map.json artifact produced by a previous `selfcure discover` run. Cheaper than re-running discovery when the map is already fresh. Returns framework, package manager, scripts, route candidates, and component count.",
126
+ inputSchema: {
127
+ type: "object",
128
+ properties: {
129
+ artifactDir: { type: "string", description: "Path to the .selfcure artifact directory. Defaults to .selfcure." }
130
+ }
131
+ }
132
+ },
133
+ {
134
+ name: "selfcure_get_testability_report",
135
+ description: 'Read the testability-report.json produced by `selfcure discover --runtime`. Returns per-route testability scores, flagged interactive elements, and an overall score. Use this to answer "which routes are hard to test?" or to feed into a test-generation plan.',
136
+ inputSchema: {
137
+ type: "object",
138
+ properties: {
139
+ artifactDir: { type: "string", description: "Path to the .selfcure artifact directory. Defaults to .selfcure." }
140
+ }
141
+ }
142
+ },
143
+ {
144
+ name: "selfcure_get_discovery_artifacts",
145
+ description: "List all discovery artifacts in the .selfcure/ directory with their sizes and modification times. Use this to check which discovery phases have run and whether the data is fresh.",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ artifactDir: { type: "string", description: "Path to the artifact directory. Defaults to .selfcure." }
150
+ }
151
+ }
152
+ },
153
+ // ── TML tools (Phase 6) ───────────────────────────────────────────────────
154
+ {
155
+ name: "selfcure_get_tml_summary",
156
+ description: "Return a TML distribution summary for the project: how many elements are at each maturity level (0\u20134), total element count, violations below the minimum level, and overall pass rate. Use this for a quick governance snapshot before drilling into specifics.",
157
+ inputSchema: {
158
+ type: "object",
159
+ properties: {
160
+ configPath: { type: "string", description: "Path to selfcure.config.mjs." },
161
+ minimumLevel: { type: "number", description: "Minimum acceptable TML level (default: 2)." },
162
+ inventoryPath: { type: "string", description: "Path to testid-inventory.json for governance signals." }
163
+ }
164
+ }
165
+ },
166
+ {
167
+ name: "selfcure_list_low_maturity_tags",
168
+ description: "List interactive elements that fall below a specified TML level. Each entry includes the file, element type, selector, testability score, current TML level, and the top required change. Use this to feed a PR-generation or patch-suggestion workflow.",
169
+ inputSchema: {
170
+ type: "object",
171
+ properties: {
172
+ configPath: { type: "string" },
173
+ belowLevel: { type: "number", description: "Return elements with TML < this value (default: 2)." },
174
+ inventoryPath: { type: "string" },
175
+ limit: { type: "number", description: "Max results to return (default: 50)." }
176
+ }
177
+ }
178
+ },
179
+ {
180
+ name: "selfcure_explain_tag_maturity",
181
+ description: "Explain why a specific element received its TML level: list all reasons (with severity) and all required changes (with priority and patch availability). Use this when an agent needs to explain a TML finding to the user or plan a targeted fix.",
182
+ inputSchema: {
183
+ type: "object",
184
+ properties: {
185
+ configPath: { type: "string" },
186
+ filePath: { type: "string", description: "Source file containing the element." },
187
+ elementIndex: { type: "number", description: "Zero-based index of the element in the component." },
188
+ inventoryPath: { type: "string" }
189
+ },
190
+ required: ["filePath", "elementIndex"]
191
+ }
192
+ },
193
+ {
194
+ name: "selfcure_suggest_tml_fixes",
195
+ description: "Return actionable patch suggestions for elements below a minimum TML level. Only includes patchable changes (add-testid, rename-testid, dedupe-testid). Non-patchable items (DOM restructuring, business logic) are excluded. Output is ready to feed a PR-generation agent.",
196
+ inputSchema: {
197
+ type: "object",
198
+ properties: {
199
+ configPath: { type: "string" },
200
+ minimumLevel: { type: "number", description: "Target minimum TML (default: 3)." },
201
+ inventoryPath: { type: "string" }
202
+ }
203
+ }
204
+ },
205
+ // ── Testability tools (existing) ──────────────────────────────────────────
206
+ {
207
+ name: "selfcure_lint",
208
+ description: "Run selfcure's testability linter on the target project. Returns interactive elements flagged as `low-score` (no stable selector) or `ambiguous` (best selector matches multiple siblings). Each issue carries a suggested `data-testid`. Use this before delegating to a test-generation agent so the agent skips or prioritises components that are not yet testable.",
209
+ inputSchema: {
210
+ type: "object",
211
+ properties: {
212
+ configPath: { type: "string", description: "Relative path to selfcure.config.mjs (defaults to ./selfcure.config.mjs)." },
213
+ threshold: { type: "number", description: "Score below which elements are flagged. Default: 65." }
214
+ }
215
+ }
216
+ },
217
+ {
218
+ name: "selfcure_list_components",
219
+ description: "List every component the crawler discovered with aggregate scores. Use this for a quick overview before drilling into specific files. Cheap \u2014 does not return AST or per-element detail.",
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ configPath: { type: "string" }
224
+ }
225
+ }
226
+ },
227
+ {
228
+ name: "selfcure_analyze_component",
229
+ description: "Return the full analysis for a single component file: every interactive element with its selectorRanking, ambiguity flag, label, actions, and testability score. Use this when an agent needs precise per-element data before generating tests or proposing FE changes.",
230
+ inputSchema: {
231
+ type: "object",
232
+ properties: {
233
+ configPath: { type: "string" },
234
+ filePath: { type: "string", description: "Absolute or workspace-relative path to the component source file." }
235
+ },
236
+ required: ["filePath"]
237
+ }
238
+ },
239
+ {
240
+ name: "selfcure_suggest_testid",
241
+ description: "Suggest a kebab-cased `data-testid` value for a single element. Uses the same heuristic as `selfcure lint --fix`: label \u2192 id \u2192 name \u2192 aria-label \u2192 `<type>-<index>` fallback. The suggestion is dedup-aware across all elements in the same file.",
242
+ inputSchema: {
243
+ type: "object",
244
+ properties: {
245
+ configPath: { type: "string" },
246
+ filePath: { type: "string", description: "Component file to inspect." },
247
+ elementIndex: { type: "number", description: "Zero-based index of the interactive element within the component." }
248
+ },
249
+ required: ["filePath", "elementIndex"]
250
+ }
251
+ }
252
+ ];
253
+ var RESOURCES = [
254
+ {
255
+ uri: "selfcure://config",
256
+ name: "selfcure config",
257
+ description: "The resolved selfcure.config.mjs from the current working directory.",
258
+ mimeType: "application/json"
259
+ },
260
+ {
261
+ uri: "selfcure://lint-summary",
262
+ name: "lint summary",
263
+ description: "Aggregate testability snapshot: total components, average score, issue breakdown (ambiguous vs low-score).",
264
+ mimeType: "application/json"
265
+ },
266
+ {
267
+ uri: "selfcure://reports/latest",
268
+ name: "latest report",
269
+ description: 'Placeholder for the last @selfcure/reporter run summary. Not implemented yet \u2014 returns {status:"not-implemented"}.',
270
+ mimeType: "application/json"
271
+ }
272
+ ];
273
+ var PROMPTS = [
274
+ {
275
+ name: "selfcure_prepare_for_testing",
276
+ description: "Survey the repo with selfcure and propose the first 3-5 frontend fixes that would unblock test generation."
277
+ },
278
+ {
279
+ name: "selfcure_handoff_to_playwright_agents",
280
+ description: "Use selfcure to filter testable components, then hand off to Playwright Test Agents (Planner \u2192 Generator \u2192 Healer) on that subset."
281
+ },
282
+ {
283
+ name: "selfcure_discover_and_test",
284
+ description: "Full agentic workflow: discover the project structure, understand routes and hidden states, then generate a testability improvement plan."
285
+ }
286
+ ];
287
+ function discoverAndTestPrompt() {
288
+ return [
289
+ "You are an AI agent with access to the selfcure MCP tools. Perform the full agentic discovery workflow:",
290
+ "",
291
+ "1. **Discover the project** \u2014 call `selfcure_discover_project` to detect framework, package manager, and route candidates.",
292
+ "2. **Check for existing artifacts** \u2014 call `selfcure_get_discovery_artifacts` to see what data is already available.",
293
+ "3. **Understand the routes** \u2014 call `selfcure_get_project_map` and summarise the discovered routes for the user.",
294
+ "4. **Check testability** \u2014 if testability-report.json exists, call `selfcure_get_testability_report` and identify the routes with score < 60.",
295
+ "5. **Lint static components** \u2014 call `selfcure_lint` to find low-score or ambiguous selectors that would break tests.",
296
+ "6. **Produce a plan** \u2014 generate a prioritised list:",
297
+ " a. Routes ready for testing (high score, reachable).",
298
+ " b. Routes needing FE fixes first (low score \u2014 suggest data-testid patches).",
299
+ " c. Routes needing authentication hints (auth-required status).",
300
+ "",
301
+ "Self-cure owns the preventive layer. Playwright Test Agents run after these fixes are applied.",
302
+ "If the user has Playwright MCP available, hand off the ready routes using `selfcure_handoff_to_playwright_agents`."
303
+ ].join("\n");
304
+ }
305
+ function prepareForTestingPrompt() {
306
+ return [
307
+ "You have access to the `selfcure_*` MCP tools. Perform the following steps:",
308
+ "",
309
+ "1. Call `selfcure_lint` with the default threshold to enumerate every component flagged as low-score or ambiguous.",
310
+ "2. Group the issues by file and pick the 3-5 files that are blocking the most test surface area (most interactive elements flagged, or the most-ambiguous ones).",
311
+ "3. For each chosen file, propose a *minimal* frontend change (typically: add the suggested `data-testid` produced by selfcure). Format each suggestion as a unified diff snippet so the user can apply it directly.",
312
+ '4. End with a one-line recommendation: "After applying these patches, the project is ready for Playwright Test Agents (`npx playwright init-agents`)."'
313
+ ].join("\n");
314
+ }
315
+ function handoffToPlaywrightPrompt() {
316
+ return [
317
+ "Selfcure handles testability *prevention*, Playwright Test Agents handle test *generation* and *healing*. Use them in sequence:",
318
+ "",
319
+ "1. Call `selfcure_lint` and identify the components with `testabilityScore >= 65` and no `ambiguous` flag \u2014 these are ready for test generation.",
320
+ "2. Pass that filtered file list to the Playwright Planner agent (`npx playwright init-agents` if not yet installed, then invoke the Planner) so it focuses only on the testable surface.",
321
+ "3. After the Planner produces a Markdown plan, run the Generator agent to produce `.spec.ts` files.",
322
+ "4. Run the test suite; the Healer agent will repair any locator drift.",
323
+ "5. Re-run `selfcure_lint` afterwards to confirm no new ambiguities were introduced by the generated tests."
324
+ ].join("\n");
325
+ }
326
+ var server = new Server(
327
+ { name: "selfcure-mcp", version: "0.1.0" },
328
+ {
329
+ capabilities: {
330
+ tools: {},
331
+ resources: {},
332
+ prompts: {}
333
+ }
334
+ }
335
+ );
336
+ var CWD = process.cwd();
337
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
338
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
339
+ const { name, arguments: rawArgs } = req.params;
340
+ const args = rawArgs ?? {};
341
+ try {
342
+ switch (name) {
343
+ // ── Discovery handlers ────────────────────────────────────────────────
344
+ case "selfcure_discover_project": {
345
+ const root = typeof args["projectRoot"] === "string" ? path.resolve(CWD, args["projectRoot"]) : CWD;
346
+ const outDir = typeof args["outDir"] === "string" ? path.resolve(CWD, args["outDir"]) : path.join(CWD, ".selfcure");
347
+ const { promises: fsp } = await import("fs");
348
+ const map = await discoverProject({ projectRoot: root });
349
+ await fsp.mkdir(outDir, { recursive: true });
350
+ await fsp.writeFile(path.join(outDir, "project-map.json"), JSON.stringify(map, null, 2), "utf-8");
351
+ const compact = {
352
+ framework: map.framework,
353
+ packageManager: map.packageManager,
354
+ devCommand: map.devCommand,
355
+ buildCommand: map.buildCommand,
356
+ routeCount: map.routeCandidates.length,
357
+ componentCount: map.componentCandidates.length,
358
+ routeCandidates: map.routeCandidates,
359
+ artifactPath: path.join(outDir, "project-map.json"),
360
+ generatedAt: map.generatedAt
361
+ };
362
+ return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] };
363
+ }
364
+ case "selfcure_get_project_map": {
365
+ const dir = typeof args["artifactDir"] === "string" ? path.resolve(CWD, args["artifactDir"]) : path.join(CWD, ".selfcure");
366
+ const file = path.join(dir, "project-map.json");
367
+ if (!fs.existsSync(file)) {
368
+ throw new McpError(ErrorCode.InvalidRequest, `project-map.json not found at ${file}. Run selfcure_discover_project first.`);
369
+ }
370
+ const map = JSON.parse(fs.readFileSync(file, "utf-8"));
371
+ return { content: [{ type: "text", text: JSON.stringify(map, null, 2) }] };
372
+ }
373
+ case "selfcure_get_testability_report": {
374
+ const dir = typeof args["artifactDir"] === "string" ? path.resolve(CWD, args["artifactDir"]) : path.join(CWD, ".selfcure");
375
+ const file = path.join(dir, "testability-report.json");
376
+ if (!fs.existsSync(file)) {
377
+ throw new McpError(
378
+ ErrorCode.InvalidRequest,
379
+ `testability-report.json not found at ${file}. Run: selfcure discover --runtime --base-url <url>`
380
+ );
381
+ }
382
+ const report = JSON.parse(fs.readFileSync(file, "utf-8"));
383
+ return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
384
+ }
385
+ case "selfcure_get_discovery_artifacts": {
386
+ const dir = typeof args["artifactDir"] === "string" ? path.resolve(CWD, args["artifactDir"]) : path.join(CWD, ".selfcure");
387
+ if (!fs.existsSync(dir)) {
388
+ return { content: [{ type: "text", text: JSON.stringify({ dir, exists: false, files: [] }, null, 2) }] };
389
+ }
390
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
391
+ const files = entries.filter((e) => e.isFile()).map((e) => {
392
+ const fp = path.join(dir, e.name);
393
+ const stat = fs.statSync(fp);
394
+ return { name: e.name, sizeBytes: stat.size, modifiedAt: stat.mtime.toISOString() };
395
+ }).sort((a, b) => a.name.localeCompare(b.name));
396
+ const subdirs = entries.filter((e) => e.isDirectory()).map((e) => {
397
+ const dp = path.join(dir, e.name);
398
+ const cnt = fs.readdirSync(dp).length;
399
+ return { name: e.name + "/", fileCount: cnt };
400
+ });
401
+ return {
402
+ content: [{
403
+ type: "text",
404
+ text: JSON.stringify({ dir, files, subdirectories: subdirs }, null, 2)
405
+ }]
406
+ };
407
+ }
408
+ // ── TML handlers ─────────────────────────────────────────────────────
409
+ case "selfcure_get_tml_summary": {
410
+ const minLvl = typeof args["minimumLevel"] === "number" ? args["minimumLevel"] : 2;
411
+ const cfgPath = typeof args["configPath"] === "string" ? args["configPath"] : void 0;
412
+ const invPath = typeof args["inventoryPath"] === "string" ? args["inventoryPath"] : void 0;
413
+ const results = await runTmlAnalysis(CWD, cfgPath, invPath);
414
+ const dist = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
415
+ let total = 0;
416
+ for (const r of results) {
417
+ for (const el of r.interactiveElements) {
418
+ if (!el.tml) continue;
419
+ dist[el.tml.level]++;
420
+ total++;
421
+ }
422
+ }
423
+ const violations = Object.entries(dist).filter(([lvl]) => Number(lvl) < minLvl).reduce((s, [, n]) => s + n, 0);
424
+ const passRate = total > 0 ? Math.round((total - violations) / total * 100) : 100;
425
+ const distWithLabels = Object.fromEntries(
426
+ Object.entries(dist).map(([lvl, cnt]) => [
427
+ `TML-${lvl} (${TML_LABELS[Number(lvl)]})`,
428
+ cnt
429
+ ])
430
+ );
431
+ return { content: [{ type: "text", text: JSON.stringify({ totalElements: total, violations, passRate, minimumLevel: minLvl, distribution: distWithLabels }, null, 2) }] };
432
+ }
433
+ case "selfcure_list_low_maturity_tags": {
434
+ const belowLvl = typeof args["belowLevel"] === "number" ? args["belowLevel"] : 2;
435
+ const limit = typeof args["limit"] === "number" ? args["limit"] : 50;
436
+ const cfgPath = typeof args["configPath"] === "string" ? args["configPath"] : void 0;
437
+ const invPath = typeof args["inventoryPath"] === "string" ? args["inventoryPath"] : void 0;
438
+ const results = await runTmlAnalysis(CWD, cfgPath, invPath);
439
+ const items = [];
440
+ for (const r of results) {
441
+ for (const el of r.interactiveElements) {
442
+ if (!el.tml || el.tml.level >= belowLvl || items.length >= limit) continue;
443
+ items.push({
444
+ filePath: r.component.filePath,
445
+ componentName: r.component.componentName,
446
+ elementType: el.type,
447
+ selector: el.selector,
448
+ score: el.testabilityScore,
449
+ tmlLevel: el.tml.level,
450
+ tmlLabel: el.tml.label,
451
+ topChange: el.tml.requiredChanges[0] ?? null
452
+ });
453
+ }
454
+ }
455
+ return { content: [{ type: "text", text: JSON.stringify({ count: items.length, belowLevel: belowLvl, items }, null, 2) }] };
456
+ }
457
+ case "selfcure_explain_tag_maturity": {
458
+ const filePath = String(args["filePath"] ?? "");
459
+ const elementIndex = Number(args["elementIndex"] ?? -1);
460
+ if (!filePath) throw new McpError(ErrorCode.InvalidParams, "filePath is required");
461
+ if (!Number.isInteger(elementIndex) || elementIndex < 0)
462
+ throw new McpError(ErrorCode.InvalidParams, "elementIndex must be a non-negative integer");
463
+ const cfgPath = typeof args["configPath"] === "string" ? args["configPath"] : void 0;
464
+ const invPath = typeof args["inventoryPath"] === "string" ? args["inventoryPath"] : void 0;
465
+ const results = await runTmlAnalysis(CWD, cfgPath, invPath);
466
+ const target = path.resolve(CWD, filePath);
467
+ const found = results.find((r) => path.resolve(r.component.filePath) === target);
468
+ if (!found) throw new McpError(ErrorCode.InvalidRequest, `Component not found: ${filePath}`);
469
+ const el = found.interactiveElements[elementIndex];
470
+ if (!el) throw new McpError(ErrorCode.InvalidRequest, `elementIndex ${elementIndex} out of range`);
471
+ if (!el.tml) throw new McpError(ErrorCode.InternalError, "TML not computed for this element");
472
+ return {
473
+ content: [{
474
+ type: "text",
475
+ text: JSON.stringify({
476
+ filePath,
477
+ elementIndex,
478
+ elementType: el.type,
479
+ selector: el.selector,
480
+ score: el.testabilityScore,
481
+ tml: {
482
+ level: el.tml.level,
483
+ label: el.tml.label,
484
+ confidence: el.tml.confidence,
485
+ reasons: el.tml.reasons,
486
+ requiredChanges: el.tml.requiredChanges
487
+ }
488
+ }, null, 2)
489
+ }]
490
+ };
491
+ }
492
+ case "selfcure_suggest_tml_fixes": {
493
+ const minLvl = typeof args["minimumLevel"] === "number" ? args["minimumLevel"] : 3;
494
+ const cfgPath = typeof args["configPath"] === "string" ? args["configPath"] : void 0;
495
+ const invPath = typeof args["inventoryPath"] === "string" ? args["inventoryPath"] : void 0;
496
+ const results = await runTmlAnalysis(CWD, cfgPath, invPath);
497
+ const PATCHABLE = /* @__PURE__ */ new Set(["add-testid", "rename-testid", "dedupe-testid"]);
498
+ const patches = [];
499
+ for (const r of results) {
500
+ r.interactiveElements.forEach((el, idx) => {
501
+ if (!el.tml || el.tml.level >= minLvl) return;
502
+ const patchableChanges = el.tml.requiredChanges.filter((c) => PATCHABLE.has(c.type) && c.patchAvailable);
503
+ if (patchableChanges.length === 0) return;
504
+ patches.push({
505
+ filePath: r.component.filePath,
506
+ componentName: r.component.componentName,
507
+ elementIndex: idx,
508
+ elementType: el.type,
509
+ selector: el.selector,
510
+ currentTml: el.tml.level,
511
+ targetTml: Math.min(minLvl, 3),
512
+ patchableChanges
513
+ });
514
+ });
515
+ }
516
+ return { content: [{ type: "text", text: JSON.stringify({ count: patches.length, minimumLevel: minLvl, patches }, null, 2) }] };
517
+ }
518
+ // ── Testability handlers (existing) ───────────────────────────────────
519
+ case "selfcure_lint": {
520
+ const threshold = typeof args["threshold"] === "number" ? args["threshold"] : 65;
521
+ const configPath = typeof args["configPath"] === "string" ? args["configPath"] : void 0;
522
+ const { config, results } = await runFullAnalysis(CWD, configPath);
523
+ const issues = collectIssues(results, threshold);
524
+ const payload = {
525
+ rootDir: config.rootDir,
526
+ threshold,
527
+ totalFiles: results.length,
528
+ issues
529
+ };
530
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
531
+ }
532
+ case "selfcure_list_components": {
533
+ const configPath = typeof args["configPath"] === "string" ? args["configPath"] : void 0;
534
+ const { results } = await runFullAnalysis(CWD, configPath);
535
+ const components = results.map((r) => ({
536
+ filePath: r.component.filePath,
537
+ componentName: r.component.componentName,
538
+ framework: r.component.framework,
539
+ score: r.score,
540
+ complexity: r.complexity,
541
+ interactiveCount: r.interactiveElements.length,
542
+ ambiguousCount: r.interactiveElements.filter((e) => e.ambiguous).length
543
+ }));
544
+ return { content: [{ type: "text", text: JSON.stringify({ count: components.length, components }, null, 2) }] };
545
+ }
546
+ case "selfcure_analyze_component": {
547
+ const filePath = String(args["filePath"] ?? "");
548
+ if (!filePath) throw new McpError(ErrorCode.InvalidParams, "filePath is required");
549
+ const configPath = typeof args["configPath"] === "string" ? args["configPath"] : void 0;
550
+ const { results } = await runFullAnalysis(CWD, configPath);
551
+ const target = path.resolve(CWD, filePath);
552
+ const found = results.find((r) => path.resolve(r.component.filePath) === target);
553
+ if (!found) {
554
+ throw new McpError(ErrorCode.InvalidRequest, `Component not found in crawl results: ${filePath}`);
555
+ }
556
+ const payload = {
557
+ filePath: found.component.filePath,
558
+ componentName: found.component.componentName,
559
+ framework: found.component.framework,
560
+ props: found.component.props,
561
+ score: found.score,
562
+ complexity: found.complexity,
563
+ interactiveElements: found.interactiveElements
564
+ };
565
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
566
+ }
567
+ case "selfcure_suggest_testid": {
568
+ const filePath = String(args["filePath"] ?? "");
569
+ const elementIndex = Number(args["elementIndex"] ?? -1);
570
+ if (!filePath) throw new McpError(ErrorCode.InvalidParams, "filePath is required");
571
+ if (!Number.isInteger(elementIndex) || elementIndex < 0) {
572
+ throw new McpError(ErrorCode.InvalidParams, "elementIndex must be a non-negative integer");
573
+ }
574
+ const configPath = typeof args["configPath"] === "string" ? args["configPath"] : void 0;
575
+ const { results } = await runFullAnalysis(CWD, configPath);
576
+ const target = path.resolve(CWD, filePath);
577
+ const found = results.find((r) => path.resolve(r.component.filePath) === target);
578
+ if (!found) throw new McpError(ErrorCode.InvalidRequest, `Component not found: ${filePath}`);
579
+ const el = found.interactiveElements[elementIndex];
580
+ if (!el) throw new McpError(ErrorCode.InvalidRequest, `elementIndex ${elementIndex} out of range`);
581
+ const file = results.find((r) => path.resolve(r.component.filePath) === target);
582
+ const used = /* @__PURE__ */ new Map();
583
+ let answer = "";
584
+ file.interactiveElements.forEach((cur, i) => {
585
+ const base = suggestTestId(cur, i);
586
+ const n = (used.get(base) ?? 0) + 1;
587
+ used.set(base, n);
588
+ const final = n > 1 ? `${base}-${n}` : base;
589
+ if (i === elementIndex) answer = final;
590
+ });
591
+ return { content: [{ type: "text", text: JSON.stringify({ filePath, elementIndex, suggestedTestId: answer }, null, 2) }] };
592
+ }
593
+ default:
594
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
595
+ }
596
+ } catch (err) {
597
+ if (err instanceof McpError) throw err;
598
+ throw new McpError(ErrorCode.InternalError, err instanceof Error ? err.message : String(err));
599
+ }
600
+ });
601
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCES }));
602
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
603
+ const { uri } = req.params;
604
+ if (uri === "selfcure://config") {
605
+ const { config, absPath } = await loadConfig(CWD);
606
+ return {
607
+ contents: [{
608
+ uri,
609
+ mimeType: "application/json",
610
+ text: JSON.stringify({ absPath, config }, null, 2)
611
+ }]
612
+ };
613
+ }
614
+ if (uri === "selfcure://lint-summary") {
615
+ const { results } = await runFullAnalysis(CWD);
616
+ const issues = collectIssues(results, 65);
617
+ const ambiguous = issues.filter((i) => i.kind === "ambiguous").length;
618
+ const lowScore = issues.length - ambiguous;
619
+ const avgScore = results.length === 0 ? 0 : Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
620
+ return {
621
+ contents: [{
622
+ uri,
623
+ mimeType: "application/json",
624
+ text: JSON.stringify({
625
+ totalComponents: results.length,
626
+ averageScore: avgScore,
627
+ issuesByKind: { ambiguous, lowScore },
628
+ totalIssues: issues.length
629
+ }, null, 2)
630
+ }]
631
+ };
632
+ }
633
+ if (uri === "selfcure://reports/latest") {
634
+ return {
635
+ contents: [{
636
+ uri,
637
+ mimeType: "application/json",
638
+ text: JSON.stringify({ status: "not-implemented", note: "Reserved for future @selfcure/reporter integration." }, null, 2)
639
+ }]
640
+ };
641
+ }
642
+ throw new McpError(ErrorCode.InvalidRequest, `Unknown resource URI: ${uri}`);
643
+ });
644
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
645
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
646
+ const { name } = req.params;
647
+ switch (name) {
648
+ case "selfcure_prepare_for_testing":
649
+ return {
650
+ messages: [{ role: "user", content: { type: "text", text: prepareForTestingPrompt() } }]
651
+ };
652
+ case "selfcure_handoff_to_playwright_agents":
653
+ return {
654
+ messages: [{ role: "user", content: { type: "text", text: handoffToPlaywrightPrompt() } }]
655
+ };
656
+ case "selfcure_discover_and_test":
657
+ return {
658
+ messages: [{ role: "user", content: { type: "text", text: discoverAndTestPrompt() } }]
659
+ };
660
+ default:
661
+ throw new McpError(ErrorCode.InvalidRequest, `Unknown prompt: ${name}`);
662
+ }
663
+ });
664
+ var transport = new StdioServerTransport();
665
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@selfcure/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server exposing selfcure's testability analysis (crawl + analyze + lint) to any MCP client — Claude Desktop, Cursor, VS Code, Claude Code, Windsurf, etc.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "ricardofrancocustodio",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ricardofrancocustodio/selfcure.git",
11
+ "directory": "packages/mcp"
12
+ },
13
+ "homepage": "https://github.com/ricardofrancocustodio/selfcure#readme",
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "bin": {
17
+ "selfcure-mcp": "dist/index.js"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": ["dist"],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup --config tsup.config.ts",
31
+ "dev": "tsup src/index.ts --format esm --watch",
32
+ "test": "vitest"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.29.0",
36
+ "@selfcure/analyzer": "^0.1.0",
37
+ "@selfcure/crawler": "^0.1.0"
38
+ }
39
+ }