0nmcp 2.5.0 → 2.7.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.
@@ -0,0 +1,578 @@
1
+ // ============================================================
2
+ // 0nMCP — Plugin Builder (0nEngine Core)
3
+ // ============================================================
4
+ // The factory that reads catalog.js + fields.js and auto-generates
5
+ // fully operational service plugins. Each plugin gets:
6
+ // - .0n field resolution (canonical → service-specific)
7
+ // - All catalog endpoints as executable methods
8
+ // - Auth header injection
9
+ // - Rate limiting
10
+ // - MCP tool schema generation
11
+ //
12
+ // This is the 0nEngine — the bridge between the .0n Field Standard
13
+ // and the Service Catalog. Write once in .0n, run on any service.
14
+ //
15
+ // Usage:
16
+ // import { PluginBuilder } from './plugin-builder.js'
17
+ //
18
+ // const builder = new PluginBuilder()
19
+ // const stripe = builder.build('stripe')
20
+ // stripe.connect({ apiKey: 'sk_live_...' })
21
+ // const result = await stripe.execute('create_customer', {
22
+ // 'email.0n': 'mike@rocketopp.com',
23
+ // 'fullname.0n': 'Mike Mento'
24
+ // })
25
+ //
26
+ // // Build ALL catalog services at once
27
+ // const plugins = builder.buildAll()
28
+ //
29
+ // // Build a custom plugin from a .0n plugin definition
30
+ // const custom = builder.buildFromSpec(pluginSpec)
31
+ //
32
+ // // Generate a plugin spec for a new service
33
+ // const spec = builder.generate({
34
+ // key: 'acme',
35
+ // name: 'Acme API',
36
+ // type: 'crm',
37
+ // baseUrl: 'https://api.acme.com/v1',
38
+ // authType: 'api_key',
39
+ // credentialKeys: ['apiKey'],
40
+ // endpoints: { ... }
41
+ // })
42
+ // ============================================================
43
+
44
+ import { SERVICE_CATALOG } from "../catalog.js";
45
+ import { resolveFields, reverseResolve, CANONICAL_FIELDS, listFields, getServiceMappings } from "../fields.js";
46
+ import { Plugin } from "./plugin.js";
47
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
48
+ import { join } from "path";
49
+ import { homedir } from "os";
50
+
51
+ const PLUGINS_DIR = join(homedir(), ".0n", "plugins");
52
+
53
+ export class PluginBuilder {
54
+ /**
55
+ * @param {object} [options]
56
+ * @param {object} [options.catalog] — Override SERVICE_CATALOG
57
+ * @param {object} [options.fields] — Override CANONICAL_FIELDS
58
+ * @param {number} [options.defaultRateLimit] — Default requests/sec per plugin
59
+ */
60
+ constructor(options = {}) {
61
+ this.catalog = options.catalog || SERVICE_CATALOG;
62
+ this.fields = options.fields || CANONICAL_FIELDS;
63
+ this.defaultRateLimit = options.defaultRateLimit || 10;
64
+
65
+ // Cache built plugins
66
+ this._cache = new Map();
67
+ }
68
+
69
+ // ── Build from Catalog ────────────────────────────────────
70
+
71
+ /**
72
+ * Build a Plugin from a catalog entry.
73
+ *
74
+ * @param {string} serviceKey — Service key from SERVICE_CATALOG
75
+ * @param {object} [options]
76
+ * @param {object} [options.credentials] — Pre-load credentials
77
+ * @param {number} [options.rateLimit] — Override rate limit
78
+ * @returns {Plugin}
79
+ */
80
+ build(serviceKey, options = {}) {
81
+ // Check cache first
82
+ const cacheKey = `catalog:${serviceKey}`;
83
+ if (this._cache.has(cacheKey) && !options.credentials) {
84
+ return this._cache.get(cacheKey);
85
+ }
86
+
87
+ const entry = this.catalog[serviceKey];
88
+ if (!entry) {
89
+ throw new Error(
90
+ `Unknown service: "${serviceKey}". Available: ${Object.keys(this.catalog).join(", ")}`
91
+ );
92
+ }
93
+
94
+ const plugin = new Plugin(serviceKey, entry, {
95
+ resolveFields,
96
+ reverseResolve,
97
+ credentials: options.credentials || null,
98
+ rateLimit: options.rateLimit || this.defaultRateLimit,
99
+ });
100
+
101
+ if (!options.credentials) {
102
+ this._cache.set(cacheKey, plugin);
103
+ }
104
+
105
+ return plugin;
106
+ }
107
+
108
+ /**
109
+ * Build ALL catalog services as plugins.
110
+ *
111
+ * @param {object} [options]
112
+ * @param {object} [options.connections] — Map of { serviceKey: credentials }
113
+ * @returns {Map<string, Plugin>}
114
+ */
115
+ buildAll(options = {}) {
116
+ const plugins = new Map();
117
+ const connections = options.connections || {};
118
+
119
+ for (const serviceKey of Object.keys(this.catalog)) {
120
+ const plugin = this.build(serviceKey, {
121
+ credentials: connections[serviceKey] || null,
122
+ });
123
+ plugins.set(serviceKey, plugin);
124
+ }
125
+
126
+ return plugins;
127
+ }
128
+
129
+ /**
130
+ * Build plugins only for connected services.
131
+ *
132
+ * @param {object} connections — Map of { serviceKey: credentials }
133
+ * @returns {Map<string, Plugin>}
134
+ */
135
+ buildConnected(connections) {
136
+ const plugins = new Map();
137
+
138
+ for (const [serviceKey, credentials] of Object.entries(connections)) {
139
+ if (!this.catalog[serviceKey]) continue;
140
+ const plugin = this.build(serviceKey, { credentials });
141
+ plugins.set(serviceKey, plugin);
142
+ }
143
+
144
+ return plugins;
145
+ }
146
+
147
+ // ── Build from Spec ───────────────────────────────────────
148
+
149
+ /**
150
+ * Build a Plugin from a .0n plugin spec (custom service definition).
151
+ * This is how users define their own services beyond the catalog.
152
+ *
153
+ * @param {object} spec — Plugin specification
154
+ * @param {string} spec.service — Service key
155
+ * @param {string} spec.name — Display name
156
+ * @param {string} spec.type — Category type
157
+ * @param {string} spec.baseUrl — API base URL
158
+ * @param {string} spec.authType — Auth type (api_key, oauth, basic)
159
+ * @param {string[]} spec.credentialKeys — Required credential keys
160
+ * @param {object} spec.endpoints — Endpoint definitions
161
+ * @param {object[]} [spec.capabilities] — Capability list
162
+ * @param {Function|string} [spec.authHeader] — Auth header builder
163
+ * @param {object} [spec.fieldMappings] — Custom .0n field mappings
164
+ * @returns {Plugin}
165
+ */
166
+ buildFromSpec(spec, options = {}) {
167
+ if (!spec.service || !spec.baseUrl || !spec.endpoints) {
168
+ throw new Error("Plugin spec requires: service, baseUrl, endpoints");
169
+ }
170
+
171
+ // Build auth header function
172
+ let authHeaderFn;
173
+ if (typeof spec.authHeader === "function") {
174
+ authHeaderFn = spec.authHeader;
175
+ } else if (spec.authType === "api_key") {
176
+ const keyName = spec.credentialKeys?.[0] || "apiKey";
177
+ authHeaderFn = (creds) => ({
178
+ Authorization: `Bearer ${creds[keyName]}`,
179
+ "Content-Type": "application/json",
180
+ });
181
+ } else if (spec.authType === "basic") {
182
+ authHeaderFn = (creds) => ({
183
+ Authorization: `Basic ${Buffer.from(`${creds.username}:${creds.password}`).toString("base64")}`,
184
+ "Content-Type": "application/json",
185
+ });
186
+ } else if (spec.authType === "oauth") {
187
+ authHeaderFn = (creds) => ({
188
+ Authorization: `Bearer ${creds.access_token}`,
189
+ "Content-Type": "application/json",
190
+ });
191
+ } else {
192
+ authHeaderFn = () => ({ "Content-Type": "application/json" });
193
+ }
194
+
195
+ // Inject custom field mappings into the resolver
196
+ const customResolver = spec.fieldMappings
197
+ ? this._buildCustomResolver(spec.service, spec.fieldMappings)
198
+ : resolveFields;
199
+
200
+ const catalogEntry = {
201
+ name: spec.name || spec.service,
202
+ type: spec.type || "custom",
203
+ description: spec.description || `Custom plugin: ${spec.service}`,
204
+ baseUrl: spec.baseUrl,
205
+ authType: spec.authType || "api_key",
206
+ credentialKeys: spec.credentialKeys || [],
207
+ capabilities: spec.capabilities || [],
208
+ endpoints: spec.endpoints,
209
+ authHeader: authHeaderFn,
210
+ };
211
+
212
+ return new Plugin(spec.service, catalogEntry, {
213
+ resolveFields: customResolver,
214
+ reverseResolve,
215
+ credentials: options.credentials || null,
216
+ rateLimit: options.rateLimit || this.defaultRateLimit,
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Build a Plugin from a .0n plugin file on disk.
222
+ */
223
+ buildFromFile(filePath) {
224
+ const raw = readFileSync(filePath, "utf-8");
225
+ const spec = JSON.parse(raw);
226
+
227
+ if (spec.$0n?.type !== "plugin") {
228
+ throw new Error(`File is not a .0n plugin: ${filePath}`);
229
+ }
230
+
231
+ return this.buildFromSpec(spec);
232
+ }
233
+
234
+ // ── Plugin Generation ─────────────────────────────────────
235
+
236
+ /**
237
+ * Generate a new plugin spec from a minimal definition.
238
+ * Auto-infers capabilities from endpoints, adds .0n field mappings.
239
+ *
240
+ * @param {object} def — Minimal service definition
241
+ * @returns {object} — Complete .0n plugin spec
242
+ */
243
+ generate(def) {
244
+ if (!def.key || !def.baseUrl || !def.endpoints) {
245
+ throw new Error("generate() requires: key, baseUrl, endpoints");
246
+ }
247
+
248
+ // Auto-infer capabilities from endpoint names
249
+ const capabilities = this._inferCapabilities(def.endpoints);
250
+
251
+ // Auto-infer field mappings from endpoint body schemas
252
+ const fieldMappings = def.fieldMappings || this._inferFieldMappings(def.endpoints);
253
+
254
+ const spec = {
255
+ $0n: {
256
+ type: "plugin",
257
+ version: "1.0.0",
258
+ name: def.name || def.key,
259
+ description: def.description || `Custom plugin for ${def.key}`,
260
+ author: def.author || "0nEngine",
261
+ created: new Date().toISOString(),
262
+ },
263
+ service: def.key,
264
+ serviceType: def.type || "custom",
265
+ description: def.description || `Custom plugin for ${def.key}`,
266
+ baseUrl: def.baseUrl,
267
+ authType: def.authType || "api_key",
268
+ credentialKeys: def.credentialKeys || ["apiKey"],
269
+ capabilities,
270
+ endpoints: def.endpoints,
271
+ fieldMappings,
272
+ };
273
+
274
+ return spec;
275
+ }
276
+
277
+ /**
278
+ * Generate a plugin spec and save it as a .0n file.
279
+ */
280
+ generateAndSave(def, outputPath) {
281
+ const spec = this.generate(def);
282
+
283
+ const savePath = outputPath || join(PLUGINS_DIR, `${def.key}.0n.json`);
284
+ const dir = join(savePath, "..");
285
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
286
+
287
+ writeFileSync(savePath, JSON.stringify(spec, null, 2));
288
+
289
+ return { spec, path: savePath };
290
+ }
291
+
292
+ // ── Bulk MCP Tool Registration ────────────────────────────
293
+
294
+ /**
295
+ * Generate MCP tool definitions for all catalog services.
296
+ * Returns a flat array of tool schemas ready for McpServer.tool().
297
+ *
298
+ * @param {object} [options]
299
+ * @param {string[]} [options.services] — Only these services (default: all)
300
+ * @returns {{ tools: object[], count: number, services: string[] }}
301
+ */
302
+ generateAllTools(options = {}) {
303
+ const serviceKeys = options.services || Object.keys(this.catalog);
304
+ const allTools = [];
305
+ const includedServices = [];
306
+
307
+ for (const key of serviceKeys) {
308
+ if (!this.catalog[key]) continue;
309
+ const plugin = this.build(key);
310
+ const tools = plugin.toMcpTools();
311
+ allTools.push(...tools);
312
+ includedServices.push(key);
313
+ }
314
+
315
+ return {
316
+ tools: allTools,
317
+ count: allTools.length,
318
+ services: includedServices,
319
+ };
320
+ }
321
+
322
+ // ── Field Analysis ────────────────────────────────────────
323
+
324
+ /**
325
+ * Get field coverage report — which .0n fields are mapped for which services.
326
+ */
327
+ getFieldCoverage() {
328
+ const fields = listFields();
329
+ const services = Object.keys(this.catalog);
330
+
331
+ const coverage = fields.map(f => {
332
+ const mapped = services.filter(s => {
333
+ const canonical = this.fields[f.field];
334
+ return canonical?.mappings?.[s] !== undefined;
335
+ });
336
+ return {
337
+ field: f.field,
338
+ label: f.label,
339
+ type: f.type,
340
+ mappedServices: mapped.length,
341
+ totalServices: services.length,
342
+ coverage: Math.round((mapped.length / services.length) * 100),
343
+ services: mapped,
344
+ };
345
+ });
346
+
347
+ const totalMappings = coverage.reduce((sum, f) => sum + f.mappedServices, 0);
348
+ const maxMappings = fields.length * services.length;
349
+
350
+ return {
351
+ fields: coverage,
352
+ summary: {
353
+ totalFields: fields.length,
354
+ totalServices: services.length,
355
+ totalMappings,
356
+ maxPossibleMappings: maxMappings,
357
+ overallCoverage: Math.round((totalMappings / maxMappings) * 100),
358
+ },
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Get all .0n field mappings for a specific service.
364
+ */
365
+ getServiceFields(serviceKey) {
366
+ return getServiceMappings(serviceKey);
367
+ }
368
+
369
+ /**
370
+ * Show what a .0n data object looks like after resolution for a service.
371
+ * Useful for debugging field mappings.
372
+ */
373
+ previewResolution(data, serviceKey) {
374
+ const resolved = resolveFields(data, serviceKey);
375
+ return {
376
+ input: data,
377
+ service: serviceKey,
378
+ resolved,
379
+ fieldCount: Object.keys(data).filter(k => k.endsWith(".0n")).length,
380
+ resolvedCount: Object.keys(resolved).length,
381
+ };
382
+ }
383
+
384
+ // ── Stats ─────────────────────────────────────────────────
385
+
386
+ /**
387
+ * Get aggregated stats across all cached plugins.
388
+ */
389
+ getStats() {
390
+ const stats = {
391
+ cachedPlugins: this._cache.size,
392
+ catalogServices: Object.keys(this.catalog).length,
393
+ totalEndpoints: 0,
394
+ totalCapabilities: 0,
395
+ serviceBreakdown: {},
396
+ };
397
+
398
+ for (const [key, entry] of Object.entries(this.catalog)) {
399
+ const endpointCount = Object.keys(entry.endpoints || {}).length;
400
+ const capCount = entry.capabilities?.length || 0;
401
+ stats.totalEndpoints += endpointCount;
402
+ stats.totalCapabilities += capCount;
403
+ stats.serviceBreakdown[key] = {
404
+ name: entry.name,
405
+ type: entry.type,
406
+ endpoints: endpointCount,
407
+ capabilities: capCount,
408
+ };
409
+ }
410
+
411
+ return stats;
412
+ }
413
+
414
+ // ── Private Helpers ───────────────────────────────────────
415
+
416
+ /**
417
+ * Build a custom field resolver that merges spec-defined mappings
418
+ * with the canonical CANONICAL_FIELDS.
419
+ */
420
+ _buildCustomResolver(serviceKey, fieldMappings) {
421
+ return (data, service) => {
422
+ if (service !== serviceKey) return resolveFields(data, service);
423
+
424
+ const resolved = {};
425
+ for (const [key, value] of Object.entries(data)) {
426
+ if (!key.endsWith(".0n")) {
427
+ resolved[key] = value;
428
+ continue;
429
+ }
430
+
431
+ // Check custom mappings first
432
+ if (fieldMappings[key]) {
433
+ const target = fieldMappings[key];
434
+ if (typeof target === "string") {
435
+ resolved[target] = value;
436
+ } else if (Array.isArray(target) && typeof value === "string") {
437
+ const parts = value.split(" ");
438
+ resolved[target[0]] = parts[0] || "";
439
+ if (target[1]) resolved[target[1]] = parts.slice(1).join(" ") || "";
440
+ }
441
+ continue;
442
+ }
443
+
444
+ // Fall back to canonical resolver
445
+ const canonical = CANONICAL_FIELDS[key];
446
+ if (canonical?.mappings?.[serviceKey]) {
447
+ const mapping = canonical.mappings[serviceKey];
448
+ if (typeof mapping === "string") {
449
+ resolved[mapping] = value;
450
+ }
451
+ } else {
452
+ // Strip .0n suffix as fallback
453
+ resolved[key.replace(".0n", "")] = value;
454
+ }
455
+ }
456
+ return resolved;
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Infer capabilities from endpoint names.
462
+ * Groups by entity and extracts actions.
463
+ */
464
+ _inferCapabilities(endpoints) {
465
+ const groups = {};
466
+
467
+ for (const name of Object.keys(endpoints)) {
468
+ // Pattern: action_entity (e.g., create_customer, list_orders)
469
+ const parts = name.split("_");
470
+ if (parts.length < 2) continue;
471
+
472
+ const action = parts[0];
473
+ let entity = parts.slice(1).join("_");
474
+ // Normalize plural → singular for grouping (contacts → contact, etc.)
475
+ if (entity.endsWith("ies")) {
476
+ entity = entity.slice(0, -3) + "y"; // e.g., entries → entry
477
+ } else if (entity.endsWith("ses") || entity.endsWith("xes") || entity.endsWith("zes")) {
478
+ entity = entity.slice(0, -2); // e.g., boxes → box
479
+ } else if (entity.endsWith("s") && !entity.endsWith("ss") && !entity.endsWith("us")) {
480
+ entity = entity.slice(0, -1); // e.g., contacts → contact
481
+ }
482
+ const capName = `manage_${entity}`;
483
+
484
+ if (!groups[capName]) {
485
+ groups[capName] = { name: capName, actions: [], description: "" };
486
+ }
487
+
488
+ if (!groups[capName].actions.includes(action)) {
489
+ groups[capName].actions.push(action);
490
+ }
491
+ }
492
+
493
+ // Build descriptions
494
+ for (const cap of Object.values(groups)) {
495
+ cap.description = `${cap.actions.join(", ")} ${cap.name.replace("manage_", "")}`;
496
+ }
497
+
498
+ return Object.values(groups);
499
+ }
500
+
501
+ /**
502
+ * Infer .0n field mappings from endpoint body schemas.
503
+ * Tries to match body keys to known canonical fields.
504
+ */
505
+ _inferFieldMappings(endpoints) {
506
+ const mappings = {};
507
+ const knownFields = new Set();
508
+
509
+ // Collect all body keys
510
+ for (const def of Object.values(endpoints)) {
511
+ if (!def.body) continue;
512
+ for (const key of Object.keys(def.body)) {
513
+ knownFields.add(key);
514
+ }
515
+ }
516
+
517
+ // Try to match against canonical fields
518
+ for (const [canonical, fieldDef] of Object.entries(CANONICAL_FIELDS)) {
519
+ // Check if any service mapping value matches a body key
520
+ for (const mapping of Object.values(fieldDef.mappings)) {
521
+ if (typeof mapping === "string" && knownFields.has(mapping)) {
522
+ mappings[canonical] = mapping;
523
+ break;
524
+ }
525
+ }
526
+
527
+ // Also check the stripped canonical name
528
+ const stripped = canonical.replace(".0n", "");
529
+ if (knownFields.has(stripped)) {
530
+ mappings[canonical] = stripped;
531
+ }
532
+ }
533
+
534
+ return Object.keys(mappings).length > 0 ? mappings : undefined;
535
+ }
536
+ }
537
+
538
+ // ── Singleton convenience ─────────────────────────────────
539
+
540
+ let _defaultBuilder = null;
541
+
542
+ /**
543
+ * Get the default PluginBuilder singleton.
544
+ */
545
+ export function getPluginBuilder() {
546
+ if (!_defaultBuilder) {
547
+ _defaultBuilder = new PluginBuilder();
548
+ }
549
+ return _defaultBuilder;
550
+ }
551
+
552
+ /**
553
+ * Quick-build a plugin from catalog.
554
+ */
555
+ export function buildPlugin(serviceKey, options) {
556
+ return getPluginBuilder().build(serviceKey, options);
557
+ }
558
+
559
+ /**
560
+ * Quick-build all catalog plugins.
561
+ */
562
+ export function buildAllPlugins(options) {
563
+ return getPluginBuilder().buildAll(options);
564
+ }
565
+
566
+ /**
567
+ * Quick-build from a .0n spec.
568
+ */
569
+ export function buildFromSpec(spec, options) {
570
+ return getPluginBuilder().buildFromSpec(spec, options);
571
+ }
572
+
573
+ /**
574
+ * Quick-generate a plugin spec.
575
+ */
576
+ export function generatePluginSpec(def) {
577
+ return getPluginBuilder().generate(def);
578
+ }