miromiro-mcp 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/index.mjs +320 -7
package/README.md CHANGED
@@ -67,7 +67,7 @@ claude mcp add miromiro -s user \
67
67
  | Tool | What it returns | Credits |
68
68
  |---|---|---|
69
69
  | `extract_code` | A section of a live page as a paste-ready component (Tailwind, HTML+CSS, JSX or Vue) plus the tokens it uses - start here to rebuild real UI | 25 |
70
- | `extract_brand` | Named color roles (primary/accent/background/text), palette, fonts split by role, logo, social profiles, and a 0-1 confidence score | 15 |
70
+ | `extract_brand` | Named color roles (primary/accent/background/text), palette, fonts split by role, logo, social profiles, a 0-1 confidence score, and `signals` explaining where the colors came from (declared tokens vs inferred) | 15 |
71
71
  | `extract_design_tokens` | Full token system with usage counts; `format: css` or `tailwind` for paste-ready output, `dtcg` for a W3C Design Tokens file (Style Dictionary / Figma variables) | 10 |
72
72
  | `extract_fonts` | Every font file and family the site loads, with weights, formats, and file URLs | 5 |
73
73
  | `extract_videos` | Video files, `og:video`, and player embeds (YouTube, Vimeo, Wistia, Loom), tagged by source | 1 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miromiro-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server for the MiroMiro design-extraction API — give your coding agent the real code, design tokens, assets, SVGs, fonts, and Lottie of any website.",
5
5
  "mcpName": "app.miromiro/miromiro",
6
6
  "type": "module",
package/src/index.mjs CHANGED
@@ -54,6 +54,22 @@ const SCREENSHOT_ARG = {
54
54
  description: "Requires render=true. Adds a screenshot field to the result: a public URL of a capture of the page (live 7 days), taken by the same browser that rendered it. Pass true for the 1440x900 viewport or 'full' for the entire page height. No extra credits beyond the render surcharge. Useful for showing the user what the extracted site looks like."
55
55
  }
56
56
 
57
+ // Shared fragments for the output schemas below, mirrored from the remote
58
+ // server (server/utils/mcpRemote.ts). Every schema keeps additionalProperties
59
+ // true and requires only `url`: the /v1 responses can carry extras (usage,
60
+ // note, truncation_note) and a strict schema that drifts from the
61
+ // implementation would make clients reject valid results.
62
+ const OUT_USAGE = { type: 'object', description: 'Credit usage for the authed key (spent, remaining, monthly limit).' }
63
+ const OUT_TRUNCATION = { type: 'string', description: 'Present when the result was trimmed - says what was dropped and how to get more.' }
64
+ const countedList = (valueKey, desc) => ({
65
+ type: 'array',
66
+ description: desc,
67
+ items: {
68
+ type: 'object',
69
+ properties: { [valueKey]: { type: 'string' }, count: { type: 'number', description: 'How many times it is used on the page' } }
70
+ }
71
+ })
72
+
57
73
  const TOOLS = [
58
74
  {
59
75
  name: 'extract_code',
@@ -72,6 +88,40 @@ const TOOLS = [
72
88
  render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
73
89
  },
74
90
  required: ['url']
91
+ },
92
+ outputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ url: { type: 'string' },
96
+ selector: { type: 'string', description: 'The CSS selector that was extracted' },
97
+ autoDetected: { type: 'boolean', description: 'True when the hero was auto-detected rather than passed in' },
98
+ sections: {
99
+ type: 'array',
100
+ description: 'Every extractable section on the page - call again with one of these selectors for a specific part',
101
+ items: {
102
+ type: 'object',
103
+ properties: {
104
+ selector: { type: 'string' },
105
+ label: { type: 'string' },
106
+ elements: { type: 'number' },
107
+ hasHeading: { type: 'boolean' },
108
+ preview: { type: 'string' }
109
+ }
110
+ }
111
+ },
112
+ framework: { type: 'string', description: 'tailwind | html | jsx | vue' },
113
+ code: { type: 'string', description: 'The generated, paste-ready component code. For jsx/vue, repeated structures (tiers, cards, list items) are collapsed into a data array rendered with .map()/v-for' },
114
+ css: { type: 'string', description: 'Companion CSS: always set for framework=html; for other frameworks it carries the @keyframes the section uses (add to the global stylesheet)' },
115
+ tokens: { type: 'object', description: 'Design tokens the extracted section actually uses' },
116
+ theme: { type: 'string', description: "light | dark: which one the code you just received actually is. Measured from the page, never echoed from your request - so if you asked for a theme the site cannot serve, this reports what you really got. Tell the user which theme they got when it may not match what they see on the site" },
117
+ assets: { description: 'Images/fonts the section references' },
118
+ summary: { description: 'What was extracted, at a glance. styledCoverage (0-100) is the quality signal: the % of elements the cascade found styles for' },
119
+ hint: { type: 'string', description: 'Only present when the extraction looks degraded (client-rendered page, runtime-injected styles) - says what to do, usually retry with render=true' },
120
+ usage: OUT_USAGE,
121
+ truncation_note: OUT_TRUNCATION
122
+ },
123
+ required: ['url'],
124
+ additionalProperties: true
75
125
  }
