paygate-mcp 3.3.0 → 3.5.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/dist/server.js CHANGED
@@ -47,6 +47,8 @@ const redis_client_1 = require("./redis-client");
47
47
  const redis_sync_1 = require("./redis-sync");
48
48
  const tokens_1 = require("./tokens");
49
49
  const admin_keys_1 = require("./admin-keys");
50
+ const plugin_1 = require("./plugin");
51
+ const groups_1 = require("./groups");
50
52
  /** Max request body size: 1MB */
51
53
  const MAX_BODY_SIZE = 1_048_576;
52
54
  class PayGateServer {
@@ -82,6 +84,9 @@ class PayGateServer {
82
84
  redisSync = null;
83
85
  /** Scoped token manager for short-lived delegated tokens */
84
86
  tokens;
87
+ /** Plugin manager for extensible middleware hooks */
88
+ plugins;
89
+ groups;
85
90
  /** Server start time (ms since epoch) */
86
91
  startedAt = Date.now();
87
92
  /** Whether the server is draining (shutting down gracefully) */
@@ -186,6 +191,17 @@ class PayGateServer {
186
191
  this.redisSync.atomicTopup(apiKey, amount).catch(() => { });
187
192
  }
188
193
  };
194
+ // Plugin manager for extensible middleware hooks
195
+ this.plugins = new plugin_1.PluginManager();
196
+ this.gate.pluginManager = this.plugins;
197
+ this.groups = new groups_1.KeyGroupManager();
198
+ this.gate.groupManager = this.groups;
199
+ this.metrics.registerGauge('paygate_plugins_total', 'Number of registered plugins', () => {
200
+ return this.plugins.count;
201
+ });
202
+ this.metrics.registerGauge('paygate_groups_total', 'Number of active key groups', () => {
203
+ return this.groups.count;
204
+ });
189
205
  // Scoped token manager (uses bootstrap admin key as signing secret, padded to min length)
190
206
  const tokenSecret = this.bootstrapAdminKey.length >= 8
191
207
  ? this.bootstrapAdminKey
@@ -212,6 +228,22 @@ class PayGateServer {
212
228
  };
213
229
  }
214
230
  }
