@runsnative/mcp-server 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/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @runsnative/mcp-server
2
+
3
+ RunsNative MCP server — gives any MCP-capable agent access to RunsNative component docs, foundations, design exercises, and theme inference.
4
+
5
+ Docs content is read from a local checkout of the [runsnative repo](https://github.com/KUKAMANGA-ENTERPRISES/runsnative) via `RUNSNATIVE_CONTENT_ROOT`. A cloud content API (no clone required, updates propagate without reinstalling) is in rollout; once live, the env var becomes optional and the server falls back to the cloud automatically.
6
+
7
+ ## Quick install
8
+
9
+ Add one block to your MCP client config, pointing `RUNSNATIVE_CONTENT_ROOT` at the `content/` directory of a runsnative checkout.
10
+
11
+ ### Claude Desktop
12
+
13
+ **macOS** — `~/Library/Application Support/Claude/claude_desktop_config.json`
14
+ **Windows** — `%APPDATA%\Claude\claude_desktop_config.json`
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "runsnative": {
20
+ "command": "npx",
21
+ "args": ["-y", "@runsnative/mcp-server"],
22
+ "env": {
23
+ "RUNSNATIVE_CONTENT_ROOT": "/path/to/runsnative/content"
24
+ }
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ ### Claude Code / Cursor (project-scoped)
31
+
32
+ Add `.mcp.json` to your project root:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "runsnative": {
38
+ "command": "npx",
39
+ "args": ["-y", "@runsnative/mcp-server"],
40
+ "env": {
41
+ "RUNSNATIVE_CONTENT_ROOT": "/path/to/runsnative/content"
42
+ }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ## Authoring skill
49
+
50
+ Once the MCP server is connected, load the RunsNative authoring skill to give your agent full component-authoring guidance. The skill is served at:
51
+
52
+ ```
53
+ https://runsnative.org/ai/authoring-skill
54
+ ```
55
+
56
+ Paste that URL into Claude's "Add custom skill" dialog, or add it to your agent's system prompt as a skill reference. The MCP tools and the authoring skill work together — the skill tells the agent *how* to author; the tools give it the live content.
57
+
58
+ ## Tools provided
59
+
60
+ | Tool | Description |
61
+ |---|---|
62
+ | `list_components` | List all available RunsNative components |
63
+ | `get_component` | Fetch usage, style, code, or accessibility docs for a component |
64
+ | `list_composition_patterns` | List composition patterns (recipes) for complete UI surfaces — sign-in form, dashboard shell, chat room, etc. |
65
+ | `get_composition_pattern` | Fetch the full pattern for a named recipe: preview spec, framework code examples, accessibility notes, customization guidance |
66
+ | `get_foundation` | Fetch a specific foundation doc |
67
+ | `get_step` | Fetch a specific step within an exercise |
68
+ | `search_docs` | Semantic search across all RunsNative docs |
69
+ | `list_exercises` | List available design exercises |
70
+ | `start_exercise` | Start a design exercise |
71
+ | `infer_theme` | Infer a RunsNative theme from a URL or description |
72
+ | `infer_brand_theme` | Infer a theme from brand imagery |
73
+ | `surface_preview` | Render a component fixture inside an MCP App iframe (RUN-477 sandbox verification, not a production surface) |
74
+
75
+ ## Environment variables
76
+
77
+ | Variable | Default | Description |
78
+ |---|---|---|
79
+ | `RUNSNATIVE_API_URL` | `https://api.runsnative.org/mcp` | Override the content API base URL (e.g. point at `wrangler dev` locally) |
80
+ | `RUNSNATIVE_CACHE_DIR` | `~/.runsnative/cache` | Override the local content cache directory |
81
+ | `RUNSNATIVE_CONTENT_ROOT` | *(unset)* | Set to use local content instead of the cloud API. For developers working in the RunsNative repo |
82
+ | `RUNSNATIVE_TENANT_TOKEN` | *(unset)* | Bearer token for tenant-specific content access |
83
+
84
+ ## Requirements
85
+
86
+ - Node.js 18 or later
87
+
88
+ ## For RunsNative repo developers
89
+
90
+ If you're working inside the RunsNative repo, use the `.mcp.json` at the repo root. It points at `packages/mcp-server/dist/index.js` directly and sets `RUNSNATIVE_CONTENT_ROOT` so you get live content from your working tree without hitting the cloud API.
91
+
92
+ Build first:
93
+
94
+ ```bash
95
+ cd packages/mcp-server && npm run build
96
+ ```
@@ -0,0 +1,324 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const VALID_TABS = ['usage', 'style', 'code', 'accessibility'];
6
+ // Content store root — same directory the dotorg-site reads from.
7
+ // fileURLToPath handles Windows drive letters correctly (avoids /C:/C:/ doubling).
8
+ const CONTENT_ROOT = fileURLToPath(new URL('../../../content', import.meta.url));
9
+ export function validateContentRoot() {
10
+ if (!existsSync(CONTENT_ROOT)) {
11
+ throw new Error(`Content store not found at ${CONTENT_ROOT}. ` +
12
+ `Run from the runsnative repo root or set RUNSNATIVE_CONTENT_ROOT.`);
13
+ }
14
+ }
15
+ export function isValidTab(tab) {
16
+ return VALID_TABS.includes(tab);
17
+ }
18
+ export async function getComponent(name, tab) {
19
+ if (!isValidTab(tab)) {
20
+ throw new RangeError(`Unknown tab "${tab}". Valid tabs: ${VALID_TABS.join(', ')}.`);
21
+ }
22
+ const filePath = join(CONTENT_ROOT, 'components', name, `${tab}.md`);
23
+ if (!existsSync(filePath)) {
24
+ throw new RangeError(`Component "${name}" has no "${tab}" tab. ` +
25
+ `Check content/components/${name}/${tab}.md exists and status is "ready".`);
26
+ }
27
+ return readFile(filePath, 'utf8');
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Frontmatter parser — no external deps; handles simple key: value pairs.
31
+ // Supports string, number, and bare boolean values. Does not handle YAML arrays.
32
+ // ---------------------------------------------------------------------------
33
+ export function parseFrontmatter(raw) {
34
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
35
+ if (!match)
36
+ return {};
37
+ const result = {};
38
+ for (const line of match[1].split(/\r?\n/)) {
39
+ const colon = line.indexOf(':');
40
+ if (colon === -1)
41
+ continue;
42
+ const key = line.slice(0, colon).trim();
43
+ const value = line.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
44
+ if (key)
45
+ result[key] = value;
46
+ }
47
+ return result;
48
+ }
49
+ export async function listComponentMeta(includeDrafts = false) {
50
+ const componentsDir = join(CONTENT_ROOT, 'components');
51
+ if (!existsSync(componentsDir))
52
+ return [];
53
+ const entries = await readdir(componentsDir, { withFileTypes: true });
54
+ const results = [];
55
+ for (const entry of entries) {
56
+ if (!entry.isDirectory())
57
+ continue;
58
+ const indexPath = join(componentsDir, entry.name, 'index.md');
59
+ if (!existsSync(indexPath))
60
+ continue;
61
+ const raw = await readFile(indexPath, 'utf8');
62
+ const fm = parseFrontmatter(raw);
63
+ const status = fm.status ?? 'unknown';
64
+ if (!includeDrafts && status !== 'ready')
65
+ continue;
66
+ results.push({
67
+ name: entry.name,
68
+ title: fm.title ?? entry.name,
69
+ element_tag: fm.element_tag ?? `run-${entry.name}`,
70
+ surface: fm.surface ?? 'unknown',
71
+ status,
72
+ purpose: fm.purpose ?? '',
73
+ });
74
+ }
75
+ return results.sort((a, b) => a.name.localeCompare(b.name));
76
+ }
77
+ export async function getFoundation(name) {
78
+ const filePath = join(CONTENT_ROOT, 'foundations', `${name}.md`);
79
+ if (!existsSync(filePath)) {
80
+ throw new RangeError(`Foundation "${name}" not found. ` +
81
+ `Check content/foundations/${name}.md exists.`);
82
+ }
83
+ return readFile(filePath, 'utf8');
84
+ }
85
+ export async function listFoundations() {
86
+ const foundationsDir = join(CONTENT_ROOT, 'foundations');
87
+ if (!existsSync(foundationsDir))
88
+ return [];
89
+ const entries = await readdir(foundationsDir);
90
+ const results = [];
91
+ for (const entry of entries) {
92
+ if (!entry.endsWith('.md'))
93
+ continue;
94
+ const filePath = join(foundationsDir, entry);
95
+ const raw = await readFile(filePath, 'utf8');
96
+ const fm = parseFrontmatter(raw);
97
+ const name = entry.replace(/\.md$/, '');
98
+ results.push({
99
+ foundation: name,
100
+ title: fm.title ?? name,
101
+ description: fm.description ?? '',
102
+ });
103
+ }
104
+ return results.sort((a, b) => a.foundation.localeCompare(b.foundation));
105
+ }
106
+ function tokenize(text) {
107
+ return text.toLowerCase().match(/[a-z0-9_-]+/g) ?? [];
108
+ }
109
+ function excerpt(text, query, maxLen = 100) {
110
+ const tokens = tokenize(query);
111
+ const lower = text.toLowerCase();
112
+ let bestIdx = 0;
113
+ let bestScore = 0;
114
+ for (const token of tokens) {
115
+ const idx = lower.indexOf(token);
116
+ if (idx !== -1 && lower.split(token).length > bestScore) {
117
+ bestScore = lower.split(token).length;
118
+ bestIdx = Math.max(0, idx - 20);
119
+ }
120
+ }
121
+ const slice = text.slice(bestIdx, bestIdx + maxLen).replace(/\s+/g, ' ').trim();
122
+ return bestIdx > 0 ? `…${slice}` : slice;
123
+ }
124
+ function scoreDoc(text, queryTokens) {
125
+ const tokens = tokenize(text);
126
+ const freq = {};
127
+ for (const t of tokens)
128
+ freq[t] = (freq[t] ?? 0) + 1;
129
+ let score = 0;
130
+ for (const qt of queryTokens) {
131
+ if (freq[qt])
132
+ score += 1 + Math.log(freq[qt]);
133
+ }
134
+ return score;
135
+ }
136
+ export async function searchDocs(query, limit = 5) {
137
+ if (!query.trim())
138
+ return [];
139
+ const queryTokens = tokenize(query);
140
+ const candidates = [];
141
+ // Index component tabs
142
+ const componentsDir = join(CONTENT_ROOT, 'components');
143
+ if (existsSync(componentsDir)) {
144
+ const componentDirs = await readdir(componentsDir, { withFileTypes: true });
145
+ for (const dir of componentDirs) {
146
+ if (!dir.isDirectory())
147
+ continue;
148
+ for (const tab of VALID_TABS) {
149
+ const filePath = join(componentsDir, dir.name, `${tab}.md`);
150
+ if (!existsSync(filePath))
151
+ continue;
152
+ const text = await readFile(filePath, 'utf8');
153
+ const score = scoreDoc(text, queryTokens);
154
+ if (score > 0) {
155
+ candidates.push({
156
+ type: 'component',
157
+ name: dir.name,
158
+ tab,
159
+ excerpt: excerpt(text, query),
160
+ score,
161
+ });
162
+ }
163
+ }
164
+ }
165
+ }
166
+ // Index foundations
167
+ const foundationsDir = join(CONTENT_ROOT, 'foundations');
168
+ if (existsSync(foundationsDir)) {
169
+ const entries = await readdir(foundationsDir);
170
+ for (const entry of entries) {
171
+ if (!entry.endsWith('.md'))
172
+ continue;
173
+ const filePath = join(foundationsDir, entry);
174
+ const text = await readFile(filePath, 'utf8');
175
+ const score = scoreDoc(text, queryTokens);
176
+ if (score > 0) {
177
+ candidates.push({
178
+ type: 'foundation',
179
+ name: entry.replace(/\.md$/, ''),
180
+ excerpt: excerpt(text, query),
181
+ score,
182
+ });
183
+ }
184
+ }
185
+ }
186
+ return candidates
187
+ .sort((a, b) => b.score - a.score)
188
+ .slice(0, limit);
189
+ }
190
+ export async function listRecipeMeta() {
191
+ const recipesDir = join(CONTENT_ROOT, 'recipes');
192
+ if (!existsSync(recipesDir))
193
+ return [];
194
+ const entries = await readdir(recipesDir);
195
+ const results = [];
196
+ for (const entry of entries) {
197
+ if (!entry.endsWith('.md'))
198
+ continue;
199
+ if (entry === '_index.md')
200
+ continue;
201
+ const filePath = join(recipesDir, entry);
202
+ const raw = await readFile(filePath, 'utf8');
203
+ const fm = parseFrontmatter(raw);
204
+ const name = entry.replace(/\.md$/, '');
205
+ results.push({
206
+ name,
207
+ title: fm['title'] ?? name,
208
+ group: fm['recipe-group'] ?? '',
209
+ status: fm['status'] ?? 'unknown',
210
+ });
211
+ }
212
+ return results.sort((a, b) => a.name.localeCompare(b.name));
213
+ }
214
+ export async function getRecipe(name) {
215
+ const filePath = join(CONTENT_ROOT, 'recipes', `${name}.md`);
216
+ if (!existsSync(filePath)) {
217
+ const available = (await listRecipeMeta()).map(r => r.name).join(', ');
218
+ throw new RangeError(`Recipe "${name}" not found. Available patterns: ${available || 'none'}.`);
219
+ }
220
+ return readFile(filePath, 'utf8');
221
+ }
222
+ function stripFrontmatter(raw) {
223
+ const match = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
224
+ if (!match)
225
+ return raw;
226
+ return raw.slice(match[0].length);
227
+ }
228
+ function extractSection(text, heading) {
229
+ // Split on ## boundaries, find the matching section, return its body.
230
+ // Regex lookahead approach breaks in /m mode because $ matches every line end.
231
+ const sections = text.split(/(?=^## )/m);
232
+ for (const section of sections) {
233
+ const headingLine = section.split(/\r?\n/)[0] ?? '';
234
+ if (headingLine.trim() === `## ${heading}`) {
235
+ const newlineIdx = section.indexOf('\n');
236
+ return newlineIdx !== -1 ? section.slice(newlineIdx).trim() : '';
237
+ }
238
+ }
239
+ return '';
240
+ }
241
+ export async function listExerciseMeta(includeDrafts = false) {
242
+ const exercisesDir = join(CONTENT_ROOT, 'exercises');
243
+ if (!existsSync(exercisesDir))
244
+ return [];
245
+ const entries = await readdir(exercisesDir, { withFileTypes: true });
246
+ const results = [];
247
+ for (const entry of entries) {
248
+ if (!entry.isDirectory())
249
+ continue;
250
+ const indexPath = join(exercisesDir, entry.name, 'index.md');
251
+ if (!existsSync(indexPath))
252
+ continue;
253
+ const raw = await readFile(indexPath, 'utf8');
254
+ const fm = parseFrontmatter(raw);
255
+ const status = fm.status ?? 'unknown';
256
+ if (!includeDrafts && status !== 'ready')
257
+ continue;
258
+ results.push({
259
+ name: entry.name,
260
+ title: fm.title ?? entry.name,
261
+ tagline: fm.tagline ?? '',
262
+ activity_type: fm.activity_type ?? 'unknown',
263
+ difficulty: fm.difficulty ?? 'unknown',
264
+ estimated_minutes: parseInt(fm.estimated_minutes ?? '0', 10),
265
+ status,
266
+ });
267
+ }
268
+ return results.sort((a, b) => a.name.localeCompare(b.name));
269
+ }
270
+ export async function getExerciseDetail(name) {
271
+ const exercisesDir = join(CONTENT_ROOT, 'exercises');
272
+ const indexPath = join(exercisesDir, name, 'index.md');
273
+ if (!existsSync(indexPath)) {
274
+ throw new RangeError(`Exercise "${name}" not found. Check content/exercises/${name}/index.md exists.`);
275
+ }
276
+ const raw = await readFile(indexPath, 'utf8');
277
+ const fm = parseFrontmatter(raw);
278
+ const body = stripFrontmatter(raw);
279
+ const stepsDir = join(exercisesDir, name, 'steps');
280
+ const steps = [];
281
+ if (existsSync(stepsDir)) {
282
+ const stepFiles = (await readdir(stepsDir)).filter(f => f.endsWith('.md')).sort();
283
+ for (const file of stepFiles) {
284
+ const stepRaw = await readFile(join(stepsDir, file), 'utf8');
285
+ const stepFm = parseFrontmatter(stepRaw);
286
+ steps.push({
287
+ step: parseInt(stepFm.step ?? '0', 10),
288
+ slug: stepFm.slug ?? file.replace(/^\d+-/, '').replace(/\.md$/, ''),
289
+ title: stepFm.title ?? file,
290
+ });
291
+ }
292
+ }
293
+ steps.sort((a, b) => a.step - b.step);
294
+ return {
295
+ exercise: name,
296
+ title: fm.title ?? name,
297
+ overview: body.trim(),
298
+ steps,
299
+ };
300
+ }
301
+ export async function getExerciseStep(name, step) {
302
+ const exercisesDir = join(CONTENT_ROOT, 'exercises');
303
+ const stepsDir = join(exercisesDir, name, 'steps');
304
+ if (!existsSync(stepsDir)) {
305
+ throw new RangeError(`Exercise "${name}" not found.`);
306
+ }
307
+ const stepFiles = (await readdir(stepsDir)).filter(f => f.endsWith('.md')).sort();
308
+ const targetFile = stepFiles.find(f => /^(\d+)-/.exec(f)?.[1] === String(step));
309
+ if (!targetFile) {
310
+ throw new RangeError(`Step ${step} not found in exercise "${name}". ` +
311
+ `Valid steps: 1–${stepFiles.length}.`);
312
+ }
313
+ const raw = await readFile(join(stepsDir, targetFile), 'utf8');
314
+ const fm = parseFrontmatter(raw);
315
+ const body = stripFrontmatter(raw);
316
+ const goal = extractSection(body, 'Goal');
317
+ return {
318
+ exercise: name,
319
+ step,
320
+ title: fm.title ?? targetFile,
321
+ goal,
322
+ content: body.trim(),
323
+ };
324
+ }
@@ -0,0 +1,213 @@
1
+ import { createServer as createHttpServer } from 'node:http';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { join, dirname } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { runInferencePipeline } from './inferrer/pipeline.js';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+ async function callGenerateTokenBundle(config) {
10
+ const mod = await import('../../components/shared/token-engine.js');
11
+ return mod.generateTokenBundle(config);
12
+ }
13
+ async function getBackend() {
14
+ return import('../../brand-toolkit-backend/dist/index.js');
15
+ }
16
+ async function getSchema() {
17
+ return import('../../components/shared/brand-toolkit-config.js');
18
+ }
19
+ // ── Helpers ───────────────────────────────────────────────────────────────────
20
+ function readBody(req) {
21
+ return new Promise((resolve, reject) => {
22
+ const chunks = [];
23
+ req.on('data', (chunk) => chunks.push(chunk));
24
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
25
+ req.on('error', reject);
26
+ });
27
+ }
28
+ function sendJson(res, status, body) {
29
+ const json = JSON.stringify(body, null, 2);
30
+ res.writeHead(status, {
31
+ 'Content-Type': 'application/json',
32
+ 'Access-Control-Allow-Origin': '*',
33
+ });
34
+ res.end(json);
35
+ }
36
+ async function buildBrandToolkitConfig(body) {
37
+ const now = new Date().toISOString();
38
+ const candidate = {
39
+ id: randomUUID(),
40
+ customerId: body['customerId'],
41
+ brandId: body['brandId'],
42
+ parentConfigId: null,
43
+ theme: body['theme'],
44
+ style: body['style'] ?? 'default',
45
+ icons: body['icons'] ?? 'material',
46
+ audio: body['audio'] ?? { level: 'none' },
47
+ voice: body['voice'] ?? { type: 'preset', preset: 'professional' },
48
+ imagery: body['imagery'] ?? { involvement: 'supplied' },
49
+ createdAt: now,
50
+ updatedAt: now,
51
+ };
52
+ const { BrandToolkitConfigSchema } = await getSchema();
53
+ const result = BrandToolkitConfigSchema.safeParse(candidate);
54
+ if (!result.success) {
55
+ return {
56
+ ok: false,
57
+ status: 422,
58
+ body: {
59
+ error: 'Invalid brand toolkit config',
60
+ violations: result.error.issues.map(i => i.message),
61
+ },
62
+ };
63
+ }
64
+ return { ok: true, config: result.data };
65
+ }
66
+ function buildResolvedConfig(theme, customerId, brandId, sourceConfigId) {
67
+ return {
68
+ customerId,
69
+ brandId,
70
+ sourceConfigId,
71
+ theme,
72
+ style: 'default',
73
+ icons: 'material',
74
+ audio: { level: 'none' },
75
+ voice: { type: 'preset', preset: 'professional' },
76
+ imagery: { involvement: 'supplied' },
77
+ };
78
+ }
79
+ // ── Request handler ───────────────────────────────────────────────────────────
80
+ async function handler(req, res) {
81
+ const method = req.method ?? 'GET';
82
+ const url = req.url ?? '/';
83
+ if (method === 'GET' && (url === '/' || url === '/brand-toolkit')) {
84
+ try {
85
+ const htmlPath = join(__dirname, '../../showcase/brand-toolkit.html');
86
+ const html = readFileSync(htmlPath, 'utf8');
87
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
88
+ res.end(html);
89
+ }
90
+ catch {
91
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
92
+ res.end('brand-toolkit.html not found. Ensure packages/showcase/brand-toolkit.html exists.');
93
+ }
94
+ return;
95
+ }
96
+ if (method === 'OPTIONS') {
97
+ res.writeHead(204, {
98
+ 'Access-Control-Allow-Origin': '*',
99
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
100
+ 'Access-Control-Allow-Headers': 'Content-Type',
101
+ });
102
+ res.end();
103
+ return;
104
+ }
105
+ if (method === 'POST' && url === '/api/infer') {
106
+ let body;
107
+ try {
108
+ body = JSON.parse(await readBody(req));
109
+ }
110
+ catch {
111
+ return sendJson(res, 400, { error: 'Request body must be valid JSON' });
112
+ }
113
+ if (typeof body !== 'object' || body === null ||
114
+ typeof body['url'] !== 'string') {
115
+ return sendJson(res, 400, { error: '{ url: string } required' });
116
+ }
117
+ const targetUrl = body['url'];
118
+ const result = await runInferencePipeline(targetUrl);
119
+ if (!result.ok)
120
+ return sendJson(res, 422, { error: result.error.message, code: result.error.code });
121
+ return sendJson(res, 200, result.result);
122
+ }
123
+ if (method === 'POST' && url === '/api/config') {
124
+ let body;
125
+ try {
126
+ body = JSON.parse(await readBody(req));
127
+ }
128
+ catch {
129
+ return sendJson(res, 400, { error: 'Request body must be valid JSON' });
130
+ }
131
+ const result = await buildBrandToolkitConfig(body);
132
+ if (!result.ok)
133
+ return sendJson(res, result.status, result.body);
134
+ const backend = await getBackend();
135
+ await backend.saveConfig(result.config);
136
+ return sendJson(res, 200, result.config);
137
+ }
138
+ if (method === 'GET' && url.startsWith('/api/config/')) {
139
+ const pathPart = url.split('?')[0];
140
+ const segments = pathPart.split('/').filter(Boolean);
141
+ // Expect: ['api', 'config', customerId, brandId]
142
+ if (segments.length !== 4) {
143
+ return sendJson(res, 400, { error: 'Expected /api/config/:customerId/:brandId' });
144
+ }
145
+ const customerId = decodeURIComponent(segments[2]);
146
+ const brandId = decodeURIComponent(segments[3]);
147
+ const backend = await getBackend();
148
+ const config = await backend.loadConfig(customerId, brandId);
149
+ if (!config)
150
+ return sendJson(res, 404, { error: 'not found' });
151
+ return sendJson(res, 200, config);
152
+ }
153
+ if (method === 'POST' && url === '/api/generate') {
154
+ let body;
155
+ try {
156
+ body = JSON.parse(await readBody(req));
157
+ }
158
+ catch {
159
+ return sendJson(res, 400, { error: 'Request body must be valid JSON' });
160
+ }
161
+ const buildResult = await buildBrandToolkitConfig(body);
162
+ if (!buildResult.ok)
163
+ return sendJson(res, buildResult.status, buildResult.body);
164
+ const config = buildResult.config;
165
+ const backend = await getBackend();
166
+ await backend.saveConfig(config);
167
+ const resolved = buildResolvedConfig(config.theme, config.customerId, config.brandId, config.id);
168
+ const engineResult = await callGenerateTokenBundle(resolved);
169
+ if (!engineResult.ok) {
170
+ return sendJson(res, 422, { error: engineResult.error.message, code: engineResult.error.code });
171
+ }
172
+ await backend.saveBundle(engineResult.bundle);
173
+ return sendJson(res, 200, {
174
+ bundle: engineResult.bundle,
175
+ configId: config.id,
176
+ engineVersion: engineResult.bundle.engineVersion,
177
+ generatedAt: engineResult.bundle.generatedAt,
178
+ });
179
+ }
180
+ sendJson(res, 404, { error: `Unknown route: ${method} ${url}` });
181
+ }
182
+ // ── Server ────────────────────────────────────────────────────────────────────
183
+ export function startServer(port = 3201) {
184
+ const server = createHttpServer((req, res) => {
185
+ handler(req, res).catch(err => {
186
+ console.error('Unhandled handler error:', err);
187
+ if (!res.headersSent) {
188
+ res.writeHead(500, { 'Content-Type': 'application/json' });
189
+ res.end(JSON.stringify({ error: 'Internal server error' }));
190
+ }
191
+ });
192
+ });
193
+ server.listen(port, () => {
194
+ console.log(`RunsNative inferrer HTTP server listening on http://localhost:${port}`);
195
+ console.log(` UI: http://localhost:${port}/brand-toolkit`);
196
+ console.log(` Config: POST http://localhost:${port}/api/config { customerId, brandId, theme, ... }`);
197
+ console.log(` Generate: POST http://localhost:${port}/api/generate { customerId, brandId, theme, ... }`);
198
+ });
199
+ return server;
200
+ }
201
+ // ── Entry point ───────────────────────────────────────────────────────────────
202
+ const isMain = process.argv[1] === __filename;
203
+ if (isMain) {
204
+ const PORT = parseInt(process.env['INFERRER_PORT'] ?? '3201', 10);
205
+ const server = startServer(PORT);
206
+ async function shutdown() {
207
+ const backend = await getBackend();
208
+ await backend.closePool();
209
+ server.close();
210
+ }
211
+ process.on('SIGTERM', () => { shutdown().catch(console.error); });
212
+ process.on('SIGINT', () => { shutdown().catch(console.error); });
213
+ }