76
126
  },
77
127
  {
@@ -79,11 +129,56 @@ const TOOLS = [
79
129
  title: 'Extract brand identity',
80
130
  endpoint: 'brand',
81
131
  description:
82
- "Get a website's curated brand identity: color roles (primary, accent, background, text) read from the site's own CSS variables and logo — each as hex plus a human-readable name like 'Royal Blue' — dark/light theme detection, the brand palette, fonts split by role (primary/mono), logo, social image, the brand's social profiles (X, GitHub, LinkedIn, …), and a 0-1 confidence score for how much the site declared vs how much was inferred. Use this when you only need a site's STYLE (colors/fonts) rather than its code — to build new UI 'in the style of' a reference site. If you need the actual markup of a section, use extract_code instead.",
132
+ "Get a website's curated brand identity: color roles (primary, accent, background, text) read from the site's own CSS variables and logo — each as hex plus a human-readable name like 'Royal Blue' — dark/light theme detection, the brand palette, fonts split by role (primary/mono), logo, social image, the brand's social profiles (X, GitHub, LinkedIn, …), and a 0-1 confidence score for how much the site declared vs how much was inferred. Use this when you only need a site's STYLE (colors/fonts) rather than its code — to build new UI 'in the style of' a reference site. If you need the actual markup of a section, use extract_code instead. The result also carries `signals`, which explains WHERE the colors came from: check `signals.primarySource` and tell the user whether these are the site's own declared tokens ('css-var'/'theme-color') or values inferred from usage frequency ('usage') — never present inferred colors as the brand's official palette. `signals.explanation` is a sentence written to be repeated to the user.",
83
133
  inputSchema: {
84
134
  type: 'object',
85
135
  properties: { url: { type: 'string', description: 'The website URL, e.g. stripe.com' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
86
136
  required: ['url']
137
+ },
138
+ outputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ url: { type: 'string' },
142
+ title: { type: ['string', 'null'], description: 'Page/og title - the brand name' },
143
+ description: { type: ['string', 'null'] },
144
+ siteName: { type: ['string', 'null'] },
145
+ themeColor: { type: ['string', 'null'], description: "The site's own declared theme-color" },
146
+ theme: { type: 'string', description: 'dark | light' },
147
+ colors: { type: 'object', description: 'Role colors as hex: primary, accent, background, text' },
148
+ colorNames: { type: 'object', description: 'Human-readable names for the role colors ("Royal Blue")' },
149
+ palette: {
150
+ type: 'array',
151
+ description: 'The wider brand palette',
152
+ items: { type: 'object', properties: { color: { type: 'string' }, count: { type: 'number' }, name: { type: ['string', 'null'] } } }
153
+ },
154
+ fonts: { type: 'array', items: { type: 'string' } },
155
+ typography: { type: 'object', description: 'Fonts split by role: primary, mono' },
156
+ logo: { type: ['string', 'null'], description: 'Best single logo URL' },
157
+ logos: { type: 'array', description: 'All icon/logo candidates, tagged by source' },
158
+ ogImage: { type: ['string', 'null'] },
159
+ socials: {
160
+ type: 'array',
161
+ description: 'Social profiles found on the page',
162
+ items: { type: 'object', properties: { platform: { type: 'string' }, url: { type: 'string' }, handle: { type: ['string', 'null'] } } }
163
+ },
164
+ confidence: { type: 'number', description: '0-1: how much the site declared vs how much was inferred' },
165
+ signals: {
166
+ type: 'object',
167
+ description: "WHY the result looks the way it does - the provenance behind `confidence`. Read `signals.explanation` and tell the user what it says instead of quoting the confidence number, which means nothing to them on its own. `primarySource` is the key field: 'css-var' or 'theme-color' means the site declared these colors and they are its real tokens; 'logo' means they were sampled from its logo; 'usage' means nothing was declared and the colors are a frequency-based GUESS - say so rather than presenting them as the brand's official palette.",
168
+ properties: {
169
+ primarySource: { type: ['string', 'null'], description: "css-var | theme-color | logo | usage | null - where colors.primary came from, strongest first. 'usage' is inferred, not declared." },
170
+ declaredBackground: { type: 'boolean', description: 'The background role came from a declared CSS variable' },
171
+ declaredText: { type: 'boolean', description: 'The text role came from a declared CSS variable' },
172
+ declaredLogo: { type: 'boolean', description: 'A real logo was found, not just an og:image fallback' },
173
+ fontsFound: { type: 'number', description: 'How many font families resolved from the CSS cascade' },
174
+ explanation: { type: 'string', description: 'One ready-to-relay sentence describing how much was declared vs inferred. Repeat this to the user when the extraction is uncertain.' }
175
+ }
176
+ },
177
+ usage: OUT_USAGE,
178
+ truncation_note: OUT_TRUNCATION
179
+ },
180
+ required: ['url'],
181
+ additionalProperties: true
87
182
  }
88
183
  },
89
184
  {
@@ -91,7 +186,7 @@ const TOOLS = [
91
186
  title: 'Extract design tokens',
92
187
  endpoint: 'extract',
93
188
  description:
94
- "Extract the full design-token system of a URL: every color, font family, font size, border radius, box shadow, line height and z-index (with usage counts). Pass format='css' for a :root {} block, format='tailwind' for a tailwind.config, or format='dtcg' for a standalone W3C Design Tokens file (drops into Style Dictionary / Figma variables pipelines). Use when you need the complete token set, not just the brand summary. Returns VALUES and how often each is used - not layout, spacing composition or shadow stacks, and not which value belongs to which element: if the user asked you to rebuild or clone a section, use extract_code instead, which returns the real markup with the values already in place. Counts are tallied over the whole stylesheet, so a site shipping both light and dark themes returns the union of the two (see colorSchemes) - do not assume the most-used color is the background.",
189
+ "Extract the full design-token system of a URL: every color, font family, font size, border radius, box shadow, line height and z-index (with usage counts). Pass format='css' for a :root {} block, format='tailwind' for a tailwind.config, or format='dtcg' for a standalone W3C Design Tokens file (drops into Style Dictionary / Figma variables pipelines). Use when you need the complete token set, not just the brand summary. Returns VALUES and how often each is used - not layout, spacing composition or shadow stacks, and not which value belongs to which element: if the user asked you to rebuild or clone a section, use extract_code instead, which returns the real markup with the values already in place. Counts are tallied over the whole stylesheet, so a site shipping both light and dark themes returns the union of the two (see colorSchemes) - do not assume the most-used color is the background. The colorSchemes field tells you which themes the page declares; when it lists both, pick colours by role rather than by frequency, and use extract_code with theme=dark when you need one theme on its own.",
95
190
  inputSchema: {
96
191
  type: 'object',
97
192
  properties: {
@@ -100,6 +195,30 @@ const TOOLS = [
100
195
  render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
101
196
  },
102
197
  required: ['url']
198
+ },
199
+ outputSchema: {
200
+ type: 'object',
201
+ description: "format='json' returns the token lists below; format='css'|'tailwind' returns { output } - the paste-ready code block - instead; format='dtcg' returns a standalone W3C Design Tokens document (groups keyed by $type).",
202
+ properties: {
203
+ url: { type: 'string' },
204
+ colors: countedList('color', 'Every color with usage count'),
205
+ fontFamilies: countedList('family', 'Font families with usage counts'),
206
+ fontSizes: countedList('size', 'Font sizes with usage counts'),
207
+ fontWeights: countedList('value', 'Font weights'),
208
+ spacing: countedList('value', 'The spacing scale from margin/padding/gap'),
209
+ borderRadiuses: countedList('value', 'Border radii'),
210
+ boxShadows: countedList('value', 'Box shadows'),
211
+ lineHeights: countedList('value', 'Line heights'),
212
+ gradients: { type: 'array', description: 'Gradients with type (linear/radial/conic) and count' },
213
+ breakpoints: countedList('value', 'Responsive breakpoints from media queries'),
214
+ colorSchemes: { type: 'array', description: 'The colour schemes this page declares: ["light"] or ["light","dark"]. When both, the counts above are the union of the two themes and neither is labelled - pick colours by role, not by frequency, and use extract_code with theme=dark when you need one theme on its own' },
215
+ summary: { type: 'object', description: 'Count per token group' },
216
+ output: { type: 'string', description: "The paste-ready :root{} or tailwind.config block when format='css'|'tailwind'" },
217
+ usage: OUT_USAGE,
218
+ truncation_note: OUT_TRUNCATION
219
+ },
220
+ required: [],
221
+ additionalProperties: true
103
222
  }
104
223
  },
105
224
  {
@@ -112,6 +231,29 @@ const TOOLS = [
112
231
  type: 'object',
113
232
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
114
233
  required: ['url']
234
+ },
235
+ outputSchema: {
236
+ type: 'object',
237
+ properties: {
238
+ url: { type: 'string' },
239
+ images: {
240
+ type: 'array',
241
+ items: {
242
+ type: 'object',
243
+ properties: {
244
+ url: { type: 'string', description: 'Resolved absolute image URL' },
245
+ source: { type: 'string', description: 'img | srcset | picture | background | favicon | og' },
246
+ alt: { type: 'string', description: 'Alt text when present' }
247
+ },
248
+ required: ['url', 'source']
249
+ }
250
+ },
251
+ count: { type: 'number', description: 'Total images found (before any trimming)' },
252
+ usage: OUT_USAGE,
253
+ truncation_note: OUT_TRUNCATION
254
+ },
255
+ required: ['url'],
256
+ additionalProperties: true
115
257
  }
116
258
  },
117
259
  {
@@ -124,6 +266,29 @@ const TOOLS = [
124
266
  type: 'object',
125
267
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
126
268
  required: ['url']
269
+ },
270
+ outputSchema: {
271
+ type: 'object',
272
+ properties: {
273
+ url: { type: 'string' },
274
+ svgs: {
275
+ type: 'array',
276
+ items: {
277
+ type: 'object',
278
+ properties: {
279
+ type: { type: 'string', description: 'inline | file' },
280
+ code: { type: 'string', description: 'Sanitized paste-ready SVG markup (inline SVGs)' },
281
+ url: { type: 'string', description: 'File URL (external .svg references)' }
282
+ },
283
+ required: ['type']
284
+ }
285
+ },
286
+ count: { type: 'number', description: 'Total SVGs found (before any trimming)' },
287
+ usage: OUT_USAGE,
288
+ truncation_note: OUT_TRUNCATION
289
+ },
290
+ required: ['url'],
291
+ additionalProperties: true
127
292
  }
128
293
  },
129
294
  {
@@ -140,6 +305,47 @@ const TOOLS = [
140
305
  render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
141
306
  },
142
307
  required: ['url']
308
+ },
309
+ outputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ url: { type: 'string' },
313
+ fonts: {
314
+ type: 'array',
315
+ description: 'Every font file / stylesheet found',
316
+ items: {
317
+ type: 'object',
318
+ properties: {
319
+ source: { type: 'string', description: 'font-face | google-fonts | preload' },
320
+ url: { type: 'string', description: 'The font file or stylesheet URL' },
321
+ family: { type: 'string' },
322
+ weight: { type: 'string', description: "'400', '700', or a variable range like '100 900'" },
323
+ style: { type: 'string' },
324
+ format: { type: 'string', description: 'woff2 | woff | ttf | otf | eot' }
325
+ }
326
+ }
327
+ },
328
+ families: {
329
+ type: 'array',
330
+ description: 'Grouped per-family summary - what most callers want',
331
+ items: {
332
+ type: 'object',
333
+ properties: {
334
+ family: { type: 'string' },
335
+ source: { type: 'string' },
336
+ weights: { type: 'array', items: { type: 'string' } },
337
+ styles: { type: 'array', items: { type: 'string' } },
338
+ files: { type: 'number' }
339
+ }
340
+ }
341
+ },
342
+ googleFontsLinks: { type: 'array', items: { type: 'string' } },
343
+ count: { type: 'number' },
344
+ usage: OUT_USAGE,
345
+ truncation_note: OUT_TRUNCATION
346
+ },
347
+ required: ['url'],
348
+ additionalProperties: true
143
349
  }
144
350
  },
