@pyreon/mcp 0.13.1 → 0.14.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,243 @@
1
+ /**
2
+ * Pattern registry for the `get_pattern` MCP tool (T2.5.3).
3
+ *
4
+ * Each pattern answers a "how do I do X the right way" question with a
5
+ * code example and rationale. The content is the body of the
6
+ * corresponding `docs/patterns/<name>.md` file, discovered at runtime
7
+ * by walking up from `process.cwd()` to the nearest repo that contains
8
+ * `docs/patterns/`.
9
+ *
10
+ * Why a filesystem lookup instead of bundled content: the patterns
11
+ * belong in the VitePress site (they're first-class docs), and having
12
+ * the MCP fetch them live means the AI sees the same text the human
13
+ * would. Bundling copies would drift.
14
+ *
15
+ * Fallback: if no `docs/patterns/` exists in the walk (e.g. the MCP is
16
+ * running in a consumer repo), the tool reports the miss and lists
17
+ * what patterns WOULD be available if running against the Pyreon
18
+ * monorepo. The list itself is seeded from the directory walk, so
19
+ * adding a new pattern file makes it discoverable without code changes.
20
+ */
21
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
22
+ import { dirname, join, resolve } from 'node:path'
23
+
24
+ export interface PatternFile {
25
+ /** Slug (filename without extension) — the value consumers pass to get_pattern */
26
+ name: string
27
+ /** Absolute path to the source markdown file */
28
+ path: string
29
+ /** Raw markdown body */
30
+ body: string
31
+ /** Title from the frontmatter or first `# ` heading */
32
+ title: string
33
+ /** Optional one-line summary from the frontmatter */
34
+ summary: string | null
35
+ /** Cross-reference slugs from the frontmatter */
36
+ seeAlso: string[]
37
+ }
38
+
39
+ export interface PatternRegistry {
40
+ /** Root dir (the `docs/patterns/` directory that was found) */
41
+ root: string | null
42
+ /** All patterns, sorted by slug */
43
+ patterns: PatternFile[]
44
+ }
45
+
46
+ // ═══════════════════════════════════════════════════════════════════════════════
47
+ // Directory walk
48
+ // ═══════════════════════════════════════════════════════════════════════════════
49
+
50
+ // Patterns live at `docs/docs/patterns/` — the VitePress content dir
51
+ // so the same file serves both the MCP tool AND the docs website. We
52
+ // also check the top-level `docs/patterns/` layout for forward
53
+ // compatibility, in case a future migration moves them up.
54
+ const PATTERN_PATH_CANDIDATES: ReadonlyArray<ReadonlyArray<string>> = [
55
+ ['docs', 'docs', 'patterns'],
56
+ ['docs', 'patterns'],
57
+ ]
58
+
59
+ function findPatternsDir(startDir: string): string | null {
60
+ let dir = resolve(startDir)
61
+ for (let i = 0; i < 30; i++) {
62
+ for (const segments of PATTERN_PATH_CANDIDATES) {
63
+ const candidate = join(dir, ...segments)
64
+ if (existsSync(candidate) && statSync(candidate).isDirectory()) {
65
+ return candidate
66
+ }
67
+ }
68
+ const parent = dirname(dir)
69
+ if (parent === dir) return null
70
+ dir = parent
71
+ }
72
+ return null
73
+ }
74
+
75
+ // ═══════════════════════════════════════════════════════════════════════════════
76
+ // Frontmatter parser (YAML-ish — no external dep)
77
+ // ═══════════════════════════════════════════════════════════════════════════════
78
+
79
+ /**
80
+ * Minimal frontmatter parser. Supports:
81
+ * title: string (unquoted or quoted)
82
+ * summary: string
83
+ * seeAlso: [one, two, three] OR seeAlso:\n - one\n - two
84
+ *
85
+ * Anything else is ignored. Full YAML would be overkill here.
86
+ */
87
+ function parseFrontmatter(source: string): {
88
+ meta: { title?: string; summary?: string; seeAlso?: string[] }
89
+ body: string
90
+ } {
91
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(source)
92
+ if (!match) return { meta: {}, body: source }
93
+ const rawMeta = match[1]!
94
+ const body = match[2]!.trim()
95
+
96
+ const meta: { title?: string; summary?: string; seeAlso?: string[] } = {}
97
+
98
+ const lines = rawMeta.split('\n')
99
+ let seeAlsoActive = false
100
+ const seeAlsoItems: string[] = []
101
+
102
+ for (const line of lines) {
103
+ if (seeAlsoActive) {
104
+ const bullet = /^\s*-\s*(.+?)\s*$/.exec(line)
105
+ if (bullet) {
106
+ seeAlsoItems.push(bullet[1]!)
107
+ continue
108
+ }
109
+ seeAlsoActive = false
110
+ }
111
+
112
+ const kv = /^([a-zA-Z]+):\s*(.*)$/.exec(line)
113
+ if (!kv) continue
114
+ const key = kv[1]!
115
+ const value = kv[2]!.trim()
116
+
117
+ if (key === 'title') {
118
+ meta.title = value.replace(/^["']|["']$/g, '')
119
+ } else if (key === 'summary') {
120
+ meta.summary = value.replace(/^["']|["']$/g, '')
121
+ } else if (key === 'seeAlso') {
122
+ if (value.startsWith('[') && value.endsWith(']')) {
123
+ meta.seeAlso = value
124
+ .slice(1, -1)
125
+ .split(',')
126
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
127
+ .filter(Boolean)
128
+ } else if (value === '') {
129
+ seeAlsoActive = true
130
+ }
131
+ }
132
+ }
133
+
134
+ if (seeAlsoActive && seeAlsoItems.length > 0) {
135
+ meta.seeAlso = seeAlsoItems
136
+ }
137
+
138
+ return { meta, body }
139
+ }
140
+
141
+ function extractFirstHeading(body: string): string | null {
142
+ for (const line of body.split('\n')) {
143
+ const h = /^#\s+(.+)$/.exec(line)
144
+ if (h) return h[1]!
145
+ }
146
+ return null
147
+ }
148
+
149
+ // ═══════════════════════════════════════════════════════════════════════════════
150
+ // Public API
151
+ // ═══════════════════════════════════════════════════════════════════════════════
152
+
153
+ export function loadPatternRegistry(startDir: string = process.cwd()): PatternRegistry {
154
+ const root = findPatternsDir(startDir)
155
+ if (!root) return { root: null, patterns: [] }
156
+
157
+ const patterns: PatternFile[] = []
158
+ const entries = readdirSync(root).sort()
159
+ for (const entry of entries) {
160
+ if (!entry.endsWith('.md')) continue
161
+ if (entry.startsWith('.') || entry === 'README.md' || entry === 'index.md') continue
162
+ const filePath = join(root, entry)
163
+ let source: string
164
+ try {
165
+ source = readFileSync(filePath, 'utf8')
166
+ } catch {
167
+ continue
168
+ }
169
+ const { meta, body } = parseFrontmatter(source)
170
+ const name = entry.replace(/\.md$/, '')
171
+ const title = meta.title ?? extractFirstHeading(body) ?? name
172
+ patterns.push({
173
+ name,
174
+ path: filePath,
175
+ body: source,
176
+ title,
177
+ summary: meta.summary ?? null,
178
+ seeAlso: meta.seeAlso ?? [],
179
+ })
180
+ }
181
+
182
+ return { root, patterns }
183
+ }
184
+
185
+ /**
186
+ * Format the registry as a short index listing for the "no arg" case.
187
+ * Each entry: ` - slug — title (summary)`.
188
+ */
189
+ export function formatPatternIndex(registry: PatternRegistry): string {
190
+ if (!registry.root || registry.patterns.length === 0) {
191
+ return (
192
+ 'No patterns found. Patterns live at `docs/docs/patterns/<name>.md` ' +
193
+ '(the VitePress content directory) in the Pyreon monorepo. If you ' +
194
+ 'are running the MCP in a consumer project, patterns are not ' +
195
+ 'available locally — run the MCP in the Pyreon repo to browse them.'
196
+ )
197
+ }
198
+
199
+ const parts: string[] = [`# Pyreon Patterns (${registry.patterns.length})`, '']
200
+ parts.push(
201
+ 'Call `get_pattern({ name: "<slug>" })` for the full body. Each pattern shows the canonical "do it this way" with code + rationale, plus the anti-pattern to avoid.',
202
+ )
203
+ parts.push('')
204
+ for (const p of registry.patterns) {
205
+ const summary = p.summary ? ` — ${p.summary}` : ''
206
+ parts.push(`- **${p.name}** — ${p.title}${summary}`)
207
+ }
208
+ return parts.join('\n')
209
+ }
210
+
211
+ /**
212
+ * Format a single pattern's full body for the MCP response. Prepends
213
+ * a breadcrumb and appends a cross-reference footer if `seeAlso` was
214
+ * populated.
215
+ */
216
+ export function formatPatternBody(pattern: PatternFile): string {
217
+ const parts: string[] = [pattern.body.trimEnd()]
218
+ if (pattern.seeAlso.length > 0) {
219
+ parts.push('')
220
+ parts.push(
221
+ `---\n\n**See also:** ${pattern.seeAlso.map((s) => `\`get_pattern({ name: "${s}" })\``).join(', ')}`,
222
+ )
223
+ }
224
+ return parts.join('\n')
225
+ }
226
+
227
+ export function findPattern(registry: PatternRegistry, name: string): PatternFile | null {
228
+ for (const p of registry.patterns) {
229
+ if (p.name === name) return p
230
+ }
231
+ return null
232
+ }
233
+
234
+ export function suggestPatterns(registry: PatternRegistry, name: string): string[] {
235
+ const needle = name.toLowerCase()
236
+ const matches: string[] = []
237
+ for (const p of registry.patterns) {
238
+ if (p.name.toLowerCase().includes(needle) || p.title.toLowerCase().includes(needle)) {
239
+ matches.push(p.name)
240
+ }
241
+ }
242
+ return matches.slice(0, 5)
243
+ }
@@ -0,0 +1,180 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import {
5
+ ANTI_PATTERN_CATEGORIES,
6
+ type AntiPatternCategory,
7
+ formatAntiPatterns,
8
+ parseAntiPatterns,
9
+ } from '../anti-patterns'
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url))
12
+ // tests/ → src/ → mcp/ → tools/ → packages/ → repo root (5 ups)
13
+ const REPO_ROOT = resolve(HERE, '../../../../../')
14
+ const ANTI_PATTERNS_PATH = resolve(REPO_ROOT, '.claude/rules/anti-patterns.md')
15
+
16
+ describe('parseAntiPatterns — real repo file', () => {
17
+ const doc = readFileSync(ANTI_PATTERNS_PATH, 'utf8')
18
+ const entries = parseAntiPatterns(doc)
19
+
20
+ it('returns at least one entry per documented category', () => {
21
+ const categoriesFound = new Set(entries.map((e) => e.category))
22
+ for (const cat of ANTI_PATTERN_CATEGORIES) {
23
+ expect(categoriesFound.has(cat)).toBe(true)
24
+ }
25
+ })
26
+
27
+ it('returns entries with non-empty name + description', () => {
28
+ for (const entry of entries) {
29
+ expect(entry.name.length).toBeGreaterThan(0)
30
+ expect(entry.description.length).toBeGreaterThan(10)
31
+ }
32
+ })
33
+
34
+ it('parses multi-code detector tags (e.g. "raw-add / raw-remove")', () => {
35
+ const withDual = entries.find((e) => e.detectorCodes.length > 1)
36
+ expect(withDual).toBeDefined()
37
+ // The raw-listener anti-pattern in `lifecycle` / `architecture` docs
38
+ // both codes on one bullet.
39
+ expect(withDual!.detectorCodes).toContain('raw-add-event-listener')
40
+ })
41
+
42
+ it('parses single-code detector tags on known entries', () => {
43
+ const forMissingBy = entries.find((e) =>
44
+ e.detectorCodes.includes('for-missing-by'),
45
+ )
46
+ expect(forMissingBy).toBeDefined()
47
+ expect(forMissingBy!.category).toBe('jsx')
48
+ expect(forMissingBy!.name).toContain('by')
49
+ })
50
+
51
+ it('includes doc-only entries (no detector tag)', () => {
52
+ const docOnly = entries.filter((e) => e.detectorCodes.length === 0)
53
+ expect(docOnly.length).toBeGreaterThan(10)
54
+ })
55
+
56
+ it('preserves file order within each category', () => {
57
+ // The first reactivity bullet is "Bare signal in JSX text"
58
+ const firstReactivity = entries.find((e) => e.category === 'reactivity')
59
+ expect(firstReactivity!.name).toBe('Bare signal in JSX text')
60
+ })
61
+ })
62
+
63
+ describe('parseAntiPatterns — synthetic inputs', () => {
64
+ it('ignores bullets outside a known category heading', () => {
65
+ const doc = `# Anti-Patterns
66
+
67
+ Intro prose — never parsed as an anti-pattern.
68
+
69
+ ## Unknown Heading Not In Map
70
+
71
+ - **Ignored** \`[detector: for-missing-by]\`: this should not land in output.
72
+
73
+ ## Reactivity Mistakes
74
+
75
+ - **Real entry**: this one does land.
76
+ `
77
+ const entries = parseAntiPatterns(doc)
78
+ expect(entries).toHaveLength(1)
79
+ expect(entries[0]!.name).toBe('Real entry')
80
+ expect(entries[0]!.category).toBe('reactivity')
81
+ })
82
+
83
+ it('strips trailing colon and leading whitespace from the description', () => {
84
+ const doc = `## JSX Mistakes
85
+
86
+ - **X** : body text here`
87
+ const [only] = parseAntiPatterns(doc)
88
+ expect(only!.description).toBe('body text here')
89
+ })
90
+
91
+ it('accepts bullets with NO detector tag', () => {
92
+ const doc = `## JSX Mistakes
93
+
94
+ - **Plain**: no tag here`
95
+ const [only] = parseAntiPatterns(doc)
96
+ expect(only!.detectorCodes).toEqual([])
97
+ })
98
+
99
+ it('survives backtick-wrapped detector tags', () => {
100
+ const doc = `## JSX Mistakes
101
+
102
+ - **X** \`[detector: for-missing-by]\`: body`
103
+ const [only] = parseAntiPatterns(doc)
104
+ expect(only!.detectorCodes).toEqual(['for-missing-by'])
105
+ })
106
+
107
+ it('splits multi-continuation-line bullets correctly', () => {
108
+ const doc = `## Reactivity Mistakes
109
+
110
+ - **Long one**: first line
111
+ continuation line
112
+ another continuation
113
+ - **Second**: short`
114
+ const entries = parseAntiPatterns(doc)
115
+ expect(entries).toHaveLength(2)
116
+ expect(entries[0]!.name).toBe('Long one')
117
+ expect(entries[0]!.description).toContain('first line')
118
+ expect(entries[0]!.description).toContain('continuation line')
119
+ expect(entries[1]!.name).toBe('Second')
120
+ })
121
+ })
122
+
123
+ describe('formatAntiPatterns', () => {
124
+ const doc = readFileSync(ANTI_PATTERNS_PATH, 'utf8')
125
+ const entries = parseAntiPatterns(doc)
126
+
127
+ it('renders an "all" header with entry count + category count', () => {
128
+ const out = formatAntiPatterns(entries, 'all')
129
+ expect(out).toMatch(/^# Pyreon Anti-Patterns \(\d+ total, \d+ categor(y|ies)\)/)
130
+ })
131
+
132
+ it('renders a category-filtered header', () => {
133
+ const reactivity = entries.filter((e) => e.category === 'reactivity')
134
+ const out = formatAntiPatterns(reactivity, 'reactivity')
135
+ expect(out).toMatch(/^# Pyreon Anti-Patterns — reactivity \(\d+\)/)
136
+ })
137
+
138
+ it('includes detector tags inline on entries that have them', () => {
139
+ const reactivity = entries.filter((e) => e.category === 'reactivity')
140
+ const out = formatAntiPatterns(reactivity, 'reactivity')
141
+ // "Destructuring props" has `[detector: props-destructured]`
142
+ expect(out).toContain('`[detector: props-destructured]`')
143
+ })
144
+
145
+ it('returns a descriptive message when entries is empty', () => {
146
+ const out = formatAntiPatterns([], 'jsx' as AntiPatternCategory)
147
+ expect(out).toContain('No anti-patterns found in category')
148
+ expect(out).toContain('Valid categories')
149
+ })
150
+
151
+ it('mentions validate + anti-patterns.md in the header prose', () => {
152
+ const out = formatAntiPatterns(entries, 'all')
153
+ expect(out).toContain('anti-patterns.md')
154
+ expect(out).toContain('validate')
155
+ })
156
+ })
157
+
158
+ describe('coverage parity — every detector code has a bullet', () => {
159
+ // This test is complementary to the one in the compiler package —
160
+ // it verifies from the MCP side that the parser surfaces every
161
+ // detector code that downstream consumers expect.
162
+ const doc = readFileSync(ANTI_PATTERNS_PATH, 'utf8')
163
+ const entries = parseAntiPatterns(doc)
164
+ const knownCodes = [
165
+ 'for-missing-by',
166
+ 'for-with-key',
167
+ 'props-destructured',
168
+ 'process-dev-gate',
169
+ 'empty-theme',
170
+ 'raw-add-event-listener',
171
+ 'raw-remove-event-listener',
172
+ 'date-math-random-id',
173
+ 'on-click-undefined',
174
+ ]
175
+
176
+ it.each(knownCodes)('%s appears on at least one parsed bullet', (code) => {
177
+ const match = entries.find((e) => e.detectorCodes.includes(code))
178
+ expect(match).toBeDefined()
179
+ })
180
+ })
@@ -0,0 +1,176 @@
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.8 get_changelog
6
+ // tool. Same InMemoryTransport shape as the patterns-server test —
7
+ // exercises tool registration, JSON-RPC framing, and the formatter
8
+ // response shape in one pass.
9
+
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
+ describe('MCP server — get_changelog tool', () => {
38
+ it('lists every package when called with no arg', async () => {
39
+ const { client, close } = await newClient()
40
+ try {
41
+ const text = await callTool(client, 'get_changelog', {})
42
+ expect(text).toMatch(/^# Pyreon Changelogs \(\d+ packages\)/)
43
+ expect(text).toContain('**@pyreon/query**')
44
+ expect(text).toContain('**@pyreon/router**')
45
+ expect(text).toContain('**@pyreon/form**')
46
+ } finally {
47
+ await close()
48
+ }
49
+ })
50
+
51
+ it('returns recent versions for a package (fully-qualified name)', async () => {
52
+ const { client, close } = await newClient()
53
+ try {
54
+ const text = await callTool(client, 'get_changelog', { package: '@pyreon/query' })
55
+ expect(text).toContain('@pyreon/query — changelog')
56
+ // At least one version heading.
57
+ expect(text).toMatch(/^## \d+\.\d+\.\d+/m)
58
+ } finally {
59
+ await close()
60
+ }
61
+ })
62
+
63
+ it('accepts the short slug (auto-prefixes @pyreon/)', async () => {
64
+ const { client, close } = await newClient()
65
+ try {
66
+ const short = await callTool(client, 'get_changelog', { package: 'query' })
67
+ const qualified = await callTool(client, 'get_changelog', { package: '@pyreon/query' })
68
+ expect(short).toBe(qualified)
69
+ } finally {
70
+ await close()
71
+ }
72
+ })
73
+
74
+ it('respects the limit parameter', async () => {
75
+ const { client, close } = await newClient()
76
+ try {
77
+ const text = await callTool(client, 'get_changelog', { package: 'query', limit: 1 })
78
+ const versionHeadings = text.split('\n').filter((l) => /^## \d+\.\d+/.test(l))
79
+ expect(versionHeadings).toHaveLength(1)
80
+ } finally {
81
+ await close()
82
+ }
83
+ })
84
+
85
+ it('omits Updated-dependencies by default, includes when flag is true', async () => {
86
+ const { client, close } = await newClient()
87
+ try {
88
+ const withoutDeps = await callTool(client, 'get_changelog', {
89
+ package: 'query',
90
+ limit: 10,
91
+ })
92
+ const withDeps = await callTool(client, 'get_changelog', {
93
+ package: 'query',
94
+ limit: 10,
95
+ includeDependencyUpdates: true,
96
+ })
97
+ expect(withoutDeps).not.toContain('Updated dependencies')
98
+ expect(withDeps).toContain('Updated dependencies')
99
+ } finally {
100
+ await close()
101
+ }
102
+ })
103
+
104
+ it('returns suggestions for a misspelled name', async () => {
105
+ const { client, close } = await newClient()
106
+ try {
107
+ const text = await callTool(client, 'get_changelog', { package: 'quer' })
108
+ expect(text).toContain('not found')
109
+ expect(text).toContain('Did you mean')
110
+ expect(text).toContain('@pyreon/query')
111
+ } finally {
112
+ await close()
113
+ }
114
+ })
115
+
116
+ it('returns a helpful miss message when no match exists at all', async () => {
117
+ const { client, close } = await newClient()
118
+ try {
119
+ const text = await callTool(client, 'get_changelog', { package: 'totally-unrelated-xxx' })
120
+ expect(text).toContain('not found')
121
+ expect(text).toContain('get_changelog()')
122
+ } finally {
123
+ await close()
124
+ }
125
+ })
126
+
127
+ it('accepts a `since` floor and returns only newer versions', async () => {
128
+ const { client, close } = await newClient()
129
+ try {
130
+ // First get the full index to pick a real floor version.
131
+ const full = await callTool(client, 'get_changelog', { package: 'query', limit: 20 })
132
+ // Extract the 3rd-oldest version heading (needs at least 3 for the test to be meaningful).
133
+ const headings = [...full.matchAll(/^## (\S+)$/gm)].map((m) => m[1]!)
134
+ expect(headings.length).toBeGreaterThanOrEqual(3)
135
+ const floor = headings[headings.length - 2]! // everything newer than the second-oldest
136
+ const filtered = await callTool(client, 'get_changelog', {
137
+ package: 'query',
138
+ limit: 20,
139
+ since: floor,
140
+ })
141
+ expect(filtered).toContain(`since v${floor}`)
142
+ expect(filtered).not.toContain(`## ${floor}\n`) // floor itself excluded (strict)
143
+ } finally {
144
+ await close()
145
+ }
146
+ })
147
+
148
+ it('returns the "no changes since" miss message when the floor is the latest', async () => {
149
+ const { client, close } = await newClient()
150
+ try {
151
+ // Find the real latest substantive version from the package.
152
+ const full = await callTool(client, 'get_changelog', { package: 'query', limit: 1 })
153
+ const latest = /^## (\S+)$/m.exec(full)![1]!
154
+ const result = await callTool(client, 'get_changelog', {
155
+ package: 'query',
156
+ since: latest,
157
+ })
158
+ expect(result).toContain(`no changes since v${latest}`)
159
+ } finally {
160
+ await close()
161
+ }
162
+ })
163
+
164
+ it('rejects a negative limit via zod', async () => {
165
+ const { client, close } = await newClient()
166
+ try {
167
+ const result = (await client.callTool({
168
+ name: 'get_changelog',
169
+ arguments: { package: 'query', limit: -5 },
170
+ })) as { isError?: boolean; content: Array<{ type: string; text: string }> }
171
+ expect(result.isError).toBe(true)
172
+ } finally {
173
+ await close()
174
+ }
175
+ })
176
+ })