miromiro-mcp 0.4.1 → 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 +7 -1
  2. package/package.json +1 -1
  3. package/src/index.mjs +322 -8
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 |
@@ -98,6 +98,12 @@ The REST API and this local npm server always return everything untrimmed, so `l
98
98
 
99
99
  The agent calls `extract_code` with the URL, gets the section's real markup, colors, fonts, and spacing, and adapts actual values instead of guessing.
100
100
 
101
+ Extraction returns a page's light styles by default, so a site you know as dark comes back light unless you ask otherwise:
102
+
103
+ > "Rebuild linear.app's pricing card in its dark theme"
104
+
105
+ The agent passes `theme: "dark"` and the page's `prefers-color-scheme: dark` rules and custom properties become the component itself. Every result also carries a `theme` field saying which one you actually got. Sites that switch themes with a JavaScript class toggle cannot be selected this way - you get the page default plus a hint saying so.
106
+
101
107
  > "Build a dashboard in the style of linear.app"
102
108
 
103
109
  The agent calls `extract_brand` and `extract_design_tokens`, gets Linear's real colors, fonts, and spacing scale, and builds to match.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miromiro-mcp",
3
- "version": "0.4.1",
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,13 +54,29 @@ 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',
60
76
  title: 'Extract section as code',
61
77
  endpoint: 'code',
62
78
  description:
63
- "Turn a section of a LIVE website into clean, paste-ready component code. Give it a URL and (optionally) a CSS selector; it resolves the page's real CSS cascade and returns a self-contained component in Tailwind, plain HTML+CSS, React (JSX) or Vue — plus the design tokens that section actually uses. The response also includes `sections`: every extractable section on the page with a human-readable label and a ready selector — when the user wants a specific part (pricing, footer, testimonials), find it there and call again with that selector. For jsx/vue, repeated structures (pricing tiers, testimonial cards, feature lists) are collapsed into a data array rendered with .map()/v-for - one template plus the data, like a human would write it. The section's ::before/::after decorations come back as real elements, dark-mode styles as dark: variants, and its @keyframes ship in the css field. USE THIS whenever the user asks to rebuild, clone, port, or copy a section of a real website: it returns the actual code with true colors, fonts, spacing and copy, so you adapt real values instead of guessing from a screenshot. Note: it reads static HTML/CSS by default - summary.styledCoverage (0-100) tells you how much styling resolved, and a client-rendered app that returns little includes a hint field saying to call again with render=true (+5 credits). Follow it.",
79
+ "Turn a section of a LIVE website into clean, paste-ready component code. Give it a URL and (optionally) a CSS selector; it resolves the page's real CSS cascade and returns a self-contained component in Tailwind, plain HTML+CSS, React (JSX) or Vue — plus the design tokens that section actually uses. The response also includes `sections`: every extractable section on the page with a human-readable label and a ready selector — when the user wants a specific part (pricing, footer, testimonials), find it there and call again with that selector. For jsx/vue, repeated structures (pricing tiers, testimonial cards, feature lists) are collapsed into a data array rendered with .map()/v-for - one template plus the data, like a human would write it. The section's ::before/::after decorations come back as real elements, dark-mode styles as dark: variants, and its @keyframes ship in the css field. The `theme` field (light | dark) tells you which theme the returned code actually is - measured from the page, never echoed back from your request. A site that expresses its themes through a prefers-color-scheme media query returns its LIGHT branch by default, so pass theme='dark' when the user is looking at that site in dark mode or asks for its dark version; sites that scope themes to a root attribute or class are read correctly either way. Say which theme you returned. USE THIS whenever the user asks to rebuild, clone, port, or copy a section of a real website: it returns the actual code with true colors, fonts, spacing and copy, so you adapt real values instead of guessing from a screenshot. Note: it reads static HTML/CSS by default - summary.styledCoverage (0-100) tells you how much styling resolved, and a client-rendered app that returns little includes a hint field saying to call again with render=true (+5 credits). Follow it.",
64
80
  inputSchema: {
65
81
  type: 'object',
66
82
  properties: {
@@ -68,9 +84,44 @@ const TOOLS = [
68
84
  selector: { type: 'string', description: 'CSS selector for the section to extract, e.g. "#hero" or ".pricing-card". Omit to auto-detect the hero.' },
69
85
  framework: { type: 'string', enum: ['tailwind', 'html', 'jsx', 'vue'], description: 'The code to generate (default tailwind).' },
70
86
  name: { type: 'string', description: 'Component name for the jsx target (default ExtractedSection). Ignored by tailwind, html and vue. Renaming never re-runs the extraction - it costs 0 credits on a cached section.' },
87
+ theme: { type: 'string', enum: ['light', 'dark'], description: "Which colour scheme to extract (default light). Pass 'dark' when the user is looking at the site in dark mode or asks for the dark version: the page's prefers-color-scheme: dark rules and custom properties become the component itself instead of riding along as dark: variants. Works whether the site expresses its themes as @media (prefers-color-scheme) or as a root attribute/class the CSS is scoped to ([data-theme=dark], html.dark). Sites that switch themes purely in JavaScript cannot be selected this way - you get the page default plus a hint saying so. No extra credits." },
71
88
  render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
72
89
  },
73
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
74
125
  }
75
126
  },
76
127
  {
@@ -78,11 +129,56 @@ const TOOLS = [
78
129
  title: 'Extract brand identity',
79
130
  endpoint: 'brand',
80
131
  description:
81
- "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.",
82
133
  inputSchema: {
83
134
  type: 'object',
84
135
  properties: { url: { type: 'string', description: 'The website URL, e.g. stripe.com' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
85
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
86
182
  }
87
183
  },
88
184
  {
@@ -90,7 +186,7 @@ const TOOLS = [
90
186
  title: 'Extract design tokens',
91
187
  endpoint: 'extract',
92
188
  description:
93
- "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.",
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.",
94
190
  inputSchema: {
95
191
  type: 'object',
96
192
  properties: {
@@ -99,6 +195,30 @@ const TOOLS = [
99
195
  render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
100
196
  },
101
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
102
222
  }
103
223
  },
104
224
  {
@@ -111,6 +231,29 @@ const TOOLS = [
111
231
  type: 'object',
112
232
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
113
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
114
257
  }
115
258
  },
116
259
  {
@@ -123,6 +266,29 @@ const TOOLS = [
123
266
  type: 'object',
124
267
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
125
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
126
292
  }
127
293
  },
128
294
  {
@@ -139,6 +305,47 @@ const TOOLS = [
139
305
  render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
140
306
  },
141
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
142
349
  }
143
350
  },
144
351
  {
@@ -151,6 +358,29 @@ const TOOLS = [
151
358
  type: 'object',
152
359
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
153
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
154
384
  }
155
385
  },
156
386
  {
@@ -163,6 +393,30 @@ const TOOLS = [
163
393
  type: 'object',
164
394
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
165
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
166
420
  }
167
421
  },
168
422
  {
@@ -175,13 +429,36 @@ const TOOLS = [
175
429
  type: 'object',
176
430
  properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
177
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
178
455
  }
179
456
  }
180
457
  ]
181
458
 
182
459
  // Query params a tool may forward to the API, beyond `url`. Anything else in the
183
460
  // tool arguments is ignored, so a hallucinated param can't reach the endpoint.
184
- const FORWARDED_PARAMS = ['format', 'selector', 'framework', 'name', 'fields', 'render', 'fresh', 'screenshot']
461
+ const FORWARDED_PARAMS = ['format', 'selector', 'framework', 'name', 'theme', 'fields', 'render', 'fresh', 'screenshot']
185
462
 
186
463
  async function callApi(endpoint, args) {
187
464
  const q = new URLSearchParams({ url: args.url })
@@ -203,17 +480,43 @@ async function callApi(endpoint, args) {
203
480
  return isJson ? JSON.stringify(body) : body
204
481
  }
205
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
+
206
508
  const server = new Server(
207
- { name: 'miromiro', version: '0.4.1' },
208
- { capabilities: { tools: {} } }
509
+ { name: 'miromiro', version: '0.6.0' },
510
+ { capabilities: { tools: {} }, instructions: INSTRUCTIONS }
209
511
  )
210
512
 
211
513
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
212
- tools: TOOLS.map(({ name, title, description, inputSchema }) => ({
514
+ tools: TOOLS.map(({ name, title, description, inputSchema, outputSchema }) => ({
213
515
  name,
214
516
  title,
215
517
  description,
216
518
  inputSchema,
519
+ ...(outputSchema ? { outputSchema } : {}),
217
520
  annotations: { title, ...TOOL_ANNOTATIONS }
218
521
  }))
219
522
  }))
@@ -235,7 +538,18 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
235
538
  }
236
539
  try {
237
540
  const out = await callApi(tool.endpoint, args)
238
- 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 }
239
553
  } catch (err) {
240
554
  return { isError: true, content: [{ type: 'text', text: String(err.message || err) }] }
241
555
  }