@fragments-sdk/cli 0.15.8 → 0.15.10

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.
@@ -37,6 +37,6 @@
37
37
  "source": "extracted",
38
38
  "verified": false,
39
39
  "frameworkSupport": "native",
40
- "extractedAt": "2026-03-26T15:34:52.519Z"
40
+ "extractedAt": "2026-03-26T14:38:30.456Z"
41
41
  }
42
42
  }
@@ -15,6 +15,6 @@
15
15
  "source": "extracted",
16
16
  "verified": false,
17
17
  "frameworkSupport": "native",
18
- "extractedAt": "2026-03-26T15:34:52.519Z"
18
+ "extractedAt": "2026-03-26T14:38:30.457Z"
19
19
  }
20
20
  }
@@ -778,7 +778,7 @@ function configureMcp(projectDir: string): void {
778
778
  mcpServers: {
779
779
  fragments: {
780
780
  command: 'npx',
781
- args: ['-y', '@fragments-sdk/cli', 'mcp'],
781
+ args: ['-y', '--package', '@fragments-sdk/cli', 'fragments-mcp'],
782
782
  },
783
783
  },
784
784
  };
@@ -0,0 +1,342 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
4
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { createMcpServer } from '../server.js';
8
+
9
+ function writeJson(path: string, value: unknown): void {
10
+ writeFileSync(path, JSON.stringify(value, null, 2));
11
+ }
12
+
13
+ function getTextPayload(result: unknown): unknown {
14
+ const content =
15
+ typeof result === 'object' && result !== null && 'content' in result
16
+ ? (result as { content?: Array<{ type?: string; text?: string }> }).content
17
+ : undefined;
18
+
19
+ if (!Array.isArray(content)) {
20
+ throw new Error('Expected content array');
21
+ }
22
+
23
+ const text = content.find((entry) => entry.type === 'text')?.text;
24
+ if (!text) {
25
+ throw new Error('Expected text content');
26
+ }
27
+
28
+ return JSON.parse(text);
29
+ }
30
+
31
+ describe('CLI MCP server integration', () => {
32
+ let projectRoot: string;
33
+
34
+ beforeEach(() => {
35
+ projectRoot = mkdtempSync(join(tmpdir(), 'fragments-cli-mcp-'));
36
+ mkdirSync(join(projectRoot, 'src'), { recursive: true });
37
+
38
+ writeJson(join(projectRoot, 'fragments.json'), {
39
+ version: '1.0.0',
40
+ generatedAt: '2026-03-26T00:00:00.000Z',
41
+ packageName: '@test/ui',
42
+ fragments: {
43
+ Button: {
44
+ filePath: 'src/Button.fragment.tsx',
45
+ meta: {
46
+ name: 'Button',
47
+ description: 'A clickable button component',
48
+ category: 'actions',
49
+ tags: ['interactive', 'form'],
50
+ status: 'stable',
51
+ },
52
+ usage: {
53
+ when: ['Use when the user needs to submit a form'],
54
+ whenNot: ['Navigation only'],
55
+ accessibility: ['Provide an accessible name'],
56
+ },
57
+ props: {
58
+ variant: {
59
+ type: 'string',
60
+ values: ['primary', 'secondary'],
61
+ default: 'primary',
62
+ description: 'Visual style',
63
+ },
64
+ },
65
+ variants: [
66
+ { name: 'Default', description: 'Default button', code: '<Button>Submit</Button>' },
67
+ ],
68
+ },
69
+ Input: {
70
+ filePath: 'src/Input.fragment.tsx',
71
+ meta: {
72
+ name: 'Input',
73
+ description: 'Single-line text input',
74
+ category: 'forms',
75
+ tags: ['form', 'field', 'text'],
76
+ status: 'stable',
77
+ },
78
+ usage: {
79
+ when: ['Collect text input in forms', 'Authentication fields'],
80
+ whenNot: ['Long-form content'],
81
+ },
82
+ props: {
83
+ type: {
84
+ type: 'string',
85
+ values: ['text', 'email', 'password'],
86
+ description: 'Input type',
87
+ },
88
+ },
89
+ variants: [
90
+ { name: 'Default', description: 'Default input', code: '<Input />' },
91
+ ],
92
+ },
93
+ Card: {
94
+ filePath: 'src/Card.fragment.tsx',
95
+ meta: {
96
+ name: 'Card',
97
+ description: 'Surface container for grouped content',
98
+ category: 'layout',
99
+ tags: ['surface', 'container'],
100
+ status: 'stable',
101
+ },
102
+ usage: {
103
+ when: ['Group related content'],
104
+ whenNot: ['Tiny inline wrappers'],
105
+ },
106
+ props: {},
107
+ variants: [
108
+ { name: 'Default', description: 'Default card', code: '<Card />' },
109
+ ],
110
+ },
111
+ Textarea: {
112
+ filePath: 'src/Textarea.fragment.tsx',
113
+ meta: {
114
+ name: 'Textarea',
115
+ description: 'Multi-line input for longer content',
116
+ category: 'forms',
117
+ tags: ['form', 'multiline', 'editor'],
118
+ status: 'stable',
119
+ },
120
+ usage: {
121
+ when: ['Collect long-form text and blog content'],
122
+ whenNot: ['Short single-line values'],
123
+ },
124
+ props: {},
125
+ variants: [
126
+ { name: 'Default', description: 'Default textarea', code: '<Textarea />' },
127
+ ],
128
+ },
129
+ },
130
+ blocks: {
131
+ 'Login Form': {
132
+ filePath: 'src/blocks/LoginForm.block.tsx',
133
+ name: 'Login Form',
134
+ description: 'Email and password authentication form',
135
+ category: 'authentication',
136
+ components: ['Card', 'Input', 'Button'],
137
+ tags: ['login', 'auth', 'form'],
138
+ code: '<Card><Input type="email" /><Input type="password" /><Button>Sign in</Button></Card>',
139
+ },
140
+ 'Checkout Form': {
141
+ filePath: 'src/blocks/CheckoutForm.block.tsx',
142
+ name: 'Checkout Form',
143
+ description: 'Address and payment collection flow',
144
+ category: 'ecommerce',
145
+ components: ['Card', 'Input', 'Button'],
146
+ tags: ['checkout', 'payment', 'form'],
147
+ code: '<Card><Input /><Input /><Button>Pay now</Button></Card>',
148
+ },
149
+ 'Blog Editor': {
150
+ filePath: 'src/blocks/BlogEditor.block.tsx',
151
+ name: 'Blog Editor',
152
+ description: 'Editor layout with title and content fields',
153
+ category: 'content',
154
+ components: ['Input', 'Textarea', 'Button'],
155
+ tags: ['blog', 'editor', 'publish'],
156
+ code: '<Input /><Textarea /><Button>Publish</Button>',
157
+ },
158
+ },
159
+ tokens: {
160
+ prefix: '--fui',
161
+ total: 6,
162
+ categories: {
163
+ 'colors---accent-(light-dark-for-automatic-dark-mode)': [
164
+ { name: '--fui-color-accent', value: '#0066cc', description: 'Accent color' },
165
+ { name: '--fui-color-accent-hover', value: '#0055aa', description: 'Accent hover' },
166
+ ],
167
+ 'spacing---core-scale': [
168
+ { name: '--fui-space-2', value: '8px', description: 'Small spacing' },
169
+ { name: '--fui-space-4', value: '16px', description: 'Medium spacing' },
170
+ ],
171
+ 'text---content': [
172
+ { name: '--fui-text-primary', value: '#111111', description: 'Primary text' },
173
+ { name: '--fui-text-secondary', value: '#666666', description: 'Secondary text' },
174
+ ],
175
+ },
176
+ },
177
+ });
178
+ });
179
+
180
+ afterEach(() => {
181
+ rmSync(projectRoot, { recursive: true, force: true });
182
+ });
183
+
184
+ async function withClient<T>(run: (client: Client) => Promise<T>): Promise<T> {
185
+ const server = createMcpServer({ projectRoot });
186
+ const client = new Client({ name: 'test-client', version: '1.0.0' });
187
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
188
+
189
+ await server.connect(serverTransport);
190
+ await client.connect(clientTransport);
191
+
192
+ try {
193
+ return await run(client);
194
+ } finally {
195
+ await client.close();
196
+ await server.close();
197
+ }
198
+ }
199
+
200
+ it('lists bare tool names and rejects old prefixed calls', async () => {
201
+ await withClient(async (client) => {
202
+ const listed = await client.listTools();
203
+ const names = listed.tools.map((tool) => tool.name);
204
+
205
+ expect(names.some((name) => name.startsWith('fragments_'))).toBe(false);
206
+ expect(names).toContain('discover');
207
+ expect(names).toContain('inspect');
208
+ expect(names).toContain('blocks');
209
+ expect(names).toContain('tokens');
210
+ expect(names).toContain('implement');
211
+
212
+ const prefixedResult = await client.callTool({
213
+ name: 'fragments_discover',
214
+ arguments: {},
215
+ });
216
+
217
+ expect(prefixedResult.isError).toBe(true);
218
+ expect(getTextPayload(prefixedResult)).toEqual({
219
+ error: 'Unknown tool: fragments_discover',
220
+ });
221
+ });
222
+ });
223
+
224
+ it('reports the CLI package version in the handshake metadata', async () => {
225
+ await withClient(async (client) => {
226
+ const pkg = JSON.parse(
227
+ readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8')
228
+ ) as { version: string };
229
+
230
+ expect(client.getServerVersion()).toEqual({
231
+ name: 'fragments-mcp',
232
+ version: pkg.version,
233
+ });
234
+ });
235
+ });
236
+
237
+ it('honors discover limits in list and use-case modes', async () => {
238
+ await withClient(async (client) => {
239
+ const listResult = await client.callTool({
240
+ name: 'discover',
241
+ arguments: { search: 'form', limit: 2, verbosity: 'compact' },
242
+ });
243
+ const listPayload = getTextPayload(listResult) as {
244
+ returned: number;
245
+ fragments: Array<{ name: string; codeExample?: string }>;
246
+ };
247
+
248
+ expect(listPayload.returned).toBe(2);
249
+ expect(listPayload.fragments.every((fragment) => fragment.codeExample === undefined)).toBe(true);
250
+
251
+ const useCaseResult = await client.callTool({
252
+ name: 'discover',
253
+ arguments: { useCase: 'login form', limit: 2 },
254
+ });
255
+ const useCasePayload = getTextPayload(useCaseResult) as {
256
+ suggestions: Array<{ component: string }>;
257
+ };
258
+
259
+ expect(useCasePayload.suggestions).toHaveLength(2);
260
+ });
261
+ });
262
+
263
+ it('matches friendly token categories and enforces per-category limits', async () => {
264
+ await withClient(async (client) => {
265
+ const categoryResult = await client.callTool({
266
+ name: 'tokens',
267
+ arguments: { category: 'color' },
268
+ });
269
+ const categoryPayload = getTextPayload(categoryResult) as {
270
+ total: number;
271
+ categories: Record<string, Array<{ name: string }>>;
272
+ };
273
+
274
+ expect(categoryPayload.total).toBeGreaterThan(0);
275
+ expect(categoryPayload.categories['colors---accent-(light-dark-for-automatic-dark-mode)']).toBeDefined();
276
+
277
+ const limitedResult = await client.callTool({
278
+ name: 'tokens',
279
+ arguments: { limit: 1 },
280
+ });
281
+ const limitedPayload = getTextPayload(limitedResult) as {
282
+ categories: Record<string, Array<{ name: string }>>;
283
+ };
284
+
285
+ for (const tokens of Object.values(limitedPayload.categories)) {
286
+ expect(tokens).toHaveLength(1);
287
+ }
288
+ });
289
+ });
290
+
291
+ it('ranks blocks by relevance and applies verbosity', async () => {
292
+ await withClient(async (client) => {
293
+ const compactResult = await client.callTool({
294
+ name: 'blocks',
295
+ arguments: { search: 'login', limit: 1, verbosity: 'compact' },
296
+ });
297
+ const compactPayload = getTextPayload(compactResult) as {
298
+ blocks: Array<{ name: string; code?: string }>;
299
+ };
300
+
301
+ expect(compactPayload.blocks[0]?.name).toBe('Login Form');
302
+ expect(compactPayload.blocks[0]?.code).toBeUndefined();
303
+
304
+ const fullResult = await client.callTool({
305
+ name: 'blocks',
306
+ arguments: { search: 'login', limit: 1, verbosity: 'full' },
307
+ });
308
+ const fullPayload = getTextPayload(fullResult) as {
309
+ blocks: Array<{ name: string; code?: string }>;
310
+ };
311
+
312
+ expect(fullPayload.blocks[0]?.name).toBe('Login Form');
313
+ expect(fullPayload.blocks[0]?.code).toContain('Sign in');
314
+ });
315
+ });
316
+
317
+ it('returns login-oriented implement results and respects component limits', async () => {
318
+ await withClient(async (client) => {
319
+ const defaultResult = await client.callTool({
320
+ name: 'implement',
321
+ arguments: { useCase: 'login form' },
322
+ });
323
+ const defaultPayload = getTextPayload(defaultResult) as {
324
+ components: Array<{ name: string }>;
325
+ blocks?: Array<{ name: string }>;
326
+ };
327
+
328
+ expect(defaultPayload.components.length).toBeGreaterThan(0);
329
+ expect(defaultPayload.blocks?.[0]?.name).toBe('Login Form');
330
+
331
+ const limitedResult = await client.callTool({
332
+ name: 'implement',
333
+ arguments: { useCase: 'login form', limit: 1 },
334
+ });
335
+ const limitedPayload = getTextPayload(limitedResult) as {
336
+ components: Array<{ name: string }>;
337
+ };
338
+
339
+ expect(limitedPayload.components).toHaveLength(1);
340
+ });
341
+ });
342
+ });