0nmcp 2.6.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.
- package/README.md +233 -695
- package/cli.js +9 -1
- package/crm/objects.js +5 -69
- package/crm/users.js +5 -80
- package/engine/index.js +338 -2
- package/engine/multi-ai.js +525 -0
- package/engine/plugin-builder.js +578 -0
- package/engine/plugin-registry.js +419 -0
- package/engine/plugin.js +448 -0
- package/engine/training-feed.js +520 -0
- package/engine/training.js +875 -0
- package/index.js +9 -1
- package/lib/stats.json +1 -1
- package/package.json +12 -2
package/engine/plugin.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 0nMCP — Plugin Class
|
|
3
|
+
// ============================================================
|
|
4
|
+
// A Plugin wraps a catalog service entry and provides:
|
|
5
|
+
// - .0n field resolution (canonical → service-specific)
|
|
6
|
+
// - Endpoint execution with path params, query, body
|
|
7
|
+
// - Auth header injection
|
|
8
|
+
// - Rate limiting
|
|
9
|
+
// - MCP tool schema generation
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// const plugin = new Plugin('stripe', catalogEntry, fieldResolver)
|
|
13
|
+
// const result = await plugin.execute('create_customer', {
|
|
14
|
+
// 'email.0n': 'mike@rocketopp.com',
|
|
15
|
+
// 'fullname.0n': 'Mike Mento'
|
|
16
|
+
// })
|
|
17
|
+
// ============================================================
|
|
18
|
+
|
|
19
|
+
import { RateLimiter } from "../ratelimit.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract path parameters from a URL template.
|
|
23
|
+
* e.g., "/contacts/{contactId}" → ["contactId"]
|
|
24
|
+
*/
|
|
25
|
+
function extractPathParams(pathTemplate) {
|
|
26
|
+
const matches = pathTemplate.match(/\{(\w+)\}/g);
|
|
27
|
+
return matches ? matches.map(m => m.slice(1, -1)) : [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Interpolate path parameters into a URL template.
|
|
32
|
+
*/
|
|
33
|
+
function interpolatePath(pathTemplate, params) {
|
|
34
|
+
let path = pathTemplate;
|
|
35
|
+
for (const [key, value] of Object.entries(params)) {
|
|
36
|
+
path = path.replace(`{${key}}`, encodeURIComponent(value));
|
|
37
|
+
}
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class Plugin {
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} key — Service key (e.g., 'stripe', 'crm')
|
|
44
|
+
* @param {object} catalogEntry — Raw catalog entry from SERVICE_CATALOG
|
|
45
|
+
* @param {object} [options]
|
|
46
|
+
* @param {Function} [options.resolveFields] — Field resolver from fields.js
|
|
47
|
+
* @param {Function} [options.reverseResolve] — Reverse resolver from fields.js
|
|
48
|
+
* @param {object} [options.credentials] — Pre-loaded credentials
|
|
49
|
+
* @param {number} [options.rateLimit] — Requests per second (default: 10)
|
|
50
|
+
*/
|
|
51
|
+
constructor(key, catalogEntry, options = {}) {
|
|
52
|
+
this.key = key;
|
|
53
|
+
this.name = catalogEntry.name;
|
|
54
|
+
this.type = catalogEntry.type;
|
|
55
|
+
this.description = catalogEntry.description;
|
|
56
|
+
this.baseUrl = catalogEntry.baseUrl;
|
|
57
|
+
this.authType = catalogEntry.authType;
|
|
58
|
+
this.credentialKeys = catalogEntry.credentialKeys || [];
|
|
59
|
+
this.capabilities = catalogEntry.capabilities || [];
|
|
60
|
+
this.endpoints = catalogEntry.endpoints || {};
|
|
61
|
+
this.authHeaderFn = catalogEntry.authHeader;
|
|
62
|
+
|
|
63
|
+
// Field resolution
|
|
64
|
+
this._resolveFields = options.resolveFields || null;
|
|
65
|
+
this._reverseResolve = options.reverseResolve || null;
|
|
66
|
+
|
|
67
|
+
// Credentials
|
|
68
|
+
this.credentials = options.credentials || null;
|
|
69
|
+
|
|
70
|
+
// Rate limiting
|
|
71
|
+
this.rateLimiter = new RateLimiter(options.rateLimit || 10, 1000);
|
|
72
|
+
|
|
73
|
+
// Execution stats
|
|
74
|
+
this.stats = {
|
|
75
|
+
totalCalls: 0,
|
|
76
|
+
successCalls: 0,
|
|
77
|
+
failedCalls: 0,
|
|
78
|
+
lastCallAt: null,
|
|
79
|
+
totalDurationMs: 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Connection ────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Set credentials for this plugin.
|
|
87
|
+
*/
|
|
88
|
+
connect(credentials) {
|
|
89
|
+
this.credentials = credentials;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if this plugin has valid credentials.
|
|
95
|
+
*/
|
|
96
|
+
get isConnected() {
|
|
97
|
+
if (!this.credentials) return false;
|
|
98
|
+
return this.credentialKeys.every(k => this.credentials[k]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get auth headers using the catalog's authHeader function.
|
|
103
|
+
*/
|
|
104
|
+
getAuthHeaders() {
|
|
105
|
+
if (!this.credentials || !this.authHeaderFn) return {};
|
|
106
|
+
return this.authHeaderFn(this.credentials);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Field Resolution ──────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve .0n canonical fields to service-specific fields.
|
|
113
|
+
* Pass-through for non-.0n keys.
|
|
114
|
+
*/
|
|
115
|
+
resolveFields(data) {
|
|
116
|
+
if (!this._resolveFields) return data;
|
|
117
|
+
return this._resolveFields(data, this.key);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reverse-resolve a service field back to .0n canonical.
|
|
122
|
+
*/
|
|
123
|
+
reverseResolve(serviceField) {
|
|
124
|
+
if (!this._reverseResolve) return null;
|
|
125
|
+
return this._reverseResolve(serviceField, this.key);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Normalize response data — reverse-resolve service fields to .0n canonical.
|
|
130
|
+
*/
|
|
131
|
+
normalizeResponse(data) {
|
|
132
|
+
if (!this._reverseResolve || !data || typeof data !== "object") return data;
|
|
133
|
+
|
|
134
|
+
if (Array.isArray(data)) {
|
|
135
|
+
return data.map(item => this.normalizeResponse(item));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const normalized = {};
|
|
139
|
+
for (const [key, value] of Object.entries(data)) {
|
|
140
|
+
const canonical = this._reverseResolve(key, this.key);
|
|
141
|
+
normalized[canonical || key] = value;
|
|
142
|
+
if (canonical && canonical !== key) {
|
|
143
|
+
normalized[key] = value; // Keep original too
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return normalized;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Endpoint Discovery ────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* List all available endpoints.
|
|
153
|
+
*/
|
|
154
|
+
listEndpoints() {
|
|
155
|
+
return Object.entries(this.endpoints).map(([name, def]) => ({
|
|
156
|
+
name,
|
|
157
|
+
method: def.method,
|
|
158
|
+
path: def.path,
|
|
159
|
+
pathParams: extractPathParams(def.path),
|
|
160
|
+
queryParams: def.query || [],
|
|
161
|
+
hasBody: !!def.body,
|
|
162
|
+
contentType: def.contentType || "application/json",
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get a specific endpoint definition.
|
|
168
|
+
*/
|
|
169
|
+
getEndpoint(name) {
|
|
170
|
+
return this.endpoints[name] || null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find endpoints matching a capability action.
|
|
175
|
+
* e.g., findEndpoints('create', 'customer') → ['create_customer']
|
|
176
|
+
*/
|
|
177
|
+
findEndpoints(action, entity) {
|
|
178
|
+
const search = entity
|
|
179
|
+
? `${action}_${entity}`.toLowerCase()
|
|
180
|
+
: action.toLowerCase();
|
|
181
|
+
|
|
182
|
+
return Object.keys(this.endpoints).filter(name =>
|
|
183
|
+
name.toLowerCase().includes(search)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Execution ─────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build a fetch request for an endpoint.
|
|
191
|
+
* Resolves .0n fields, interpolates path params, builds query string.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} endpointName — Endpoint key from catalog
|
|
194
|
+
* @param {object} params — Mixed .0n canonical + raw params
|
|
195
|
+
* @returns {{ url: string, options: RequestInit, endpoint: object }}
|
|
196
|
+
*/
|
|
197
|
+
buildRequest(endpointName, params = {}) {
|
|
198
|
+
const endpoint = this.endpoints[endpointName];
|
|
199
|
+
if (!endpoint) {
|
|
200
|
+
throw new Error(`Unknown endpoint: ${endpointName} (service: ${this.key})`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Resolve .0n fields to service-specific
|
|
204
|
+
const resolved = this.resolveFields(params);
|
|
205
|
+
|
|
206
|
+
// Interpolate base URL (some services like MongoDB have {appId} in baseUrl)
|
|
207
|
+
let baseUrl = this.baseUrl;
|
|
208
|
+
if (this.credentials) {
|
|
209
|
+
for (const [key, value] of Object.entries(this.credentials)) {
|
|
210
|
+
baseUrl = baseUrl.replace(`{${key}}`, encodeURIComponent(value));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Extract path params from resolved data
|
|
215
|
+
const pathParams = extractPathParams(endpoint.path);
|
|
216
|
+
const pathValues = {};
|
|
217
|
+
const remaining = { ...resolved };
|
|
218
|
+
|
|
219
|
+
for (const param of pathParams) {
|
|
220
|
+
if (remaining[param] !== undefined) {
|
|
221
|
+
pathValues[param] = remaining[param];
|
|
222
|
+
delete remaining[param];
|
|
223
|
+
} else if (this.credentials && this.credentials[param] !== undefined) {
|
|
224
|
+
pathValues[param] = this.credentials[param];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const path = interpolatePath(endpoint.path, pathValues);
|
|
229
|
+
|
|
230
|
+
// Build query string
|
|
231
|
+
const queryParams = endpoint.query || [];
|
|
232
|
+
const queryParts = [];
|
|
233
|
+
for (const qp of queryParams) {
|
|
234
|
+
if (remaining[qp] !== undefined) {
|
|
235
|
+
queryParts.push(`${encodeURIComponent(qp)}=${encodeURIComponent(remaining[qp])}`);
|
|
236
|
+
delete remaining[qp];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const queryString = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
|
|
241
|
+
const url = `${baseUrl}${path}${queryString}`;
|
|
242
|
+
|
|
243
|
+
// Build request options
|
|
244
|
+
const headers = this.getAuthHeaders();
|
|
245
|
+
const options = {
|
|
246
|
+
method: endpoint.method,
|
|
247
|
+
headers,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Build body for POST/PUT/PATCH
|
|
251
|
+
if (["POST", "PUT", "PATCH"].includes(endpoint.method) && Object.keys(remaining).length > 0) {
|
|
252
|
+
const contentType = endpoint.contentType || headers["Content-Type"] || "application/json";
|
|
253
|
+
|
|
254
|
+
if (contentType === "application/x-www-form-urlencoded") {
|
|
255
|
+
options.body = new URLSearchParams(remaining).toString();
|
|
256
|
+
options.headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
257
|
+
} else {
|
|
258
|
+
// Merge with endpoint body template
|
|
259
|
+
const body = endpoint.body ? { ...endpoint.body, ...remaining } : remaining;
|
|
260
|
+
options.body = JSON.stringify(body);
|
|
261
|
+
if (!options.headers["Content-Type"]) {
|
|
262
|
+
options.headers["Content-Type"] = "application/json";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { url, options, endpoint };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Execute an endpoint with automatic field resolution, auth, and rate limiting.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} endpointName — Endpoint key from catalog
|
|
274
|
+
* @param {object} params — Mixed .0n canonical + raw params
|
|
275
|
+
* @returns {Promise<{ success: boolean, data?: any, error?: string, meta: object }>}
|
|
276
|
+
*/
|
|
277
|
+
async execute(endpointName, params = {}) {
|
|
278
|
+
if (!this.isConnected) {
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
error: `Plugin "${this.key}" is not connected. Call plugin.connect(credentials) first.`,
|
|
282
|
+
meta: { service: this.key, endpoint: endpointName },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Rate limit check
|
|
287
|
+
if (!this.rateLimiter.tryAcquire()) {
|
|
288
|
+
return {
|
|
289
|
+
success: false,
|
|
290
|
+
error: `Rate limit exceeded for ${this.key}. Try again shortly.`,
|
|
291
|
+
meta: { service: this.key, endpoint: endpointName, rateLimited: true },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const startTime = Date.now();
|
|
296
|
+
this.stats.totalCalls++;
|
|
297
|
+
this.stats.lastCallAt = new Date().toISOString();
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const { url, options } = this.buildRequest(endpointName, params);
|
|
301
|
+
|
|
302
|
+
const response = await fetch(url, options);
|
|
303
|
+
const duration = Date.now() - startTime;
|
|
304
|
+
this.stats.totalDurationMs += duration;
|
|
305
|
+
|
|
306
|
+
let data;
|
|
307
|
+
const contentType = response.headers.get("content-type") || "";
|
|
308
|
+
if (contentType.includes("application/json")) {
|
|
309
|
+
data = await response.json();
|
|
310
|
+
} else {
|
|
311
|
+
data = await response.text();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (response.ok) {
|
|
315
|
+
this.stats.successCalls++;
|
|
316
|
+
return {
|
|
317
|
+
success: true,
|
|
318
|
+
data: this.normalizeResponse(data),
|
|
319
|
+
meta: {
|
|
320
|
+
service: this.key,
|
|
321
|
+
endpoint: endpointName,
|
|
322
|
+
status: response.status,
|
|
323
|
+
durationMs: duration,
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
} else {
|
|
327
|
+
this.stats.failedCalls++;
|
|
328
|
+
return {
|
|
329
|
+
success: false,
|
|
330
|
+
error: typeof data === "object" ? JSON.stringify(data) : data,
|
|
331
|
+
meta: {
|
|
332
|
+
service: this.key,
|
|
333
|
+
endpoint: endpointName,
|
|
334
|
+
status: response.status,
|
|
335
|
+
durationMs: duration,
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
const duration = Date.now() - startTime;
|
|
341
|
+
this.stats.totalDurationMs += duration;
|
|
342
|
+
this.stats.failedCalls++;
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
error: err.message,
|
|
347
|
+
meta: {
|
|
348
|
+
service: this.key,
|
|
349
|
+
endpoint: endpointName,
|
|
350
|
+
durationMs: duration,
|
|
351
|
+
errorType: err.constructor.name,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── MCP Tool Generation ───────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Generate MCP tool schemas for every endpoint in this plugin.
|
|
361
|
+
* Returns an array of { name, description, schema, handler } objects
|
|
362
|
+
* ready to register on a McpServer.
|
|
363
|
+
*/
|
|
364
|
+
toMcpTools() {
|
|
365
|
+
const tools = [];
|
|
366
|
+
|
|
367
|
+
for (const [endpointName, endpointDef] of Object.entries(this.endpoints)) {
|
|
368
|
+
const pathParams = extractPathParams(endpointDef.path);
|
|
369
|
+
const queryParams = endpointDef.query || [];
|
|
370
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(endpointDef.method);
|
|
371
|
+
const bodyKeys = endpointDef.body ? Object.keys(endpointDef.body) : [];
|
|
372
|
+
|
|
373
|
+
// Build tool name: service_endpoint (e.g., stripe_create_customer)
|
|
374
|
+
const toolName = `${this.key}_${endpointName}`;
|
|
375
|
+
|
|
376
|
+
// Build description
|
|
377
|
+
const methodLabel = endpointDef.method;
|
|
378
|
+
const desc = `[${this.name}] ${methodLabel} ${endpointDef.path}`;
|
|
379
|
+
|
|
380
|
+
// Build parameter list for documentation
|
|
381
|
+
const allParams = [
|
|
382
|
+
...pathParams.map(p => `${p} (path, required)`),
|
|
383
|
+
...queryParams.map(p => `${p} (query)`),
|
|
384
|
+
...bodyKeys.map(p => `${p} (body)`),
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
tools.push({
|
|
388
|
+
name: toolName,
|
|
389
|
+
description: `${desc}\nParams: ${allParams.join(", ") || "none"}\nSupports .0n canonical fields (email.0n, fullname.0n, etc.)`,
|
|
390
|
+
pathParams,
|
|
391
|
+
queryParams,
|
|
392
|
+
bodyKeys,
|
|
393
|
+
hasBody,
|
|
394
|
+
endpointName,
|
|
395
|
+
service: this.key,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return tools;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Introspection ─────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Get plugin metadata for inspection.
|
|
406
|
+
*/
|
|
407
|
+
inspect() {
|
|
408
|
+
return {
|
|
409
|
+
key: this.key,
|
|
410
|
+
name: this.name,
|
|
411
|
+
type: this.type,
|
|
412
|
+
description: this.description,
|
|
413
|
+
baseUrl: this.baseUrl,
|
|
414
|
+
authType: this.authType,
|
|
415
|
+
credentialKeys: this.credentialKeys,
|
|
416
|
+
connected: this.isConnected,
|
|
417
|
+
capabilities: this.capabilities.map(c => ({
|
|
418
|
+
name: c.name,
|
|
419
|
+
actions: c.actions,
|
|
420
|
+
description: c.description,
|
|
421
|
+
})),
|
|
422
|
+
endpoints: this.listEndpoints(),
|
|
423
|
+
stats: { ...this.stats },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Serialize plugin to a portable .0n format.
|
|
429
|
+
*/
|
|
430
|
+
toJSON() {
|
|
431
|
+
return {
|
|
432
|
+
$0n: {
|
|
433
|
+
type: "plugin",
|
|
434
|
+
version: "1.0.0",
|
|
435
|
+
name: this.name,
|
|
436
|
+
created: new Date().toISOString(),
|
|
437
|
+
},
|
|
438
|
+
service: this.key,
|
|
439
|
+
serviceType: this.type,
|
|
440
|
+
description: this.description,
|
|
441
|
+
baseUrl: this.baseUrl,
|
|
442
|
+
authType: this.authType,
|
|
443
|
+
credentialKeys: this.credentialKeys,
|
|
444
|
+
capabilities: this.capabilities,
|
|
445
|
+
endpoints: this.endpoints,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|