@soleri/core 0.0.1 → 1.0.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 (63) hide show
  1. package/dist/brain/brain.d.ts +76 -0
  2. package/dist/brain/brain.d.ts.map +1 -0
  3. package/dist/brain/brain.js +431 -0
  4. package/dist/brain/brain.js.map +1 -0
  5. package/dist/facades/facade-factory.d.ts +5 -0
  6. package/dist/facades/facade-factory.d.ts.map +1 -0
  7. package/dist/facades/facade-factory.js +49 -0
  8. package/dist/facades/facade-factory.js.map +1 -0
  9. package/dist/facades/types.d.ts +42 -0
  10. package/dist/facades/types.d.ts.map +1 -0
  11. package/dist/facades/types.js +6 -0
  12. package/dist/facades/types.js.map +1 -0
  13. package/dist/index.d.ts +17 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +17 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/intelligence/loader.d.ts +3 -0
  18. package/dist/intelligence/loader.d.ts.map +1 -0
  19. package/dist/intelligence/loader.js +41 -0
  20. package/dist/intelligence/loader.js.map +1 -0
  21. package/dist/intelligence/types.d.ts +20 -0
  22. package/dist/intelligence/types.d.ts.map +1 -0
  23. package/dist/intelligence/types.js +2 -0
  24. package/dist/intelligence/types.js.map +1 -0
  25. package/dist/llm/key-pool.d.ts +38 -0
  26. package/dist/llm/key-pool.d.ts.map +1 -0
  27. package/dist/llm/key-pool.js +154 -0
  28. package/dist/llm/key-pool.js.map +1 -0
  29. package/dist/llm/types.d.ts +80 -0
  30. package/dist/llm/types.d.ts.map +1 -0
  31. package/dist/llm/types.js +37 -0
  32. package/dist/llm/types.js.map +1 -0
  33. package/dist/llm/utils.d.ts +26 -0
  34. package/dist/llm/utils.d.ts.map +1 -0
  35. package/dist/llm/utils.js +197 -0
  36. package/dist/llm/utils.js.map +1 -0
  37. package/dist/planning/planner.d.ts +48 -0
  38. package/dist/planning/planner.d.ts.map +1 -0
  39. package/dist/planning/planner.js +109 -0
  40. package/dist/planning/planner.js.map +1 -0
  41. package/dist/vault/vault.d.ts +80 -0
  42. package/dist/vault/vault.d.ts.map +1 -0
  43. package/dist/vault/vault.js +353 -0
  44. package/dist/vault/vault.js.map +1 -0
  45. package/package.json +56 -4
  46. package/src/__tests__/brain.test.ts +545 -0
  47. package/src/__tests__/llm.test.ts +556 -0
  48. package/src/__tests__/loader.test.ts +176 -0
  49. package/src/__tests__/planner.test.ts +261 -0
  50. package/src/__tests__/vault.test.ts +494 -0
  51. package/src/brain/brain.ts +581 -0
  52. package/src/facades/facade-factory.ts +64 -0
  53. package/src/facades/types.ts +42 -0
  54. package/src/index.ts +64 -0
  55. package/src/intelligence/loader.ts +42 -0
  56. package/src/intelligence/types.ts +20 -0
  57. package/src/llm/key-pool.ts +190 -0
  58. package/src/llm/types.ts +116 -0
  59. package/src/llm/utils.ts +248 -0
  60. package/src/planning/planner.ts +151 -0
  61. package/src/vault/vault.ts +455 -0
  62. package/tsconfig.json +22 -0
  63. package/vitest.config.ts +15 -0
