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.
@@ -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
+ }