145
351
  {
@@ -152,6 +358,29 @@ const TOOLS = [
152
358
  type: 'object',
153
359
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
154
360
  required: ['url']
361
+ },
362
+ outputSchema: {
363
+ type: 'object',
364
+ properties: {
365
+ url: { type: 'string' },
366
+ lotties: {
367
+ type: 'array',
368
+ items: {
369
+ type: 'object',
370
+ properties: {
371
+ url: { type: 'string', description: 'The animation JSON / .lottie file URL' },
372
+ source: { type: 'string', description: 'lottie-player | dotlottie-player | data-attr | json-ref' }
373
+ },
374
+ required: ['url']
375
+ }
376
+ },
377
+ count: { type: 'number' },
378
+ note: { type: 'string', description: 'Coverage caveat: only Lotties declared in the initial HTML are found' },
379
+ usage: OUT_USAGE,
380
+ truncation_note: OUT_TRUNCATION
381
+ },
382
+ required: ['url'],
383
+ additionalProperties: true
155
384
  }
156
385
  },
157
386
  {
@@ -164,6 +393,30 @@ const TOOLS = [
164
393
  type: 'object',
165
394
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
166
395
  required: ['url']
396
+ },
397
+ outputSchema: {
398
+ type: 'object',
399
+ properties: {
400
+ url: { type: 'string' },
401
+ videos: {
402
+ type: 'array',
403
+ items: {
404
+ type: 'object',
405
+ properties: {
406
+ url: { type: 'string', description: 'Resolved absolute video (or player page) URL' },
407
+ source: { type: 'string', description: 'video | source | og | embed | link. embed is a player page, not a file.' },
408
+ poster: { type: 'string', description: 'Poster image URL when the tag declared one' }
409
+ },
410
+ required: ['url', 'source']
411
+ }
412
+ },
413
+ count: { type: 'number', description: 'Total videos found (before any trimming)' },
414
+ note: { type: 'string', description: 'Coverage caveat: only videos declared in the initial HTML are found - use render=true for JS-mounted players' },
415
+ usage: OUT_USAGE,
416
+ truncation_note: OUT_TRUNCATION
417
+ },
418
+ required: ['url'],
419
+ additionalProperties: true
167
420
  }
168
421
  },
