@pyreon/mcp 0.15.0 → 0.16.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/src/changelog.ts CHANGED
@@ -177,16 +177,16 @@ export function parseChangelog(body: string): ChangelogEntry[] {
177
177
 
178
178
  // `### Patch Changes` / `### Minor Changes` / `### Major Changes` — ignore
179
179
  // the heading itself but keep reading bullets under it.
180
- if (/^### /.test(line)) {
180
+ if (line.startsWith('### ')) {
181
181
  flushBullet()
182
182
  continue
183
183
  }
184
184
 
185
185
  // Start of a new top-level bullet.
186
- if (/^- /.test(line)) {
186
+ if (line.startsWith('- ')) {
187
187
  flushBullet()
188
188
  currentBuf = [line.replace(/^- /, '')]
189
- bufKind = /^- Updated dependencies/.test(line) ? 'dep' : 'change'
189
+ bufKind = line.startsWith('- Updated dependencies') ? 'dep' : 'change'
190
190
  continue
191
191
  }
192
192
 
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * to generate, validate, and migrate Pyreon code.
7
7
  *
8
8
  * Tools:
9
+ * mcp_overview — Discoverability map: every tool's "when to use" + example, in one call
9
10
  * get_api — Look up any Pyreon API: signature, usage, common mistakes
10
11
  * validate — Check a code snippet for Pyreon anti-patterns
11
12
  * migrate_react — Convert React code to idiomatic Pyreon
@@ -17,6 +18,7 @@
17
18
  * get_anti_patterns — Browse the anti-patterns catalog, optionally filtered by category
18
19
  * get_changelog — Recent release notes for a @pyreon/* package, parsed from CHANGELOG.md
19
20
  * audit_test_environment — Scan test files for mock-vnode patterns (PR #197 bug class)
21
+ * audit_islands — Project-wide islands audit (5 cross-file foot-guns)
20
22
  *
21
23
  * Usage:
22
24
  * bunx @pyreon/mcp # stdio transport (for IDE integration)
@@ -26,10 +28,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
26
28
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
27
29
  import {
28
30
  type AuditRisk,
31
+ auditIslands,
29
32
  auditTestEnvironment,
30
33
  detectPyreonPatterns,
31
34
  detectReactPatterns,
32
35
  diagnoseError,
36
+ formatIslandAudit,
33
37
  formatTestAudit,
34
38
  migrateReactCode,
35
39
  } from '@pyreon/compiler'
@@ -602,6 +606,55 @@ server.tool(
602
606
  },
603
607
  )
604
608
 
609
+ // ═══════════════════════════════════════════════════════════════════════════════
610
+ // Tool: audit_islands — project-wide islands audit (PR C of islands DX roadmap)
611
+ // ═══════════════════════════════════════════════════════════════════════════════
612
+
613
+ server.tool(
614
+ 'audit_islands',
615
+ {
616
+ json: z
617
+ .boolean()
618
+ .optional()
619
+ .describe(
620
+ 'Return raw JSON output instead of human-readable markdown. Useful when an agent wants to programmatically count findings by code or filter by location.',
621
+ ),
622
+ },
623
+ async ({ json }) => {
624
+ const result = auditIslands(process.cwd())
625
+ return textResult(formatIslandAudit(result, { json }))
626
+ },
627
+ )
628
+
629
+ // ═══════════════════════════════════════════════════════════════════════════════
630
+ // Tool: mcp_overview — discoverability "what tool when" map (T2.5.9)
631
+ //
632
+ // Reads from this package's own manifest at runtime — single source of truth.
633
+ // Reuses the same data that drives api-reference.ts + llms-full.txt + the
634
+ // generated docs/docs/mcp.md sections. Adding a new tool to manifest.ts
635
+ // automatically surfaces it here on next call; no second wiring step.
636
+ // ═══════════════════════════════════════════════════════════════════════════════
637
+
638
+ server.tool('mcp_overview', {}, async () => {
639
+ const { default: manifest } = await import('./manifest')
640
+ const tools = manifest.api.filter((e) => e.signature.startsWith('tool: '))
641
+
642
+ const rows = tools.map((e) => {
643
+ const whenToUse = (e.summary.split(/(?<=[.!?])\s+/)[0] ?? e.summary)
644
+ .trim()
645
+ .replace(/\|/g, '\\|')
646
+ const example = (e.example.split('\n')[0] ?? '').trim().replace(/\|/g, '\\|')
647
+ return `| \`${e.name}\` | ${whenToUse} | \`${example}\` |`
648
+ })
649
+
650
+ return textResult(
651
+ `**MCP Tools (${tools.length}):**\n\n` +
652
+ '| Tool | When to use | Example |\n' +
653
+ '|---|---|---|\n' +
654
+ rows.join('\n'),
655
+ )
656
+ })
657
+
605
658
  return server
606
659
  }
607
660
 
package/src/manifest.ts CHANGED
@@ -4,17 +4,17 @@ export default defineManifest({
4
4
  name: '@pyreon/mcp',
5
5
  title: 'MCP Server',
6
6
  tagline:
7
- 'Model Context Protocol server — live API lookup, validation, migration, anti-pattern catalog, changelog, test-environment audit',
7
+ 'Model Context Protocol server — discoverability map, live API lookup, validation, migration, anti-pattern catalog, changelog, test-environment audit',
8
8
  description:
9
- 'MCP server (stdio transport) that exposes Pyreon\\\'s structured knowledge to AI coding assistants (Claude Code, Cursor, etc.). Eleven tools: `get_api` (look up any Pyreon API), `validate` (catch React + Pyreon-specific anti-patterns in a snippet), `migrate_react` (auto-convert React code), `diagnose` (parse a Pyreon error into structured fix info), `get_routes` / `get_components` (project introspection), `get_browser_smoke_status` (which packages need a browser smoke test), `get_pattern` (canonical "how do I do X" docs), `get_anti_patterns` (the catalog from `.claude/rules/anti-patterns.md`), `get_changelog` (recent release notes per package), and `audit_test_environment` (mock-vnode test scanner — PR #197 bug class).',
9
+ 'MCP server (stdio transport) that exposes Pyreon\\\'s structured knowledge to AI coding assistants (Claude Code, Cursor, etc.). Thirteen tools: `mcp_overview` (start here — markdown table of every tool with "when to use" + example, read straight from this manifest), `get_api` (look up any Pyreon API), `validate` (catch React + Pyreon-specific anti-patterns in a snippet), `migrate_react` (auto-convert React code), `diagnose` (parse a Pyreon error into structured fix info), `get_routes` / `get_components` (project introspection), `get_browser_smoke_status` (which packages need a browser smoke test), `get_pattern` (canonical "how do I do X" docs), `get_anti_patterns` (the catalog from `.claude/rules/anti-patterns.md`), `get_changelog` (recent release notes per package), `audit_test_environment` (mock-vnode test scanner — PR #197 bug class), and `audit_islands` (project-wide islands cross-file audit — duplicate names, dead islands, registry drift, nested islands, never-with-registry).',
10
10
  category: 'server',
11
11
  features: [
12
- 'Eleven tools covering lookup, validation, migration, diagnosis, introspection, audit',
12
+ 'Thirteen tools covering discovery, lookup, validation, migration, diagnosis, introspection, audit',
13
13
  'stdio transport — drop-in compatible with every MCP client',
14
14
  'Project context cached per server instance, auto-invalidates on cwd change',
15
15
  'Manifest-driven — `get_api` reads `api-reference.ts`, regenerated from package manifests',
16
16
  'AST-based detectors — `validate` catches React + Pyreon-specific patterns statically',
17
- 'Real-repo audit tools (`audit_test_environment`, `get_browser_smoke_status`) walk packages/',
17
+ 'Real-repo audit tools (`audit_test_environment`, `audit_islands`, `get_browser_smoke_status`) walk packages/',
18
18
  ],
19
19
  longExample: `// .mcp/config.json — register the server with any MCP-aware client
20
20
  {
@@ -27,6 +27,8 @@ export default defineManifest({
27
27
  }
28
28
 
29
29
  // Then from the client (Claude Code, Cursor, etc.):
30
+ // mcp_overview()
31
+ // → markdown table: tool | when_to_use | example (start here)
30
32
  // get_api({ package: 'flow', symbol: 'createFlow' })
31
33
  // → signature, example, common mistakes
32
34
  // validate({ code: '<MyButton onClick={handler}>...' })
@@ -38,8 +40,27 @@ export default defineManifest({
38
40
  // get_changelog({ package: 'flow', limit: 5 })
39
41
  // → recent release notes filtered through ceremonial-bump removal
40
42
  // audit_test_environment({ minRisk: 'medium' })
41
- // → mock-vnode test files ranked HIGH / MEDIUM / LOW`,
43
+ // → mock-vnode test files ranked HIGH / MEDIUM / LOW
44
+ // audit_islands({})
45
+ // → project-wide islands audit (5 cross-file foot-guns)`,
42
46
  api: [
47
+ {
48
+ name: 'mcp_overview',
49
+ kind: 'constant',
50
+ signature: 'tool: mcp_overview() → MarkdownTable',
51
+ summary:
52
+ 'Returns a markdown table of every registered MCP tool with a one-sentence "when to use" description and a one-line example. Reads from this same manifest at runtime — single source of truth (the same data feeds `api-reference.ts`, `llms-full.txt`, and `docs/docs/mcp.md`). Intended as the first call for any AI agent connecting to the server: enumerates the surface so the agent can navigate by intent (e.g. "I need release notes" → `get_changelog`) rather than guessing tool names from `tools/list`.',
53
+ example: `mcp_overview()
54
+ // → | Tool | When to use | Example |
55
+ // |------|-------------|---------|
56
+ // | mcp_overview | Returns a markdown table of every registered MCP tool... | mcp_overview() |
57
+ // | get_api | Look up any Pyreon API by package and symbol... | get_api({ package: 'flow', symbol: 'createFlow' }) |
58
+ // | ...`,
59
+ mistakes: [
60
+ 'Skipping this tool and calling `tools/list` instead — that returns names + parameter schemas but no "when to use" guidance, so an agent has to call multiple tools to figure out which one fits the task.',
61
+ ],
62
+ seeAlso: ['get_api'],
63
+ },
43
64
  {
44
65
  name: 'get_browser_smoke_status',
45
66
  kind: 'constant',
@@ -169,7 +190,20 @@ get_changelog({ package: '@pyreon/router', since: '0.12.0' })`,
169
190
  'Scan every `*.test.{ts,tsx}` under `packages/` for the mock-vnode anti-pattern that caused PR #197\\\'s silent metadata drop. Files are classified HIGH / MEDIUM / LOW based on the balance of mock-vnode literals + helpers + helper-call sites vs real `h()` calls + `@pyreon/core` import. Three context-aware skips (helper-def vs binding discrimination, type-guard call-arg skip, template-string fixture mask) keep the false-positive rate low. Run before merging a new test file or after a framework change.',
170
191
  example: `audit_test_environment({ minRisk: 'medium', limit: 10 })
171
192
  // → grouped report with HIGH / MEDIUM / LOW sections`,
172
- seeAlso: ['get_browser_smoke_status'],
193
+ seeAlso: ['get_browser_smoke_status', 'audit_islands'],
194
+ },
195
+ {
196
+ name: 'audit_islands',
197
+ kind: 'constant',
198
+ signature: 'tool: audit_islands({ json?: boolean }) → IslandAuditReport',
199
+ summary:
200
+ 'Project-wide cross-file islands audit (PR C of the islands DX roadmap). Walks `packages/` + `examples/` and runs five detectors that auto-registry can\\\'t reach (manual `hydrateIslands({...})` for non-Vite consumers / library authors) AND PR G\\\'s per-file `island-never-with-registry-entry` detector misses (it only catches the same-file shape): `duplicate-name`, `never-with-registry-entry`, `registry-mismatch`, `nested-island`, `dead-island`. Each finding ships with file path + line/column + actionable fix suggestion. Companion to the `pyreon doctor --check-islands` CLI flag (same scanner, same five detectors). Run before merging an island PR; CI gate by piping `--json` and grepping `findings.length > 0`.',
201
+ example: `audit_islands({})
202
+ // → markdown-grouped report with one section per finding code
203
+
204
+ audit_islands({ json: true })
205
+ // → machine-readable { root, findings: [...], summary: {...} }`,
206
+ seeAlso: ['audit_test_environment', 'get_anti_patterns'],
173
207
  },
174
208
  ],
175
209
  gotchas: [
@@ -0,0 +1,100 @@
1
+ import { callTool, newClient } from './helpers'
2
+
3
+ // MCP server <-> client round-trip for the PR C `audit_islands` tool.
4
+ // Same InMemoryTransport shape as `test-audit-server.test.ts`, so we
5
+ // exercise the registration, JSON-RPC framing, formatter, and zod
6
+ // validation in one pass. Pairs with the unit-level test in the
7
+ // compiler package (`island-audit.test.ts`) — those test the
8
+ // underlying `auditIslands()` function directly; this one proves the
9
+ // MCP wiring.
10
+
11
+ // `audit_islands` walks the real repo (packages/ + examples/) — under CI's
12
+ // parallel test load that can exceed vitest's 5s default. 30s per call is
13
+ // well above empirical worst case (~2s locally) but safe headroom for
14
+ // concurrent runs.
15
+ const AUDIT_TIMEOUT_MS = 30_000
16
+
17
+ describe('MCP server — audit_islands tool', () => {
18
+ it(
19
+ 'renders the audit header + summary line',
20
+ async () => {
21
+ const { client, close } = await newClient()
22
+ try {
23
+ const text = await callTool(client, 'audit_islands', {})
24
+ // The header always renders the file/decl/registry counts.
25
+ expect(text).toMatch(/^# Islands audit — \d+ files scanned/)
26
+ expect(text).toMatch(/`island\(\)` declarations?/)
27
+ expect(text).toMatch(/`hydrateIslands` registry entr/)
28
+ } finally {
29
+ await close()
30
+ }
31
+ },
32
+ AUDIT_TIMEOUT_MS,
33
+ )
34
+
35
+ it(
36
+ 'returns the green-light message when the audit is clean',
37
+ async () => {
38
+ const { client, close } = await newClient()
39
+ try {
40
+ const text = await callTool(client, 'audit_islands', {})
41
+ // Real-repo baseline: examples/islands-showcase has 6 islands
42
+ // and uses hydrateIslandsAuto() — no manual registry, so audit
43
+ // is clean. The bisect verification (planted typo + never-with-
44
+ // registry) lives in the unit suite. If THIS assertion fails,
45
+ // somebody added a real island defect to main, OR the audit
46
+ // started false-positive-ing.
47
+ expect(text).toContain('No island findings')
48
+ } finally {
49
+ await close()
50
+ }
51
+ },
52
+ AUDIT_TIMEOUT_MS,
53
+ )
54
+
55
+ it(
56
+ 'emits machine-readable JSON when json=true',
57
+ async () => {
58
+ const { client, close } = await newClient()
59
+ try {
60
+ const text = await callTool(client, 'audit_islands', { json: true })
61
+ // Should be valid JSON with the expected shape.
62
+ const parsed = JSON.parse(text)
63
+ expect(parsed).toHaveProperty('root')
64
+ expect(parsed).toHaveProperty('findings')
65
+ expect(parsed).toHaveProperty('summary')
66
+ expect(parsed.summary).toHaveProperty('filesScanned')
67
+ expect(parsed.summary).toHaveProperty('islandsDeclared')
68
+ expect(parsed.summary).toHaveProperty('registryEntries')
69
+ expect(parsed.summary).toHaveProperty('findingsByCode')
70
+ // The 5 finding codes always appear in the summary, with 0 counts
71
+ // when no findings — useful for CI gates that map code → exit
72
+ // status.
73
+ const codes = Object.keys(parsed.summary.findingsByCode).sort()
74
+ expect(codes).toEqual([
75
+ 'dead-island',
76
+ 'duplicate-name',
77
+ 'nested-island',
78
+ 'never-with-registry-entry',
79
+ 'registry-mismatch',
80
+ ])
81
+ } finally {
82
+ await close()
83
+ }
84
+ },
85
+ AUDIT_TIMEOUT_MS,
86
+ )
87
+
88
+ it('rejects a non-boolean json arg via zod', async () => {
89
+ const { client, close } = await newClient()
90
+ try {
91
+ const result = (await client.callTool({
92
+ name: 'audit_islands',
93
+ arguments: { json: 'yes' },
94
+ })) as { isError?: boolean; content: Array<{ type: string; text: string }> }
95
+ expect(result.isError).toBe(true)
96
+ } finally {
97
+ await close()
98
+ }
99
+ })
100
+ })
@@ -1,39 +1,10 @@
1
- import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
- import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
3
- import { createServer } from '../index'
1
+ import { callTool, newClient } from './helpers'
4
2
 
5
3
  // Real MCP server <-> client round-trip for the T2.5.8 get_changelog
6
4
  // tool. Same InMemoryTransport shape as the patterns-server test —
7
5
  // exercises tool registration, JSON-RPC framing, and the formatter
8
6
  // response shape in one pass.
9
7
 
10
- async function newClient(): Promise<{ client: Client; close: () => Promise<void> }> {
11
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
12
- const server = createServer()
13
- await server.connect(serverTransport)
14
- const client = new Client({ name: 'test', version: '0.0.0' })
15
- await client.connect(clientTransport)
16
- return {
17
- client,
18
- close: async () => {
19
- await client.close()
20
- await server.close()
21
- },
22
- }
23
- }
24
-
25
- async function callTool(
26
- client: Client,
27
- name: string,
28
- args: Record<string, unknown>,
29
- ): Promise<string> {
30
- const result = (await client.callTool({ name, arguments: args })) as {
31
- content: Array<{ type: string; text: string }>
32
- }
33
- expect(result.content[0]!.type).toBe('text')
34
- return result.content[0]!.text
35
- }
36
-
37
8
  describe('MCP server — get_changelog tool', () => {
38
9
  it('lists every package when called with no arg', async () => {
39
10
  const { client, close } = await newClient()
@@ -168,7 +168,7 @@ describe('formatChangelog', () => {
168
168
  it('respects the limit option', () => {
169
169
  const q = findChangelog(registry, 'query')!
170
170
  const limited = formatChangelog(q, { limit: 1 })
171
- const versionHeadings = limited.split('\n').filter((l) => /^## /.test(l))
171
+ const versionHeadings = limited.split('\n').filter((l) => l.startsWith('## '))
172
172
  expect(versionHeadings).toHaveLength(1)
173
173
  })
174
174
 
@@ -0,0 +1,76 @@
1
+ import { callTool, newClient } from './helpers'
2
+
3
+ // Real MCP server <-> client round-trip for the `diagnose` tool. The
4
+ // `ERROR_PATTERNS` catalog itself is unit-tested in the compiler's
5
+ // `react-intercept.test.ts`; this test pins the JSON-RPC wiring +
6
+ // the formatter that splits cause / fix / fixCode / related into
7
+ // markdown sections.
8
+
9
+ describe('MCP server — diagnose tool', () => {
10
+ it('matches a known pattern and emits cause + fix + code sections', async () => {
11
+ const { client, close } = await newClient()
12
+ try {
13
+ // The signal-not-callable pattern is one of the foundational
14
+ // entries in ERROR_PATTERNS — captures the `(name) is not a
15
+ // function` shape that almost every React refugee hits when they
16
+ // forget signals are callable.
17
+ const text = await callTool(client, 'diagnose', {
18
+ error: 'count is not a function',
19
+ })
20
+ expect(text).toContain('**Cause:**')
21
+ expect(text).toContain('**Fix:**')
22
+ // The fix-code section appears for patterns that produce one.
23
+ expect(text).toContain('**Code:**')
24
+ expect(text).toContain('count')
25
+ } finally {
26
+ await close()
27
+ }
28
+ })
29
+
30
+ it('includes the Related section when the pattern provides one', async () => {
31
+ const { client, close } = await newClient()
32
+ try {
33
+ // Hydration-mismatch is the canonical pattern that carries a
34
+ // `related` hint (typeof window guard / onMount). Verifies the
35
+ // formatter actually wires the `related` field through.
36
+ const text = await callTool(client, 'diagnose', {
37
+ error: 'Hydration mismatch at /about',
38
+ })
39
+ expect(text).toContain('**Cause:**')
40
+ expect(text).toContain('**Fix:**')
41
+ expect(text).toContain('**Related:**')
42
+ } finally {
43
+ await close()
44
+ }
45
+ })
46
+
47
+ it('returns the generic fallback when no pattern matches', async () => {
48
+ const { client, close } = await newClient()
49
+ try {
50
+ // Arbitrary text that doesn't match any ERROR_PATTERNS regex —
51
+ // exercises the fallback branch (no diagnosis → suggestions
52
+ // list).
53
+ const text = await callTool(client, 'diagnose', {
54
+ error: 'something completely unfamiliar that no pattern would match',
55
+ })
56
+ expect(text).toContain('Could not identify a Pyreon-specific pattern')
57
+ expect(text).toContain('pyreon doctor')
58
+ expect(text).toContain('bun run typecheck')
59
+ } finally {
60
+ await close()
61
+ }
62
+ })
63
+
64
+ it('rejects missing `error` arg via zod', async () => {
65
+ const { client, close } = await newClient()
66
+ try {
67
+ const result = (await client.callTool({
68
+ name: 'diagnose',
69
+ arguments: {},
70
+ })) as { isError?: boolean; content: Array<{ type: string; text: string }> }
71
+ expect(result.isError).toBe(true)
72
+ } finally {
73
+ await close()
74
+ }
75
+ })
76
+ })
@@ -0,0 +1,66 @@
1
+ import { callTool, newClient } from './helpers'
2
+
3
+ // Real MCP server <-> client round-trip for the `get_api` tool. The
4
+ // unit-level coverage of `API_REFERENCE` itself lives in
5
+ // `api-reference.test.ts` (manifest-generation contract + structural
6
+ // shape). This test proves the JSON-RPC wiring: zod validates the
7
+ // `package` + `symbol` args, the handler looks up `API_REFERENCE[key]`
8
+ // against the merged manifest output, and the formatter assembles the
9
+ // markdown response. Pairs with the manifest-driven docs pipeline
10
+ // (T2.5.1) — every entry in `api-reference.ts` flows through this tool.
11
+
12
+ describe('MCP server — get_api tool', () => {
13
+ it('returns signature + usage for a known symbol', async () => {
14
+ const { client, close } = await newClient()
15
+ try {
16
+ // `signal` is one of the foundational reactivity APIs — every
17
+ // manifest in the pipeline must publish it, so this is a stable
18
+ // assertion for the JSON-RPC contract.
19
+ const text = await callTool(client, 'get_api', {
20
+ package: 'reactivity',
21
+ symbol: 'signal',
22
+ })
23
+ expect(text).toContain('## @pyreon/reactivity — signal')
24
+ expect(text).toContain('**Signature:**')
25
+ expect(text).toContain('```typescript')
26
+ expect(text).toContain('**Usage:**')
27
+ } finally {
28
+ await close()
29
+ }
30
+ })
31
+
32
+ it('returns a not-found message with suggestions for an unknown symbol', async () => {
33
+ const { client, close } = await newClient()
34
+ try {
35
+ // `sig` is intentionally a substring of real entries (`signal`,
36
+ // `Signal`, ...) so the substring-match suggestion path fires —
37
+ // the handler does `allKeys.filter(k => k.includes(symbol))`.
38
+ const text = await callTool(client, 'get_api', {
39
+ package: 'reactivity',
40
+ symbol: 'sig',
41
+ })
42
+ expect(text).toContain("Symbol 'sig' not found")
43
+ expect(text).toContain('Did you mean')
44
+ // The matcher should surface real signal-related entries.
45
+ expect(text).toMatch(/reactivity\/\w*[Ss]ig/)
46
+ } finally {
47
+ await close()
48
+ }
49
+ })
50
+
51
+ it('rejects missing required args via zod', async () => {
52
+ // Both `package` and `symbol` are required; omitting one must
53
+ // produce a JSON-RPC error rather than silently returning an
54
+ // empty response.
55
+ const { client, close } = await newClient()
56
+ try {
57
+ const result = (await client.callTool({
58
+ name: 'get_api',
59
+ arguments: { package: 'reactivity' },
60
+ })) as { isError?: boolean; content: Array<{ type: string; text: string }> }
61
+ expect(result.isError).toBe(true)
62
+ } finally {
63
+ await close()
64
+ }
65
+ })
66
+ })
@@ -0,0 +1,75 @@
1
+ import { callTool, newClient } from './helpers'
2
+ import * as path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ // Real MCP server <-> client round-trip for the `get_browser_smoke_status`
6
+ // tool. The tool walks `<cwd>/packages` looking for browser-categorised
7
+ // packages from `.claude/rules/browser-packages.json` and checks each
8
+ // for `*.browser.test.{ts,tsx}` files. CI script `check-browser-smoke.ts`
9
+ // covers the same shape from a script entrypoint; this test pins the
10
+ // JSON-RPC handler + formatter output.
11
+ //
12
+ // vitest runs from `packages/tools/mcp` by default — walking
13
+ // `packages/tools/mcp/packages` would find nothing, exercising the
14
+ // "unknown package" branch. To exercise the COVERED + MISSING paths
15
+ // against real package directories, we `chdir` to the monorepo root
16
+ // inside one of the specs (then restore). The repo-walk-from-cwd
17
+ // pattern matches how the tool is intended to be used by an MCP
18
+ // client (CLI invocation from project root).
19
+
20
+ const _filename = fileURLToPath(import.meta.url)
21
+ const _dirname = path.dirname(_filename)
22
+ // .../packages/tools/mcp/src/tests → walk up 4 levels to the repo root.
23
+ const REPO_ROOT = path.resolve(_dirname, '..', '..', '..', '..', '..')
24
+
25
+ describe('MCP server — get_browser_smoke_status tool', () => {
26
+ it('emits the coverage header (covered / total)', async () => {
27
+ const { client, close } = await newClient()
28
+ try {
29
+ const text = await callTool(client, 'get_browser_smoke_status', {})
30
+ // Header format: `**Browser smoke coverage** (N / M):`.
31
+ expect(text).toMatch(/^\*\*Browser smoke coverage\*\* \(\d+ \/ \d+\):/)
32
+ } finally {
33
+ await close()
34
+ }
35
+ })
36
+
37
+ it('flags packages as Covered / Missing / Unknown when run from repo root', async () => {
38
+ // chdir to the monorepo root so the walker finds real package
39
+ // directories instead of the default cwd's empty `packages/`.
40
+ // Restore in finally so adjacent tests aren't affected.
41
+ const originalCwd = process.cwd()
42
+ try {
43
+ process.chdir(REPO_ROOT)
44
+ const { client, close } = await newClient()
45
+ try {
46
+ const text = await callTool(client, 'get_browser_smoke_status', {})
47
+ // From the repo root, real browser packages are present and
48
+ // have shipped `*.browser.test.*` files (lint rule enforces
49
+ // this). So `Covered (N):` should appear with N > 0.
50
+ expect(text).toMatch(/✓ Covered \(\d+\):/)
51
+ // At least one canonical browser-package name in the body.
52
+ expect(text).toMatch(/- @pyreon\/(runtime-dom|router|styler)/)
53
+ } finally {
54
+ await close()
55
+ }
56
+ } finally {
57
+ process.chdir(originalCwd)
58
+ }
59
+ })
60
+
61
+ it('reports "not found in this repo" packages from outside the monorepo', async () => {
62
+ // Default cwd (`packages/tools/mcp`) means `<cwd>/packages` is
63
+ // missing — every browser-package becomes "unknown" (listed in
64
+ // the JSON catalog but absent from this directory). Exercises
65
+ // the unknown-package branch of the formatter.
66
+ const { client, close } = await newClient()
67
+ try {
68
+ const text = await callTool(client, 'get_browser_smoke_status', {})
69
+ expect(text).toContain('Listed in browser-packages.json but not found')
70
+ expect(text).toMatch(/- @pyreon\/runtime-dom/)
71
+ } finally {
72
+ await close()
73
+ }
74
+ })
75
+ })
@@ -0,0 +1,57 @@
1
+ import { callTool, newClient } from './helpers'
2
+
3
+ // Real MCP server <-> client round-trip for the `get_components` tool.
4
+ // Pairs with `get-routes-server.test.ts` — both flow through the
5
+ // shared `generateContext` walker but render different fields. Unit
6
+ // coverage of the scanner regex lives in
7
+ // `compiler/src/tests/project-scanner.test.ts`; this proves the
8
+ // JSON-RPC wiring + the formatter that renders each `ComponentInfo`
9
+ // (name, file path, `props: { ... }`, `signals: [ ... ]`).
10
+
11
+ describe('MCP server — get_components tool', () => {
12
+ it('emits the components header with count and at least one entry', async () => {
13
+ const { client, close } = await newClient()
14
+ try {
15
+ const text = await callTool(client, 'get_components', {})
16
+ // Header format `**Components (N):**` for a non-empty repo.
17
+ expect(text).toMatch(/^\*\*Components \(\d+\):\*\*/)
18
+ // At least one component rendered: ` Name — relative/file.ts`.
19
+ expect(text).toMatch(/^ {2}\w+ — \S+/m)
20
+ } finally {
21
+ await close()
22
+ }
23
+ })
24
+
25
+ it('renders the optional props + signals details when present', async () => {
26
+ const { client, close } = await newClient()
27
+ try {
28
+ const text = await callTool(client, 'get_components', {})
29
+ // The fixture set carries components with both `props: { ... }`
30
+ // and `signals: [...]` — verifying the formatter wires the
31
+ // detail branches. The unit scanner test covers detection
32
+ // accuracy; this verifies the rendered detail format reaches
33
+ // the consumer over JSON-RPC.
34
+ expect(text).toMatch(/props: \{ [^}]+ \}/)
35
+ expect(text).toMatch(/signals: \[[^\]]+\]/)
36
+ } finally {
37
+ await close()
38
+ }
39
+ })
40
+
41
+ it('rejects unexpected args (no-args contract)', async () => {
42
+ const { client, close } = await newClient()
43
+ try {
44
+ const result = (await client.callTool({
45
+ name: 'get_components',
46
+ arguments: { unexpected: 'value' },
47
+ })) as { isError?: boolean; content: Array<{ type: string; text: string }> }
48
+ const text = result.content?.[0]?.text ?? ''
49
+ // Either zod rejects or the unknown arg is coerced into the
50
+ // no-args path. Both contracts are valid — assert the call
51
+ // didn't crash, returning at minimum the header.
52
+ expect(result.isError === true || text.includes('**Components')).toBe(true)
53
+ } finally {
54
+ await close()
55
+ }
56
+ })
57
+ })