231
+ /**
232
+ * Register a plugin for extensible middleware hooks.
233
+ * Plugins run in registration order.
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * server.use({
238
+ * name: 'custom-pricing',
239
+ * transformPrice: (tool, base) => tool.startsWith('premium_') ? base * 5 : null,
240
+ * });
241
+ * ```
242
+ */
243
+ use(plugin) {
244
+ this.plugins.register(plugin);
245
+ return this;
246
+ }
215
247
  async start() {
216
248
  // Initialize Redis sync before starting (loads state from Redis + starts pub/sub)
217
249
  if (this.redisSync) {
@@ -220,6 +252,10 @@ class PayGateServer {
220
252
  console.log('[paygate] Redis distributed state enabled');
221
253
  }
222
254
  await this.handler.start();
255
+ // Plugin lifecycle: onStart
256
+ if (this.plugins.count > 0) {
257
+ await this.plugins.executeStart();
258
+ }
223
259
  return new Promise((resolve, reject) => {
224
260
  this.server = (0, http_1.createServer)(async (req, res) => {
225
261
  try {
@@ -249,6 +285,12 @@ class PayGateServer {
249
285
  res.end();
250
286
  return;
251
287
  }
288
+ // Plugin: onRequest — let plugins handle custom endpoints before routing
289
+ if (this.plugins.count > 0) {
290
+ const handled = await this.plugins.executeOnRequest(req, res);
291
+ if (handled)
292
+ return;
293
+ }
252
294
  const url = req.url?.split('?')[0] || '/';
253
295
  switch (url) {
254
296
  case '/mcp':
@@ -366,6 +408,23 @@ class PayGateServer {
366
408
  break;
367
409
  case '/admin/keys/revoke':
368
410
  return this.handleRevokeAdminKey(req, res);
411
+ // ─── Plugin endpoints ──────────────────────────────────────────────
412
+ case '/plugins':
413
+ return this.handleListPlugins(req, res);
414
+ case '/groups':
415
+ if (req.method === 'GET')
416
+ return this.handleListGroups(req, res);
417
+ if (req.method === 'POST')
418
+ return this.handleCreateGroup(req, res);
419
+ break;
420
+ case '/groups/update':
421
+ return this.handleUpdateGroup(req, res);
422
+ case '/groups/delete':
423
+ return this.handleDeleteGroup(req, res);
424
+ case '/groups/assign':
425
+ return this.handleAssignKeyToGroup(req, res);
426
+ case '/groups/remove':
427
+ return this.handleRemoveKeyFromGroup(req, res);
369
428
  // ─── OAuth 2.1 endpoints ─────────────────────────────────────────
370
429
  case '/.well-known/oauth-authorization-server':
371
430
  return this.handleOAuthMetadata(req, res);
@@ -488,7 +547,22 @@ class PayGateServer {
488
547
  }
489
548
  return;
490
549
  }
491
- const response = await this.handler.handleRequest(request, apiKey, clientIp, scopedTokenTools);
550
+ // Plugin: beforeToolCall let plugins modify the request before forwarding
551
+ let pluginRequest = request;
552
+ if (this.plugins.count > 0 && request.method === 'tools/call') {
553
+ const toolName = request.params?.name || '';
554
+ const toolArgs = request.params?.arguments;
555
+ const pluginCtx = { apiKey, toolName, toolArgs, request };
556
+ pluginRequest = await this.plugins.executeBeforeToolCall(pluginCtx);
557
+ }
558
+ let response = await this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools);
559
+ // Plugin: afterToolCall — let plugins modify the response
560
+ if (this.plugins.count > 0 && request.method === 'tools/call') {
561
+ const toolName = request.params?.name || '';
562
+ const toolArgs = request.params?.arguments;
563
+ const pluginCtx = { apiKey, toolName, toolArgs, request };
564
+ response = await this.plugins.executeAfterToolCall(pluginCtx, response);
565
+ }
492
566
  // Inject pricing metadata into tools/list responses
493
567
  if (request.method === 'tools/list' && response.result) {
494
568
  const result = response.result;
@@ -760,6 +834,13 @@ class PayGateServer {
760
834
  adminKeys: 'GET /admin/keys — List admin keys (requires X-Admin-Key, super_admin)',
761
835
  createAdminKey: 'POST /admin/keys — Create admin key with role (requires X-Admin-Key, super_admin)',
762
836
  revokeAdminKey: 'POST /admin/keys/revoke — Revoke an admin key (requires X-Admin-Key, super_admin)',
837
+ plugins: 'GET /plugins — List registered plugins (requires X-Admin-Key)',
838
+ listGroups: 'GET /groups — List key groups (requires X-Admin-Key)',
839
+ createGroup: 'POST /groups — Create a key group (requires X-Admin-Key)',
840
+ updateGroup: 'POST /groups/update — Update group settings (requires X-Admin-Key)',
841
+ deleteGroup: 'POST /groups/delete — Delete a key group (requires X-Admin-Key)',
842
+ assignKeyToGroup: 'POST /groups/assign — Assign a key to a group (requires X-Admin-Key)',
843
+ removeKeyFromGroup: 'POST /groups/remove — Remove a key from a group (requires X-Admin-Key)',
763
844
  ...(this.oauth ? {
764
845
  oauthMetadata: 'GET /.well-known/oauth-authorization-server — OAuth 2.1 server metadata',
765
846
  oauthRegister: 'POST /oauth/register — Register OAuth client',
@@ -2484,6 +2565,241 @@ class PayGateServer {
2484
2565
  }));
2485
2566
  }
2486
2567
  // ─── /admin/keys — Admin key management ────────────────────────────────────
2568
+ // ─── GET /plugins — List registered plugins ─────────────────────────────
2569
+ handleListPlugins(req, res) {
2570
+ if (req.method !== 'GET') {
2571
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2572
+ res.end(JSON.stringify({ error: 'Method not allowed. Use GET.' }));
2573
+ return;
2574
+ }
2575
+ if (!this.checkAdmin(req, res))
2576
+ return;
2577
+ const plugins = this.plugins.list();
2578
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2579
+ res.end(JSON.stringify({ count: plugins.length, plugins }));
2580
+ }
2581
+ // ─── Key Group Endpoints ─────────────────────────────────────────────────
2582
+ handleListGroups(req, res) {
2583
+ if (!this.checkAdmin(req, res))
2584
+ return;
2585
+ const groups = this.groups.listGroups();
2586
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2587
+ res.end(JSON.stringify({ count: groups.length, groups }));
2588
+ }
2589
+ async handleCreateGroup(req, res) {
2590
+ if (!this.checkAdmin(req, res, 'admin'))
2591
+ return;
2592
+ const body = await this.readBody(req);
2593
+ let params;
2594
+ try {
2595
+ params = JSON.parse(body);
2596
+ }
2597
+ catch {
2598
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2599
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2600
+ return;
2601
+ }
2602
+ try {
2603
+ const group = this.groups.createGroup({
2604
+ name: String(params.name || ''),
2605
+ description: params.description,
2606
+ allowedTools: params.allowedTools,
2607
+ deniedTools: params.deniedTools,
2608
+ rateLimitPerMin: params.rateLimitPerMin,
2609
+ toolPricing: params.toolPricing,
2610
+ quota: params.quota ? {
2611
+ dailyCallLimit: Math.max(0, Math.floor(Number(params.quota.dailyCallLimit) || 0)),
2612
+ monthlyCallLimit: Math.max(0, Math.floor(Number(params.quota.monthlyCallLimit) || 0)),
2613
+ dailyCreditLimit: Math.max(0, Math.floor(Number(params.quota.dailyCreditLimit) || 0)),
2614
+ monthlyCreditLimit: Math.max(0, Math.floor(Number(params.quota.monthlyCreditLimit) || 0)),
2615
+ } : undefined,
2616
+ ipAllowlist: params.ipAllowlist,
2617
+ defaultCredits: params.defaultCredits,
2618
+ maxSpendingLimit: params.maxSpendingLimit,
2619
+ tags: params.tags,
2620
+ });
2621
+ this.audit.log('group.created', 'admin', `Group created: ${group.name}`, { groupId: group.id, name: group.name });
2622
+ res.writeHead(201, { 'Content-Type': 'application/json' });
2623
+ res.end(JSON.stringify(group));
2624
+ }
2625
+ catch (err) {
2626
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2627
+ res.end(JSON.stringify({ error: err.message }));
2628
+ }
2629
+ }
2630
+ async handleUpdateGroup(req, res) {
2631
+ if (req.method !== 'POST') {
2632
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2633
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
2634
+ return;
2635
+ }
2636
+ if (!this.checkAdmin(req, res, 'admin'))
2637
+ return;
2638
+ const body = await this.readBody(req);
2639
+ let params;
2640
+ try {
2641
+ params = JSON.parse(body);
2642
+ }
2643
+ catch {
2644
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2645
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2646
+ return;
2647
+ }
2648
+ const groupId = String(params.id || '');
2649
+ if (!groupId) {
2650
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2651
+ res.end(JSON.stringify({ error: 'Missing id field' }));
2652
+ return;
2653
+ }
2654
+ try {
2655
+ const group = this.groups.updateGroup(groupId, {
2656
+ name: params.name,
2657
+ description: params.description,
2658
+ allowedTools: params.allowedTools,
2659
+ deniedTools: params.deniedTools,
2660
+ rateLimitPerMin: params.rateLimitPerMin,
2661
+ toolPricing: params.toolPricing,
2662
+ quota: params.quota === null ? null : params.quota ? {
2663
+ dailyCallLimit: Math.max(0, Math.floor(Number(params.quota.dailyCallLimit) || 0)),
2664
+ monthlyCallLimit: Math.max(0, Math.floor(Number(params.quota.monthlyCallLimit) || 0)),
2665
+ dailyCreditLimit: Math.max(0, Math.floor(Number(params.quota.dailyCreditLimit) || 0)),
2666
+ monthlyCreditLimit: Math.max(0, Math.floor(Number(params.quota.monthlyCreditLimit) || 0)),
2667
+ } : undefined,
2668
+ ipAllowlist: params.ipAllowlist,
2669
+ defaultCredits: params.defaultCredits,
2670
+ maxSpendingLimit: params.maxSpendingLimit,
2671
+ tags: params.tags,
2672
+ });
2673
+ this.audit.log('group.updated', 'admin', `Group updated: ${group.name}`, { groupId: group.id });
2674
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2675
+ res.end(JSON.stringify(group));
2676
+ }
2677
+ catch (err) {
2678
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2679
+ res.end(JSON.stringify({ error: err.message }));
2680
+ }
2681
+ }
2682
+ async handleDeleteGroup(req, res) {
2683
+ if (req.method !== 'POST') {
2684
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2685
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
2686
+ return;
2687
+ }
2688
+ if (!this.checkAdmin(req, res, 'admin'))
2689
+ return;
2690
+ const body = await this.readBody(req);
2691
+ let params;
2692
+ try {
2693
+ params = JSON.parse(body);
2694
+ }
2695
+ catch {
2696
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2697
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2698
+ return;
2699
+ }
2700
+ const groupId = String(params.id || '');
2701
+ if (!groupId) {
2702
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2703
+ res.end(JSON.stringify({ error: 'Missing id field' }));
2704
+ return;
2705
+ }
2706
+ const deleted = this.groups.deleteGroup(groupId);
2707
+ if (!deleted) {
2708
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2709
+ res.end(JSON.stringify({ error: 'Group not found' }));
2710
+ return;
2711
+ }
2712
+ this.audit.log('group.deleted', 'admin', `Group deleted: ${groupId}`, { groupId });
2713
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2714
+ res.end(JSON.stringify({ ok: true, message: `Group ${groupId} deleted` }));
2715
+ }
2716
+ async handleAssignKeyToGroup(req, res) {
2717
+ if (req.method !== 'POST') {
2718
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2719
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
2720
+ return;
2721
+ }
2722
+ if (!this.checkAdmin(req, res, 'admin'))
2723
+ return;
2724
+ const body = await this.readBody(req);
2725
+ let params;
2726
+ try {
2727
+ params = JSON.parse(body);
2728
+ }
2729
+ catch {
2730
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2731
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2732
+ return;
2733
+ }
2734
+ const apiKey = String(params.key || '');
2735
+ const groupId = String(params.groupId || '');
2736
+ if (!apiKey || !groupId) {
2737
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2738
+ res.end(JSON.stringify({ error: 'Missing key or groupId field' }));
2739
+ return;
2740
+ }
2741
+ // Verify key exists
2742
+ const keyRecord = this.gate.store.getKey(apiKey);
2743
+ if (!keyRecord) {
2744
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2745
+ res.end(JSON.stringify({ error: 'API key not found' }));
2746
+ return;
2747
+ }
2748
+ try {
2749
+ this.groups.assignKey(apiKey, groupId);
2750
+ // Update key record's group field
2751
+ keyRecord.group = groupId;
2752
+ this.audit.log('group.key_assigned', 'admin', `Key assigned to group ${groupId}`, {
2753
+ keyMasked: (0, audit_1.maskKeyForAudit)(apiKey), groupId,
2754
+ });
2755
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2756
+ res.end(JSON.stringify({ ok: true, message: `Key assigned to group ${groupId}` }));
2757
+ }
2758
+ catch (err) {
2759
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2760
+ res.end(JSON.stringify({ error: err.message }));
2761
+ }
2762
+ }
2763
+ async handleRemoveKeyFromGroup(req, res) {
2764
+ if (req.method !== 'POST') {
2765
+ res.writeHead(405, { 'Content-Type': 'application/json' });
2766
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
2767
+ return;
2768
+ }
2769
+ if (!this.checkAdmin(req, res, 'admin'))
2770
+ return;
2771
+ const body = await this.readBody(req);
2772
+ let params;
2773
+ try {
2774
+ params = JSON.parse(body);
2775
+ }
2776
+ catch {
2777
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2778
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
2779
+ return;
2780
+ }
2781
+ const apiKey = String(params.key || '');
2782
+ if (!apiKey) {
2783
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2784
+ res.end(JSON.stringify({ error: 'Missing key field' }));
2785
+ return;
2786
+ }
2787
+ const removed = this.groups.removeKey(apiKey);
2788
+ if (!removed) {
2789
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2790
+ res.end(JSON.stringify({ error: 'Key not in any group' }));
2791
+ return;
2792
+ }
2793
+ // Clear group field on key record
2794
+ const keyRecord = this.gate.store.getKey(apiKey);
2795
+ if (keyRecord) {
2796
+ delete keyRecord.group;
2797
+ }
2798
+ this.audit.log('group.key_removed', 'admin', `Key removed from group`, { keyMasked: (0, audit_1.maskKeyForAudit)(apiKey) });
2799
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2800
+ res.end(JSON.stringify({ ok: true, message: 'Key removed from group' }));
2801
+ }
2802
+ // ─── Admin Key Management ────────────────────────────────────────────────
2487
2803
  handleListAdminKeys(req, res) {
2488
2804
  if (req.method !== 'GET') {
2489
2805
  res.writeHead(405, { 'Content-Type': 'application/json' });
@@ -2634,6 +2950,10 @@ class PayGateServer {
2634
2950
  });
2635
2951
  }
2636
2952
  async stop() {
2953
+ // Plugin lifecycle: onStop (reverse order)
2954
+ if (this.plugins.count > 0) {
2955
+ await this.plugins.executeStop();
2956
+ }
2637
2957
  await this.handler.stop();
2638
2958
  this.gate.destroy();
2639
2959
  this.oauth?.destroy();
@@ -2684,6 +3004,10 @@ class PayGateServer {
2684
3004
  check();
2685
3005
  });
2686
3006
  console.log('[paygate] Drained — tearing down resources');
3007
+ // Plugin lifecycle: onStop (reverse order)
3008
+ if (this.plugins.count > 0) {
3009
+ await this.plugins.executeStop();
3010
+ }
2687
3011
  // Tear down resources (but skip server.close, already closed above)
2688
3012
  await this.handler.stop();
2689
3013
  this.gate.destroy();