169
422
  {
@@ -176,13 +429,36 @@ const TOOLS = [
176
429
  type: 'object',
177
430
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
178
431
  required: ['url']
432
+ },
433
+ outputSchema: {
434
+ type: 'object',
435
+ properties: {
436
+ url: { type: 'string' },
437
+ audio: {
438
+ type: 'array',
439
+ items: {
440
+ type: 'object',
441
+ properties: {
442
+ url: { type: 'string', description: 'Resolved absolute audio file URL' },
443
+ source: { type: 'string', description: 'audio | source | og | link' }
444
+ },
445
+ required: ['url', 'source']
446
+ }
447
+ },
448
+ count: { type: 'number', description: 'Total audio files found (before any trimming)' },
449
+ note: { type: 'string', description: 'Coverage caveat: only audio declared in the initial HTML is found - use render=true for JS-mounted players' },
450
+ usage: OUT_USAGE,
451
+ truncation_note: OUT_TRUNCATION
452
+ },
453
+ required: ['url'],
454
+ additionalProperties: true
179
455
  }
180
456
  }
181
457
  ]
182
458
 
183
459
  // Query params a tool may forward to the API, beyond `url`. Anything else in the
184
460
  // tool arguments is ignored, so a hallucinated param can't reach the endpoint.
185
- const FORWARDED_PARAMS = ['format', 'selector', 'framework', 'name', 'fields', 'render', 'fresh', 'screenshot']
461
+ const FORWARDED_PARAMS = ['format', 'selector', 'framework', 'name', 'theme', 'fields', 'render', 'fresh', 'screenshot']
186
462
 
