miromiro-mcp 0.7.1 → 0.7.2
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/package.json +1 -1
- package/src/index.mjs +657 -657
package/src/index.mjs
CHANGED
|
@@ -1,657 +1,657 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* MiroMiro MCP server (playbook §3).
|
|
4
|
-
*
|
|
5
|
-
* Exposes the MiroMiro design-extraction API as MCP tools so a coding agent
|
|
6
|
-
* (Cursor / Claude Code / Windsurf) can pull the real design of any URL and
|
|
7
|
-
* build UI in that style — instead of guessing at colors and fonts.
|
|
8
|
-
*
|
|
9
|
-
* Auth: set MIROMIRO_API_KEY in the environment (get one free, no card, at
|
|
10
|
-
* https://miromiro.app/api/dashboard).
|
|
11
|
-
*
|
|
12
|
-
* Run: MIROMIRO_API_KEY=mm_live_… npx miromiro-mcp
|
|
13
|
-
* Or wire it into your agent's MCP config (see README).
|
|
14
|
-
*/
|
|
15
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
16
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
17
|
-
import {
|
|
18
|
-
CallToolRequestSchema,
|
|
19
|
-
ListToolsRequestSchema,
|
|
20
|
-
ListPromptsRequestSchema,
|
|
21
|
-
GetPromptRequestSchema
|
|
22
|
-
} from '@modelcontextprotocol/sdk/types.js'
|
|
23
|
-
|
|
24
|
-
const API_BASE = process.env.MIROMIRO_API_BASE || 'https://miromiro.app/api/v1'
|
|
25
|
-
const API_KEY = process.env.MIROMIRO_API_KEY
|
|
26
|
-
|
|
27
|
-
// Tool → endpoint map. Each tool takes a single `url` (design_tokens also takes
|
|
28
|
-
// an optional `format`). Descriptions are written for the agent, so it knows
|
|
29
|
-
// WHEN to reach for each one.
|
|
30
|
-
// Every tool is a read against the public web: no writes, retry-safe (cached
|
|
31
|
-
// repeats), open-world. Clients use readOnlyHint to label tools as safe.
|
|
32
|
-
const TOOL_ANNOTATIONS = {
|
|
33
|
-
readOnlyHint: true,
|
|
34
|
-
destructiveHint: false,
|
|
35
|
-
idempotentHint: true,
|
|
36
|
-
openWorldHint: true
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Sections an agent can ask for by name instead of inventing a CSS selector
|
|
40
|
-
// (mirrors NAMED_SECTIONS in the API's extractCode.ts).
|
|
41
|
-
const NAMED_SECTIONS = ['hero', 'header', 'footer', 'pricing', 'features', 'testimonials', 'faq', 'cta']
|
|
42
|
-
|
|
43
|
-
// Shared input arg: every tool accepts render=true to execute the page's
|
|
44
|
-
// JavaScript in a real (rented) browser before extraction - the fix for
|
|
45
|
-
// client-rendered React/Next/SPA sites that return little statically.
|
|
46
|
-
const RENDER_ARG = {
|
|
47
|
-
type: 'boolean',
|
|
48
|
-
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.'
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Shared input arg: skip the 24h cache for this one call (charged normally).
|
|
52
|
-
const FRESH_ARG = {
|
|
53
|
-
type: 'boolean',
|
|
54
|
-
description: 'Skip the 24-hour cache and re-extract the live page - use when the site just changed and a cached result would be stale. Charged normally; the fresh result replaces the cached copy.'
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Shared input arg: a screenshot URL alongside the data (requires render=true).
|
|
58
|
-
const SCREENSHOT_ARG = {
|
|
59
|
-
type: ['boolean', 'string'],
|
|
60
|
-
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."
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Shared fragments for the output schemas below, mirrored from the remote
|
|
64
|
-
// server (server/utils/mcpRemote.ts). Every schema keeps additionalProperties
|
|
65
|
-
// true and requires only `url`: the /v1 responses can carry extras (usage,
|
|
66
|
-
// note, truncation_note) and a strict schema that drifts from the
|
|
67
|
-
// implementation would make clients reject valid results.
|
|
68
|
-
const OUT_USAGE = { type: 'object', description: 'Credit usage for the authed key (spent, remaining, monthly limit).' }
|
|
69
|
-
const OUT_TRUNCATION = { type: 'string', description: 'Present when the result was trimmed - says what was dropped and how to get more.' }
|
|
70
|
-
const countedList = (valueKey, desc) => ({
|
|
71
|
-
type: 'array',
|
|
72
|
-
description: desc,
|
|
73
|
-
items: {
|
|
74
|
-
type: 'object',
|
|
75
|
-
properties: { [valueKey]: { type: 'string' }, count: { type: 'number', description: 'How many times it is used on the page' } }
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
const TOOLS = [
|
|
80
|
-
{
|
|
81
|
-
name: 'extract_code',
|
|
82
|
-
title: 'Extract section as code',
|
|
83
|
-
endpoint: 'code',
|
|
84
|
-
description:
|
|
85
|
-
"Use when the user asks to rebuild, clone, copy, port, restyle or 'make it look like' part of a real website, or wants a site's section as Tailwind, HTML+CSS, React (JSX) or Vue. Give it the URL and, when the user named a part of the page, section (hero, header, footer, pricing, features, testimonials, faq, cta) - never invent a CSS selector. With neither, it auto-detects the hero; every call also returns `sections`, each other extractable section with a label and a ready selector for a follow-up call. Returns a self-contained component with the page's real resolved styles (true colors, fonts, spacing, copy) plus the tokens and assets it uses, so you adapt real values instead of estimating from a screenshot. For jsx/vue, repeated structures collapse into a data array rendered with .map()/v-for; ::before/::after come back as real elements; @keyframes ship in the css field. The `theme` field says which theme the code actually is - sites with a prefers-color-scheme dark variant return the light branch unless you pass theme='dark'; say which you returned. Not for: whole-page scraping, page text, or sites that need a login. Reads static HTML/CSS by default; if the result is thin or carries a `hint`, call again with render=true (+5 credits).",
|
|
86
|
-
inputSchema: {
|
|
87
|
-
type: 'object',
|
|
88
|
-
properties: {
|
|
89
|
-
url: { type: 'string', description: 'The website URL, e.g. stripe.com' },
|
|
90
|
-
section: { type: 'string', enum: NAMED_SECTIONS, description: "Which part of the page, by name - use this when the user asked for the pricing, footer, header/nav, features, testimonials, FAQ or call-to-action block. Resolved on the page itself, so it works without seeing the markup. Omit for the hero. If the page has no such section you get the hero plus a `note` listing what is there." },
|
|
91
|
-
selector: { type: 'string', description: 'CSS selector for the section, ONLY when you have one from a previous result\'s `sections` list (e.g. "section.pricing-grid"). Do not guess selectors: a selector that matches nothing falls back to the hero with a `note`, and the guess costs a call. Prefer `section` for named parts of a page.' },
|
|
92
|
-
framework: { type: 'string', enum: ['tailwind', 'html', 'jsx', 'vue'], description: 'The code to generate (default tailwind).' },
|
|
93
|
-
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.' },
|
|
94
|
-
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." },
|
|
95
|
-
render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
|
|
96
|
-
},
|
|
97
|
-
required: ['url']
|
|
98
|
-
},
|
|
99
|
-
outputSchema: {
|
|
100
|
-
type: 'object',
|
|
101
|
-
properties: {
|
|
102
|
-
url: { type: 'string' },
|
|
103
|
-
selector: { type: 'string', description: 'The CSS selector that was extracted' },
|
|
104
|
-
section: { type: 'string', description: 'The named section that was resolved, when one was requested' },
|
|
105
|
-
autoDetected: { type: 'boolean', description: 'True when the hero was auto-detected rather than passed in' },
|
|
106
|
-
note: { type: 'string', description: 'Present when the requested selector or section was not found and the hero was returned instead - says what was asked for and which sections the page does have. Tell the user.' },
|
|
107
|
-
sections: {
|
|
108
|
-
type: 'array',
|
|
109
|
-
description: 'Every extractable section on the page - call again with one of these selectors for a specific part',
|
|
110
|
-
items: {
|
|
111
|
-
type: 'object',
|
|
112
|
-
properties: {
|
|
113
|
-
selector: { type: 'string' },
|
|
114
|
-
label: { type: 'string' },
|
|
115
|
-
elements: { type: 'number' },
|
|
116
|
-
hasHeading: { type: 'boolean' },
|
|
117
|
-
preview: { type: 'string' }
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
},
|
|
121
|
-
framework: { type: 'string', description: 'tailwind | html | jsx | vue' },
|
|
122
|
-
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' },
|
|
123
|
-
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)' },
|
|
124
|
-
tokens: { type: 'object', description: 'Design tokens the extracted section actually uses' },
|
|
125
|
-
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" },
|
|
126
|
-
assets: { description: 'Images/fonts the section references' },
|
|
127
|
-
summary: { description: 'What was extracted, at a glance. styledCoverage (0-100) is the quality signal: the % of elements the cascade found styles for' },
|
|
128
|
-
hint: { type: 'string', description: 'Only present when the extraction looks degraded (client-rendered page, runtime-injected styles) - says what to do: on a static call usually retry with render=true; on a render=true call the styles could not be resolved even in a browser, do not retry' },
|
|
129
|
-
usage: OUT_USAGE,
|
|
130
|
-
truncation_note: OUT_TRUNCATION
|
|
131
|
-
},
|
|
132
|
-
required: ['url'],
|
|
133
|
-
additionalProperties: true
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
name: 'extract_brand',
|
|
138
|
-
title: 'Extract brand identity',
|
|
139
|
-
endpoint: 'brand',
|
|
140
|
-
description:
|
|
141
|
-
"Use when the user wants a site's brand: its colors, fonts, logo, theme or social links - 'what colors does stripe use', 'match this brand', 'build something in the style of X'. Returns role colors (primary, accent, background, text) as hex with human-readable names, the wider palette, fonts by role, logo candidates, social profiles, dark/light theme, and a 0-1 confidence score with `signals` explaining where the colors came from: read signals.explanation and tell the user whether they were declared by the site ('css-var'/'theme-color') or inferred from usage frequency ('usage', a guess) - never present inferred colors as the official palette. Not for: markup or layout (use extract_code) or the full token list (use extract_design_tokens).",
|
|
142
|
-
inputSchema: {
|
|
143
|
-
type: 'object',
|
|
144
|
-
properties: { url: { type: 'string', description: 'The website URL, e.g. stripe.com' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
145
|
-
required: ['url']
|
|
146
|
-
},
|
|
147
|
-
outputSchema: {
|
|
148
|
-
type: 'object',
|
|
149
|
-
properties: {
|
|
150
|
-
url: { type: 'string' },
|
|
151
|
-
title: { type: ['string', 'null'], description: 'Page/og title - the brand name' },
|
|
152
|
-
description: { type: ['string', 'null'] },
|
|
153
|
-
siteName: { type: ['string', 'null'] },
|
|
154
|
-
themeColor: { type: ['string', 'null'], description: "The site's own declared theme-color" },
|
|
155
|
-
theme: { type: 'string', description: 'dark | light' },
|
|
156
|
-
colors: { type: 'object', description: 'Role colors as hex: primary, accent, background, text' },
|
|
157
|
-
colorNames: { type: 'object', description: 'Human-readable names for the role colors ("Royal Blue")' },
|
|
158
|
-
palette: {
|
|
159
|
-
type: 'array',
|
|
160
|
-
description: 'The wider brand palette',
|
|
161
|
-
items: { type: 'object', properties: { color: { type: 'string' }, count: { type: 'number' }, name: { type: ['string', 'null'] } } }
|
|
162
|
-
},
|
|
163
|
-
fonts: { type: 'array', items: { type: 'string' } },
|
|
164
|
-
typography: { type: 'object', description: 'Fonts split by role: primary, mono' },
|
|
165
|
-
logo: { type: ['string', 'null'], description: 'Best single logo URL' },
|
|
166
|
-
logos: { type: 'array', description: 'All icon/logo candidates, tagged by source' },
|
|
167
|
-
ogImage: { type: ['string', 'null'] },
|
|
168
|
-
socials: {
|
|
169
|
-
type: 'array',
|
|
170
|
-
description: 'Social profiles found on the page',
|
|
171
|
-
items: { type: 'object', properties: { platform: { type: 'string' }, url: { type: 'string' }, handle: { type: ['string', 'null'] } } }
|
|
172
|
-
},
|
|
173
|
-
confidence: { type: 'number', description: '0-1: how much the site declared vs how much was inferred' },
|
|
174
|
-
signals: {
|
|
175
|
-
type: 'object',
|
|
176
|
-
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.",
|
|
177
|
-
properties: {
|
|
178
|
-
primarySource: { type: ['string', 'null'], description: "css-var | theme-color | logo | usage | null - where colors.primary came from, strongest first. 'usage' is inferred, not declared." },
|
|
179
|
-
declaredBackground: { type: 'boolean', description: 'The background role came from a declared CSS variable' },
|
|
180
|
-
declaredText: { type: 'boolean', description: 'The text role came from a declared CSS variable' },
|
|
181
|
-
declaredLogo: { type: 'boolean', description: 'A real logo was found, not just an og:image fallback' },
|
|
182
|
-
fontsFound: { type: 'number', description: 'How many font families resolved from the CSS cascade' },
|
|
183
|
-
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.' }
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
usage: OUT_USAGE,
|
|
187
|
-
truncation_note: OUT_TRUNCATION
|
|
188
|
-
},
|
|
189
|
-
required: ['url'],
|
|
190
|
-
additionalProperties: true
|
|
191
|
-
}
|
|
192
|
-
},
|
|
193
|
-
{
|
|
194
|
-
name: 'extract_design_tokens',
|
|
195
|
-
title: 'Extract design tokens',
|
|
196
|
-
endpoint: 'extract',
|
|
197
|
-
description:
|
|
198
|
-
"Use when the user wants a site's complete token system - every color, font family, size, weight, spacing value, radius, shadow, line height, gradient and breakpoint with usage counts - or a paste-ready :root block (format='css'), tailwind.config (format='tailwind') or W3C Design Tokens file (format='dtcg', for Style Dictionary / Figma variables). Returns values and counts only, not layout or which element uses what: if the user asked to rebuild or clone a section, use extract_code, which returns the markup with the values in place. A site shipping light and dark themes returns the union of both (see colorSchemes) - pick colors by role, not by frequency, and use extract_code with theme=dark for one theme on its own. Not for: a short brand summary (use extract_brand).",
|
|
199
|
-
inputSchema: {
|
|
200
|
-
type: 'object',
|
|
201
|
-
properties: {
|
|
202
|
-
url: { type: 'string', description: 'The website URL' },
|
|
203
|
-
format: { type: 'string', enum: ['json', 'css', 'tailwind', 'dtcg'], description: "Output shape (default json). 'dtcg' returns a W3C Design Tokens file." },
|
|
204
|
-
render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
|
|
205
|
-
},
|
|
206
|
-
required: ['url']
|
|
207
|
-
},
|
|
208
|
-
outputSchema: {
|
|
209
|
-
type: 'object',
|
|
210
|
-
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).",
|
|
211
|
-
properties: {
|
|
212
|
-
url: { type: 'string' },
|
|
213
|
-
colors: countedList('color', 'Every color with usage count'),
|
|
214
|
-
fontFamilies: countedList('family', 'Font families with usage counts'),
|
|
215
|
-
fontSizes: countedList('size', 'Font sizes with usage counts'),
|
|
216
|
-
fontWeights: countedList('value', 'Font weights'),
|
|
217
|
-
spacing: countedList('value', 'The spacing scale from margin/padding/gap'),
|
|
218
|
-
borderRadiuses: countedList('value', 'Border radii'),
|
|
219
|
-
boxShadows: countedList('value', 'Box shadows'),
|
|
220
|
-
lineHeights: countedList('value', 'Line heights'),
|
|
221
|
-
gradients: { type: 'array', description: 'Gradients with type (linear/radial/conic) and count' },
|
|
222
|
-
breakpoints: countedList('value', 'Responsive breakpoints from media queries'),
|
|
223
|
-
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' },
|
|
224
|
-
summary: { type: 'object', description: 'Count per token group' },
|
|
225
|
-
output: { type: 'string', description: "The paste-ready :root{} or tailwind.config block when format='css'|'tailwind'" },
|
|
226
|
-
usage: OUT_USAGE,
|
|
227
|
-
truncation_note: OUT_TRUNCATION
|
|
228
|
-
},
|
|
229
|
-
required: [],
|
|
230
|
-
additionalProperties: true
|
|
231
|
-
}
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
name: 'extract_assets',
|
|
235
|
-
title: 'Extract images',
|
|
236
|
-
endpoint: 'images',
|
|
237
|
-
description:
|
|
238
|
-
"Use when the user wants the images from a page - photos, illustrations, backgrounds, favicon, social image - as resolved URLs tagged by source (img, srcset, picture, CSS background, favicon, og). Not for: icons and vector logos (use extract_svgs) or fonts (use extract_fonts). If images are mounted by JavaScript, call again with render=true (+5 credits).",
|
|
239
|
-
inputSchema: {
|
|
240
|
-
type: 'object',
|
|
241
|
-
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
242
|
-
required: ['url']
|
|
243
|
-
},
|
|
244
|
-
outputSchema: {
|
|
245
|
-
type: 'object',
|
|
246
|
-
properties: {
|
|
247
|
-
url: { type: 'string' },
|
|
248
|
-
images: {
|
|
249
|
-
type: 'array',
|
|
250
|
-
items: {
|
|
251
|
-
type: 'object',
|
|
252
|
-
properties: {
|
|
253
|
-
url: { type: 'string', description: 'Resolved absolute image URL' },
|
|
254
|
-
source: { type: 'string', description: 'img | srcset | picture | background | favicon | og' },
|
|
255
|
-
alt: { type: 'string', description: 'Alt text when present' }
|
|
256
|
-
},
|
|
257
|
-
required: ['url', 'source']
|
|
258
|
-
}
|
|
259
|
-
},
|
|
260
|
-
count: { type: 'number', description: 'Total images found (before any trimming)' },
|
|
261
|
-
usage: OUT_USAGE,
|
|
262
|
-
truncation_note: OUT_TRUNCATION
|
|
263
|
-
},
|
|
264
|
-
required: ['url'],
|
|
265
|
-
additionalProperties: true
|
|
266
|
-
}
|
|
267
|
-
},
|
|
268
|
-
{
|
|
269
|
-
name: 'extract_svgs',
|
|
270
|
-
title: 'Extract SVGs',
|
|
271
|
-
endpoint: 'svg',
|
|
272
|
-
description:
|
|
273
|
-
"Use when the user wants a site's icons or vector logos: inline SVGs as paste-ready markup plus external .svg file URLs. Not for: raster images (use extract_assets). If icons are mounted by JavaScript, call again with render=true (+5 credits).",
|
|
274
|
-
inputSchema: {
|
|
275
|
-
type: 'object',
|
|
276
|
-
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
277
|
-
required: ['url']
|
|
278
|
-
},
|
|
279
|
-
outputSchema: {
|
|
280
|
-
type: 'object',
|
|
281
|
-
properties: {
|
|
282
|
-
url: { type: 'string' },
|
|
283
|
-
svgs: {
|
|
284
|
-
type: 'array',
|
|
285
|
-
items: {
|
|
286
|
-
type: 'object',
|
|
287
|
-
properties: {
|
|
288
|
-
type: { type: 'string', description: 'inline | file' },
|
|
289
|
-
code: { type: 'string', description: 'Sanitized paste-ready SVG markup (inline SVGs)' },
|
|
290
|
-
url: { type: 'string', description: 'File URL (external .svg references)' }
|
|
291
|
-
},
|
|
292
|
-
required: ['type']
|
|
293
|
-
}
|
|
294
|
-
},
|
|
295
|
-
count: { type: 'number', description: 'Total SVGs found (before any trimming)' },
|
|
296
|
-
usage: OUT_USAGE,
|
|
297
|
-
truncation_note: OUT_TRUNCATION
|
|
298
|
-
},
|
|
299
|
-
required: ['url'],
|
|
300
|
-
additionalProperties: true
|
|
301
|
-
}
|
|
302
|
-
},
|
|
303
|
-
{
|
|
304
|
-
name: 'extract_fonts',
|
|
305
|
-
title: 'Extract fonts',
|
|
306
|
-
endpoint: 'fonts',
|
|
307
|
-
description:
|
|
308
|
-
"Use when the user asks which fonts a site uses or wants to match its typography: every @font-face file with family/weight/style/format, Google Fonts links and preloads, grouped per family in `families` (pass fields='families' for just that). On a type-foundry site the families served are demo cuts (subset, features stripped), flagged demo:true with a note - pass that on, they are not the usable typeface. Not for: colors (use extract_brand) or code (use extract_code).",
|
|
309
|
-
inputSchema: {
|
|
310
|
-
type: 'object',
|
|
311
|
-
properties: {
|
|
312
|
-
url: { type: 'string', description: 'The website URL' },
|
|
313
|
-
fields: { type: 'string', description: "Optional comma-separated fields to return (e.g. 'families')" },
|
|
314
|
-
render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
|
|
315
|
-
},
|
|
316
|
-
required: ['url']
|
|
317
|
-
},
|
|
318
|
-
outputSchema: {
|
|
319
|
-
type: 'object',
|
|
320
|
-
properties: {
|
|
321
|
-
url: { type: 'string' },
|
|
322
|
-
fonts: {
|
|
323
|
-
type: 'array',
|
|
324
|
-
description: 'Every font file / stylesheet found',
|
|
325
|
-
items: {
|
|
326
|
-
type: 'object',
|
|
327
|
-
properties: {
|
|
328
|
-
source: { type: 'string', description: 'font-face | google-fonts | preload' },
|
|
329
|
-
url: { type: 'string', description: 'The font file or stylesheet URL' },
|
|
330
|
-
family: { type: 'string' },
|
|
331
|
-
weight: { type: 'string', description: "'400', '700', or a variable range like '100 900'" },
|
|
332
|
-
style: { type: 'string' },
|
|
333
|
-
format: { type: 'string', description: 'woff2 | woff | ttf | otf | eot' }
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
},
|
|
337
|
-
families: {
|
|
338
|
-
type: 'array',
|
|
339
|
-
description: 'Grouped per-family summary - what most callers want',
|
|
340
|
-
items: {
|
|
341
|
-
type: 'object',
|
|
342
|
-
properties: {
|
|
343
|
-
family: { type: 'string' },
|
|
344
|
-
source: { type: 'string' },
|
|
345
|
-
weights: { type: 'array', items: { type: 'string' } },
|
|
346
|
-
styles: { type: 'array', items: { type: 'string' } },
|
|
347
|
-
files: { type: 'number' },
|
|
348
|
-
demo: { type: 'boolean', description: 'The family name marks it a foundry demo/trial cut, not the real font' }
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
},
|
|
352
|
-
googleFontsLinks: { type: 'array', items: { type: 'string' } },
|
|
353
|
-
count: { type: 'number' },
|
|
354
|
-
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' },
|
|
355
|
-
usage: OUT_USAGE,
|
|
356
|
-
truncation_note: OUT_TRUNCATION
|
|
357
|
-
},
|
|
358
|
-
required: ['url'],
|
|
359
|
-
additionalProperties: true
|
|
360
|
-
}
|
|
361
|
-
},
|
|
362
|
-
{
|
|
363
|
-
name: 'extract_lottie',
|
|
364
|
-
title: 'Extract Lottie animations',
|
|
365
|
-
endpoint: 'lottie',
|
|
366
|
-
description:
|
|
367
|
-
"Use when the user wants a site's Lottie or animation JSON files. Most Lotties are injected at runtime, so pass render=true (+5 credits) when the static call finds none.",
|
|
368
|
-
inputSchema: {
|
|
369
|
-
type: 'object',
|
|
370
|
-
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
371
|
-
required: ['url']
|
|
372
|
-
},
|
|
373
|
-
outputSchema: {
|
|
374
|
-
type: 'object',
|
|
375
|
-
properties: {
|
|
376
|
-
url: { type: 'string' },
|
|
377
|
-
lotties: {
|
|
378
|
-
type: 'array',
|
|
379
|
-
items: {
|
|
380
|
-
type: 'object',
|
|
381
|
-
properties: {
|
|
382
|
-
url: { type: 'string', description: 'The animation JSON / .lottie file URL' },
|
|
383
|
-
source: { type: 'string', description: 'lottie-player | dotlottie-player | data-attr | json-ref' }
|
|
384
|
-
},
|
|
385
|
-
required: ['url']
|
|
386
|
-
}
|
|
387
|
-
},
|
|
388
|
-
count: { type: 'number' },
|
|
389
|
-
note: { type: 'string', description: 'Coverage caveat: only Lotties declared in the initial HTML are found' },
|
|
390
|
-
usage: OUT_USAGE,
|
|
391
|
-
truncation_note: OUT_TRUNCATION
|
|
392
|
-
},
|
|
393
|
-
required: ['url'],
|
|
394
|
-
additionalProperties: true
|
|
395
|
-
}
|
|
396
|
-
},
|
|
397
|
-
{
|
|
398
|
-
name: 'extract_videos',
|
|
399
|
-
title: 'Extract videos',
|
|
400
|
-
endpoint: 'videos',
|
|
401
|
-
description:
|
|
402
|
-
'Use when the user wants the videos on a page: <video>/<source> files, og:video, YouTube/Vimeo/Wistia/Loom embeds and direct video links, each tagged by source with a poster when declared. `source: "embed"` is a player page, not a downloadable file. Most players mount with JavaScript: if the result is thin or empty, call again with render=true (+5 credits).',
|
|
403
|
-
inputSchema: {
|
|
404
|
-
type: 'object',
|
|
405
|
-
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
406
|
-
required: ['url']
|
|
407
|
-
},
|
|
408
|
-
outputSchema: {
|
|
409
|
-
type: 'object',
|
|
410
|
-
properties: {
|
|
411
|
-
url: { type: 'string' },
|
|
412
|
-
videos: {
|
|
413
|
-
type: 'array',
|
|
414
|
-
items: {
|
|
415
|
-
type: 'object',
|
|
416
|
-
properties: {
|
|
417
|
-
url: { type: 'string', description: 'Resolved absolute video (or player page) URL' },
|
|
418
|
-
source: { type: 'string', description: 'video | source | og | embed | link. embed is a player page, not a file.' },
|
|
419
|
-
poster: { type: 'string', description: 'Poster image URL when the tag declared one' }
|
|
420
|
-
},
|
|
421
|
-
required: ['url', 'source']
|
|
422
|
-
}
|
|
423
|
-
},
|
|
424
|
-
count: { type: 'number', description: 'Total videos found (before any trimming)' },
|
|
425
|
-
note: { type: 'string', description: 'Coverage caveat: only videos declared in the initial HTML are found - use render=true for JS-mounted players' },
|
|
426
|
-
usage: OUT_USAGE,
|
|
427
|
-
truncation_note: OUT_TRUNCATION
|
|
428
|
-
},
|
|
429
|
-
required: ['url'],
|
|
430
|
-
additionalProperties: true
|
|
431
|
-
}
|
|
432
|
-
},
|
|
433
|
-
{
|
|
434
|
-
name: 'extract_audio',
|
|
435
|
-
title: 'Extract audio',
|
|
436
|
-
endpoint: 'audio',
|
|
437
|
-
description:
|
|
438
|
-
'Use when the user wants the audio files on a page: <audio>/<source> files, og:audio, podcast enclosures and download links, each tagged by source. If a JavaScript player mounts the audio at runtime, call again with render=true (+5 credits).',
|
|
439
|
-
inputSchema: {
|
|
440
|
-
type: 'object',
|
|
441
|
-
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
442
|
-
required: ['url']
|
|
443
|
-
},
|
|
444
|
-
outputSchema: {
|
|
445
|
-
type: 'object',
|
|
446
|
-
properties: {
|
|
447
|
-
url: { type: 'string' },
|
|
448
|
-
audio: {
|
|
449
|
-
type: 'array',
|
|
450
|
-
items: {
|
|
451
|
-
type: 'object',
|
|
452
|
-
properties: {
|
|
453
|
-
url: { type: 'string', description: 'Resolved absolute audio file URL' },
|
|
454
|
-
source: { type: 'string', description: 'audio | source | og | link' }
|
|
455
|
-
},
|
|
456
|
-
required: ['url', 'source']
|
|
457
|
-
}
|
|
458
|
-
},
|
|
459
|
-
count: { type: 'number', description: 'Total audio files found (before any trimming)' },
|
|
460
|
-
note: { type: 'string', description: 'Coverage caveat: only audio declared in the initial HTML is found - use render=true for JS-mounted players' },
|
|
461
|
-
usage: OUT_USAGE,
|
|
462
|
-
truncation_note: OUT_TRUNCATION
|
|
463
|
-
},
|
|
464
|
-
required: ['url'],
|
|
465
|
-
additionalProperties: true
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
]
|
|
469
|
-
|
|
470
|
-
// Query params a tool may forward to the API, beyond `url`. Anything else in the
|
|
471
|
-
// tool arguments is ignored, so a hallucinated param can't reach the endpoint.
|
|
472
|
-
const FORWARDED_PARAMS = ['format', 'selector', 'section', 'framework', 'name', 'theme', 'fields', 'render', 'fresh', 'screenshot']
|
|
473
|
-
|
|
474
|
-
// A selector/section that matched nothing. The API answers 404 (free); the
|
|
475
|
-
// stdio server then falls back to the hero so the agent gets the `sections`
|
|
476
|
-
// list in the same turn instead of a dead end - mirrors the remote server.
|
|
477
|
-
const SELECTOR_MISS_RE = /^No (element matched|"[a-z]+" section was found)/i
|
|
478
|
-
|
|
479
|
-
async function fetchApi(endpoint, q) {
|
|
480
|
-
const res = await fetch(`${API_BASE}/${endpoint}?${q}`, {
|
|
481
|
-
headers: { Authorization: `Bearer ${API_KEY}` }
|
|
482
|
-
})
|
|
483
|
-
const isJson = (res.headers.get('content-type') || '').includes('application/json')
|
|
484
|
-
const body = isJson ? await res.json() : await res.text()
|
|
485
|
-
if (!res.ok) {
|
|
486
|
-
const msg = isJson ? (body.statusMessage || body.message || JSON.stringify(body)) : body
|
|
487
|
-
const err = new Error(`MiroMiro API ${res.status}: ${msg}`)
|
|
488
|
-
err.status = res.status
|
|
489
|
-
err.apiMessage = String(msg)
|
|
490
|
-
throw err
|
|
491
|
-
}
|
|
492
|
-
return { body, isJson }
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async function callApi(endpoint, args) {
|
|
496
|
-
const q = new URLSearchParams({ url: args.url })
|
|
497
|
-
for (const p of FORWARDED_PARAMS) {
|
|
498
|
-
if (args[p] !== undefined && args[p] !== null && args[p] !== '') {
|
|
499
|
-
q.set(p, String(args[p]))
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
let result
|
|
503
|
-
let note = null
|
|
504
|
-
try {
|
|
505
|
-
result = await fetchApi(endpoint, q)
|
|
506
|
-
} catch (err) {
|
|
507
|
-
const asked = q.get('selector') ? `selector "${q.get('selector')}"` : q.get('section') ? `section "${q.get('section')}"` : null
|
|
508
|
-
if (err.status !== 404 || !asked || !SELECTOR_MISS_RE.test(err.apiMessage || '')) throw err
|
|
509
|
-
q.delete('selector')
|
|
510
|
-
q.delete('section')
|
|
511
|
-
result = await fetchApi(endpoint, q)
|
|
512
|
-
note = `The requested ${asked} was not found on this page, so the auto-detected hero was returned instead. ` +
|
|
513
|
-
'Pick the part the user wants from `sections` (call again with its selector) or pass section=' +
|
|
514
|
-
`${NAMED_SECTIONS.filter(s => s !== 'hero').join('|')}. Tell the user which section you returned.`
|
|
515
|
-
}
|
|
516
|
-
const { body, isJson } = result
|
|
517
|
-
// Compact JSON: pretty-printing spends ~30% more model context on whitespace.
|
|
518
|
-
if (!isJson) return body
|
|
519
|
-
return JSON.stringify(note && body && typeof body === 'object' && !Array.isArray(body) ? { ...body, note } : body)
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Prompts: the one MCP primitive a user sees BEFORE the first call. Claude Code
|
|
523
|
-
// lists them as /mcp__miromiro__<name>, VS Code as /miromiro.<name>, Cursor in
|
|
524
|
-
// its prompt menu. Same text as the remote server.
|
|
525
|
-
const PROMPT_URL_ARG = { name: 'url', description: 'The website URL, e.g. stripe.com', required: true }
|
|
526
|
-
const PROMPTS = [
|
|
527
|
-
{
|
|
528
|
-
name: 'clone_section',
|
|
529
|
-
title: 'Clone a section of a live site',
|
|
530
|
-
description: 'Rebuild a section of a real website (hero by default) as paste-ready code in the framework you use, with its real colors, fonts and spacing.',
|
|
531
|
-
arguments: [
|
|
532
|
-
PROMPT_URL_ARG,
|
|
533
|
-
{ name: 'section', description: `Which part: ${NAMED_SECTIONS.join(', ')} (default hero)` },
|
|
534
|
-
{ name: 'framework', description: 'tailwind (default), html, jsx or vue' }
|
|
535
|
-
],
|
|
536
|
-
render: ({ url, section, framework }) =>
|
|
537
|
-
`Rebuild the ${section || 'hero'} section of ${url} as ${framework || 'tailwind'} code I can paste into my project. ` +
|
|
538
|
-
`Call extract_code with url="${url}"${section ? `, section="${section}"` : ''}${framework ? `, framework="${framework}"` : ''} ` +
|
|
539
|
-
'and adapt the result: keep its real colors, fonts, spacing and copy, fix anything that is clearly page-specific, and tell me which theme (light or dark) the extraction returned. ' +
|
|
540
|
-
'If the result is thin or carries a hint, retry with render=true before giving up. Then list the other sections the page has in case I want one of those next.'
|
|
541
|
-
},
|
|
542
|
-
{
|
|
543
|
-
name: 'brand_kit',
|
|
544
|
-
title: 'Brand kit from a URL',
|
|
545
|
-
description: "A site's brand identity - role colors with names, fonts, logo, socials - explained, not dumped, with a note on how much was declared vs inferred.",
|
|
546
|
-
arguments: [PROMPT_URL_ARG],
|
|
547
|
-
render: ({ url }) =>
|
|
548
|
-
`Give me the brand kit for ${url}: primary, accent, background and text colors (hex plus names), the fonts by role, the best logo URL and social profiles. ` +
|
|
549
|
-
`Call extract_brand with url="${url}". Read signals.explanation and tell me whether the colors were declared by the site or inferred from usage; if they were inferred, say so plainly. ` +
|
|
550
|
-
'Finish with one line on what I could extract next (a section as code, or the full token set).'
|
|
551
|
-
},
|
|
552
|
-
{
|
|
553
|
-
name: 'design_tokens',
|
|
554
|
-
title: 'Design tokens from a URL',
|
|
555
|
-
description: "A site's complete token system as a paste-ready block: CSS custom properties, a Tailwind theme, or a W3C Design Tokens file.",
|
|
556
|
-
arguments: [
|
|
557
|
-
PROMPT_URL_ARG,
|
|
558
|
-
{ name: 'format', description: 'css (default), tailwind, dtcg or json' }
|
|
559
|
-
],
|
|
560
|
-
render: ({ url, format }) =>
|
|
561
|
-
`Extract the design tokens of ${url} as ${format || 'css'}. Call extract_design_tokens with url="${url}", format="${format || 'css'}". ` +
|
|
562
|
-
'Return the block ready to paste, then summarise the system in a few lines: the palette by role, the type scale, the spacing and radius scale. ' +
|
|
563
|
-
'If colorSchemes lists both light and dark, say that the values are the union of the two and that extract_code with theme=dark separates them.'
|
|
564
|
-
}
|
|
565
|
-
]
|
|
566
|
-
|
|
567
|
-
// Server-level guidance, sent once at initialize. Mirrors the `instructions`
|
|
568
|
-
// string the remote server (miromiro.app/mcp) returns, so a stdio client gets
|
|
569
|
-
// the same steering as a hosted one — chiefly: explain results, don't just dump
|
|
570
|
-
// them, and never present inferred brand colors as declared ones.
|
|
571
|
-
const INSTRUCTIONS =
|
|
572
|
-
'Use MiroMiro when the user wants anything from a real, live website\'s design: rebuild, clone, copy or ' +
|
|
573
|
-
'restyle a section, or "make mine look like X" (extract_code); its brand colors, fonts and logo (extract_brand); ' +
|
|
574
|
-
'its complete design-token set as CSS variables or a W3C tokens file (extract_design_tokens); or its images, ' +
|
|
575
|
-
'SVG icons, font files, videos, audio or Lottie files (the extract_* asset tools). Any public URL works, no setup. ' +
|
|
576
|
-
'Not for: general web scraping or search, page text, pages behind a login, or CSS questions with no specific site. ' +
|
|
577
|
-
'How to call: give extract_code the URL plus section=hero|header|footer|pricing|features|testimonials|faq|cta when ' +
|
|
578
|
-
'the user named a part of the page; never invent a CSS selector (a miss falls back to the hero with a `note`). Every ' +
|
|
579
|
-
'result lists the page\'s other `sections` with ready selectors for follow-ups. Static HTML+CSS is read by default: when a result is thin, carries a ' +
|
|
580
|
-
'`hint`, or a 403 target_blocked comes back, retry the SAME url with render=true (+5 credits) before telling the ' +
|
|
581
|
-
'user a site cannot be extracted. Theme: a site with a prefers-color-scheme dark variant returns its light branch ' +
|
|
582
|
-
'by default; pass theme=dark to extract_code for the dark version and report the `theme` field you actually got. ' +
|
|
583
|
-
'extract_design_tokens returns values and counts, not layout - not a substitute for extract_code on a rebuild; ' +
|
|
584
|
-
'format=dtcg gives a W3C Design Tokens file. Explain results, do not dump them: extract_brand\'s ' +
|
|
585
|
-
'signals.explanation says whether the colors were declared by the site or inferred (primarySource "usage" = a ' +
|
|
586
|
-
'guess) - repeat it to the user. Rendered calls accept screenshot=true for a public image URL of the page. ' +
|
|
587
|
-
'fresh=true bypasses the 24h cache. Every result carries `usage` (credits left; `packCredits` = never-expiring pack balance when the account holds one).'
|
|
588
|
-
|
|
589
|
-
const server = new Server(
|
|
590
|
-
{ name: 'miromiro', version: '0.7.
|
|
591
|
-
{ capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS }
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
595
|
-
prompts: PROMPTS.map(({ name, title, description, arguments: args }) => ({ name, title, description, arguments: args }))
|
|
596
|
-
}))
|
|
597
|
-
|
|
598
|
-
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
599
|
-
const prompt = PROMPTS.find(p => p.name === req.params.name)
|
|
600
|
-
if (!prompt) throw new Error(`Unknown prompt: ${req.params.name}`)
|
|
601
|
-
const args = req.params.arguments || {}
|
|
602
|
-
const missing = prompt.arguments.filter(a => a.required && !args[a.name])
|
|
603
|
-
if (missing.length) throw new Error(`Missing required argument: ${missing.map(a => a.name).join(', ')}`)
|
|
604
|
-
return {
|
|
605
|
-
description: prompt.description,
|
|
606
|
-
messages: [{ role: 'user', content: { type: 'text', text: prompt.render(args) } }]
|
|
607
|
-
}
|
|
608
|
-
})
|
|
609
|
-
|
|
610
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
611
|
-
tools: TOOLS.map(({ name, title, description, inputSchema, outputSchema }) => ({
|
|
612
|
-
name,
|
|
613
|
-
title,
|
|
614
|
-
description,
|
|
615
|
-
inputSchema,
|
|
616
|
-
...(outputSchema ? { outputSchema } : {}),
|
|
617
|
-
annotations: { title, ...TOOL_ANNOTATIONS }
|
|
618
|
-
}))
|
|
619
|
-
}))
|
|
620
|
-
|
|
621
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
622
|
-
if (!API_KEY) {
|
|
623
|
-
return {
|
|
624
|
-
isError: true,
|
|
625
|
-
content: [{ type: 'text', text: 'MIROMIRO_API_KEY is not set. Get a free key at https://miromiro.app/api/dashboard and set it in your MCP config env.' }]
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
const tool = TOOLS.find(t => t.name === req.params.name)
|
|
629
|
-
if (!tool) {
|
|
630
|
-
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }] }
|
|
631
|
-
}
|
|
632
|
-
const args = req.params.arguments || {}
|
|
633
|
-
if (!args.url) {
|
|
634
|
-
return { isError: true, content: [{ type: 'text', text: 'The "url" argument is required.' }] }
|
|
635
|
-
}
|
|
636
|
-
try {
|
|
637
|
-
const out = await callApi(tool.endpoint, args)
|
|
638
|
-
// A tool that declares an outputSchema must also return structuredContent
|
|
639
|
-
// matching it (2025-06-18 spec), and structuredContent must be an object.
|
|
640
|
-
// format=css|tailwind on the tokens endpoint returns a raw code string —
|
|
641
|
-
// wrap it as { output }, which that tool's outputSchema declares.
|
|
642
|
-
let structured
|
|
643
|
-
try {
|
|
644
|
-
const parsed = JSON.parse(out)
|
|
645
|
-
structured = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { output: parsed }
|
|
646
|
-
} catch {
|
|
647
|
-
structured = { output: out } // non-JSON body (css/tailwind block)
|
|
648
|
-
}
|
|
649
|
-
return { content: [{ type: 'text', text: out }], structuredContent: structured }
|
|
650
|
-
} catch (err) {
|
|
651
|
-
return { isError: true, content: [{ type: 'text', text: String(err.message || err) }] }
|
|
652
|
-
}
|
|
653
|
-
})
|
|
654
|
-
|
|
655
|
-
const transport = new StdioServerTransport()
|
|
656
|
-
await server.connect(transport)
|
|
657
|
-
console.error('miromiro-mcp running on stdio')
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MiroMiro MCP server (playbook §3).
|
|
4
|
+
*
|
|
5
|
+
* Exposes the MiroMiro design-extraction API as MCP tools so a coding agent
|
|
6
|
+
* (Cursor / Claude Code / Windsurf) can pull the real design of any URL and
|
|
7
|
+
* build UI in that style — instead of guessing at colors and fonts.
|
|
8
|
+
*
|
|
9
|
+
* Auth: set MIROMIRO_API_KEY in the environment (get one free, no card, at
|
|
10
|
+
* https://miromiro.app/api/dashboard).
|
|
11
|
+
*
|
|
12
|
+
* Run: MIROMIRO_API_KEY=mm_live_… npx miromiro-mcp
|
|
13
|
+
* Or wire it into your agent's MCP config (see README).
|
|
14
|
+
*/
|
|
15
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
16
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
17
|
+
import {
|
|
18
|
+
CallToolRequestSchema,
|
|
19
|
+
ListToolsRequestSchema,
|
|
20
|
+
ListPromptsRequestSchema,
|
|
21
|
+
GetPromptRequestSchema
|
|
22
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
23
|
+
|
|
24
|
+
const API_BASE = process.env.MIROMIRO_API_BASE || 'https://miromiro.app/api/v1'
|
|
25
|
+
const API_KEY = process.env.MIROMIRO_API_KEY
|
|
26
|
+
|
|
27
|
+
// Tool → endpoint map. Each tool takes a single `url` (design_tokens also takes
|
|
28
|
+
// an optional `format`). Descriptions are written for the agent, so it knows
|
|
29
|
+
// WHEN to reach for each one.
|
|
30
|
+
// Every tool is a read against the public web: no writes, retry-safe (cached
|
|
31
|
+
// repeats), open-world. Clients use readOnlyHint to label tools as safe.
|
|
32
|
+
const TOOL_ANNOTATIONS = {
|
|
33
|
+
readOnlyHint: true,
|
|
34
|
+
destructiveHint: false,
|
|
35
|
+
idempotentHint: true,
|
|
36
|
+
openWorldHint: true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Sections an agent can ask for by name instead of inventing a CSS selector
|
|
40
|
+
// (mirrors NAMED_SECTIONS in the API's extractCode.ts).
|
|
41
|
+
const NAMED_SECTIONS = ['hero', 'header', 'footer', 'pricing', 'features', 'testimonials', 'faq', 'cta']
|
|
42
|
+
|
|
43
|
+
// Shared input arg: every tool accepts render=true to execute the page's
|
|
44
|
+
// JavaScript in a real (rented) browser before extraction - the fix for
|
|
45
|
+
// client-rendered React/Next/SPA sites that return little statically.
|
|
46
|
+
const RENDER_ARG = {
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
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.'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Shared input arg: skip the 24h cache for this one call (charged normally).
|
|
52
|
+
const FRESH_ARG = {
|
|
53
|
+
type: 'boolean',
|
|
54
|
+
description: 'Skip the 24-hour cache and re-extract the live page - use when the site just changed and a cached result would be stale. Charged normally; the fresh result replaces the cached copy.'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Shared input arg: a screenshot URL alongside the data (requires render=true).
|
|
58
|
+
const SCREENSHOT_ARG = {
|
|
59
|
+
type: ['boolean', 'string'],
|
|
60
|
+
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."
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Shared fragments for the output schemas below, mirrored from the remote
|
|
64
|
+
// server (server/utils/mcpRemote.ts). Every schema keeps additionalProperties
|
|
65
|
+
// true and requires only `url`: the /v1 responses can carry extras (usage,
|
|
66
|
+
// note, truncation_note) and a strict schema that drifts from the
|
|
67
|
+
// implementation would make clients reject valid results.
|
|
68
|
+
const OUT_USAGE = { type: 'object', description: 'Credit usage for the authed key (spent, remaining, monthly limit).' }
|
|
69
|
+
const OUT_TRUNCATION = { type: 'string', description: 'Present when the result was trimmed - says what was dropped and how to get more.' }
|
|
70
|
+
const countedList = (valueKey, desc) => ({
|
|
71
|
+
type: 'array',
|
|
72
|
+
description: desc,
|
|
73
|
+
items: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: { [valueKey]: { type: 'string' }, count: { type: 'number', description: 'How many times it is used on the page' } }
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const TOOLS = [
|
|
80
|
+
{
|
|
81
|
+
name: 'extract_code',
|
|
82
|
+
title: 'Extract section as code',
|
|
83
|
+
endpoint: 'code',
|
|
84
|
+
description:
|
|
85
|
+
"Use when the user asks to rebuild, clone, copy, port, restyle or 'make it look like' part of a real website, or wants a site's section as Tailwind, HTML+CSS, React (JSX) or Vue. Give it the URL and, when the user named a part of the page, section (hero, header, footer, pricing, features, testimonials, faq, cta) - never invent a CSS selector. With neither, it auto-detects the hero; every call also returns `sections`, each other extractable section with a label and a ready selector for a follow-up call. Returns a self-contained component with the page's real resolved styles (true colors, fonts, spacing, copy) plus the tokens and assets it uses, so you adapt real values instead of estimating from a screenshot. For jsx/vue, repeated structures collapse into a data array rendered with .map()/v-for; ::before/::after come back as real elements; @keyframes ship in the css field. The `theme` field says which theme the code actually is - sites with a prefers-color-scheme dark variant return the light branch unless you pass theme='dark'; say which you returned. Not for: whole-page scraping, page text, or sites that need a login. Reads static HTML/CSS by default; if the result is thin or carries a `hint`, call again with render=true (+5 credits).",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
url: { type: 'string', description: 'The website URL, e.g. stripe.com' },
|
|
90
|
+
section: { type: 'string', enum: NAMED_SECTIONS, description: "Which part of the page, by name - use this when the user asked for the pricing, footer, header/nav, features, testimonials, FAQ or call-to-action block. Resolved on the page itself from its ids, classes, headings and, failing those, its content (prices, blockquotes, FAQ items), so it works without seeing the markup and on pages that label nothing. Omit for the hero. If the page has no such section you get the hero plus a `note` listing what is there." },
|
|
91
|
+
selector: { type: 'string', description: 'CSS selector for the section, ONLY when you have one from a previous result\'s `sections` list (e.g. "section.pricing-grid"). Do not guess selectors: a selector that matches nothing falls back to the hero with a `note`, and the guess costs a call. Prefer `section` for named parts of a page.' },
|
|
92
|
+
framework: { type: 'string', enum: ['tailwind', 'html', 'jsx', 'vue'], description: 'The code to generate (default tailwind).' },
|
|
93
|
+
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.' },
|
|
94
|
+
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." },
|
|
95
|
+
render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
|
|
96
|
+
},
|
|
97
|
+
required: ['url']
|
|
98
|
+
},
|
|
99
|
+
outputSchema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
url: { type: 'string' },
|
|
103
|
+
selector: { type: 'string', description: 'The CSS selector that was extracted' },
|
|
104
|
+
section: { type: 'string', description: 'The named section that was resolved, when one was requested' },
|
|
105
|
+
autoDetected: { type: 'boolean', description: 'True when the hero was auto-detected rather than passed in' },
|
|
106
|
+
note: { type: 'string', description: 'Present when the requested selector or section was not found and the hero was returned instead - says what was asked for and which sections the page does have. Tell the user.' },
|
|
107
|
+
sections: {
|
|
108
|
+
type: 'array',
|
|
109
|
+
description: 'Every extractable section on the page - call again with one of these selectors for a specific part',
|
|
110
|
+
items: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
selector: { type: 'string' },
|
|
114
|
+
label: { type: 'string' },
|
|
115
|
+
elements: { type: 'number' },
|
|
116
|
+
hasHeading: { type: 'boolean' },
|
|
117
|
+
preview: { type: 'string' }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
framework: { type: 'string', description: 'tailwind | html | jsx | vue' },
|
|
122
|
+
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' },
|
|
123
|
+
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)' },
|
|
124
|
+
tokens: { type: 'object', description: 'Design tokens the extracted section actually uses' },
|
|
125
|
+
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" },
|
|
126
|
+
assets: { description: 'Images/fonts the section references' },
|
|
127
|
+
summary: { description: 'What was extracted, at a glance. styledCoverage (0-100) is the quality signal: the % of elements the cascade found styles for' },
|
|
128
|
+
hint: { type: 'string', description: 'Only present when the extraction looks degraded (client-rendered page, runtime-injected styles) - says what to do: on a static call usually retry with render=true; on a render=true call the styles could not be resolved even in a browser, do not retry' },
|
|
129
|
+
usage: OUT_USAGE,
|
|
130
|
+
truncation_note: OUT_TRUNCATION
|
|
131
|
+
},
|
|
132
|
+
required: ['url'],
|
|
133
|
+
additionalProperties: true
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'extract_brand',
|
|
138
|
+
title: 'Extract brand identity',
|
|
139
|
+
endpoint: 'brand',
|
|
140
|
+
description:
|
|
141
|
+
"Use when the user wants a site's brand: its colors, fonts, logo, theme or social links - 'what colors does stripe use', 'match this brand', 'build something in the style of X'. Returns role colors (primary, accent, background, text) as hex with human-readable names, the wider palette, fonts by role, logo candidates, social profiles, dark/light theme, and a 0-1 confidence score with `signals` explaining where the colors came from: read signals.explanation and tell the user whether they were declared by the site ('css-var'/'theme-color') or inferred from usage frequency ('usage', a guess) - never present inferred colors as the official palette. Not for: markup or layout (use extract_code) or the full token list (use extract_design_tokens).",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: { url: { type: 'string', description: 'The website URL, e.g. stripe.com' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
145
|
+
required: ['url']
|
|
146
|
+
},
|
|
147
|
+
outputSchema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
url: { type: 'string' },
|
|
151
|
+
title: { type: ['string', 'null'], description: 'Page/og title - the brand name' },
|
|
152
|
+
description: { type: ['string', 'null'] },
|
|
153
|
+
siteName: { type: ['string', 'null'] },
|
|
154
|
+
themeColor: { type: ['string', 'null'], description: "The site's own declared theme-color" },
|
|
155
|
+
theme: { type: 'string', description: 'dark | light' },
|
|
156
|
+
colors: { type: 'object', description: 'Role colors as hex: primary, accent, background, text' },
|
|
157
|
+
colorNames: { type: 'object', description: 'Human-readable names for the role colors ("Royal Blue")' },
|
|
158
|
+
palette: {
|
|
159
|
+
type: 'array',
|
|
160
|
+
description: 'The wider brand palette',
|
|
161
|
+
items: { type: 'object', properties: { color: { type: 'string' }, count: { type: 'number' }, name: { type: ['string', 'null'] } } }
|
|
162
|
+
},
|
|
163
|
+
fonts: { type: 'array', items: { type: 'string' } },
|
|
164
|
+
typography: { type: 'object', description: 'Fonts split by role: primary, mono' },
|
|
165
|
+
logo: { type: ['string', 'null'], description: 'Best single logo URL' },
|
|
166
|
+
logos: { type: 'array', description: 'All icon/logo candidates, tagged by source' },
|
|
167
|
+
ogImage: { type: ['string', 'null'] },
|
|
168
|
+
socials: {
|
|
169
|
+
type: 'array',
|
|
170
|
+
description: 'Social profiles found on the page',
|
|
171
|
+
items: { type: 'object', properties: { platform: { type: 'string' }, url: { type: 'string' }, handle: { type: ['string', 'null'] } } }
|
|
172
|
+
},
|
|
173
|
+
confidence: { type: 'number', description: '0-1: how much the site declared vs how much was inferred' },
|
|
174
|
+
signals: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
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.",
|
|
177
|
+
properties: {
|
|
178
|
+
primarySource: { type: ['string', 'null'], description: "css-var | theme-color | logo | usage | null - where colors.primary came from, strongest first. 'usage' is inferred, not declared." },
|
|
179
|
+
declaredBackground: { type: 'boolean', description: 'The background role came from a declared CSS variable' },
|
|
180
|
+
declaredText: { type: 'boolean', description: 'The text role came from a declared CSS variable' },
|
|
181
|
+
declaredLogo: { type: 'boolean', description: 'A real logo was found, not just an og:image fallback' },
|
|
182
|
+
fontsFound: { type: 'number', description: 'How many font families resolved from the CSS cascade' },
|
|
183
|
+
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.' }
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
usage: OUT_USAGE,
|
|
187
|
+
truncation_note: OUT_TRUNCATION
|
|
188
|
+
},
|
|
189
|
+
required: ['url'],
|
|
190
|
+
additionalProperties: true
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'extract_design_tokens',
|
|
195
|
+
title: 'Extract design tokens',
|
|
196
|
+
endpoint: 'extract',
|
|
197
|
+
description:
|
|
198
|
+
"Use when the user wants a site's complete token system - every color, font family, size, weight, spacing value, radius, shadow, line height, gradient and breakpoint with usage counts - or a paste-ready :root block (format='css'), tailwind.config (format='tailwind') or W3C Design Tokens file (format='dtcg', for Style Dictionary / Figma variables). Returns values and counts only, not layout or which element uses what: if the user asked to rebuild or clone a section, use extract_code, which returns the markup with the values in place. A site shipping light and dark themes returns the union of both (see colorSchemes) - pick colors by role, not by frequency, and use extract_code with theme=dark for one theme on its own. Not for: a short brand summary (use extract_brand).",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {
|
|
202
|
+
url: { type: 'string', description: 'The website URL' },
|
|
203
|
+
format: { type: 'string', enum: ['json', 'css', 'tailwind', 'dtcg'], description: "Output shape (default json). 'dtcg' returns a W3C Design Tokens file." },
|
|
204
|
+
render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
|
|
205
|
+
},
|
|
206
|
+
required: ['url']
|
|
207
|
+
},
|
|
208
|
+
outputSchema: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
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).",
|
|
211
|
+
properties: {
|
|
212
|
+
url: { type: 'string' },
|
|
213
|
+
colors: countedList('color', 'Every color with usage count'),
|
|
214
|
+
fontFamilies: countedList('family', 'Font families with usage counts'),
|
|
215
|
+
fontSizes: countedList('size', 'Font sizes with usage counts'),
|
|
216
|
+
fontWeights: countedList('value', 'Font weights'),
|
|
217
|
+
spacing: countedList('value', 'The spacing scale from margin/padding/gap'),
|
|
218
|
+
borderRadiuses: countedList('value', 'Border radii'),
|
|
219
|
+
boxShadows: countedList('value', 'Box shadows'),
|
|
220
|
+
lineHeights: countedList('value', 'Line heights'),
|
|
221
|
+
gradients: { type: 'array', description: 'Gradients with type (linear/radial/conic) and count' },
|
|
222
|
+
breakpoints: countedList('value', 'Responsive breakpoints from media queries'),
|
|
223
|
+
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' },
|
|
224
|
+
summary: { type: 'object', description: 'Count per token group' },
|
|
225
|
+
output: { type: 'string', description: "The paste-ready :root{} or tailwind.config block when format='css'|'tailwind'" },
|
|
226
|
+
usage: OUT_USAGE,
|
|
227
|
+
truncation_note: OUT_TRUNCATION
|
|
228
|
+
},
|
|
229
|
+
required: [],
|
|
230
|
+
additionalProperties: true
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'extract_assets',
|
|
235
|
+
title: 'Extract images',
|
|
236
|
+
endpoint: 'images',
|
|
237
|
+
description:
|
|
238
|
+
"Use when the user wants the images from a page - photos, illustrations, backgrounds, favicon, social image - as resolved URLs tagged by source (img, srcset, picture, CSS background, favicon, og). Not for: icons and vector logos (use extract_svgs) or fonts (use extract_fonts). If images are mounted by JavaScript, call again with render=true (+5 credits).",
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
242
|
+
required: ['url']
|
|
243
|
+
},
|
|
244
|
+
outputSchema: {
|
|
245
|
+
type: 'object',
|
|
246
|
+
properties: {
|
|
247
|
+
url: { type: 'string' },
|
|
248
|
+
images: {
|
|
249
|
+
type: 'array',
|
|
250
|
+
items: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
url: { type: 'string', description: 'Resolved absolute image URL' },
|
|
254
|
+
source: { type: 'string', description: 'img | srcset | picture | background | favicon | og' },
|
|
255
|
+
alt: { type: 'string', description: 'Alt text when present' }
|
|
256
|
+
},
|
|
257
|
+
required: ['url', 'source']
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
count: { type: 'number', description: 'Total images found (before any trimming)' },
|
|
261
|
+
usage: OUT_USAGE,
|
|
262
|
+
truncation_note: OUT_TRUNCATION
|
|
263
|
+
},
|
|
264
|
+
required: ['url'],
|
|
265
|
+
additionalProperties: true
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'extract_svgs',
|
|
270
|
+
title: 'Extract SVGs',
|
|
271
|
+
endpoint: 'svg',
|
|
272
|
+
description:
|
|
273
|
+
"Use when the user wants a site's icons or vector logos: inline SVGs as paste-ready markup plus external .svg file URLs. Not for: raster images (use extract_assets). If icons are mounted by JavaScript, call again with render=true (+5 credits).",
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: 'object',
|
|
276
|
+
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
277
|
+
required: ['url']
|
|
278
|
+
},
|
|
279
|
+
outputSchema: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
url: { type: 'string' },
|
|
283
|
+
svgs: {
|
|
284
|
+
type: 'array',
|
|
285
|
+
items: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
properties: {
|
|
288
|
+
type: { type: 'string', description: 'inline | file' },
|
|
289
|
+
code: { type: 'string', description: 'Sanitized paste-ready SVG markup (inline SVGs)' },
|
|
290
|
+
url: { type: 'string', description: 'File URL (external .svg references)' }
|
|
291
|
+
},
|
|
292
|
+
required: ['type']
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
count: { type: 'number', description: 'Total SVGs found (before any trimming)' },
|
|
296
|
+
usage: OUT_USAGE,
|
|
297
|
+
truncation_note: OUT_TRUNCATION
|
|
298
|
+
},
|
|
299
|
+
required: ['url'],
|
|
300
|
+
additionalProperties: true
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'extract_fonts',
|
|
305
|
+
title: 'Extract fonts',
|
|
306
|
+
endpoint: 'fonts',
|
|
307
|
+
description:
|
|
308
|
+
"Use when the user asks which fonts a site uses or wants to match its typography: every @font-face file with family/weight/style/format, Google Fonts links and preloads, grouped per family in `families` (pass fields='families' for just that). On a type-foundry site the families served are demo cuts (subset, features stripped), flagged demo:true with a note - pass that on, they are not the usable typeface. Not for: colors (use extract_brand) or code (use extract_code).",
|
|
309
|
+
inputSchema: {
|
|
310
|
+
type: 'object',
|
|
311
|
+
properties: {
|
|
312
|
+
url: { type: 'string', description: 'The website URL' },
|
|
313
|
+
fields: { type: 'string', description: "Optional comma-separated fields to return (e.g. 'families')" },
|
|
314
|
+
render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG
|
|
315
|
+
},
|
|
316
|
+
required: ['url']
|
|
317
|
+
},
|
|
318
|
+
outputSchema: {
|
|
319
|
+
type: 'object',
|
|
320
|
+
properties: {
|
|
321
|
+
url: { type: 'string' },
|
|
322
|
+
fonts: {
|
|
323
|
+
type: 'array',
|
|
324
|
+
description: 'Every font file / stylesheet found',
|
|
325
|
+
items: {
|
|
326
|
+
type: 'object',
|
|
327
|
+
properties: {
|
|
328
|
+
source: { type: 'string', description: 'font-face | google-fonts | preload' },
|
|
329
|
+
url: { type: 'string', description: 'The font file or stylesheet URL' },
|
|
330
|
+
family: { type: 'string' },
|
|
331
|
+
weight: { type: 'string', description: "'400', '700', or a variable range like '100 900'" },
|
|
332
|
+
style: { type: 'string' },
|
|
333
|
+
format: { type: 'string', description: 'woff2 | woff | ttf | otf | eot' }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
families: {
|
|
338
|
+
type: 'array',
|
|
339
|
+
description: 'Grouped per-family summary - what most callers want',
|
|
340
|
+
items: {
|
|
341
|
+
type: 'object',
|
|
342
|
+
properties: {
|
|
343
|
+
family: { type: 'string' },
|
|
344
|
+
source: { type: 'string' },
|
|
345
|
+
weights: { type: 'array', items: { type: 'string' } },
|
|
346
|
+
styles: { type: 'array', items: { type: 'string' } },
|
|
347
|
+
files: { type: 'number' },
|
|
348
|
+
demo: { type: 'boolean', description: 'The family name marks it a foundry demo/trial cut, not the real font' }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
googleFontsLinks: { type: 'array', items: { type: 'string' } },
|
|
353
|
+
count: { type: 'number' },
|
|
354
|
+
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' },
|
|
355
|
+
usage: OUT_USAGE,
|
|
356
|
+
truncation_note: OUT_TRUNCATION
|
|
357
|
+
},
|
|
358
|
+
required: ['url'],
|
|
359
|
+
additionalProperties: true
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
name: 'extract_lottie',
|
|
364
|
+
title: 'Extract Lottie animations',
|
|
365
|
+
endpoint: 'lottie',
|
|
366
|
+
description:
|
|
367
|
+
"Use when the user wants a site's Lottie or animation JSON files. Most Lotties are injected at runtime, so pass render=true (+5 credits) when the static call finds none.",
|
|
368
|
+
inputSchema: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
371
|
+
required: ['url']
|
|
372
|
+
},
|
|
373
|
+
outputSchema: {
|
|
374
|
+
type: 'object',
|
|
375
|
+
properties: {
|
|
376
|
+
url: { type: 'string' },
|
|
377
|
+
lotties: {
|
|
378
|
+
type: 'array',
|
|
379
|
+
items: {
|
|
380
|
+
type: 'object',
|
|
381
|
+
properties: {
|
|
382
|
+
url: { type: 'string', description: 'The animation JSON / .lottie file URL' },
|
|
383
|
+
source: { type: 'string', description: 'lottie-player | dotlottie-player | data-attr | json-ref' }
|
|
384
|
+
},
|
|
385
|
+
required: ['url']
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
count: { type: 'number' },
|
|
389
|
+
note: { type: 'string', description: 'Coverage caveat: only Lotties declared in the initial HTML are found' },
|
|
390
|
+
usage: OUT_USAGE,
|
|
391
|
+
truncation_note: OUT_TRUNCATION
|
|
392
|
+
},
|
|
393
|
+
required: ['url'],
|
|
394
|
+
additionalProperties: true
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
name: 'extract_videos',
|
|
399
|
+
title: 'Extract videos',
|
|
400
|
+
endpoint: 'videos',
|
|
401
|
+
description:
|
|
402
|
+
'Use when the user wants the videos on a page: <video>/<source> files, og:video, YouTube/Vimeo/Wistia/Loom embeds and direct video links, each tagged by source with a poster when declared. `source: "embed"` is a player page, not a downloadable file. Most players mount with JavaScript: if the result is thin or empty, call again with render=true (+5 credits).',
|
|
403
|
+
inputSchema: {
|
|
404
|
+
type: 'object',
|
|
405
|
+
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
406
|
+
required: ['url']
|
|
407
|
+
},
|
|
408
|
+
outputSchema: {
|
|
409
|
+
type: 'object',
|
|
410
|
+
properties: {
|
|
411
|
+
url: { type: 'string' },
|
|
412
|
+
videos: {
|
|
413
|
+
type: 'array',
|
|
414
|
+
items: {
|
|
415
|
+
type: 'object',
|
|
416
|
+
properties: {
|
|
417
|
+
url: { type: 'string', description: 'Resolved absolute video (or player page) URL' },
|
|
418
|
+
source: { type: 'string', description: 'video | source | og | embed | link. embed is a player page, not a file.' },
|
|
419
|
+
poster: { type: 'string', description: 'Poster image URL when the tag declared one' }
|
|
420
|
+
},
|
|
421
|
+
required: ['url', 'source']
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
count: { type: 'number', description: 'Total videos found (before any trimming)' },
|
|
425
|
+
note: { type: 'string', description: 'Coverage caveat: only videos declared in the initial HTML are found - use render=true for JS-mounted players' },
|
|
426
|
+
usage: OUT_USAGE,
|
|
427
|
+
truncation_note: OUT_TRUNCATION
|
|
428
|
+
},
|
|
429
|
+
required: ['url'],
|
|
430
|
+
additionalProperties: true
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
name: 'extract_audio',
|
|
435
|
+
title: 'Extract audio',
|
|
436
|
+
endpoint: 'audio',
|
|
437
|
+
description:
|
|
438
|
+
'Use when the user wants the audio files on a page: <audio>/<source> files, og:audio, podcast enclosures and download links, each tagged by source. If a JavaScript player mounts the audio at runtime, call again with render=true (+5 credits).',
|
|
439
|
+
inputSchema: {
|
|
440
|
+
type: 'object',
|
|
441
|
+
properties: { url: { type: 'string', description: 'The website URL' }, render: RENDER_ARG, fresh: FRESH_ARG, screenshot: SCREENSHOT_ARG },
|
|
442
|
+
required: ['url']
|
|
443
|
+
},
|
|
444
|
+
outputSchema: {
|
|
445
|
+
type: 'object',
|
|
446
|
+
properties: {
|
|
447
|
+
url: { type: 'string' },
|
|
448
|
+
audio: {
|
|
449
|
+
type: 'array',
|
|
450
|
+
items: {
|
|
451
|
+
type: 'object',
|
|
452
|
+
properties: {
|
|
453
|
+
url: { type: 'string', description: 'Resolved absolute audio file URL' },
|
|
454
|
+
source: { type: 'string', description: 'audio | source | og | link' }
|
|
455
|
+
},
|
|
456
|
+
required: ['url', 'source']
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
count: { type: 'number', description: 'Total audio files found (before any trimming)' },
|
|
460
|
+
note: { type: 'string', description: 'Coverage caveat: only audio declared in the initial HTML is found - use render=true for JS-mounted players' },
|
|
461
|
+
usage: OUT_USAGE,
|
|
462
|
+
truncation_note: OUT_TRUNCATION
|
|
463
|
+
},
|
|
464
|
+
required: ['url'],
|
|
465
|
+
additionalProperties: true
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
// Query params a tool may forward to the API, beyond `url`. Anything else in the
|
|
471
|
+
// tool arguments is ignored, so a hallucinated param can't reach the endpoint.
|
|
472
|
+
const FORWARDED_PARAMS = ['format', 'selector', 'section', 'framework', 'name', 'theme', 'fields', 'render', 'fresh', 'screenshot']
|
|
473
|
+
|
|
474
|
+
// A selector/section that matched nothing. The API answers 404 (free); the
|
|
475
|
+
// stdio server then falls back to the hero so the agent gets the `sections`
|
|
476
|
+
// list in the same turn instead of a dead end - mirrors the remote server.
|
|
477
|
+
const SELECTOR_MISS_RE = /^No (element matched|"[a-z]+" section was found)/i
|
|
478
|
+
|
|
479
|
+
async function fetchApi(endpoint, q) {
|
|
480
|
+
const res = await fetch(`${API_BASE}/${endpoint}?${q}`, {
|
|
481
|
+
headers: { Authorization: `Bearer ${API_KEY}` }
|
|
482
|
+
})
|
|
483
|
+
const isJson = (res.headers.get('content-type') || '').includes('application/json')
|
|
484
|
+
const body = isJson ? await res.json() : await res.text()
|
|
485
|
+
if (!res.ok) {
|
|
486
|
+
const msg = isJson ? (body.statusMessage || body.message || JSON.stringify(body)) : body
|
|
487
|
+
const err = new Error(`MiroMiro API ${res.status}: ${msg}`)
|
|
488
|
+
err.status = res.status
|
|
489
|
+
err.apiMessage = String(msg)
|
|
490
|
+
throw err
|
|
491
|
+
}
|
|
492
|
+
return { body, isJson }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function callApi(endpoint, args) {
|
|
496
|
+
const q = new URLSearchParams({ url: args.url })
|
|
497
|
+
for (const p of FORWARDED_PARAMS) {
|
|
498
|
+
if (args[p] !== undefined && args[p] !== null && args[p] !== '') {
|
|
499
|
+
q.set(p, String(args[p]))
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
let result
|
|
503
|
+
let note = null
|
|
504
|
+
try {
|
|
505
|
+
result = await fetchApi(endpoint, q)
|
|
506
|
+
} catch (err) {
|
|
507
|
+
const asked = q.get('selector') ? `selector "${q.get('selector')}"` : q.get('section') ? `section "${q.get('section')}"` : null
|
|
508
|
+
if (err.status !== 404 || !asked || !SELECTOR_MISS_RE.test(err.apiMessage || '')) throw err
|
|
509
|
+
q.delete('selector')
|
|
510
|
+
q.delete('section')
|
|
511
|
+
result = await fetchApi(endpoint, q)
|
|
512
|
+
note = `The requested ${asked} was not found on this page, so the auto-detected hero was returned instead. ` +
|
|
513
|
+
'Pick the part the user wants from `sections` (call again with its selector) or pass section=' +
|
|
514
|
+
`${NAMED_SECTIONS.filter(s => s !== 'hero').join('|')}. Tell the user which section you returned.`
|
|
515
|
+
}
|
|
516
|
+
const { body, isJson } = result
|
|
517
|
+
// Compact JSON: pretty-printing spends ~30% more model context on whitespace.
|
|
518
|
+
if (!isJson) return body
|
|
519
|
+
return JSON.stringify(note && body && typeof body === 'object' && !Array.isArray(body) ? { ...body, note } : body)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Prompts: the one MCP primitive a user sees BEFORE the first call. Claude Code
|
|
523
|
+
// lists them as /mcp__miromiro__<name>, VS Code as /miromiro.<name>, Cursor in
|
|
524
|
+
// its prompt menu. Same text as the remote server.
|
|
525
|
+
const PROMPT_URL_ARG = { name: 'url', description: 'The website URL, e.g. stripe.com', required: true }
|
|
526
|
+
const PROMPTS = [
|
|
527
|
+
{
|
|
528
|
+
name: 'clone_section',
|
|
529
|
+
title: 'Clone a section of a live site',
|
|
530
|
+
description: 'Rebuild a section of a real website (hero by default) as paste-ready code in the framework you use, with its real colors, fonts and spacing.',
|
|
531
|
+
arguments: [
|
|
532
|
+
PROMPT_URL_ARG,
|
|
533
|
+
{ name: 'section', description: `Which part: ${NAMED_SECTIONS.join(', ')} (default hero)` },
|
|
534
|
+
{ name: 'framework', description: 'tailwind (default), html, jsx or vue' }
|
|
535
|
+
],
|
|
536
|
+
render: ({ url, section, framework }) =>
|
|
537
|
+
`Rebuild the ${section || 'hero'} section of ${url} as ${framework || 'tailwind'} code I can paste into my project. ` +
|
|
538
|
+
`Call extract_code with url="${url}"${section ? `, section="${section}"` : ''}${framework ? `, framework="${framework}"` : ''} ` +
|
|
539
|
+
'and adapt the result: keep its real colors, fonts, spacing and copy, fix anything that is clearly page-specific, and tell me which theme (light or dark) the extraction returned. ' +
|
|
540
|
+
'If the result is thin or carries a hint, retry with render=true before giving up. Then list the other sections the page has in case I want one of those next.'
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: 'brand_kit',
|
|
544
|
+
title: 'Brand kit from a URL',
|
|
545
|
+
description: "A site's brand identity - role colors with names, fonts, logo, socials - explained, not dumped, with a note on how much was declared vs inferred.",
|
|
546
|
+
arguments: [PROMPT_URL_ARG],
|
|
547
|
+
render: ({ url }) =>
|
|
548
|
+
`Give me the brand kit for ${url}: primary, accent, background and text colors (hex plus names), the fonts by role, the best logo URL and social profiles. ` +
|
|
549
|
+
`Call extract_brand with url="${url}". Read signals.explanation and tell me whether the colors were declared by the site or inferred from usage; if they were inferred, say so plainly. ` +
|
|
550
|
+
'Finish with one line on what I could extract next (a section as code, or the full token set).'
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
name: 'design_tokens',
|
|
554
|
+
title: 'Design tokens from a URL',
|
|
555
|
+
description: "A site's complete token system as a paste-ready block: CSS custom properties, a Tailwind theme, or a W3C Design Tokens file.",
|
|
556
|
+
arguments: [
|
|
557
|
+
PROMPT_URL_ARG,
|
|
558
|
+
{ name: 'format', description: 'css (default), tailwind, dtcg or json' }
|
|
559
|
+
],
|
|
560
|
+
render: ({ url, format }) =>
|
|
561
|
+
`Extract the design tokens of ${url} as ${format || 'css'}. Call extract_design_tokens with url="${url}", format="${format || 'css'}". ` +
|
|
562
|
+
'Return the block ready to paste, then summarise the system in a few lines: the palette by role, the type scale, the spacing and radius scale. ' +
|
|
563
|
+
'If colorSchemes lists both light and dark, say that the values are the union of the two and that extract_code with theme=dark separates them.'
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
|
|
567
|
+
// Server-level guidance, sent once at initialize. Mirrors the `instructions`
|
|
568
|
+
// string the remote server (miromiro.app/mcp) returns, so a stdio client gets
|
|
569
|
+
// the same steering as a hosted one — chiefly: explain results, don't just dump
|
|
570
|
+
// them, and never present inferred brand colors as declared ones.
|
|
571
|
+
const INSTRUCTIONS =
|
|
572
|
+
'Use MiroMiro when the user wants anything from a real, live website\'s design: rebuild, clone, copy or ' +
|
|
573
|
+
'restyle a section, or "make mine look like X" (extract_code); its brand colors, fonts and logo (extract_brand); ' +
|
|
574
|
+
'its complete design-token set as CSS variables or a W3C tokens file (extract_design_tokens); or its images, ' +
|
|
575
|
+
'SVG icons, font files, videos, audio or Lottie files (the extract_* asset tools). Any public URL works, no setup. ' +
|
|
576
|
+
'Not for: general web scraping or search, page text, pages behind a login, or CSS questions with no specific site. ' +
|
|
577
|
+
'How to call: give extract_code the URL plus section=hero|header|footer|pricing|features|testimonials|faq|cta when ' +
|
|
578
|
+
'the user named a part of the page; never invent a CSS selector (a miss falls back to the hero with a `note`). Every ' +
|
|
579
|
+
'result lists the page\'s other `sections` with ready selectors for follow-ups. Static HTML+CSS is read by default: when a result is thin, carries a ' +
|
|
580
|
+
'`hint`, or a 403 target_blocked comes back, retry the SAME url with render=true (+5 credits) before telling the ' +
|
|
581
|
+
'user a site cannot be extracted. Theme: a site with a prefers-color-scheme dark variant returns its light branch ' +
|
|
582
|
+
'by default; pass theme=dark to extract_code for the dark version and report the `theme` field you actually got. ' +
|
|
583
|
+
'extract_design_tokens returns values and counts, not layout - not a substitute for extract_code on a rebuild; ' +
|
|
584
|
+
'format=dtcg gives a W3C Design Tokens file. Explain results, do not dump them: extract_brand\'s ' +
|
|
585
|
+
'signals.explanation says whether the colors were declared by the site or inferred (primarySource "usage" = a ' +
|
|
586
|
+
'guess) - repeat it to the user. Rendered calls accept screenshot=true for a public image URL of the page. ' +
|
|
587
|
+
'fresh=true bypasses the 24h cache. Every result carries `usage` (credits left; `packCredits` = never-expiring pack balance when the account holds one).'
|
|
588
|
+
|
|
589
|
+
const server = new Server(
|
|
590
|
+
{ name: 'miromiro', version: '0.7.2' },
|
|
591
|
+
{ capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS }
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
595
|
+
prompts: PROMPTS.map(({ name, title, description, arguments: args }) => ({ name, title, description, arguments: args }))
|
|
596
|
+
}))
|
|
597
|
+
|
|
598
|
+
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
599
|
+
const prompt = PROMPTS.find(p => p.name === req.params.name)
|
|
600
|
+
if (!prompt) throw new Error(`Unknown prompt: ${req.params.name}`)
|
|
601
|
+
const args = req.params.arguments || {}
|
|
602
|
+
const missing = prompt.arguments.filter(a => a.required && !args[a.name])
|
|
603
|
+
if (missing.length) throw new Error(`Missing required argument: ${missing.map(a => a.name).join(', ')}`)
|
|
604
|
+
return {
|
|
605
|
+
description: prompt.description,
|
|
606
|
+
messages: [{ role: 'user', content: { type: 'text', text: prompt.render(args) } }]
|
|
607
|
+
}
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
611
|
+
tools: TOOLS.map(({ name, title, description, inputSchema, outputSchema }) => ({
|
|
612
|
+
name,
|
|
613
|
+
title,
|
|
614
|
+
description,
|
|
615
|
+
inputSchema,
|
|
616
|
+
...(outputSchema ? { outputSchema } : {}),
|
|
617
|
+
annotations: { title, ...TOOL_ANNOTATIONS }
|
|
618
|
+
}))
|
|
619
|
+
}))
|
|
620
|
+
|
|
621
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
622
|
+
if (!API_KEY) {
|
|
623
|
+
return {
|
|
624
|
+
isError: true,
|
|
625
|
+
content: [{ type: 'text', text: 'MIROMIRO_API_KEY is not set. Get a free key at https://miromiro.app/api/dashboard and set it in your MCP config env.' }]
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const tool = TOOLS.find(t => t.name === req.params.name)
|
|
629
|
+
if (!tool) {
|
|
630
|
+
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }] }
|
|
631
|
+
}
|
|
632
|
+
const args = req.params.arguments || {}
|
|
633
|
+
if (!args.url) {
|
|
634
|
+
return { isError: true, content: [{ type: 'text', text: 'The "url" argument is required.' }] }
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const out = await callApi(tool.endpoint, args)
|
|
638
|
+
// A tool that declares an outputSchema must also return structuredContent
|
|
639
|
+
// matching it (2025-06-18 spec), and structuredContent must be an object.
|
|
640
|
+
// format=css|tailwind on the tokens endpoint returns a raw code string —
|
|
641
|
+
// wrap it as { output }, which that tool's outputSchema declares.
|
|
642
|
+
let structured
|
|
643
|
+
try {
|
|
644
|
+
const parsed = JSON.parse(out)
|
|
645
|
+
structured = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { output: parsed }
|
|
646
|
+
} catch {
|
|
647
|
+
structured = { output: out } // non-JSON body (css/tailwind block)
|
|
648
|
+
}
|
|
649
|
+
return { content: [{ type: 'text', text: out }], structuredContent: structured }
|
|
650
|
+
} catch (err) {
|
|
651
|
+
return { isError: true, content: [{ type: 'text', text: String(err.message || err) }] }
|
|
652
|
+
}
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
const transport = new StdioServerTransport()
|
|
656
|
+
await server.connect(transport)
|
|
657
|
+
console.error('miromiro-mcp running on stdio')
|