@@ -0,0 +1,581 @@
1
+ import type { Vault } from '../vault/vault.js';
2
+ import type { SearchResult } from '../vault/vault.js';
3
+ import type { IntelligenceEntry } from '../intelligence/types.js';
4
+
5
+ // ─── Types ───────────────────────────────────────────────────────────
6
+
7
+ export interface ScoringWeights {
8
+ semantic: number;
9
+ severity: number;
10
+ recency: number;
11
+ tagOverlap: number;
12
+ domainMatch: number;
13
+ }
14
+
15
+ export interface ScoreBreakdown {
16
+ semantic: number;
17
+ severity: number;
18
+ recency: number;
19
+ tagOverlap: number;
20
+ domainMatch: number;
21
+ total: number;
22
+ }
23
+
24
+ export interface RankedResult {
25
+ entry: IntelligenceEntry;
26
+ score: number;
27
+ breakdown: ScoreBreakdown;
28
+ }
29
+
30
+ export interface SearchOptions {
31
+ domain?: string;
32
+ type?: string;
33
+ severity?: string;
34
+ limit?: number;
35
+ tags?: string[];
36
+ }
37
+
38
+ export interface CaptureResult {
39
+ captured: boolean;
40
+ id: string;
41
+ autoTags: string[];
42
+ duplicate?: { id: string; similarity: number };
43
+ blocked?: boolean;
44
+ }
45
+
46
+ export interface BrainStats {
47
+ vocabularySize: number;
48
+ feedbackCount: number;
49
+ weights: ScoringWeights;
50
+ }
51
+
52
+ export interface QueryContext {
53
+ query: string;
54
+ domain?: string;
55
+ tags?: string[];
56
+ }
57
+
58
+ type SparseVector = Map<string, number>;
59
+
60
+ // ─── Stopwords ─────────────────────────────────────────────────────
61
+
62
+ const STOPWORDS = new Set([
63
+ 'a',
64
+ 'an',
65
+ 'the',
66
+ 'and',
67
+ 'or',
68
+ 'but',
69
+ 'in',
70
+ 'on',
71
+ 'at',
72
+ 'to',
73
+ 'for',
74
+ 'of',
75
+ 'with',
76
+ 'by',
77
+ 'from',
78
+ 'as',
79
+ 'is',
80
+ 'was',
81
+ 'are',
82
+ 'were',
83
+ 'been',
84
+ 'be',
85
+ 'have',
86
+ 'has',
87
+ 'had',
88
+ 'do',
89
+ 'does',
90
+ 'did',
91
+ 'will',
92
+ 'would',
93
+ 'could',
94
+ 'should',
95
+ 'may',
96
+ 'might',
97
+ 'shall',
98
+ 'can',
99
+ 'need',
100
+ 'must',
101
+ 'it',
102
+ 'its',
103
+ 'this',
104
+ 'that',
105
+ 'these',
106
+ 'those',
107
+ 'i',
108
+ 'you',
109
+ 'he',
110
+ 'she',
111
+ 'we',
112
+ 'they',
113
+ 'me',
114
+ 'him',
115
+ 'her',
116
+ 'us',
117
+ 'them',
118
+ 'my',
119
+ 'your',
120
+ 'his',
121
+ 'our',
122
+ 'their',
123
+ 'what',
124
+ 'which',
125
+ 'who',
126
+ 'whom',
127
+ 'when',
128
+ 'where',
129
+ 'why',
130
+ 'how',
131
+ 'all',
132
+ 'each',
133
+ 'every',
134
+ 'both',
135
+ 'few',
136
+ 'more',
137
+ 'most',
138
+ 'other',
139
+ 'some',
140
+ 'such',
141
+ 'no',
142
+ 'not',
143
+ 'only',
144
+ 'same',
145
+ 'so',
146
+ 'than',
147
+ 'too',
148
+ 'very',
149
+ 'just',
150
+ 'because',
151
+ 'if',
152
+ 'then',
153
+ 'else',
154
+ 'about',
155
+ 'up',
156
+ 'out',
157
+ 'into',
158
+ ]);
159
+
160
+ // ─── Text Processing (pure functions) ────────────────────────────
161
+
162
+ function tokenize(text: string): string[] {
163
+ return text
164
+ .toLowerCase()
165
+ .replace(/[^a-z0-9\s-]/g, ' ')
166
+ .split(/\s+/)
167
+ .filter((t) => t.length > 2 && !STOPWORDS.has(t));
168
+ }
169
+
170
+ function calculateTf(tokens: string[]): SparseVector {
171
+ const tf: SparseVector = new Map();
172
+ for (const token of tokens) {
173
+ tf.set(token, (tf.get(token) ?? 0) + 1);
174
+ }
175
+ const len = tokens.length || 1;
176
+ for (const [term, count] of tf) {
177
+ tf.set(term, count / len);
178
+ }
179
+ return tf;
180
+ }
181
+
182
+ function calculateTfIdf(tokens: string[], vocabulary: Map<string, number>): SparseVector {
183
+ const tf = calculateTf(tokens);
184
+ const tfidf: SparseVector = new Map();
185
+ for (const [term, tfValue] of tf) {
186
+ const idf = vocabulary.get(term) ?? 0;
187
+ if (idf > 0) {
188
+ tfidf.set(term, tfValue * idf);
189
+ }
190
+ }
191
+ return tfidf;
192
+ }
193
+
194
+ function cosineSimilarity(a: SparseVector, b: SparseVector): number {
195
+ let dot = 0;
196
+ let normA = 0;
197
+ let normB = 0;
198
+ for (const [term, valA] of a) {
199
+ normA += valA * valA;
200
+ const valB = b.get(term);
201
+ if (valB !== undefined) {
202
+ dot += valA * valB;
203
+ }
204
+ }
205
+ for (const [, valB] of b) {
206
+ normB += valB * valB;
207
+ }
208
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
209
+ return denom === 0 ? 0 : dot / denom;
210
+ }
211
+
212
+ function jaccardSimilarity(a: string[], b: string[]): number {
213
+ if (a.length === 0 && b.length === 0) return 0;
214
+ const setA = new Set(a);
215
+ const setB = new Set(b);
216
+ let intersection = 0;
217
+ for (const item of setA) {
218
+ if (setB.has(item)) intersection++;
219
+ }
220
+ const union = new Set([...a, ...b]).size;
221
+ return union === 0 ? 0 : intersection / union;
222
+ }
223
+
224
+ // ─── Severity scoring ──────────────────────────────────────────────
225
+
226
+ const SEVERITY_SCORES: Record<string, number> = {
227
+ critical: 1.0,
228
+ warning: 0.7,
229
+ suggestion: 0.4,
230
+ };
231
+
232
+ // ─── Brain Class ─────────────────────────────────────────────────
233
+
234
+ const DEFAULT_WEIGHTS: ScoringWeights = {
235
+ semantic: 0.4,
236
+ severity: 0.15,
237
+ recency: 0.15,
238
+ tagOverlap: 0.15,
239
+ domainMatch: 0.15,
240
+ };
241
+
242
+ const WEIGHT_BOUND = 0.15;
243
+ const FEEDBACK_THRESHOLD = 30;
244
+ const DUPLICATE_BLOCK_THRESHOLD = 0.8;
245
+ const DUPLICATE_WARN_THRESHOLD = 0.6;
246
+ const RECENCY_HALF_LIFE_DAYS = 365;
247
+
248
+ export class Brain {
249
+ private vault: Vault;
250
+ private vocabulary: Map<string, number> = new Map();
251
+ private weights: ScoringWeights = { ...DEFAULT_WEIGHTS };
252
+
253
+ constructor(vault: Vault) {
254
+ this.vault = vault;
255
+ this.rebuildVocabulary();
256
+ this.recomputeWeights();
257
+ }
258
+
259
+ intelligentSearch(query: string, options?: SearchOptions): RankedResult[] {
260
+ const limit = options?.limit ?? 10;
261
+ const rawResults = this.vault.search(query, {
262
+ domain: options?.domain,
263
+ type: options?.type,
264
+ severity: options?.severity,
265
+ limit: Math.max(limit * 3, 30),
266
+ });
267
+
268
+ if (rawResults.length === 0) return [];
269
+
270
+ const queryTokens = tokenize(query);
271
+ const queryTags = options?.tags ?? [];
272
+ const queryDomain = options?.domain;
273
+ const now = Math.floor(Date.now() / 1000);
274
+
275
+ const ranked = rawResults.map((result) => {
276
+ const entry = result.entry;
277
+ const breakdown = this.scoreEntry(entry, queryTokens, queryTags, queryDomain, now);
278
+ return { entry, score: breakdown.total, breakdown };
279
+ });
280
+
281
+ ranked.sort((a, b) => b.score - a.score);
282
+ return ranked.slice(0, limit);
283
+ }
284
+
285
+ enrichAndCapture(
286
+ entry: Partial<IntelligenceEntry> & {
287
+ id: string;
288
+ type: IntelligenceEntry['type'];
289
+ domain: string;
290
+ title: string;
291
+ severity: IntelligenceEntry['severity'];
292
+ description: string;
293
+ },
294
+ ): CaptureResult {
295
+ const autoTags = this.generateTags(entry.title, entry.description, entry.context);
296
+ const mergedTags = Array.from(new Set([...(entry.tags ?? []), ...autoTags]));
297
+
298
+ const duplicate = this.detectDuplicate(entry.title, entry.domain);
299
+
300
+ if (duplicate && duplicate.similarity >= DUPLICATE_BLOCK_THRESHOLD) {
301
+ return {
302
+ captured: false,
303
+ id: entry.id,
304
+ autoTags,
305
+ duplicate,
306
+ blocked: true,
307
+ };
308
+ }
309
+
310
+ const fullEntry: IntelligenceEntry = {
311
+ id: entry.id,
312
+ type: entry.type,
313
+ domain: entry.domain,
314
+ title: entry.title,
315
+ severity: entry.severity,
316
+ description: entry.description,
317
+ context: entry.context,
318
+ example: entry.example,
319
+ counterExample: entry.counterExample,
320
+ why: entry.why,
321
+ tags: mergedTags,
322
+ appliesTo: entry.appliesTo,
323
+ };
324
+
325
+ this.vault.add(fullEntry);
326
+ this.updateVocabularyIncremental(fullEntry);
327
+
328
+ const result: CaptureResult = {
329
+ captured: true,
330
+ id: entry.id,
331
+ autoTags,
332
+ };
333
+
334
+ if (duplicate && duplicate.similarity >= DUPLICATE_WARN_THRESHOLD) {
335
+ result.duplicate = duplicate;
336
+ }
337
+
338
+ return result;
339
+ }
340
+
341
+ recordFeedback(query: string, entryId: string, action: 'accepted' | 'dismissed'): void {
342
+ const db = this.vault.getDb();
343
+ db.prepare('INSERT INTO brain_feedback (query, entry_id, action) VALUES (?, ?, ?)').run(
344
+ query,
345
+ entryId,
346
+ action,
347
+ );
348
+ this.recomputeWeights();
349
+ }
350
+
351
+ getRelevantPatterns(context: QueryContext): RankedResult[] {
352
+ return this.intelligentSearch(context.query, {
353
+ domain: context.domain,
354
+ tags: context.tags,
355
+ });
356
+ }
357
+
358
+ rebuildVocabulary(): void {
359
+ const entries = this.vault.list({ limit: 100000 });
360
+ const docCount = entries.length;
361
+ if (docCount === 0) {
362
+ this.vocabulary.clear();
363
+ this.persistVocabulary();
364
+ return;
365
+ }
366
+
367
+ const termDocFreq = new Map<string, number>();
368
+ for (const entry of entries) {
369
+ const text = [entry.title, entry.description, entry.context ?? '', entry.tags.join(' ')].join(
370
+ ' ',
371
+ );
372
+ const tokens = new Set(tokenize(text));
373
+ for (const token of tokens) {
374
+ termDocFreq.set(token, (termDocFreq.get(token) ?? 0) + 1);
375
+ }
376
+ }
377
+
378
+ this.vocabulary.clear();
379
+ for (const [term, df] of termDocFreq) {
380
+ const idf = Math.log((docCount + 1) / (df + 1)) + 1;
381
+ this.vocabulary.set(term, idf);
382
+ }
383
+
384
+ this.persistVocabulary();
385
+ }
386
+
387
+ getStats(): BrainStats {
388
+ const db = this.vault.getDb();
389
+ const feedbackCount = (
390
+ db.prepare('SELECT COUNT(*) as count FROM brain_feedback').get() as { count: number }
391
+ ).count;
392
+ return {
393
+ vocabularySize: this.vocabulary.size,
394
+ feedbackCount,
395
+ weights: { ...this.weights },
396
+ };
397
+ }
398
+
399
+ getVocabularySize(): number {
400
+ return this.vocabulary.size;
401
+ }
402
+
403
+ // ─── Private methods ─────────────────────────────────────────────
404
+
405
+ private scoreEntry(
406
+ entry: IntelligenceEntry,
407
+ queryTokens: string[],
408
+ queryTags: string[],
409
+ queryDomain: string | undefined,
410
+ now: number,
411
+ ): ScoreBreakdown {
412
+ let semantic = 0;
413
+ if (this.vocabulary.size > 0 && queryTokens.length > 0) {
414
+ const entryText = [
415
+ entry.title,
416
+ entry.description,
417
+ entry.context ?? '',
418
+ entry.tags.join(' '),
419
+ ].join(' ');
420
+ const entryTokens = tokenize(entryText);
421
+ const queryVec = calculateTfIdf(queryTokens, this.vocabulary);
422
+ const entryVec = calculateTfIdf(entryTokens, this.vocabulary);
423
+ semantic = cosineSimilarity(queryVec, entryVec);
424
+ }
425
+
426
+ const severity = SEVERITY_SCORES[entry.severity] ?? 0.4;
427
+
428
+ const entryAge = now - (entry as unknown as { created_at?: number }).created_at!;
429
+ const halfLifeSeconds = RECENCY_HALF_LIFE_DAYS * 86400;
430
+ const recency = entryAge > 0 ? Math.exp((-Math.LN2 * entryAge) / halfLifeSeconds) : 1;
431
+
432
+ const tagOverlap = queryTags.length > 0 ? jaccardSimilarity(queryTags, entry.tags) : 0;
433
+
434
+ const domainMatch = queryDomain && entry.domain === queryDomain ? 1.0 : 0;
435
+
436
+ const total =
437
+ this.weights.semantic * semantic +
438
+ this.weights.severity * severity +
439
+ this.weights.recency * recency +
440
+ this.weights.tagOverlap * tagOverlap +
441
+ this.weights.domainMatch * domainMatch;
442
+
443
+ return { semantic, severity, recency, tagOverlap, domainMatch, total };
444
+ }
445
+
446
+ private generateTags(title: string, description: string, context?: string): string[] {
447
+ const text = [title, description, context ?? ''].join(' ');
448
+ const tokens = tokenize(text);
449
+ if (tokens.length === 0) return [];
450
+
451
+ const tf = calculateTf(tokens);
452
+ const scored: Array<[string, number]> = [];
453
+ for (const [term, tfValue] of tf) {
454
+ const idf = this.vocabulary.get(term) ?? 1;
455
+ scored.push([term, tfValue * idf]);
456
+ }
457
+
458
+ scored.sort((a, b) => b[1] - a[1]);
459
+ return scored.slice(0, 5).map(([term]) => term);
460
+ }
461
+
462
+ private detectDuplicate(
463
+ title: string,
464
+ domain: string,
465
+ ): { id: string; similarity: number } | null {
466
+ let candidates: SearchResult[];
467
+ try {
468
+ candidates = this.vault.search(title, { domain, limit: 50 });
469
+ } catch {
470
+ return null;
471
+ }
472
+ if (candidates.length === 0) return null;
473
+
474
+ const titleTokens = tokenize(title);
475
+ if (titleTokens.length === 0) return null;
476
+ const titleVec = calculateTfIdf(titleTokens, this.vocabulary);
477
+ if (titleVec.size === 0) {
478
+ const titleTf = calculateTf(titleTokens);
479
+ let bestMatch: { id: string; similarity: number } | null = null;
480
+ for (const candidate of candidates) {
481
+ const candidateTokens = tokenize(candidate.entry.title);
482
+ const candidateTf = calculateTf(candidateTokens);
483
+ const sim = cosineSimilarity(titleTf, candidateTf);
484
+ if (!bestMatch || sim > bestMatch.similarity) {
485
+ bestMatch = { id: candidate.entry.id, similarity: sim };
486
+ }
487
+ }
488
+ return bestMatch;
489
+ }
490
+
491
+ let bestMatch: { id: string; similarity: number } | null = null;
492
+ for (const candidate of candidates) {
493
+ const candidateText = [candidate.entry.title, candidate.entry.description].join(' ');
494
+ const candidateTokens = tokenize(candidateText);
495
+ const candidateVec = calculateTfIdf(candidateTokens, this.vocabulary);
496
+ const sim = cosineSimilarity(titleVec, candidateVec);
497
+ if (!bestMatch || sim > bestMatch.similarity) {
498
+ bestMatch = { id: candidate.entry.id, similarity: sim };
499
+ }
500
+ }
501
+ return bestMatch;
502
+ }
503
+
504
+ private updateVocabularyIncremental(entry: IntelligenceEntry): void {
505
+ const text = [entry.title, entry.description, entry.context ?? '', entry.tags.join(' ')].join(
506
+ ' ',
507
+ );
508
+ const tokens = new Set(tokenize(text));
509
+ const totalDocs = this.vault.stats().totalEntries;
510
+
511
+ for (const token of tokens) {
512
+ const currentDocCount = this.vocabulary.has(token)
513
+ ? Math.round(totalDocs / Math.exp(this.vocabulary.get(token)! - 1)) + 1
514
+ : 1;
515
+ const newIdf = Math.log((totalDocs + 1) / (currentDocCount + 1)) + 1;
516
+ this.vocabulary.set(token, newIdf);
517
+ }
518
+
519
+ this.persistVocabulary();
520
+ }
521
+
522
+ private persistVocabulary(): void {
523
+ const db = this.vault.getDb();
524
+ db.prepare('DELETE FROM brain_vocabulary').run();
525
+ if (this.vocabulary.size === 0) return;
526
+ const insert = db.prepare(
527
+ 'INSERT INTO brain_vocabulary (term, idf, doc_count) VALUES (?, ?, ?)',
528
+ );
529
+ const tx = db.transaction(() => {
530
+ for (const [term, idf] of this.vocabulary) {
531
+ insert.run(term, idf, 1);
532
+ }
533
+ });
534
+ tx();
535
+ }
536
+
537
+ private recomputeWeights(): void {
538
+ const db = this.vault.getDb();
539
+ const feedbackCount = (
540
+ db.prepare('SELECT COUNT(*) as count FROM brain_feedback').get() as { count: number }
541
+ ).count;
542
+ if (feedbackCount < FEEDBACK_THRESHOLD) {
543
+ this.weights = { ...DEFAULT_WEIGHTS };
544
+ return;
545
+ }
546
+
547
+ const accepted = (
548
+ db
549
+ .prepare("SELECT COUNT(*) as count FROM brain_feedback WHERE action = 'accepted'")
550
+ .get() as { count: number }
551
+ ).count;
552
+ const acceptRate = feedbackCount > 0 ? accepted / feedbackCount : 0.5;
553
+
554
+ const semanticDelta = (acceptRate - 0.5) * WEIGHT_BOUND * 2;
555
+
556
+ const newWeights = { ...DEFAULT_WEIGHTS };
557
+ newWeights.semantic = clamp(
558
+ DEFAULT_WEIGHTS.semantic + semanticDelta,
559
+ DEFAULT_WEIGHTS.semantic - WEIGHT_BOUND,
560
+ DEFAULT_WEIGHTS.semantic + WEIGHT_BOUND,
561
+ );
562
+
563
+ const remaining = 1.0 - newWeights.semantic;
564
+ const otherSum =
565
+ DEFAULT_WEIGHTS.severity +
566
+ DEFAULT_WEIGHTS.recency +
567
+ DEFAULT_WEIGHTS.tagOverlap +
568
+ DEFAULT_WEIGHTS.domainMatch;
569
+ const scale = remaining / otherSum;
570
+ newWeights.severity = DEFAULT_WEIGHTS.severity * scale;
571
+ newWeights.recency = DEFAULT_WEIGHTS.recency * scale;
572
+ newWeights.tagOverlap = DEFAULT_WEIGHTS.tagOverlap * scale;
573
+ newWeights.domainMatch = DEFAULT_WEIGHTS.domainMatch * scale;
574
+
575
+ this.weights = newWeights;
576
+ }
577
+ }
578
+
579
+ function clamp(value: number, min: number, max: number): number {
580
+ return Math.max(min, Math.min(max, value));
581
+ }
@@ -0,0 +1,64 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { FacadeConfig, FacadeResponse } from './types.js';
4
+
5
+ export function registerFacade(server: McpServer, facade: FacadeConfig): void {
6
+ const opNames = facade.ops.map((o) => o.name);
7
+
8
+ server.tool(
9
+ facade.name,
10
+ `${facade.description}\n\nOperations: ${opNames.join(', ')}`,
11
+ {
12
+ op: z.string().describe(`Operation: ${opNames.join(' | ')}`),
13
+ params: z.record(z.unknown()).optional().default({}).describe('Operation parameters'),
14
+ },
15
+ async ({ op, params }): Promise<{ content: Array<{ type: 'text'; text: string }> }> => {
16
+ const response = await dispatchOp(facade, op, params);
17
+ return { content: [{ type: 'text' as const, text: JSON.stringify(response, null, 2) }] };
18
+ },
19
+ );
20
+ }
21
+
22
+ async function dispatchOp(
23
+ facade: FacadeConfig,
24
+ opName: string,
25
+ params: Record<string, unknown>,
26
+ ): Promise<FacadeResponse> {
27
+ const op = facade.ops.find((o) => o.name === opName);
28
+ if (!op) {
29
+ return {
30
+ success: false,
31
+ error: `Unknown operation "${opName}" on ${facade.name}. Available: ${facade.ops.map((o) => o.name).join(', ')}`,
32
+ op: opName,
33
+ facade: facade.name,
34
+ };
35
+ }
36
+
37
+ try {
38
+ let validatedParams = params;
39
+ if (op.schema) {
40
+ const result = op.schema.safeParse(params);
41
+ if (!result.success) {
42
+ return {
43
+ success: false,
44
+ error: `Invalid params for ${opName}: ${result.error.message}`,
45
+ op: opName,
46
+ facade: facade.name,
47
+ };
48
+ }
49
+ validatedParams = result.data as Record<string, unknown>;
50
+ }
51
+
52
+ const data = await op.handler(validatedParams);
53
+ return { success: true, data, op: opName, facade: facade.name };
54
+ } catch (err) {
55
+ const message = err instanceof Error ? err.message : String(err);
56
+ return { success: false, error: message, op: opName, facade: facade.name };
57
+ }
58
+ }
59
+
60
+ export function registerAllFacades(server: McpServer, facades: FacadeConfig[]): void {
61
+ for (const facade of facades) {
62
+ registerFacade(server, facade);
63
+ }
64
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Handler function for a single facade operation */
4
+ export type OpHandler = (params: Record<string, unknown>) => Promise<unknown>;
5
+
6
+ /** Auth level required for an operation */
7
+ export type AuthLevel = 'read' | 'write' | 'admin';
8
+
9
+ /** Operation definition within a facade */
10
+ export interface OpDefinition {
11
+ name: string;
12
+ description: string;
13
+ auth: AuthLevel;
14
+ handler: OpHandler;
15
+ schema?: z.ZodType;
16
+ }
17
+
18
+ /** Facade configuration — one MCP tool */
19
+ export interface FacadeConfig {
20
+ /** MCP tool name */
21
+ name: string;
22
+ /** Human-readable description */
23
+ description: string;
24
+ /** Domain operations */
25
+ ops: OpDefinition[];
26
+ }
27
+
28
+ /** Standard facade response envelope */
29
+ export interface FacadeResponse {
30
+ success: boolean;
31
+ data?: unknown;
32
+ error?: string;
33
+ op?: string;
34
+ facade?: string;
35
+ }
36
+
37
+ export const facadeInputSchema = z.object({
38
+ op: z.string().describe('Operation name'),
39
+ params: z.record(z.unknown()).optional().default({}),
40
+ });
41
+
42
+ export type FacadeInput = z.infer<typeof facadeInputSchema>;