187
463
  async function callApi(endpoint, args) {
188
464
  const q = new URLSearchParams({ url: args.url })
@@ -204,17 +480,43 @@ async function callApi(endpoint, args) {
204
480
  return isJson ? JSON.stringify(body) : body
205
481
  }
206
482
 
483
+ // Server-level guidance, sent once at initialize. Mirrors the `instructions`
484
+ // string the remote server (miromiro.app/mcp) returns, so a stdio client gets
485
+ // the same steering as a hosted one — chiefly: explain results, don't just dump
486
+ // them, and never present inferred brand colors as declared ones.
487
+ const INSTRUCTIONS =
488
+ 'MiroMiro turns any live URL into design data: section-level code (extract_code), brand identity ' +
489
+ '(extract_brand), the full design-token system (extract_design_tokens), images, SVGs, fonts, videos, audio and Lottie files. ' +
490
+ 'Reach for extract_code when asked to rebuild or clone part of a real site; extract_brand when only ' +
491
+ 'colors/fonts are needed; extract_design_tokens when you want the whole value system (it returns values ' +
492
+ 'and usage counts, NOT layout, spacing composition or which value belongs to which element - it is not a ' +
493
+ 'substitute for extract_code on a rebuild). When a site expresses its themes through a ' +
494
+ 'prefers-color-scheme media query, extraction returns the LIGHT branch by default and carries the dark ' +
495
+ 'one as dark: variants - so pass theme=dark to extract_code when the user is viewing that site in dark ' +
496
+ 'mode or asks for its dark version. Every code response reports the theme you actually got: check it and ' +
497
+ 'say which one you returned. The API reads static HTML+CSS by default; pass render=true on any tool to ' +
498
+ 'execute the page JavaScript in a real browser first (client-rendered sites, +5 credits). When a static ' +
499
+ 'call comes back thin because the page is client-rendered, the response includes a hint field saying to ' +
500
+ 'retry with render=true - follow it. Explain your results, do not just dump them: extract_brand returns a ' +
501
+ 'signals object saying where the colors came from (signals.explanation is a sentence written to be repeated ' +
502
+ 'to the user) - when signals.primarySource is "usage" the site declared nothing and the colors are an ' +
503
+ 'inferred guess, so say that instead of presenting them as the official palette. Rendered calls can also ' +
504
+ 'take screenshot=true for a public image URL of the page (show it to the user to verify an extraction). ' +
505
+ 'extract_design_tokens takes format=dtcg for a W3C Design Tokens file. Pass fresh=true on any tool to ' +
506
+ 'bypass the 24h cache when a site just changed.'
507
+
207
508
  const server = new Server(
208
- { name: 'miromiro', version: '0.5.0' },
209
- { capabilities: { tools: {} } }
509
+ { name: 'miromiro', version: '0.6.0' },
510
+ { capabilities: { tools: {} }, instructions: INSTRUCTIONS }
210
511
  )
211
512
 
212
513
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
213
- tools: TOOLS.map(({ name, title, description, inputSchema }) => ({
514
+ tools: TOOLS.map(({ name, title, description, inputSchema, outputSchema }) => ({
214
515
  name,
215
516
  title,
216
517
  description,
217
518
  inputSchema,
519
+ ...(outputSchema ? { outputSchema } : {}),
218
520
  annotations: { title, ...TOOL_ANNOTATIONS }
219
521
  }))
220
522
  }))
@@ -236,7 +538,18 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
236
538
  }
237
539
  try {
238
540
  const out = await callApi(tool.endpoint, args)
239
- return { content: [{ type: 'text', text: out }] }
541
+ // A tool that declares an outputSchema must also return structuredContent
542
+ // matching it (2025-06-18 spec), and structuredContent must be an object.
543
+ // format=css|tailwind on the tokens endpoint returns a raw code string —
544
+ // wrap it as { output }, which that tool's outputSchema declares.
545
+ let structured
546
+ try {
547
+ const parsed = JSON.parse(out)
548
+ structured = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { output: parsed }
549
+ } catch {
550
+ structured = { output: out } // non-JSON body (css/tailwind block)
551
+ }
552
+ return { content: [{ type: 'text', text: out }], structuredContent: structured }
240
553
  } catch (err) {
241
554
  return { isError: true, content: [{ type: 'text', text: String(err.message || err) }] }
242
555
  }