@ingenx-io/valets-schema-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.
Files changed (2) hide show
  1. package/index.js +367 -0
  2. package/package.json +34 -0
package/index.js ADDED
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @valets/schema MCP Server
4
+ *
5
+ * Exposes the Valets data model documentation to AI agents via the
6
+ * Model Context Protocol.
7
+ *
8
+ * Data strategy: fetch-first with bundled fallback.
9
+ * - If VALETS_SCHEMA_URL is set, fetches live data from the deployed
10
+ * Docusaurus site (static assets: /schemas.json, /llms.txt, etc.)
11
+ * - Falls back to bundled data/ directory when offline or URL not set
12
+ * - Fetched data is cached in memory with a configurable TTL (default 5 min)
13
+ *
14
+ * Tools:
15
+ * - schema_overview → llms.txt summary (start here)
16
+ * - get_schema → JSON Schema for a specific model or enum
17
+ * - get_doc → raw Markdown documentation for a model/enum/decision page
18
+ * - search_docs → keyword search across all doc pages
19
+ * - list_decisions → all data model decisions with status
20
+ * - get_decision → single decision detail
21
+ * - get_openapi → OpenAPI 3.1 spec (full or single component)
22
+ *
23
+ * Resources:
24
+ * - valets://schemas.json → full schema bundle
25
+ * - valets://llms.txt → LLM-optimized summary
26
+ * - valets://openapi.yaml → OpenAPI spec
27
+ * - valets://decisions.json → decision metadata
28
+ */
29
+
30
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
31
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
32
+ import { z } from "zod";
33
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
34
+ import { join, relative } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
38
+ const DATA_DIR = join(__dirname, "data");
39
+ const BUNDLED_STATIC = join(DATA_DIR, "static");
40
+ const BUNDLED_DOCS = join(DATA_DIR, "docs");
41
+
42
+ const BASE_URL = process.env.VALETS_SCHEMA_URL?.replace(/\/+$/, "") || "";
43
+ const CACHE_TTL_MS = parseInt(process.env.VALETS_CACHE_TTL || "300000", 10); // 5 min
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Fetch-first with bundled fallback
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const _cache = new Map(); // key → { data, ts }
50
+
51
+ /**
52
+ * Fetch a static asset from the deployed site, falling back to the
53
+ * bundled file. Results are cached in memory for CACHE_TTL_MS.
54
+ */
55
+ async function fetchOrRead(remotePath, localPath) {
56
+ const cacheKey = remotePath;
57
+ const cached = _cache.get(cacheKey);
58
+ if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
59
+ return cached.data;
60
+ }
61
+
62
+ // Try remote fetch
63
+ if (BASE_URL) {
64
+ try {
65
+ const url = `${BASE_URL}${remotePath}`;
66
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
67
+ if (res.ok) {
68
+ const data = await res.text();
69
+ _cache.set(cacheKey, { data, ts: Date.now() });
70
+ return data;
71
+ }
72
+ } catch {
73
+ // Network error — fall through to bundled
74
+ }
75
+ }
76
+
77
+ // Bundled fallback
78
+ if (existsSync(localPath)) {
79
+ const data = readFileSync(localPath, "utf8");
80
+ _cache.set(cacheKey, { data, ts: Date.now() });
81
+ return data;
82
+ }
83
+
84
+ throw new Error(`Cannot load ${remotePath}: no remote URL configured and bundled file not found at ${localPath}`);
85
+ }
86
+
87
+ // Convenience loaders for the four static assets
88
+ async function loadSchemas() {
89
+ const raw = await fetchOrRead("/schemas.json", join(BUNDLED_STATIC, "schemas.json"));
90
+ return JSON.parse(raw);
91
+ }
92
+
93
+ async function loadDecisions() {
94
+ const raw = await fetchOrRead("/decisions.json", join(BUNDLED_STATIC, "decisions.json"));
95
+ return JSON.parse(raw);
96
+ }
97
+
98
+ async function loadLlmsTxt() {
99
+ return fetchOrRead("/llms.txt", join(BUNDLED_STATIC, "llms.txt"));
100
+ }
101
+
102
+ async function loadOpenapi() {
103
+ return fetchOrRead("/openapi.yaml", join(BUNDLED_STATIC, "openapi.yaml"));
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Doc page loading (Markdown)
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /** Known doc directories served by Docusaurus as raw Markdown. */
111
+ const DOC_DIRS = ["models", "enums", "decisions", "collections", "triggers"];
112
+
113
+ /**
114
+ * Load a doc page by slug. Tries fetching the raw .md from the site's
115
+ * source path, falls back to bundled docs/.
116
+ */
117
+ async function loadDoc(slug) {
118
+ const localPath = join(BUNDLED_DOCS, `${slug}.md`);
119
+ // Docusaurus serves source Markdown at /docs/{slug} as HTML, not raw .md.
120
+ // So for docs we only use the bundled fallback (or a future raw endpoint).
121
+ if (existsSync(localPath)) {
122
+ return readFileSync(localPath, "utf8");
123
+ }
124
+ return null;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Helpers
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /** Recursively list all .md files under a directory. */
132
+ function listMarkdownFiles(dir) {
133
+ const results = [];
134
+ if (!existsSync(dir)) return results;
135
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
136
+ const full = join(dir, entry.name);
137
+ if (entry.isDirectory()) {
138
+ results.push(...listMarkdownFiles(full));
139
+ } else if (entry.name.endsWith(".md")) {
140
+ results.push(full);
141
+ }
142
+ }
143
+ return results;
144
+ }
145
+
146
+ /** Get a short slug from a markdown file path. */
147
+ function docSlug(filepath) {
148
+ return relative(BUNDLED_DOCS, filepath).replace(/\.md$/, "");
149
+ }
150
+
151
+ /** List all available doc slugs. */
152
+ function allDocSlugs() {
153
+ return listMarkdownFiles(BUNDLED_DOCS).map(docSlug);
154
+ }
155
+
156
+ /** Fuzzy match: check if all query words appear in the text. */
157
+ function fuzzyMatch(text, query) {
158
+ const words = query.toLowerCase().split(/\s+/);
159
+ const lower = text.toLowerCase();
160
+ return words.every((w) => lower.includes(w));
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Server setup
165
+ // ---------------------------------------------------------------------------
166
+
167
+ const server = new McpServer({
168
+ name: "@valets/schema",
169
+ version: "0.2.0",
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Tools
174
+ // ---------------------------------------------------------------------------
175
+
176
+ server.tool(
177
+ "schema_overview",
178
+ "Get a high-level overview of the entire Valets data model. Start here to understand what models and enums exist before drilling into specifics.",
179
+ {},
180
+ async () => ({
181
+ content: [{ type: "text", text: await loadLlmsTxt() }],
182
+ })
183
+ );
184
+
185
+ server.tool(
186
+ "get_schema",
187
+ "Get the JSON Schema definition for a specific model or enum. Use kebab-case names (e.g. 'order', 'payment-status', 'customer-payment').",
188
+ { name: z.string().describe("Schema name in kebab-case (e.g. 'order', 'payment-method', 'customer-payment-allocation')") },
189
+ async ({ name }) => {
190
+ const bundle = await loadSchemas();
191
+ const schema = bundle.schemas?.[name] || bundle.definitions?.[name];
192
+ if (!schema) {
193
+ const available = [
194
+ ...Object.keys(bundle.schemas || {}),
195
+ ...Object.keys(bundle.definitions || {}),
196
+ ];
197
+ return {
198
+ content: [{ type: "text", text: `Schema "${name}" not found.\n\nAvailable:\n${available.sort().map((n) => ` - ${n}`).join("\n")}` }],
199
+ isError: true,
200
+ };
201
+ }
202
+ return {
203
+ content: [{ type: "text", text: JSON.stringify(schema, null, 2) }],
204
+ };
205
+ }
206
+ );
207
+
208
+ server.tool(
209
+ "get_doc",
210
+ "Get the raw Markdown documentation for a schema page. Use slugs like 'models/order', 'enums/payment-status', 'decisions/summary'.",
211
+ { slug: z.string().describe("Document slug (e.g. 'models/order', 'enums/payment-method', 'decisions/summary')") },
212
+ async ({ slug }) => {
213
+ const content = await loadDoc(slug);
214
+ if (!content) {
215
+ const available = allDocSlugs();
216
+ return {
217
+ content: [{ type: "text", text: `Document "${slug}" not found.\n\nAvailable:\n${available.sort().map((s) => ` - ${s}`).join("\n")}` }],
218
+ isError: true,
219
+ };
220
+ }
221
+ return {
222
+ content: [{ type: "text", text: content }],
223
+ };
224
+ }
225
+ );
226
+
227
+ server.tool(
228
+ "search_docs",
229
+ "Search across all documentation pages for a keyword or phrase. Returns matching doc slugs with the first matching line for context.",
230
+ { query: z.string().describe("Search keyword or phrase (e.g. 'paymentMethod', 'loyalty', 'immutable')") },
231
+ async ({ query }) => {
232
+ const files = listMarkdownFiles(BUNDLED_DOCS);
233
+ const results = [];
234
+ for (const filepath of files) {
235
+ const content = readFileSync(filepath, "utf8");
236
+ if (fuzzyMatch(content, query)) {
237
+ const slug = docSlug(filepath);
238
+ const lines = content.split("\n");
239
+ const matchLine = lines.find((l) => fuzzyMatch(l, query)) || lines[0];
240
+ results.push({ slug, match: matchLine.trim().slice(0, 120) });
241
+ }
242
+ }
243
+ if (results.length === 0) {
244
+ return {
245
+ content: [{ type: "text", text: `No documents match "${query}".` }],
246
+ };
247
+ }
248
+ const text = results
249
+ .map((r) => `- **${r.slug}**: ${r.match}`)
250
+ .join("\n");
251
+ return {
252
+ content: [{ type: "text", text: `Found ${results.length} matching document(s):\n\n${text}` }],
253
+ };
254
+ }
255
+ );
256
+
257
+ server.tool(
258
+ "list_decisions",
259
+ "List all data model decisions (D00-D37+) with their status and summary.",
260
+ {},
261
+ async () => {
262
+ const data = await loadDecisions();
263
+ const lines = data.decisions.map((d) => {
264
+ return `- **${d.id}** [${d.status}] ${d.title}: ${d.decision}`;
265
+ });
266
+ return {
267
+ content: [{ type: "text", text: lines.join("\n") }],
268
+ };
269
+ }
270
+ );
271
+
272
+ server.tool(
273
+ "get_decision",
274
+ "Get full details for a specific data model decision by ID (e.g. 'D12', 'D22').",
275
+ { id: z.string().describe("Decision ID (e.g. 'D01', 'D12', 'D22')") },
276
+ async ({ id }) => {
277
+ const data = await loadDecisions();
278
+ const decision = data.decisions.find(
279
+ (d) => d.id.toUpperCase() === id.toUpperCase()
280
+ );
281
+ if (!decision) {
282
+ return {
283
+ content: [{ type: "text", text: `Decision "${id}" not found. Use list_decisions to see all available.` }],
284
+ isError: true,
285
+ };
286
+ }
287
+ return {
288
+ content: [{ type: "text", text: JSON.stringify(decision, null, 2) }],
289
+ };
290
+ }
291
+ );
292
+
293
+ server.tool(
294
+ "get_openapi",
295
+ "Get the OpenAPI 3.1 specification. Optionally filter to a single component schema by name (PascalCase, e.g. 'Order', 'CustomerPayment').",
296
+ { component: z.string().optional().describe("Optional PascalCase component name to extract (e.g. 'Order', 'OrderCreate', 'PaymentMethod')") },
297
+ async ({ component }) => {
298
+ const yaml = await loadOpenapi();
299
+ if (!component) {
300
+ return { content: [{ type: "text", text: yaml }] };
301
+ }
302
+ const marker = ` ${component}:`;
303
+ const lines = yaml.split("\n");
304
+ const startIdx = lines.findIndex((l) => l === marker);
305
+ if (startIdx === -1) {
306
+ return {
307
+ content: [{ type: "text", text: `Component "${component}" not found in OpenAPI spec.` }],
308
+ isError: true,
309
+ };
310
+ }
311
+ const block = [lines[startIdx]];
312
+ for (let i = startIdx + 1; i < lines.length; i++) {
313
+ if (lines[i].match(/^ [A-Z]/) && !lines[i].startsWith(" ")) break;
314
+ block.push(lines[i]);
315
+ }
316
+ return {
317
+ content: [{ type: "text", text: block.join("\n") }],
318
+ };
319
+ }
320
+ );
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Resources
324
+ // ---------------------------------------------------------------------------
325
+
326
+ server.resource(
327
+ "schemas-bundle",
328
+ "valets://schemas.json",
329
+ { description: "Consolidated JSON Schema bundle for all Valets models and enums", mimeType: "application/json" },
330
+ async () => ({
331
+ contents: [{ uri: "valets://schemas.json", text: await fetchOrRead("/schemas.json", join(BUNDLED_STATIC, "schemas.json")), mimeType: "application/json" }],
332
+ })
333
+ );
334
+
335
+ server.resource(
336
+ "llms-txt",
337
+ "valets://llms.txt",
338
+ { description: "LLM-optimized summary of the Valets data model", mimeType: "text/plain" },
339
+ async () => ({
340
+ contents: [{ uri: "valets://llms.txt", text: await loadLlmsTxt(), mimeType: "text/plain" }],
341
+ })
342
+ );
343
+
344
+ server.resource(
345
+ "openapi-spec",
346
+ "valets://openapi.yaml",
347
+ { description: "OpenAPI 3.1 specification for all Valets schemas", mimeType: "application/yaml" },
348
+ async () => ({
349
+ contents: [{ uri: "valets://openapi.yaml", text: await loadOpenapi(), mimeType: "application/yaml" }],
350
+ })
351
+ );
352
+
353
+ server.resource(
354
+ "decisions",
355
+ "valets://decisions.json",
356
+ { description: "Data model decisions metadata (D00-D37+)", mimeType: "application/json" },
357
+ async () => ({
358
+ contents: [{ uri: "valets://decisions.json", text: await fetchOrRead("/decisions.json", join(BUNDLED_STATIC, "decisions.json")), mimeType: "application/json" }],
359
+ })
360
+ );
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Start
364
+ // ---------------------------------------------------------------------------
365
+
366
+ const transport = new StdioServerTransport();
367
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@ingenx-io/valets-schema-mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server exposing @valets/schema documentation to AI agents",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "valets-schema-mcp": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "data/"
13
+ ],
14
+ "scripts": {
15
+ "bundle": "node bundle.js",
16
+ "prebuild": "node bundle.js",
17
+ "start": "node index.js"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.12.1",
21
+ "zod": "^4.3.6"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/IngenX-IO/valets-data-architecture.git",
29
+ "directory": "mcp-server"
30
+ },
31
+ "engines": {
32
+ "node": ">=20.0"
33
+ }
34
+ }