@pyreon/mcp 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { API_REFERENCE } from '../api-reference'
5
+ import { loadPatternRegistry } from '../patterns'
6
+
7
+ // Content-quality test for the patterns corpus. Catches drift that
8
+ // `loadPatternRegistry` and the seeAlso consistency test don't see:
9
+ //
10
+ // (a) Structural shape — every pattern must follow the standardised
11
+ // # Title / ## Anti-pattern / ## Related skeleton so the MCP
12
+ // response has predictable content sections. A file missing any
13
+ // of these is incomplete doc content; failing the build beats
14
+ // shipping a half-written pattern.
15
+ // (b) Detector references — every `Detector: \`<code>\`` mention in
16
+ // prose must match a real `PyreonDiagnosticCode`. A rename in
17
+ // the detector without updating the pattern writes a footer
18
+ // that points at a ghost code.
19
+ // (c) get_api references — every `get_api({ package: "X", symbol:
20
+ // "Y" })` prose call with a CONCRETE symbol must resolve in
21
+ // API_REFERENCE. A rename or deletion of an API entry without
22
+ // updating the pattern leaves an AI agent chasing a 404.
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url))
25
+ // tests/ → src/ → mcp/ → tools/ → packages/ → repo root (5 ups)
26
+ const REPO_ROOT = resolve(HERE, '../../../../../')
27
+
28
+ // Must stay in sync with `PyreonDiagnosticCode` in
29
+ // `packages/core/compiler/src/pyreon-intercept.ts`. Consistency with
30
+ // that source is enforced by `detector-tag-consistency.test.ts` in
31
+ // the compiler package — we hardcode it here to avoid a cross-package
32
+ // runtime import that would complicate bundling.
33
+ const KNOWN_DETECTOR_CODES = new Set([
34
+ 'for-missing-by',
35
+ 'for-with-key',
36
+ 'props-destructured',
37
+ 'process-dev-gate',
38
+ 'empty-theme',
39
+ 'raw-add-event-listener',
40
+ 'raw-remove-event-listener',
41
+ 'date-math-random-id',
42
+ 'on-click-undefined',
43
+ ])
44
+
45
+ describe('patterns content — structural shape', () => {
46
+ const registry = loadPatternRegistry(REPO_ROOT)
47
+
48
+ it.each(registry.patterns.map((p) => [p.name, p.path]))(
49
+ '%s has exactly one top-level # heading',
50
+ (_name, path) => {
51
+ const body = readFileSync(path, 'utf8')
52
+ const h1Count = body.split('\n').filter((l) => /^# /.test(l)).length
53
+ expect(h1Count).toBe(1)
54
+ },
55
+ )
56
+
57
+ it.each(registry.patterns.map((p) => [p.name, p.path]))(
58
+ '%s has a ## Anti-pattern section',
59
+ (_name, path) => {
60
+ const body = readFileSync(path, 'utf8')
61
+ // Match the heading at column 0, so "## Anti-pattern" and
62
+ // "## Anti-pattern (something)" both pass but "### Anti-pattern"
63
+ // (nested) does not.
64
+ const hasAnti = /^## Anti-pattern/m.test(body)
65
+ expect(hasAnti).toBe(true)
66
+ },
67
+ )
68
+
69
+ it.each(registry.patterns.map((p) => [p.name, p.path]))(
70
+ '%s has a ## Related section',
71
+ (_name, path) => {
72
+ const body = readFileSync(path, 'utf8')
73
+ const hasRelated = /^## Related/m.test(body)
74
+ expect(hasRelated).toBe(true)
75
+ },
76
+ )
77
+ })
78
+
79
+ describe('patterns content — detector code references are real', () => {
80
+ const registry = loadPatternRegistry(REPO_ROOT)
81
+
82
+ it('every `Detector: \\`<code>\\`` reference names a known PyreonDiagnosticCode', () => {
83
+ const unknown: Array<{ pattern: string; code: string }> = []
84
+ for (const p of registry.patterns) {
85
+ const body = readFileSync(p.path, 'utf8')
86
+ // Matches lines like "- Detector: `for-missing-by`" or
87
+ // "- Detector: `raw-add-event-listener` / `raw-remove-event-listener`"
88
+ const rx = /Detector: `([a-z0-9-]+)`/g
89
+ for (const m of body.matchAll(rx)) {
90
+ const code = m[1]!
91
+ if (!KNOWN_DETECTOR_CODES.has(code)) {
92
+ unknown.push({ pattern: p.name, code })
93
+ }
94
+ }
95
+ }
96
+ // If this fails, either (a) the detector code was renamed and
97
+ // the pattern still cites the old name — fix the pattern —
98
+ // or (b) a new detector code was added and `KNOWN_DETECTOR_CODES`
99
+ // in this test is stale — add the new code here AND in
100
+ // `pyreon-intercept.ts`.
101
+ expect(unknown).toEqual([])
102
+ })
103
+ })
104
+
105
+ describe('patterns content — get_api references resolve', () => {
106
+ const registry = loadPatternRegistry(REPO_ROOT)
107
+
108
+ it('every concrete `get_api({ package, symbol })` reference exists in API_REFERENCE', () => {
109
+ const dangling: Array<{ pattern: string; key: string }> = []
110
+ for (const p of registry.patterns) {
111
+ const body = readFileSync(p.path, 'utf8')
112
+ // Matches: get_api({ package: "hooks", symbol: "useEventListener" })
113
+ // Skips placeholder symbol values like "..." via the character class.
114
+ const rx = /get_api\(\{\s*package:\s*"([a-z-]+)"\s*,\s*symbol:\s*"([a-zA-Z]+)"\s*\}\)/g
115
+ for (const m of body.matchAll(rx)) {
116
+ const key = `${m[1]}/${m[2]}`
117
+ if (!API_REFERENCE[key]) {
118
+ dangling.push({ pattern: p.name, key })
119
+ }
120
+ }
121
+ }
122
+ // If this fails, the API entry was renamed or removed but the
123
+ // pattern still cites the old key. Either update the pattern or
124
+ // restore the API entry.
125
+ expect(dangling).toEqual([])
126
+ })
127
+
128
+ it('every documented package slug matches a real @pyreon/<pkg>', () => {
129
+ // Any `get_api({ package: "X" })` whose X is not a valid scope
130
+ // would pass the key check only if the symbol happens not to
131
+ // exist (null match). This assertion checks packages regardless
132
+ // of whether a concrete symbol was provided.
133
+ const knownPackages = new Set(Object.keys(API_REFERENCE).map((k) => k.split('/')[0]))
134
+ const unknown: Array<{ pattern: string; pkg: string }> = []
135
+ for (const p of registry.patterns) {
136
+ const body = readFileSync(p.path, 'utf8')
137
+ const rx = /get_api\(\{\s*package:\s*"([a-z-]+)"/g
138
+ for (const m of body.matchAll(rx)) {
139
+ const pkg = m[1]!
140
+ if (!knownPackages.has(pkg)) {
141
+ unknown.push({ pattern: p.name, pkg })
142
+ }
143
+ }
144
+ }
145
+ expect(unknown).toEqual([])
146
+ })
147
+ })
@@ -0,0 +1,160 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
3
+ import { createServer } from '../index'
4
+
5
+ // Real MCP server <-> client round-trip for the T2.5.3 (`get_pattern`)
6
+ // and T2.5.4 (`get_anti_patterns`) tools. Same setup as the validate
7
+ // integration test — linked in-memory transports + the SDK's Client —
8
+ // so we exercise tool registration, JSON-RPC framing, and the formatter
9
+ // response shape in one pass.
10
+
11
+ async function newClient(): Promise<{ client: Client; close: () => Promise<void> }> {
12
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
13
+ const server = createServer()
14
+ await server.connect(serverTransport)
15
+ const client = new Client({ name: 'test', version: '0.0.0' })
16
+ await client.connect(clientTransport)
17
+ return {
18
+ client,
19
+ close: async () => {
20
+ await client.close()
21
+ await server.close()
22
+ },
23
+ }
24
+ }
25
+
26
+ async function callTool(
27
+ client: Client,
28
+ name: string,
29
+ args: Record<string, unknown>,
30
+ ): Promise<string> {
31
+ const result = (await client.callTool({ name, arguments: args })) as {
32
+ content: Array<{ type: string; text: string }>
33
+ }
34
+ expect(result.content[0]!.type).toBe('text')
35
+ return result.content[0]!.text
36
+ }
37
+
38
+ describe('MCP server — get_pattern tool', () => {
39
+ it('lists available patterns when called with no arg', async () => {
40
+ const { client, close } = await newClient()
41
+ try {
42
+ const text = await callTool(client, 'get_pattern', {})
43
+ expect(text).toMatch(/^# Pyreon Patterns \(\d+\)/)
44
+ expect(text).toContain('**dev-warnings**')
45
+ expect(text).toContain('**controllable-state**')
46
+ expect(text).toContain('**form-fields**')
47
+ } finally {
48
+ await close()
49
+ }
50
+ })
51
+
52
+ it('returns the full body when called with a valid slug', async () => {
53
+ const { client, close } = await newClient()
54
+ try {
55
+ const text = await callTool(client, 'get_pattern', { name: 'dev-warnings' })
56
+ expect(text).toContain('# Dev-mode warnings')
57
+ expect(text).toContain('import.meta.env?.DEV')
58
+ expect(text).toContain('typeof process')
59
+ // Seealso footer is rendered.
60
+ expect(text).toContain('**See also:**')
61
+ expect(text).toContain('ssr-safe-hooks')
62
+ } finally {
63
+ await close()
64
+ }
65
+ })
66
+
67
+ it('returns suggestions when called with a misspelled name', async () => {
68
+ const { client, close } = await newClient()
69
+ try {
70
+ const text = await callTool(client, 'get_pattern', { name: 'dev-warn' })
71
+ expect(text).toContain('not found')
72
+ expect(text).toContain('Did you mean')
73
+ expect(text).toContain('dev-warnings')
74
+ } finally {
75
+ await close()
76
+ }
77
+ })
78
+
79
+ it('returns a helpful miss message when no match exists at all', async () => {
80
+ const { client, close } = await newClient()
81
+ try {
82
+ const text = await callTool(client, 'get_pattern', { name: 'totally-unrelated-xxx' })
83
+ expect(text).toContain('not found')
84
+ expect(text).toContain('get_pattern()')
85
+ } finally {
86
+ await close()
87
+ }
88
+ })
89
+ })
90
+
91
+ describe('MCP server — get_anti_patterns tool', () => {
92
+ it('returns all categories when called with no arg', async () => {
93
+ const { client, close } = await newClient()
94
+ try {
95
+ const text = await callTool(client, 'get_anti_patterns', {})
96
+ expect(text).toMatch(/^# Pyreon Anti-Patterns \(\d+ total, \d+ categor(y|ies)\)/)
97
+ // Multiple categories rendered.
98
+ expect(text).toContain('## Reactivity Mistakes')
99
+ expect(text).toContain('## JSX Mistakes')
100
+ expect(text).toContain('## Architecture Mistakes')
101
+ } finally {
102
+ await close()
103
+ }
104
+ })
105
+
106
+ it('filters to a single category', async () => {
107
+ const { client, close } = await newClient()
108
+ try {
109
+ const text = await callTool(client, 'get_anti_patterns', { category: 'reactivity' })
110
+ expect(text).toMatch(/^# Pyreon Anti-Patterns — reactivity \(\d+\)/)
111
+ expect(text).toContain('## Reactivity Mistakes')
112
+ // Other categories must NOT be present.
113
+ expect(text).not.toContain('## JSX Mistakes')
114
+ expect(text).not.toContain('## Architecture Mistakes')
115
+ } finally {
116
+ await close()
117
+ }
118
+ })
119
+
120
+ it('surfaces detector tags inline on tagged entries', async () => {
121
+ const { client, close } = await newClient()
122
+ try {
123
+ const text = await callTool(client, 'get_anti_patterns', { category: 'jsx' })
124
+ expect(text).toContain('[detector: for-missing-by]')
125
+ expect(text).toContain('[detector: for-with-key]')
126
+ } finally {
127
+ await close()
128
+ }
129
+ })
130
+
131
+ it('accepts "all" explicitly (same output as no arg)', async () => {
132
+ const { client, close } = await newClient()
133
+ try {
134
+ const withArg = await callTool(client, 'get_anti_patterns', { category: 'all' })
135
+ const noArg = await callTool(client, 'get_anti_patterns', {})
136
+ expect(withArg).toBe(noArg)
137
+ } finally {
138
+ await close()
139
+ }
140
+ })
141
+
142
+ it('returns an error response for an unknown category (zod validation)', async () => {
143
+ const { client, close } = await newClient()
144
+ try {
145
+ // The SDK returns { isError: true, content: [...] } for zod
146
+ // validation failures rather than rejecting the promise —
147
+ // a structured error response surfaces the same detail to
148
+ // consumers without the throw unwinding their request loop.
149
+ const result = (await client.callTool({
150
+ name: 'get_anti_patterns',
151
+ arguments: { category: 'bogus' },
152
+ })) as { isError?: boolean; content: Array<{ type: string; text: string }> }
153
+ expect(result.isError).toBe(true)
154
+ expect(result.content[0]!.text).toMatch(/Invalid (option|arguments)/i)
155
+ expect(result.content[0]!.text).toContain('reactivity')
156
+ } finally {
157
+ await close()
158
+ }
159
+ })
160
+ })
@@ -0,0 +1,236 @@
1
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import {
6
+ findPattern,
7
+ formatPatternBody,
8
+ formatPatternIndex,
9
+ loadPatternRegistry,
10
+ suggestPatterns,
11
+ } from '../patterns'
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url))
14
+ // tests/ → src/ → mcp/ → tools/ → packages/ → repo root (5 ups)
15
+ const REPO_ROOT = resolve(HERE, '../../../../../')
16
+
17
+ describe('loadPatternRegistry — real repo docs/patterns', () => {
18
+ const registry = loadPatternRegistry(REPO_ROOT)
19
+
20
+ it('finds the docs/patterns directory', () => {
21
+ expect(registry.root).toBeTruthy()
22
+ expect(registry.root).toContain('docs/patterns')
23
+ })
24
+
25
+ it('loads all 14 seeded patterns', () => {
26
+ const names = registry.patterns.map((p) => p.name).sort()
27
+ expect(names).toEqual([
28
+ 'controllable-state',
29
+ 'data-fetching',
30
+ 'dev-warnings',
31
+ 'dynamic-fields',
32
+ 'event-listeners',
33
+ 'form-fields',
34
+ 'imperative-toasts',
35
+ 'keyed-lists',
36
+ 'reactive-context',
37
+ 'routing-setup',
38
+ 'signal-writes',
39
+ 'ssr-safe-hooks',
40
+ 'state-management',
41
+ 'styler-theming',
42
+ ])
43
+ })
44
+
45
+ it('every pattern has a resolvable seeAlso (no dangling references)', () => {
46
+ // Drift guard: if pattern A says seeAlso: [b] and file b.md is
47
+ // renamed or removed, this test fails loudly. Otherwise the
48
+ // footer link in the MCP response would point at a ghost.
49
+ const valid = new Set(registry.patterns.map((p) => p.name))
50
+ const dangling: Array<{ from: string; to: string }> = []
51
+ for (const p of registry.patterns) {
52
+ for (const ref of p.seeAlso) {
53
+ if (!valid.has(ref)) dangling.push({ from: p.name, to: ref })
54
+ }
55
+ }
56
+ expect(dangling).toEqual([])
57
+ })
58
+
59
+ it('parses frontmatter on every seeded pattern', () => {
60
+ for (const p of registry.patterns) {
61
+ expect(p.title.length).toBeGreaterThan(0)
62
+ expect(p.summary).not.toBeNull()
63
+ expect(p.summary!.length).toBeGreaterThan(10)
64
+ }
65
+ })
66
+
67
+ it('parses the seeAlso array-style frontmatter', () => {
68
+ const devWarnings = registry.patterns.find((p) => p.name === 'dev-warnings')
69
+ expect(devWarnings).toBeDefined()
70
+ expect(devWarnings!.seeAlso).toEqual(['ssr-safe-hooks'])
71
+ })
72
+ })
73
+
74
+ describe('loadPatternRegistry — synthetic tmp dir', () => {
75
+ let tmp: string
76
+
77
+ beforeEach(() => {
78
+ tmp = mkdtempSync(join(tmpdir(), 'pyreon-mcp-patterns-'))
79
+ mkdirSync(join(tmp, 'docs', 'patterns'), { recursive: true })
80
+ })
81
+ afterEach(() => {
82
+ rmSync(tmp, { recursive: true, force: true })
83
+ })
84
+
85
+ it('returns root=null when no docs/patterns exists', () => {
86
+ // Point to a sibling dir without patterns
87
+ const empty = mkdtempSync(join(tmpdir(), 'pyreon-mcp-empty-'))
88
+ try {
89
+ const r = loadPatternRegistry(empty)
90
+ expect(r.root).toBeNull()
91
+ expect(r.patterns).toEqual([])
92
+ } finally {
93
+ rmSync(empty, { recursive: true, force: true })
94
+ }
95
+ })
96
+
97
+ it('falls back to the first heading as title when frontmatter lacks one', () => {
98
+ writeFileSync(
99
+ join(tmp, 'docs/patterns/no-title.md'),
100
+ `---
101
+ summary: Has summary but no title
102
+ ---
103
+
104
+ # The Heading
105
+
106
+ Body text.`,
107
+ )
108
+ const r = loadPatternRegistry(tmp)
109
+ const p = r.patterns.find((x) => x.name === 'no-title')!
110
+ expect(p.title).toBe('The Heading')
111
+ })
112
+
113
+ it('falls back to the slug when neither frontmatter nor heading has a title', () => {
114
+ writeFileSync(
115
+ join(tmp, 'docs/patterns/slug-only.md'),
116
+ 'Body with no title.',
117
+ )
118
+ const r = loadPatternRegistry(tmp)
119
+ const p = r.patterns.find((x) => x.name === 'slug-only')!
120
+ expect(p.title).toBe('slug-only')
121
+ })
122
+
123
+ it('parses inline-array seeAlso: [a, b, c]', () => {
124
+ writeFileSync(
125
+ join(tmp, 'docs/patterns/inline.md'),
126
+ `---
127
+ title: Inline
128
+ seeAlso: [one, two, three]
129
+ ---
130
+
131
+ Body.`,
132
+ )
133
+ const r = loadPatternRegistry(tmp)
134
+ expect(r.patterns[0]!.seeAlso).toEqual(['one', 'two', 'three'])
135
+ })
136
+
137
+ it('parses block-style seeAlso with YAML bullets', () => {
138
+ writeFileSync(
139
+ join(tmp, 'docs/patterns/block.md'),
140
+ `---
141
+ title: Block
142
+ seeAlso:
143
+ - one
144
+ - two
145
+ ---
146
+
147
+ Body.`,
148
+ )
149
+ const r = loadPatternRegistry(tmp)
150
+ expect(r.patterns[0]!.seeAlso).toEqual(['one', 'two'])
151
+ })
152
+
153
+ it('skips README.md and index.md (reserved doc filenames)', () => {
154
+ writeFileSync(join(tmp, 'docs/patterns/README.md'), '# Should be skipped')
155
+ writeFileSync(join(tmp, 'docs/patterns/index.md'), '# Also skipped')
156
+ writeFileSync(join(tmp, 'docs/patterns/real.md'), '# Real pattern')
157
+ const r = loadPatternRegistry(tmp)
158
+ expect(r.patterns.map((p) => p.name)).toEqual(['real'])
159
+ })
160
+ })
161
+
162
+ describe('findPattern + suggestPatterns', () => {
163
+ const registry = loadPatternRegistry(REPO_ROOT)
164
+
165
+ it('findPattern returns an exact match', () => {
166
+ const p = findPattern(registry, 'dev-warnings')
167
+ expect(p).toBeDefined()
168
+ expect(p!.name).toBe('dev-warnings')
169
+ })
170
+
171
+ it('findPattern returns null for an unknown name', () => {
172
+ const p = findPattern(registry, 'nonexistent-pattern')
173
+ expect(p).toBeNull()
174
+ })
175
+
176
+ it('suggestPatterns returns slug substring matches', () => {
177
+ const s = suggestPatterns(registry, 'warn')
178
+ expect(s).toContain('dev-warnings')
179
+ })
180
+
181
+ it('suggestPatterns returns title substring matches', () => {
182
+ const s = suggestPatterns(registry, 'controlled')
183
+ expect(s).toContain('controllable-state')
184
+ })
185
+
186
+ it('suggestPatterns caps at 5 results', () => {
187
+ const s = suggestPatterns(registry, 's') // matches many
188
+ expect(s.length).toBeLessThanOrEqual(5)
189
+ })
190
+ })
191
+
192
+ describe('formatPatternIndex', () => {
193
+ const registry = loadPatternRegistry(REPO_ROOT)
194
+
195
+ it('renders a header with the pattern count', () => {
196
+ const out = formatPatternIndex(registry)
197
+ expect(out).toMatch(/^# Pyreon Patterns \(\d+\)/)
198
+ })
199
+
200
+ it('lists every pattern as a bullet', () => {
201
+ const out = formatPatternIndex(registry)
202
+ for (const p of registry.patterns) {
203
+ expect(out).toContain(`**${p.name}**`)
204
+ }
205
+ })
206
+
207
+ it('returns a helpful miss message when no patterns exist', () => {
208
+ const empty = mkdtempSync(join(tmpdir(), 'pyreon-mcp-empty-idx-'))
209
+ try {
210
+ const r = loadPatternRegistry(empty)
211
+ const out = formatPatternIndex(r)
212
+ expect(out).toContain('No patterns found')
213
+ expect(out).toContain('docs/patterns')
214
+ } finally {
215
+ rmSync(empty, { recursive: true, force: true })
216
+ }
217
+ })
218
+ })
219
+
220
+ describe('formatPatternBody', () => {
221
+ const registry = loadPatternRegistry(REPO_ROOT)
222
+
223
+ it('returns the pattern body', () => {
224
+ const devWarnings = findPattern(registry, 'dev-warnings')!
225
+ const out = formatPatternBody(devWarnings)
226
+ expect(out).toContain('# Dev-mode warnings')
227
+ expect(out).toContain('import.meta.env')
228
+ })
229
+
230
+ it('appends a See also footer when seeAlso is populated', () => {
231
+ const devWarnings = findPattern(registry, 'dev-warnings')!
232
+ const out = formatPatternBody(devWarnings)
233
+ expect(out).toContain('**See also:**')
234
+ expect(out).toContain('get_pattern({ name: "ssr-safe-hooks" })')
235
+ })
236
+ })
@@ -0,0 +1,155 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
3
+ import { createServer } from '../index'
4
+
5
+ // Real MCP server <-> client round-trip via InMemoryTransport. The
6
+ // tool-call payload goes through the full JSON-RPC framing, the tool
7
+ // dispatcher resolves `validate`, and the handler runs with the exact
8
+ // same code path production stdio uses. This locks down the bits the
9
+ // detector unit test cannot: handler registration, merge contract, and
10
+ // the serialised response shape a consumer actually sees.
11
+
12
+ async function newConnectedClient(): Promise<{ client: Client; close: () => Promise<void> }> {
13
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
14
+
15
+ const server = createServer()
16
+ await server.connect(serverTransport)
17
+
18
+ const client = new Client({ name: 'test', version: '0.0.0' })
19
+ await client.connect(clientTransport)
20
+
21
+ return {
22
+ client,
23
+ close: async () => {
24
+ await client.close()
25
+ await server.close()
26
+ },
27
+ }
28
+ }
29
+
30
+ async function callValidate(client: Client, code: string): Promise<string> {
31
+ const result = (await client.callTool({
32
+ name: 'validate',
33
+ arguments: { code },
34
+ })) as { content: Array<{ type: string; text: string }> }
35
+
36
+ // Response is { content: [{ type: 'text', text: '...' }] }
37
+ expect(result.content).toHaveLength(1)
38
+ expect(result.content[0]!.type).toBe('text')
39
+ return result.content[0]!.text
40
+ }
41
+
42
+ describe('MCP server — validate tool over real JSON-RPC transport', () => {
43
+ it('invokes the merged detector pipeline on a mixed React + Pyreon snippet', async () => {
44
+ const { client, close } = await newConnectedClient()
45
+ try {
46
+ const code = `
47
+ import { useState } from 'react'
48
+
49
+ const Counter = ({ count }: { count: number }) => {
50
+ const [local, setLocal] = useState(count)
51
+ return <For each={items}>{(i) => <li className="x" />}</For>
52
+ }
53
+ `
54
+ const text = await callValidate(client, code)
55
+
56
+ // Merged output MUST contain codes from BOTH detectors.
57
+ // React detector:
58
+ expect(text).toContain('use-state')
59
+ expect(text).toContain('react-import')
60
+ expect(text).toContain('class-name-prop')
61
+ // Pyreon detector:
62
+ expect(text).toContain('props-destructured')
63
+ expect(text).toContain('for-missing-by')
64
+
65
+ // Header format "Found N issues" survived serialisation.
66
+ expect(text).toMatch(/^Found \d+ issues?:/)
67
+ } finally {
68
+ await close()
69
+ }
70
+ })
71
+
72
+ it('returns the no-issues fast path unchanged for idiomatic Pyreon code', async () => {
73
+ const { client, close } = await newConnectedClient()
74
+ try {
75
+ const code = `
76
+ import { signal } from '@pyreon/reactivity'
77
+ import { For } from '@pyreon/core'
78
+
79
+ const List = (props: { items: Array<{ id: string; name: string }> }) => {
80
+ const q = signal('')
81
+ return (
82
+ <For each={props.items} by={(i) => i.id}>
83
+ {(i) => <li>{i.name}: {q()}</li>}
84
+ </For>
85
+ )
86
+ }
87
+ `
88
+ const text = await callValidate(client, code)
89
+ expect(text).toBe('✓ No issues found. The code follows Pyreon patterns correctly.')
90
+ } finally {
91
+ await close()
92
+ }
93
+ })
94
+
95
+ it('surfaces the on-click-undefined Pyreon-only diagnostic via the handler', async () => {
96
+ // Covers the "Pyreon-only, no React match" path — the React detector
97
+ // returns [], the Pyreon detector returns 1, and the MCP handler
98
+ // must still emit the "Found N issues" header and the diagnostic.
99
+ const { client, close } = await newConnectedClient()
100
+ try {
101
+ const text = await callValidate(client, `<button onClick={undefined}>x</button>`)
102
+ expect(text).toContain('on-click-undefined')
103
+ expect(text).toContain('omit')
104
+ expect(text).toMatch(/^Found 1 issue:/)
105
+ } finally {
106
+ await close()
107
+ }
108
+ })
109
+
110
+ it('locks the MCP response format (toMatchInlineSnapshot)', async () => {
111
+ // The textual format consumers render in their UI. An unannounced
112
+ // format change would drift consumer UIs silently. Update the
113
+ // snapshot deliberately via `bun run test -- -u` when the format
114
+ // is genuinely supposed to change.
115
+ const { client, close } = await newConnectedClient()
116
+ try {
117
+ // Single diagnostic so the snapshot stays readable.
118
+ const text = await callValidate(client, `<button onClick={undefined}>x</button>`)
119
+ expect(text).toMatchInlineSnapshot(`
120
+ "Found 1 issue:
121
+
122
+ 1. **on-click-undefined** (line 1)
123
+ \`onClick={undefined}\` explicitly passes undefined as a listener. Pyreon's runtime guards against this, but the cleanest pattern is to omit the attribute entirely or use a conditional: \`onClick={condition ? handler : undefined}\`.
124
+ Current: \`onClick={undefined}\`
125
+ Fix: \`/* omit onClick when the handler is not defined */\`
126
+ Auto-fixable: no"
127
+ `)
128
+ } finally {
129
+ await close()
130
+ }
131
+ })
132
+
133
+ it('sorts the merged diagnostics by line number in the response', async () => {
134
+ // The handler merges React + Pyreon diagnostics and sorts by
135
+ // (line, column). Verify the sort survived the round trip.
136
+ const { client, close } = await newConnectedClient()
137
+ try {
138
+ const code = `
139
+ import { useState } from 'react'
140
+ // eslint-disable-next-line
141
+ const X = () => <For each={items}>{(i) => <li className="y" />}</For>
142
+ `
143
+ const text = await callValidate(client, code)
144
+
145
+ // Extract "(line N)" tokens and assert monotonically non-decreasing.
146
+ const lines = [...text.matchAll(/\(line (\d+)\)/g)].map((m) => Number(m[1]))
147
+ expect(lines.length).toBeGreaterThan(1)
148
+ for (let i = 1; i < lines.length; i++) {
149
+ expect(lines[i]).toBeGreaterThanOrEqual(lines[i - 1]!)
150
+ }
151
+ } finally {
152
+ await close()
153
+ }
154
+ })
155
+ })