@sknoble/slvsx-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/mcp-server.js ADDED
@@ -0,0 +1,598 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MCP Server for SLVSX Constraint Solver
5
+ *
6
+ * Exposes the SLVSX constraint solver as an MCP server that can be used
7
+ * by Claude and other AI assistants to solve geometric constraints.
8
+ */
9
+
10
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import {
13
+ CallToolRequestSchema,
14
+ ListToolsRequestSchema
15
+ } from '@modelcontextprotocol/sdk/types.js';
16
+ import { execSync, spawn } from 'child_process';
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import * as os from 'os';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ // Get directory of this script
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+
26
+ // Check if slvsx binary exists
27
+ const SLVSX_BINARY = process.env.SLVSX_BINARY || './target/release/slvsx';
28
+
29
+ // Load documentation embeddings if available
30
+ let docsIndex = null;
31
+ let embedder = null;
32
+
33
+ const DOCS_INDEX_PATH = path.join(__dirname, 'dist', 'docs.json');
34
+ const EMBEDDING_MODEL = 'Xenova/all-MiniLM-L6-v2';
35
+
36
+ async function loadDocsIndex() {
37
+ if (fs.existsSync(DOCS_INDEX_PATH)) {
38
+ try {
39
+ const data = fs.readFileSync(DOCS_INDEX_PATH, 'utf-8');
40
+ const parsed = JSON.parse(data);
41
+ // Validate structure before assigning
42
+ if (!parsed.chunks || !Array.isArray(parsed.chunks)) {
43
+ throw new Error('Invalid docs.json structure: missing or invalid chunks array');
44
+ }
45
+ // Validate model matches runtime embedder
46
+ if (parsed.model && parsed.model !== EMBEDDING_MODEL) {
47
+ throw new Error(`Model mismatch: docs.json uses '${parsed.model}' but runtime uses '${EMBEDDING_MODEL}'. Rebuild with 'npm run build:docs'.`);
48
+ }
49
+ docsIndex = parsed;
50
+ console.error(`Loaded ${docsIndex.chunks.length} documentation chunks (model: ${EMBEDDING_MODEL})`);
51
+ } catch (e) {
52
+ docsIndex = null; // Reset to null on any error
53
+ console.error('Warning: Failed to load docs index:', e.message);
54
+ }
55
+ } else {
56
+ console.error('Note: docs.json not found. Run "npm run build:docs" to enable documentation search.');
57
+ }
58
+ }
59
+
60
+ async function getEmbedder() {
61
+ if (!embedder) {
62
+ const { pipeline } = await import('@xenova/transformers');
63
+ embedder = await pipeline('feature-extraction', EMBEDDING_MODEL);
64
+ }
65
+ return embedder;
66
+ }
67
+
68
+ function cosineSimilarity(a, b) {
69
+ let dotProduct = 0;
70
+ let normA = 0;
71
+ let normB = 0;
72
+ for (let i = 0; i < a.length; i++) {
73
+ dotProduct += a[i] * b[i];
74
+ normA += a[i] * a[i];
75
+ normB += b[i] * b[i];
76
+ }
77
+ return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
78
+ }
79
+
80
+ async function searchDocumentation(query, topK = 3) {
81
+ if (!docsIndex) {
82
+ return { error: 'Documentation index not loaded. Run "npm run build:docs" first.' };
83
+ }
84
+
85
+ try {
86
+ const embed = await getEmbedder();
87
+ const output = await embed(query, { pooling: 'mean', normalize: true });
88
+ const queryEmbedding = Array.from(output.data);
89
+
90
+ // Calculate similarities
91
+ const results = docsIndex.chunks.map(chunk => ({
92
+ ...chunk,
93
+ similarity: cosineSimilarity(queryEmbedding, chunk.embedding),
94
+ }));
95
+
96
+ // Sort by similarity and take top K
97
+ results.sort((a, b) => b.similarity - a.similarity);
98
+ const topResults = results.slice(0, topK);
99
+
100
+ return {
101
+ results: topResults.map(r => ({
102
+ source: r.source,
103
+ content: r.content,
104
+ score: r.similarity.toFixed(4),
105
+ })),
106
+ };
107
+ } catch (e) {
108
+ return { error: `Search failed: ${e.message}` };
109
+ }
110
+ }
111
+
112
+ class SlvsxServer {
113
+ constructor() {
114
+ this.server = new Server(
115
+ {
116
+ name: 'slvsx-mcp',
117
+ version: '0.1.0',
118
+ },
119
+ {
120
+ capabilities: {
121
+ tools: {},
122
+ },
123
+ }
124
+ );
125
+
126
+ this.setupHandlers();
127
+ }
128
+
129
+ setupHandlers() {
130
+ // List available tools
131
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
132
+ tools: [
133
+ {
134
+ name: 'solve_constraints',
135
+ description: 'Solve geometric constraints using SLVSX solver',
136
+ inputSchema: {
137
+ type: 'object',
138
+ properties: {
139
+ constraints: {
140
+ type: 'object',
141
+ description: 'JSON constraint document following SLVSX schema',
142
+ properties: {
143
+ schema: { type: 'string', enum: ['slvs-json/1'] },
144
+ units: { type: 'string', enum: ['mm', 'cm', 'm', 'in', 'ft'] },
145
+ parameters: { type: 'object' },
146
+ entities: { type: 'array' },
147
+ constraints: { type: 'array' }
148
+ },
149
+ required: ['schema', 'entities', 'constraints']
150
+ }
151
+ },
152
+ required: ['constraints'],
153
+ },
154
+ },
155
+ {
156
+ name: 'validate_constraints',
157
+ description: 'Validate a constraint document without solving',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ constraints: {
162
+ type: 'object',
163
+ description: 'JSON constraint document to validate',
164
+ }
165
+ },
166
+ required: ['constraints'],
167
+ },
168
+ },
169
+ {
170
+ name: 'export_to_svg',
171
+ description: 'Solve constraints and export result to SVG',
172
+ inputSchema: {
173
+ type: 'object',
174
+ properties: {
175
+ constraints: {
176
+ type: 'object',
177
+ description: 'JSON constraint document',
178
+ },
179
+ width: {
180
+ type: 'number',
181
+ description: 'SVG width in pixels',
182
+ default: 800
183
+ },
184
+ height: {
185
+ type: 'number',
186
+ description: 'SVG height in pixels',
187
+ default: 600
188
+ }
189
+ },
190
+ required: ['constraints'],
191
+ },
192
+ },
193
+ {
194
+ name: 'get_schema',
195
+ description: 'Get the JSON schema for constraint documents',
196
+ inputSchema: {
197
+ type: 'object',
198
+ properties: {},
199
+ },
200
+ },
201
+ {
202
+ name: 'create_example',
203
+ description: 'Create an example constraint document',
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ type: {
208
+ type: 'string',
209
+ description: 'Type of example to create',
210
+ enum: ['triangle', 'square', 'circle', 'linkage', 'parametric', '3d']
211
+ }
212
+ },
213
+ required: ['type'],
214
+ },
215
+ },
216
+ {
217
+ name: 'search_documentation',
218
+ description: 'Search SLVSX documentation for relevant information about using the constraint solver, JSON schema, examples, and best practices',
219
+ inputSchema: {
220
+ type: 'object',
221
+ properties: {
222
+ query: {
223
+ type: 'string',
224
+ description: 'Search query to find relevant documentation',
225
+ }
226
+ },
227
+ required: ['query'],
228
+ },
229
+ }
230
+ ],
231
+ }));
232
+
233
+ // Handle tool calls
234
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
235
+ const { name, arguments: args } = request.params;
236
+
237
+ try {
238
+ switch (name) {
239
+ case 'solve_constraints':
240
+ return await this.solveConstraints(args.constraints);
241
+
242
+ case 'validate_constraints':
243
+ return await this.validateConstraints(args.constraints);
244
+
245
+ case 'export_to_svg':
246
+ return await this.exportToSvg(args.constraints, args.width, args.height);
247
+
248
+ case 'get_schema':
249
+ return await this.getSchema();
250
+
251
+ case 'create_example':
252
+ return await this.createExample(args.type);
253
+
254
+ case 'search_documentation':
255
+ return await this.searchDocs(args.query);
256
+
257
+ default:
258
+ throw new Error(`Unknown tool: ${name}`);
259
+ }
260
+ } catch (error) {
261
+ return {
262
+ content: [
263
+ {
264
+ type: 'text',
265
+ text: `Error: ${error.message}`,
266
+ },
267
+ ],
268
+ };
269
+ }
270
+ });
271
+ }
272
+
273
+ async solveConstraints(constraints) {
274
+ // Write constraints to temp file
275
+ const tmpFile = path.join(os.tmpdir(), `slvsx-${Date.now()}.json`);
276
+ fs.writeFileSync(tmpFile, JSON.stringify(constraints, null, 2));
277
+
278
+ try {
279
+ // Run slvsx solve
280
+ const result = execSync(`${SLVSX_BINARY} solve ${tmpFile}`, {
281
+ encoding: 'utf-8',
282
+ maxBuffer: 10 * 1024 * 1024 // 10MB
283
+ });
284
+
285
+ // Parse the result
286
+ const lines = result.split('\n');
287
+ let jsonResult = '';
288
+ let inJson = false;
289
+
290
+ for (const line of lines) {
291
+ if (line.startsWith('{')) inJson = true;
292
+ if (inJson) jsonResult += line + '\n';
293
+ if (line.startsWith('}')) inJson = false;
294
+ }
295
+
296
+ const solved = jsonResult ? JSON.parse(jsonResult) : { error: 'No solution found' };
297
+
298
+ return {
299
+ content: [
300
+ {
301
+ type: 'text',
302
+ text: JSON.stringify(solved, null, 2),
303
+ },
304
+ ],
305
+ };
306
+ } finally {
307
+ // Clean up temp file
308
+ if (fs.existsSync(tmpFile)) {
309
+ fs.unlinkSync(tmpFile);
310
+ }
311
+ }
312
+ }
313
+
314
+ async validateConstraints(constraints) {
315
+ const tmpFile = path.join(os.tmpdir(), `slvsx-validate-${Date.now()}.json`);
316
+ fs.writeFileSync(tmpFile, JSON.stringify(constraints, null, 2));
317
+
318
+ try {
319
+ const result = execSync(`${SLVSX_BINARY} validate ${tmpFile}`, {
320
+ encoding: 'utf-8'
321
+ });
322
+
323
+ return {
324
+ content: [
325
+ {
326
+ type: 'text',
327
+ text: result.includes('✓') ? 'Valid constraint document' : result,
328
+ },
329
+ ],
330
+ };
331
+ } catch (error) {
332
+ return {
333
+ content: [
334
+ {
335
+ type: 'text',
336
+ text: `Validation failed: ${error.message}`,
337
+ },
338
+ ],
339
+ };
340
+ } finally {
341
+ if (fs.existsSync(tmpFile)) {
342
+ fs.unlinkSync(tmpFile);
343
+ }
344
+ }
345
+ }
346
+
347
+ async exportToSvg(constraints, width = 800, height = 600) {
348
+ const tmpInput = path.join(os.tmpdir(), `slvsx-input-${Date.now()}.json`);
349
+ const tmpOutput = path.join(os.tmpdir(), `slvsx-output-${Date.now()}.svg`);
350
+
351
+ fs.writeFileSync(tmpInput, JSON.stringify(constraints, null, 2));
352
+
353
+ try {
354
+ execSync(`${SLVSX_BINARY} export --format svg --output ${tmpOutput} ${tmpInput}`, {
355
+ encoding: 'utf-8'
356
+ });
357
+
358
+ const svg = fs.readFileSync(tmpOutput, 'utf-8');
359
+
360
+ return {
361
+ content: [
362
+ {
363
+ type: 'text',
364
+ text: svg,
365
+ },
366
+ ],
367
+ };
368
+ } finally {
369
+ if (fs.existsSync(tmpInput)) fs.unlinkSync(tmpInput);
370
+ if (fs.existsSync(tmpOutput)) fs.unlinkSync(tmpOutput);
371
+ }
372
+ }
373
+
374
+ async getSchema() {
375
+ try {
376
+ const schema = execSync(`${SLVSX_BINARY} schema`, {
377
+ encoding: 'utf-8'
378
+ });
379
+
380
+ return {
381
+ content: [
382
+ {
383
+ type: 'text',
384
+ text: schema,
385
+ },
386
+ ],
387
+ };
388
+ } catch (error) {
389
+ return {
390
+ content: [
391
+ {
392
+ type: 'text',
393
+ text: `Failed to get schema: ${error.message}`,
394
+ },
395
+ ],
396
+ };
397
+ }
398
+ }
399
+
400
+ async createExample(type) {
401
+ const examples = {
402
+ triangle: {
403
+ schema: 'slvs-json/1',
404
+ units: 'mm',
405
+ entities: [
406
+ { id: 'p1', type: 'Point', x: 0, y: 0 },
407
+ { id: 'p2', type: 'Point', x: 100, y: 0 },
408
+ { id: 'p3', type: 'Point', x: 50, y: 86.6 }
409
+ ],
410
+ constraints: [
411
+ { type: 'Fixed', entity: 'p1' },
412
+ { type: 'Distance', entities: ['p1', 'p2'], distance: 100 },
413
+ { type: 'Distance', entities: ['p2', 'p3'], distance: 100 },
414
+ { type: 'Distance', entities: ['p3', 'p1'], distance: 100 }
415
+ ]
416
+ },
417
+ square: {
418
+ schema: 'slvs-json/1',
419
+ units: 'mm',
420
+ entities: [
421
+ { id: 'p1', type: 'Point', x: 0, y: 0 },
422
+ { id: 'p2', type: 'Point', x: 100, y: 0 },
423
+ { id: 'p3', type: 'Point', x: 100, y: 100 },
424
+ { id: 'p4', type: 'Point', x: 0, y: 100 },
425
+ { id: 'l1', type: 'Line', points: ['p1', 'p2'] },
426
+ { id: 'l2', type: 'Line', points: ['p2', 'p3'] },
427
+ { id: 'l3', type: 'Line', points: ['p3', 'p4'] },
428
+ { id: 'l4', type: 'Line', points: ['p4', 'p1'] }
429
+ ],
430
+ constraints: [
431
+ { type: 'Fixed', entity: 'p1' },
432
+ { type: 'Fixed', entity: 'p2' },
433
+ { type: 'Perpendicular', entities: ['l1', 'l2'] },
434
+ { type: 'Perpendicular', entities: ['l2', 'l3'] },
435
+ { type: 'Perpendicular', entities: ['l3', 'l4'] },
436
+ { type: 'Equal', entities: ['l1', 'l2'] }
437
+ ]
438
+ },
439
+ circle: {
440
+ schema: 'slvs-json/1',
441
+ units: 'mm',
442
+ entities: [
443
+ { id: 'center', type: 'Point', x: 50, y: 50 },
444
+ { id: 'c1', type: 'Circle', center: 'center', radius: 30 }
445
+ ],
446
+ constraints: [
447
+ { type: 'Fixed', entity: 'center' },
448
+ { type: 'Radius', entity: 'c1', radius: 30 }
449
+ ]
450
+ },
451
+ linkage: {
452
+ schema: 'slvs-json/1',
453
+ units: 'mm',
454
+ parameters: {
455
+ input_angle: 45
456
+ },
457
+ entities: [
458
+ { id: 'ground1', type: 'Point', x: 0, y: 0 },
459
+ { id: 'ground2', type: 'Point', x: 100, y: 0 },
460
+ { id: 'joint1', type: 'Point', x: 30, y: 30 },
461
+ { id: 'joint2', type: 'Point', x: 70, y: 40 },
462
+ { id: 'link1', type: 'Line', points: ['ground1', 'joint1'] },
463
+ { id: 'link2', type: 'Line', points: ['joint1', 'joint2'] },
464
+ { id: 'link3', type: 'Line', points: ['joint2', 'ground2'] }
465
+ ],
466
+ constraints: [
467
+ { type: 'Fixed', entity: 'ground1' },
468
+ { type: 'Fixed', entity: 'ground2' },
469
+ { type: 'Distance', entities: ['ground1', 'joint1'], distance: 40 },
470
+ { type: 'Distance', entities: ['joint1', 'joint2'], distance: 50 },
471
+ { type: 'Distance', entities: ['joint2', 'ground2'], distance: 35 },
472
+ { type: 'Angle', entities: ['link1'], angle: '$input_angle' }
473
+ ]
474
+ },
475
+ parametric: {
476
+ schema: 'slvs-json/1',
477
+ units: 'mm',
478
+ parameters: {
479
+ width: 150,
480
+ height: 100,
481
+ hole_radius: 10
482
+ },
483
+ entities: [
484
+ { id: 'p1', type: 'Point', x: 0, y: 0 },
485
+ { id: 'p2', type: 'Point', x: '$width', y: 0 },
486
+ { id: 'p3', type: 'Point', x: '$width', y: '$height' },
487
+ { id: 'p4', type: 'Point', x: 0, y: '$height' },
488
+ { id: 'hole_center', type: 'Point', x: 75, y: 50 },
489
+ { id: 'hole', type: 'Circle', center: 'hole_center', radius: '$hole_radius' }
490
+ ],
491
+ constraints: [
492
+ { type: 'Fixed', entity: 'p1' },
493
+ { type: 'HorizontalDistance', entities: ['p1', 'p2'], distance: '$width' },
494
+ { type: 'VerticalDistance', entities: ['p1', 'p4'], distance: '$height' },
495
+ { type: 'Horizontal', entity: 'p2' },
496
+ { type: 'Vertical', entity: 'p4' },
497
+ { type: 'Radius', entity: 'hole', radius: '$hole_radius' }
498
+ ]
499
+ },
500
+ '3d': {
501
+ schema: 'slvs-json/1',
502
+ units: 'mm',
503
+ entities: [
504
+ { id: 'p1', type: 'Point', x: 0, y: 0, z: 0 },
505
+ { id: 'p2', type: 'Point', x: 100, y: 0, z: 0 },
506
+ { id: 'p3', type: 'Point', x: 100, y: 100, z: 0 },
507
+ { id: 'p4', type: 'Point', x: 0, y: 100, z: 0 },
508
+ { id: 'p5', type: 'Point', x: 0, y: 0, z: 50 },
509
+ { id: 'p6', type: 'Point', x: 100, y: 0, z: 50 },
510
+ { id: 'p7', type: 'Point', x: 100, y: 100, z: 50 },
511
+ { id: 'p8', type: 'Point', x: 0, y: 100, z: 50 }
512
+ ],
513
+ constraints: [
514
+ { type: 'Fixed', entity: 'p1' },
515
+ { type: 'Distance', entities: ['p1', 'p2'], distance: 100 },
516
+ { type: 'Distance', entities: ['p2', 'p3'], distance: 100 },
517
+ { type: 'Distance', entities: ['p3', 'p4'], distance: 100 },
518
+ { type: 'Distance', entities: ['p4', 'p1'], distance: 100 },
519
+ { type: 'Distance', entities: ['p1', 'p5'], distance: 50 },
520
+ { type: 'Distance', entities: ['p2', 'p6'], distance: 50 },
521
+ { type: 'Distance', entities: ['p3', 'p7'], distance: 50 },
522
+ { type: 'Distance', entities: ['p4', 'p8'], distance: 50 }
523
+ ]
524
+ }
525
+ };
526
+
527
+ const example = examples[type];
528
+ if (!example) {
529
+ return {
530
+ content: [
531
+ {
532
+ type: 'text',
533
+ text: `Unknown example type: ${type}. Available: ${Object.keys(examples).join(', ')}`,
534
+ },
535
+ ],
536
+ };
537
+ }
538
+
539
+ return {
540
+ content: [
541
+ {
542
+ type: 'text',
543
+ text: JSON.stringify(example, null, 2),
544
+ },
545
+ ],
546
+ };
547
+ }
548
+
549
+ async searchDocs(query) {
550
+ const result = await searchDocumentation(query, 3);
551
+
552
+ if (result.error) {
553
+ return {
554
+ content: [
555
+ {
556
+ type: 'text',
557
+ text: result.error,
558
+ },
559
+ ],
560
+ };
561
+ }
562
+
563
+ // Format results as readable text
564
+ const formatted = result.results.map((r, i) =>
565
+ `--- Result ${i + 1} (${r.source}, score: ${r.score}) ---\n${r.content}`
566
+ ).join('\n\n');
567
+
568
+ return {
569
+ content: [
570
+ {
571
+ type: 'text',
572
+ text: formatted || 'No results found.',
573
+ },
574
+ ],
575
+ };
576
+ }
577
+
578
+ async run() {
579
+ // Load documentation index for search
580
+ await loadDocsIndex();
581
+
582
+ const transport = new StdioServerTransport();
583
+ await this.server.connect(transport);
584
+ console.error('SLVSX MCP Server running on stdio');
585
+ }
586
+ }
587
+
588
+ // Check if slvsx binary exists
589
+ if (!fs.existsSync(SLVSX_BINARY)) {
590
+ console.error(`Error: SLVSX binary not found at ${SLVSX_BINARY}`);
591
+ console.error('Please build the project first with: cargo build --release');
592
+ console.error('Or set SLVSX_BINARY environment variable to point to the binary');
593
+ process.exit(1);
594
+ }
595
+
596
+ // Run the server
597
+ const server = new SlvsxServer();
598
+ server.run().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@sknoble/slvsx-mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for SLVSX geometric constraint solver",
5
+ "type": "module",
6
+ "bin": {
7
+ "slvsx-mcp": "./mcp-server.js"
8
+ },
9
+ "main": "mcp-server.js",
10
+ "scripts": {
11
+ "start": "node mcp-server.js",
12
+ "build:docs": "npx tsx scripts/build-docs.ts",
13
+ "test": "node tests/mcp-server.test.js"
14
+ },
15
+ "dependencies": {
16
+ "@langchain/textsplitters": "^0.1.0",
17
+ "@modelcontextprotocol/sdk": "^0.5.0",
18
+ "@xenova/transformers": "^2.17.0"
19
+ },
20
+ "devDependencies": {
21
+ "tsx": "^4.7.0",
22
+ "typescript": "^5.3.0",
23
+ "@types/node": "^20.10.0"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "constraint-solver",
28
+ "geometry",
29
+ "cad",
30
+ "solvespace"
31
+ ],
32
+ "author": "slvsx contributors",
33
+ "license": "GPL-3.0-or-later",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/snoble/slvsx-cli.git"
37
+ },
38
+ "files": [
39
+ "mcp-server.js",
40
+ "dist/docs.json",
41
+ "README.md"
42
+ ],
43
+ "engines": {
44
+ "node": ">=18"
45
+ }
46
+ }