miromiro-mcp 0.5.0 → 0.6.1
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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/index.mjs +326 -9
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,
|
|
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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
|
@@ -39,7 +39,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
39
39
|
// client-rendered React/Next/SPA sites that return little statically.
|
|
40
40
|
const RENDER_ARG = {
|
|
41
41
|
type: 'boolean',
|
|
42
|
-
description: 'Run the page in a real browser first so its JavaScript executes - use for client-rendered (React/Next/SPA) sites,
|
|
42
|
+
description: 'Run the page in a real browser first so its JavaScript executes - use for client-rendered (React/Next/SPA) sites, when a normal call came back thin, or after a 403 target_blocked (bot protection refuses plain HTTP requests but usually lets a browser through). Slower, and adds 5 credits to the call.'
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Shared input arg: skip the 24h cache for this one call (charged normally).
|
|
@@ -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
|
{
|
|
@@ -131,7 +296,7 @@ const TOOLS = [
|
|
|
131
296
|
title: 'Extract fonts',
|
|
132
297
|
endpoint: 'fonts',
|
|
133
298
|
description:
|
|
134
|
-
"Detect the real typography a site loads by resolving its full CSS cascade: every @font-face file with family/weight/style/format, Google Fonts links, preloads, plus a grouped per-family summary (families). Pass fields='families' for just the summary. Use to match typography.",
|
|
299
|
+
"Detect the real typography a site loads by resolving its full CSS cascade: every @font-face file with family/weight/style/format, Google Fonts links, preloads, plus a grouped per-family summary (families). Pass fields='families' for just the summary. Use to match typography. On a type-foundry site the families served are usually demo cuts of the products (subset character set, kerning and OpenType features stripped) - those are flagged demo:true with a note, and you should pass that on rather than presenting them as the real font.",
|
|
135
300
|
inputSchema: {
|
|
136
301
|
type: 'object',
|
|
137
302
|
properties: {
|
|
@@ -140,6 +305,49 @@ 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
|
+
demo: { type: 'boolean', description: 'The family name marks it a foundry demo/trial cut, not the real font' }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
googleFontsLinks: { type: 'array', items: { type: 'string' } },
|
|
344
|
+
count: { type: 'number' },
|
|
345
|
+
note: { type: 'string', description: 'Only present when demo/trial families were found - tell the user, these files are subset and feature-stripped, not the usable typeface' },
|
|
346
|
+
usage: OUT_USAGE,
|
|
347
|
+
truncation_note: OUT_TRUNCATION
|
|
348
|
+
},
|
|
349
|
+
required: ['url'],
|
|
350
|
+
additionalProperties: true
|
|
143
351
|
}
|
|
144
352
|
},
|
|
145
353
|
{
|
|
@@ -152,6 +360,29 @@ const TOOLS = [
|
|
|
152
360
|
type: 'object',
|
|
153
361
|
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
154
362
|
required: ['url']
|
|
363
|
+
},
|
|
364
|
+
outputSchema: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
url: { type: 'string' },
|
|
368
|
+
lotties: {
|
|
369
|
+
type: 'array',
|
|
370
|
+
items: {
|
|
371
|
+
type: 'object',
|
|
372
|
+
properties: {
|
|
373
|
+
url: { type: 'string', description: 'The animation JSON / .lottie file URL' },
|
|
374
|
+
source: { type: 'string', description: 'lottie-player | dotlottie-player | data-attr | json-ref' }
|
|
375
|
+
},
|
|
376
|
+
required: ['url']
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
count: { type: 'number' },
|
|
380
|
+
note: { type: 'string', description: 'Coverage caveat: only Lotties declared in the initial HTML are found' },
|
|
381
|
+
usage: OUT_USAGE,
|
|
382
|
+
truncation_note: OUT_TRUNCATION
|
|
383
|
+
},
|
|
384
|
+
required: ['url'],
|
|
385
|
+
additionalProperties: true
|
|
155
386
|
}
|
|
156
387
|
},
|
|
157
388
|
{
|
|
@@ -164,6 +395,30 @@ const TOOLS = [
|
|
|
164
395
|
type: 'object',
|
|
165
396
|
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
166
397
|
required: ['url']
|
|
398
|
+
},
|
|
399
|
+
outputSchema: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
url: { type: 'string' },
|
|
403
|
+
videos: {
|
|
404
|
+
type: 'array',
|
|
405
|
+
items: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
properties: {
|
|
408
|
+
url: { type: 'string', description: 'Resolved absolute video (or player page) URL' },
|
|
409
|
+
source: { type: 'string', description: 'video | source | og | embed | link. embed is a player page, not a file.' },
|
|
410
|
+
poster: { type: 'string', description: 'Poster image URL when the tag declared one' }
|
|
411
|
+
},
|
|
412
|
+
required: ['url', 'source']
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
count: { type: 'number', description: 'Total videos found (before any trimming)' },
|
|
416
|
+
note: { type: 'string', description: 'Coverage caveat: only videos declared in the initial HTML are found - use render=true for JS-mounted players' },
|
|
417
|
+
usage: OUT_USAGE,
|
|
418
|
+
truncation_note: OUT_TRUNCATION
|
|
419
|
+
},
|
|
420
|
+
required: ['url'],
|
|
421
|
+
additionalProperties: true
|
|
167
422
|
}
|
|
168
423
|
},
|
|
169
424
|
{
|
|
@@ -176,13 +431,36 @@ const TOOLS = [
|
|
|
176
431
|
type: 'object',
|
|
177
432
|
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
178
433
|
required: ['url']
|
|
434
|
+
},
|
|
435
|
+
outputSchema: {
|
|
436
|
+
type: 'object',
|
|
437
|
+
properties: {
|
|
438
|
+
url: { type: 'string' },
|
|
439
|
+
audio: {
|
|
440
|
+
type: 'array',
|
|
441
|
+
items: {
|
|
442
|
+
type: 'object',
|
|
443
|
+
properties: {
|
|
444
|
+
url: { type: 'string', description: 'Resolved absolute audio file URL' },
|
|
445
|
+
source: { type: 'string', description: 'audio | source | og | link' }
|
|
446
|
+
},
|
|
447
|
+
required: ['url', 'source']
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
count: { type: 'number', description: 'Total audio files found (before any trimming)' },
|
|
451
|
+
note: { type: 'string', description: 'Coverage caveat: only audio declared in the initial HTML is found - use render=true for JS-mounted players' },
|
|
452
|
+
usage: OUT_USAGE,
|
|
453
|
+
truncation_note: OUT_TRUNCATION
|
|
454
|
+
},
|
|
455
|
+
required: ['url'],
|
|
456
|
+
additionalProperties: true
|
|
179
457
|
}
|
|
180
458
|
}
|
|
181
459
|
]
|
|
182
460
|
|
|
183
461
|
// Query params a tool may forward to the API, beyond `url`. Anything else in the
|
|
184
462
|
// 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']
|
|
463
|
+
const FORWARDED_PARAMS = ['format', 'selector', 'framework', 'name', 'theme', 'fields', 'render', 'fresh', 'screenshot']
|
|
186
464
|
|
|
187
465
|
async function callApi(endpoint, args) {
|
|
188
466
|
const q = new URLSearchParams({ url: args.url })
|
|
@@ -204,17 +482,45 @@ async function callApi(endpoint, args) {
|
|
|
204
482
|
return isJson ? JSON.stringify(body) : body
|
|
205
483
|
}
|
|
206
484
|
|
|
485
|
+
// Server-level guidance, sent once at initialize. Mirrors the `instructions`
|
|
486
|
+
// string the remote server (miromiro.app/mcp) returns, so a stdio client gets
|
|
487
|
+
// the same steering as a hosted one — chiefly: explain results, don't just dump
|
|
488
|
+
// them, and never present inferred brand colors as declared ones.
|
|
489
|
+
const INSTRUCTIONS =
|
|
490
|
+
'MiroMiro turns any live URL into design data: section-level code (extract_code), brand identity ' +
|
|
491
|
+
'(extract_brand), the full design-token system (extract_design_tokens), images, SVGs, fonts, videos, audio and Lottie files. ' +
|
|
492
|
+
'Reach for extract_code when asked to rebuild or clone part of a real site; extract_brand when only ' +
|
|
493
|
+
'colors/fonts are needed; extract_design_tokens when you want the whole value system (it returns values ' +
|
|
494
|
+
'and usage counts, NOT layout, spacing composition or which value belongs to which element - it is not a ' +
|
|
495
|
+
'substitute for extract_code on a rebuild). When a site expresses its themes through a ' +
|
|
496
|
+
'prefers-color-scheme media query, extraction returns the LIGHT branch by default and carries the dark ' +
|
|
497
|
+
'one as dark: variants - so pass theme=dark to extract_code when the user is viewing that site in dark ' +
|
|
498
|
+
'mode or asks for its dark version. Every code response reports the theme you actually got: check it and ' +
|
|
499
|
+
'say which one you returned. The API reads static HTML+CSS by default; pass render=true on any tool to ' +
|
|
500
|
+
'execute the page JavaScript in a real browser first (client-rendered sites, +5 credits). When a static ' +
|
|
501
|
+
'call comes back thin because the page is client-rendered, the response includes a hint field saying to ' +
|
|
502
|
+
'retry with render=true - follow it. Likewise a 403 target_blocked is rarely a dead end: it is usually the ' +
|
|
503
|
+
"site's bot protection turning away a plain HTTP request, so retry the SAME url with render=true before " +
|
|
504
|
+
'telling the user the site cannot be extracted. Explain your results, do not just dump them: extract_brand returns a ' +
|
|
505
|
+
'signals object saying where the colors came from (signals.explanation is a sentence written to be repeated ' +
|
|
506
|
+
'to the user) - when signals.primarySource is "usage" the site declared nothing and the colors are an ' +
|
|
507
|
+
'inferred guess, so say that instead of presenting them as the official palette. Rendered calls can also ' +
|
|
508
|
+
'take screenshot=true for a public image URL of the page (show it to the user to verify an extraction). ' +
|
|
509
|
+
'extract_design_tokens takes format=dtcg for a W3C Design Tokens file. Pass fresh=true on any tool to ' +
|
|
510
|
+
'bypass the 24h cache when a site just changed.'
|
|
511
|
+
|
|
207
512
|
const server = new Server(
|
|
208
|
-
{ name: 'miromiro', version: '0.
|
|
209
|
-
{ capabilities: { tools: {} } }
|
|
513
|
+
{ name: 'miromiro', version: '0.6.1' },
|
|
514
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS }
|
|
210
515
|
)
|
|
211
516
|
|
|
212
517
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
213
|
-
tools: TOOLS.map(({ name, title, description, inputSchema }) => ({
|
|
518
|
+
tools: TOOLS.map(({ name, title, description, inputSchema, outputSchema }) => ({
|
|
214
519
|
name,
|
|
215
520
|
title,
|
|
216
521
|
description,
|
|
217
522
|
inputSchema,
|
|
523
|
+
...(outputSchema ? { outputSchema } : {}),
|
|
218
524
|
annotations: { title, ...TOOL_ANNOTATIONS }
|
|
219
525
|
}))
|
|
220
526
|
}))
|
|
@@ -236,7 +542,18 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
236
542
|
}
|
|
237
543
|
try {
|
|
238
544
|
const out = await callApi(tool.endpoint, args)
|
|
239
|
-
|
|
545
|
+
// A tool that declares an outputSchema must also return structuredContent
|
|
546
|
+
// matching it (2025-06-18 spec), and structuredContent must be an object.
|
|
547
|
+
// format=css|tailwind on the tokens endpoint returns a raw code string —
|
|
548
|
+
// wrap it as { output }, which that tool's outputSchema declares.
|
|
549
|
+
let structured
|
|
550
|
+
try {
|
|
551
|
+
const parsed = JSON.parse(out)
|
|
552
|
+
structured = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { output: parsed }
|
|
553
|
+
} catch {
|
|
554
|
+
structured = { output: out } // non-JSON body (css/tailwind block)
|
|
555
|
+
}
|
|
556
|
+
return { content: [{ type: 'text', text: out }], structuredContent: structured }
|
|
240
557
|
} catch (err) {
|
|
241
558
|
return { isError: true, content: [{ type: 'text', text: String(err.message || err) }] }
|
|
242
559
|
}
|