@moonarc/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js ADDED
@@ -0,0 +1,196 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { detail, findComponent, libraryChunks, loadCatalog, search, summary } from './catalog.js';
6
+ import { audit, formatAudit } from './audit.js';
7
+ import { formatMeasure, measureDist } from './measure.js';
8
+ import { compose } from './compose.js';
9
+ import { hasKey, licenseStatus } from './license.js';
10
+ import { installText } from './install.js';
11
+
12
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
13
+
14
+ // What each tool does to the world, for clients that ask before running one: every tool reads, none writes; the
15
+ // catalogue tools answer from a bundled file, get_usage asks Polar about the key, and the Pro tools read your files.
16
+ const READS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
17
+
18
+ const text = (t) => ({ content: [{ type: 'text', text: t }] });
19
+ const failure = (t) => ({ isError: true, content: [{ type: 'text', text: t }] });
20
+
21
+ /** Letters to change, add or drop to turn one name into the other. */
22
+ const distance = (a, b) => {
23
+ let row = [...Array(b.length + 1).keys()];
24
+ for (let i = 1; i <= a.length; i++) {
25
+ const next = [i];
26
+ for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
27
+ row = next;
28
+ }
29
+ return row[b.length];
30
+ };
31
+ /** Names close to one that is not in the catalogue: a misspelling of a name first, then the ranking search_motion uses. */
32
+ const closest = (cat, name) => {
33
+ const key = name.toLowerCase().replace(/[^a-z0-9]/g, '');
34
+ const typos = cat.components.filter((c) => distance(key, c.name.toLowerCase()) <= Math.max(1, Math.floor(key.length / 3))).map((c) => c.name);
35
+ return [...new Set([...typos, ...search(cat.components, name).map((e) => e.name)])].slice(0, 5);
36
+ };
37
+
38
+ export function createServer() {
39
+ const server = new McpServer({ name: 'moonarc', version });
40
+
41
+ server.registerTool(
42
+ 'search_motion',
43
+ {
44
+ title: 'Search the Moonarc catalogue',
45
+ description:
46
+ 'Find components for an intent ("fade in on scroll", "logo marquee", "typewriter"). Returns metadata only: name, description, tier, measured JS cost (raw and gzip; jsBytes is raw), page URL, best match first, at most `limit` of them (10 unless set) with the count of all matches. Filter by max_js_bytes (own JS, raw bytes) or tier: A has no script of its own, B its own script up to 1024 B raw, C up to 3072 B raw. Call get_component for source and craft notes.',
47
+ inputSchema: {
48
+ intent: z.string().describe('What the user wants, in plain words'),
49
+ max_js_bytes: z.number().int().nonnegative().optional().describe('Only components whose own JS is at most this many raw bytes'),
50
+ tier: z.enum(['A', 'B', 'C']).optional().describe('A: no script of its own; B: its own script up to 1024 B raw; C: up to 3072 B raw'),
51
+ limit: z.number().int().min(1).max(50).optional().describe('How many results to return, best first (default 10, at most 50)'),
52
+ },
53
+ annotations: READS,
54
+ },
55
+ async ({ intent, max_js_bytes, tier, limit = 10 }) => {
56
+ const cat = await loadCatalog();
57
+ const hits = search(cat.components, intent, { maxJsBytes: max_js_bytes, tier });
58
+ if (hits.length === 0) return text(`No component answers "${intent}". The catalogue is deliberately small (${cat.components.length} components); see ${cat.site}/components/ for what exists, or build it from the runtime: ${cat.site}/docs/runtime/`);
59
+ return text(JSON.stringify({ runtimeSharedBytes: cat.runtime?.raw ?? null, matched: hits.length, results: hits.slice(0, limit).map(summary) }, null, 2));
60
+ },
61
+ );
62
+
63
+ server.registerTool(
64
+ 'get_component',
65
+ {
66
+ title: 'Get a component: source, props, craft',
67
+ description: 'Full source, props table, reduced-motion branch, ClientRouter behaviour and the craft decisions (easing, duration, why) for one component. Use the exact name from search_motion (e.g. "Reveal").',
68
+ inputSchema: { name: z.string().describe('Component name or slug, e.g. Reveal or reveal') },
69
+ annotations: READS,
70
+ },
71
+ async ({ name }) => {
72
+ const cat = await loadCatalog();
73
+ const e = findComponent(cat, name);
74
+ if (!e) {
75
+ const near = closest(cat, name);
76
+ return failure(`Unknown component "${name}". ${near.length ? `Closest: ${near.join(', ')}.` : `Known: ${cat.components.map((c) => c.name).join(', ')}.`} Call search_motion to find one by intent.`);
77
+ }
78
+ return text(detail(e, cat.runtime));
79
+ },
80
+ );
81
+
82
+ server.registerTool(
83
+ 'get_install_command',
84
+ {
85
+ title: 'Get the install command',
86
+ description: 'Returns the command to run. The server never writes files. "package" (default) installs the integration once and you import per component; "copy" copies the component source into the project through the shadcn CLI, and the answer carries the components.json and the @/* alias that an Astro project needs for it. Every name must be in the catalogue: an unknown one fails the call and names the closest.',
87
+ inputSchema: { names: z.array(z.string()).min(1).describe('Component names'), method: z.enum(['package', 'copy']).optional() },
88
+ annotations: READS,
89
+ },
90
+ async ({ names, method = 'package' }) => {
91
+ const cat = await loadCatalog();
92
+ const found = names.map((n) => [n, findComponent(cat, n)]);
93
+ const unknown = found.filter(([, e]) => !e).map(([n]) => n);
94
+ // a command for some of the names would look complete: every name is known, or none is answered
95
+ if (unknown.length) {
96
+ const named = unknown.map((n) => { const near = closest(cat, n); return `"${n}"${near.length ? ` (closest: ${near.join(', ')})` : ''}`; });
97
+ return failure(`Not in the catalogue: ${named.join('; ')}. Call search_motion to find the right names, then ask again with all of them.`);
98
+ }
99
+ const entries = [...new Map(found.map(([, e]) => [e.name, e])).values()];
100
+ return text(installText(cat.site, entries, method));
101
+ },
102
+ );
103
+
104
+ server.registerTool(
105
+ 'get_usage',
106
+ {
107
+ title: 'Plan and quota',
108
+ description: 'Which plan this server runs under and whether Pro tools are available. Free is unlimited and needs no account. With a key it asks Polar once whether the key is valid. On subscription_required, do not retry: tell the user.',
109
+ inputSchema: {},
110
+ annotations: { ...READS, openWorldHint: true },
111
+ },
112
+ async () => {
113
+ const st = await licenseStatus();
114
+ const cat = await loadCatalog();
115
+ return text(JSON.stringify({ plan: st.plan, validated: st.validated ?? false, reason: st.reason ?? null, quota: 'unlimited (plan-level, not metered)', proTools: st.ok ? ['audit_motion', 'measure_budget', 'compose_section'] : [], upgrade: `${cat.site}/pro/` }, null, 2));
116
+ },
117
+ );
118
+
119
+ // Pro tools occupy agent context only when a key is present (capability gating).
120
+ if (hasKey) {
121
+ const gate = async () => {
122
+ const st = await licenseStatus();
123
+ if (st.ok) return null;
124
+ // a key Polar refused says subscription_required itself; a failure that says nothing about the key (no answer,
125
+ // a 5xx) is not a refusal, and the agent may try again later
126
+ return failure(/subscription_required/.test(st.reason ?? '') ? st.reason : `Pro tools are unavailable for now: ${st.reason}`);
127
+ };
128
+
129
+ server.registerTool(
130
+ 'audit_motion',
131
+ {
132
+ title: 'Audit motion in a codebase (Pro)',
133
+ description: 'Static findings against the motion critique rubric on your own files, 13 rules: transition: all, transitions and keyframes on layout properties, permanent will-change, animated blur, long UI durations, animation-timeline folded into the shorthand, missing reduced-motion branches, astro:page-load listeners without teardown, single-instance querySelector in component scripts, define:vars on scripts, ungated hover motion, scale(0). Reads style sheets, scripts and markup, never prose or comments. Line-accurate, with the fix and a severity (error, warn, info). Runs on .astro/.css/.scss/.ts/.tsx/.js/.jsx/.svelte/.vue/.html.',
134
+ inputSchema: { paths: z.array(z.string()).min(1).describe('Files or directories to audit, e.g. ["src"]'), format: z.enum(['text', 'json']).optional() },
135
+ annotations: READS,
136
+ },
137
+ async ({ paths, format = 'text' }) => {
138
+ const blocked = await gate();
139
+ if (blocked) return blocked;
140
+ try {
141
+ const r = audit(paths);
142
+ return text(format === 'json' ? JSON.stringify(r, null, 2) : formatAudit(r));
143
+ } catch (err) {
144
+ return failure(err.message);
145
+ }
146
+ },
147
+ );
148
+
149
+ server.registerTool(
150
+ 'measure_budget',
151
+ {
152
+ title: 'Measure per-route JS of a built site (Pro)',
153
+ description: 'The method of Moonarc\'s own CI: per page, every byte of JavaScript the browser loads (external scripts, inline scripts, modulepreloads, the import graph, the component and renderer of every framework island), with Astro\'s router and Moonarc\'s share broken out and framework runtimes named. JSON and import-map blocks are data and do not count. Pass the folder of a finished `astro build` (dist; a server build\'s dist/client is found by itself); the tool never builds for you. A file a page references that is not in the folder fails the call and names it.',
154
+ inputSchema: {
155
+ dist: z.string().describe('Path to the built output, e.g. "dist"'),
156
+ ceiling: z.number().int().positive().optional().describe('Raw bytes per route you consider acceptable'),
157
+ base: z.string().optional().describe('The base path the site is built with, when astro.config sets one (e.g. "/docs")'),
158
+ format: z.enum(['text', 'json']).optional(),
159
+ },
160
+ annotations: READS,
161
+ },
162
+ async ({ dist, ceiling, base, format = 'text' }) => {
163
+ const blocked = await gate();
164
+ if (blocked) return blocked;
165
+ try {
166
+ const r = measureDist(dist, { ceiling, base, library: libraryChunks(await loadCatalog()) });
167
+ return text(format === 'json' ? JSON.stringify({ ...r, routes: r.routes.map(({ assets, ...x }) => x) }, null, 2) : formatMeasure(r));
168
+ } catch (err) {
169
+ return failure(err.message);
170
+ }
171
+ },
172
+ );
173
+
174
+ server.registerTool(
175
+ 'compose_section',
176
+ {
177
+ title: 'Compose a section for a goal (Pro)',
178
+ description: 'An ordered composition of components for a stated goal ("premium dark SaaS hero", "logo strip", "feature grid", "testimonial wall", "stat row", "CTA", "FAQ") with the total measured cost, the imports and a code skeleton to adapt. A goal with no recipe says so and lists the goals it knows.',
179
+ inputSchema: { intent: z.string().describe('What the section is for') },
180
+ annotations: READS,
181
+ },
182
+ async ({ intent }) => {
183
+ const blocked = await gate();
184
+ if (blocked) return blocked;
185
+ return text(compose(intent, await loadCatalog()));
186
+ },
187
+ );
188
+ }
189
+
190
+ return server;
191
+ }
192
+
193
+ export async function start() {
194
+ const server = createServer();
195
+ await server.connect(new StdioServerTransport());
196
+ }