paygate-mcp 7.2.0 → 7.4.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
@@ -231,6 +231,16 @@ class PayGateServer {
231
231
  scheduleTimer = null;
232
232
  /** Auto-incrementing schedule ID counter */
233
233
  nextScheduleId = 1;
234
+ /** Credit reservations (holds) */
235
+ creditReservations = new Map();
236
+ /** Auto-incrementing reservation ID counter */
237
+ nextReservationId = 1;
238
+ /** Request log — ring buffer of tool call entries */
239
+ requestLog = [];
240
+ /** Next request log entry ID */
241
+ nextRequestLogId = 1;
242
+ /** Max request log entries (ring buffer) */
243
+ maxRequestLogEntries = 5000;
234
244
  /** Number of in-flight /mcp requests */
235
245
  inflight = 0;
236
246
  /** Config file path for hot reload (null if not using config file) */
@@ -574,6 +584,26 @@ class PayGateServer {
574
584
  res.writeHead(405, { 'Content-Type': 'application/json' });
575
585
  res.end(JSON.stringify({ error: 'Method not allowed. Use GET.' }));
576
586
  return;
587
+ case '/keys/reserve':
588
+ if (req.method === 'GET')
589
+ return this.handleListReservations(req, res);
590
+ if (req.method === 'POST')
591
+ return this.handleCreateReservation(req, res);
592
+ res.writeHead(405, { 'Content-Type': 'application/json' });
593
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
594
+ return;
595
+ case '/keys/reserve/commit':
596
+ if (req.method === 'POST')
597
+ return this.handleCommitReservation(req, res);
598
+ res.writeHead(405, { 'Content-Type': 'application/json' });
599
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
600
+ return;
601
+ case '/keys/reserve/release':
602
+ if (req.method === 'POST')
603
+ return this.handleReleaseReservation(req, res);
604
+ res.writeHead(405, { 'Content-Type': 'application/json' });
605
+ res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
606
+ return;
577
607
  case '/keys/rotate':
578
608
  return this.handleRotateKey(req, res);
579
609
  case '/keys/acl':
@@ -642,6 +672,12 @@ class PayGateServer {
642
672
  return this.handleAuditExport(req, res);
643
673
  case '/audit/stats':
644
674
  return this.handleAuditStats(req, res);
675
+ case '/requests':
676
+ if (req.method === 'GET')
677
+ return this.handleRequestLog(req, res);
678
+ res.writeHead(405, { 'Content-Type': 'application/json' });
679
+ res.end(JSON.stringify({ error: 'Method not allowed. Use GET.' }));
680
+ return;
645
681
  // ─── Registry / Discovery endpoints ──────────────────────────────
646
682
  case '/.well-known/mcp-payment':
647
683
  return this.handlePaymentMetadata(req, res);
@@ -891,6 +927,7 @@ class PayGateServer {
891
927
  const pluginCtx = { apiKey, toolName, toolArgs, request };
892
928
  pluginRequest = await this.plugins.executeBeforeToolCall(pluginCtx);
893
929
  }
930
+ const toolCallStartTime = Date.now();
894
931
  let response = await this.handler.handleRequest(pluginRequest, apiKey, clientIp, scopedTokenTools);
895
932
  // Plugin: afterToolCall — let plugins modify the response
896
933
  if (this.plugins.count > 0 && request.method === 'tools/call') {
@@ -958,6 +995,49 @@ class PayGateServer {
958
995
  }
959
996
  }
960
997
  }
998
+ // Record request log entry for tools/call
999
+ if (request.method === 'tools/call') {
1000
+ const toolName = request.params?.name || 'unknown';
1001
+ const durationMs = Date.now() - toolCallStartTime;
1002
+ const isError = !!response.error;
1003
+ let denyReason;
1004
+ if (isError) {
1005
+ const msg = response.error.message || '';
1006
+ if (msg.includes('rate_limited'))
1007
+ denyReason = 'rate_limited';
1008
+ else if (msg.includes('insufficient_credits'))
1009
+ denyReason = 'insufficient_credits';
1010
+ else if (msg.includes('invalid_api_key'))
1011
+ denyReason = 'invalid_api_key';
1012
+ else if (msg.includes('key_suspended'))
1013
+ denyReason = 'key_suspended';
1014
+ else if (msg.includes('api_key_expired'))
1015
+ denyReason = 'api_key_expired';
1016
+ else if (msg.includes('tool_not_allowed'))
1017
+ denyReason = 'tool_not_allowed';
1018
+ else if (msg.includes('quota_exceeded'))
1019
+ denyReason = 'quota_exceeded';
1020
+ else
1021
+ denyReason = msg || 'denied';
1022
+ }
1023
+ const creditsCharged = isError ? 0 : this.gate.getToolPrice(toolName, request.params?.arguments);
1024
+ const logEntry = {
1025
+ id: this.nextRequestLogId++,
1026
+ timestamp: new Date().toISOString(),
1027
+ tool: toolName,
1028
+ key: (0, audit_1.maskKeyForAudit)(apiKey || 'anonymous'),
1029
+ status: (isError ? 'denied' : 'allowed'),
1030
+ credits: creditsCharged,
1031
+ durationMs,
1032
+ ...(denyReason ? { denyReason } : {}),
1033
+ requestId,
1034
+ };
1035
+ this.requestLog.push(logEntry);
1036
+ // Enforce ring buffer size
1037
+ if (this.requestLog.length > this.maxRequestLogEntries) {
1038
+ this.requestLog = this.requestLog.slice(-this.maxRequestLogEntries);
1039
+ }
1040
+ }
961
1041
  // Build rate limit + credits headers for tools/call responses
962
1042
  const rateLimitHeaders = this.buildRateLimitHeaders(apiKey, request);
963
1043
  // Check if client accepts SSE
@@ -1214,6 +1294,8 @@ class PayGateServer {
1214
1294
  keyNotes: 'GET /keys/notes?key=... — List notes + POST to add + DELETE to remove (requires X-Admin-Key)',
1215
1295
  keySchedule: 'GET /keys/schedule?key=... — List schedules + POST to create + DELETE to cancel (requires X-Admin-Key)',
1216
1296
  keyActivity: 'GET /keys/activity?key=... — Unified activity timeline for a key (requires X-Admin-Key)',
1297
+ creditReservations: 'POST /keys/reserve to hold credits, POST /keys/reserve/commit to deduct, POST /keys/reserve/release to release, GET /keys/reserve to list (requires X-Admin-Key)',
1298
+ requestLog: 'GET /requests — Queryable log of tool call requests with timing, credits, status (requires X-Admin-Key)',
1217
1299
  ...(this.oauth ? {
1218
1300
  oauthMetadata: 'GET /.well-known/oauth-authorization-server — OAuth 2.1 server metadata',
1219
1301
  oauthRegister: 'POST /oauth/register — Register OAuth client',
@@ -4382,6 +4464,244 @@ class PayGateServer {
4382
4464
  events: page,
4383
4465
  }));
4384
4466
  }
4467
+ // ─── /keys/reserve — Credit reservations (hold, commit, release) ─────────
4468
+ handleListReservations(req, res) {
4469
+ if (!this.checkAdmin(req, res))
4470
+ return;
4471
+ const urlParts = req.url?.split('?') || [];
4472
+ const params = new URLSearchParams(urlParts[1] || '');
4473
+ const keyParam = params.get('key');
4474
+ // Clean up expired reservations first
4475
+ this.cleanupExpiredReservations();
4476
+ let reservations = [...this.creditReservations.values()];
4477
+ if (keyParam) {
4478
+ const record = this.gate.store.resolveKeyRaw(keyParam);
4479
+ if (!record) {
4480
+ res.writeHead(404, { 'Content-Type': 'application/json' });
4481
+ res.end(JSON.stringify({ error: 'Key not found' }));
4482
+ return;
4483
+ }
4484
+ reservations = reservations.filter(r => r.key === record.key);
4485
+ }
4486
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4487
+ res.end(JSON.stringify({
4488
+ reservations: reservations.map(r => ({
4489
+ ...r,
4490
+ key: (0, audit_1.maskKeyForAudit)(r.key),
4491
+ })),
4492
+ count: reservations.length,
4493
+ totalHeld: reservations.reduce((sum, r) => sum + r.credits, 0),
4494
+ }));
4495
+ }
4496
+ handleCreateReservation(req, res) {
4497
+ if (!this.checkAdmin(req, res))
4498
+ return;
4499
+ let body = '';
4500
+ req.on('data', (chunk) => { body += chunk; });
4501
+ req.on('end', () => {
4502
+ let params;
4503
+ try {
4504
+ params = JSON.parse(body);
4505
+ }
4506
+ catch {
4507
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4508
+ res.end(JSON.stringify({ error: 'Invalid JSON body' }));
4509
+ return;
4510
+ }
4511
+ if (!params.key || typeof params.key !== 'string') {
4512
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4513
+ res.end(JSON.stringify({ error: 'Missing required field: key' }));
4514
+ return;
4515
+ }
4516
+ if (!params.credits || typeof params.credits !== 'number' || params.credits <= 0) {
4517
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4518
+ res.end(JSON.stringify({ error: 'Missing or invalid credits (must be positive number)' }));
4519
+ return;
4520
+ }
4521
+ const record = this.gate.store.resolveKeyRaw(params.key);
4522
+ if (!record) {
4523
+ res.writeHead(404, { 'Content-Type': 'application/json' });
4524
+ res.end(JSON.stringify({ error: 'Key not found' }));
4525
+ return;
4526
+ }
4527
+ if (!record.active) {
4528
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4529
+ res.end(JSON.stringify({ error: 'Key is revoked' }));
4530
+ return;
4531
+ }
4532
+ if (record.suspended) {
4533
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4534
+ res.end(JSON.stringify({ error: 'Key is suspended' }));
4535
+ return;
4536
+ }
4537
+ // Cleanup expired before checking availability
4538
+ this.cleanupExpiredReservations();
4539
+ // Calculate available credits (total - held)
4540
+ const heldCredits = this.getHeldCredits(record.key);
4541
+ const available = record.credits - heldCredits;
4542
+ if (params.credits > available) {
4543
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4544
+ res.end(JSON.stringify({
4545
+ error: 'Insufficient available credits',
4546
+ available,
4547
+ held: heldCredits,
4548
+ total: record.credits,
4549
+ requested: params.credits,
4550
+ }));
4551
+ return;
4552
+ }
4553
+ // Max 50 active reservations per key
4554
+ const keyReservations = [...this.creditReservations.values()].filter(r => r.key === record.key);
4555
+ if (keyReservations.length >= 50) {
4556
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4557
+ res.end(JSON.stringify({ error: 'Maximum 50 active reservations per key' }));
4558
+ return;
4559
+ }
4560
+ const ttl = Math.min(3600, Math.max(10, params.ttlSeconds || 300)); // 10s – 1h, default 5m
4561
+ const reservation = {
4562
+ id: `rsv_${this.nextReservationId++}`,
4563
+ key: record.key,
4564
+ credits: params.credits,
4565
+ createdAt: new Date().toISOString(),
4566
+ expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
4567
+ memo: params.memo?.slice(0, 200),
4568
+ };
4569
+ this.creditReservations.set(reservation.id, reservation);
4570
+ this.audit.log('credits.reserved', 'admin', `Reserved ${params.credits} credits`, {
4571
+ reservationId: reservation.id,
4572
+ key: (0, audit_1.maskKeyForAudit)(record.key),
4573
+ credits: params.credits,
4574
+ ttlSeconds: ttl,
4575
+ });
4576
+ res.writeHead(201, { 'Content-Type': 'application/json' });
4577
+ res.end(JSON.stringify({
4578
+ ...reservation,
4579
+ key: (0, audit_1.maskKeyForAudit)(reservation.key),
4580
+ available: available - params.credits,
4581
+ }));
4582
+ });
4583
+ }
4584
+ handleCommitReservation(req, res) {
4585
+ if (!this.checkAdmin(req, res))
4586
+ return;
4587
+ let body = '';
4588
+ req.on('data', (chunk) => { body += chunk; });
4589
+ req.on('end', () => {
4590
+ let params;
4591
+ try {
4592
+ params = JSON.parse(body);
4593
+ }
4594
+ catch {
4595
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4596
+ res.end(JSON.stringify({ error: 'Invalid JSON body' }));
4597
+ return;
4598
+ }
4599
+ if (!params.reservationId || typeof params.reservationId !== 'string') {
4600
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4601
+ res.end(JSON.stringify({ error: 'Missing required field: reservationId' }));
4602
+ return;
4603
+ }
4604
+ const reservation = this.creditReservations.get(params.reservationId);
4605
+ if (!reservation) {
4606
+ res.writeHead(404, { 'Content-Type': 'application/json' });
4607
+ res.end(JSON.stringify({ error: 'Reservation not found (may have expired)' }));
4608
+ return;
4609
+ }
4610
+ // Check if expired
4611
+ if (new Date(reservation.expiresAt).getTime() <= Date.now()) {
4612
+ this.creditReservations.delete(params.reservationId);
4613
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4614
+ res.end(JSON.stringify({ error: 'Reservation has expired' }));
4615
+ return;
4616
+ }
4617
+ const record = this.gate.store.resolveKeyRaw(reservation.key);
4618
+ if (!record) {
4619
+ this.creditReservations.delete(params.reservationId);
4620
+ res.writeHead(404, { 'Content-Type': 'application/json' });
4621
+ res.end(JSON.stringify({ error: 'Key not found' }));
4622
+ return;
4623
+ }
4624
+ // Deduct credits
4625
+ record.credits = Math.max(0, record.credits - reservation.credits);
4626
+ this.gate.store.save();
4627
+ // Remove reservation
4628
+ this.creditReservations.delete(params.reservationId);
4629
+ this.audit.log('credits.committed', 'admin', `Committed reservation: ${reservation.credits} credits deducted`, {
4630
+ reservationId: reservation.id,
4631
+ key: (0, audit_1.maskKeyForAudit)(record.key),
4632
+ credits: reservation.credits,
4633
+ remainingCredits: record.credits,
4634
+ });
4635
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4636
+ res.end(JSON.stringify({
4637
+ committed: {
4638
+ ...reservation,
4639
+ key: (0, audit_1.maskKeyForAudit)(reservation.key),
4640
+ },
4641
+ remainingCredits: record.credits,
4642
+ }));
4643
+ });
4644
+ }
4645
+ handleReleaseReservation(req, res) {
4646
+ if (!this.checkAdmin(req, res))
4647
+ return;
4648
+ let body = '';
4649
+ req.on('data', (chunk) => { body += chunk; });
4650
+ req.on('end', () => {
4651
+ let params;
4652
+ try {
4653
+ params = JSON.parse(body);
4654
+ }
4655
+ catch {
4656
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4657
+ res.end(JSON.stringify({ error: 'Invalid JSON body' }));
4658
+ return;
4659
+ }
4660
+ if (!params.reservationId || typeof params.reservationId !== 'string') {
4661
+ res.writeHead(400, { 'Content-Type': 'application/json' });
4662
+ res.end(JSON.stringify({ error: 'Missing required field: reservationId' }));
4663
+ return;
4664
+ }
4665
+ const reservation = this.creditReservations.get(params.reservationId);
4666
+ if (!reservation) {
4667
+ res.writeHead(404, { 'Content-Type': 'application/json' });
4668
+ res.end(JSON.stringify({ error: 'Reservation not found (may have expired)' }));
4669
+ return;
4670
+ }
4671
+ // Remove reservation (release the hold)
4672
+ this.creditReservations.delete(params.reservationId);
4673
+ this.audit.log('credits.released', 'admin', `Released reservation: ${reservation.credits} credits freed`, {
4674
+ reservationId: reservation.id,
4675
+ key: (0, audit_1.maskKeyForAudit)(reservation.key),
4676
+ credits: reservation.credits,
4677
+ });
4678
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4679
+ res.end(JSON.stringify({
4680
+ released: {
4681
+ ...reservation,
4682
+ key: (0, audit_1.maskKeyForAudit)(reservation.key),
4683
+ },
4684
+ }));
4685
+ });
4686
+ }
4687
+ /** Get total held credits for a key across all active reservations. */
4688
+ getHeldCredits(key) {
4689
+ let held = 0;
4690
+ for (const r of this.creditReservations.values()) {
4691
+ if (r.key === key)
4692
+ held += r.credits;
4693
+ }
4694
+ return held;
4695
+ }
4696
+ /** Remove expired reservations. */
4697
+ cleanupExpiredReservations() {
4698
+ const now = Date.now();
4699
+ for (const [id, r] of this.creditReservations) {
4700
+ if (new Date(r.expiresAt).getTime() <= now) {
4701
+ this.creditReservations.delete(id);
4702
+ }
4703
+ }
4704
+ }
4385
4705
  // ─── /config/reload — Hot reload configuration from file ─────────────────
4386
4706
  async handleConfigReload(req, res) {
4387
4707
  if (req.method !== 'POST') {
@@ -5961,6 +6281,65 @@ class PayGateServer {
5961
6281
  await this.redisSync.destroy();
5962
6282
  }
5963
6283
  }
6284
+ // ─── /requests — Request Log (queryable tool call log) ──────────────────────
6285
+ handleRequestLog(req, res) {
6286
+ if (!this.checkAdmin(req, res))
6287
+ return;
6288
+ const urlParts = req.url?.split('?') || [];
6289
+ const params = new URLSearchParams(urlParts[1] || '');
6290
+ // Filters
6291
+ const keyFilter = params.get('key');
6292
+ const toolFilter = params.get('tool');
6293
+ const statusFilter = params.get('status'); // 'allowed' | 'denied'
6294
+ const sinceFilter = params.get('since');
6295
+ const limit = Math.min(1000, Math.max(1, parseInt(params.get('limit') || '100', 10) || 100));
6296
+ const offset = Math.max(0, parseInt(params.get('offset') || '0', 10) || 0);
6297
+ let filtered = this.requestLog;
6298
+ // Filter by key (partial match on masked key)
6299
+ if (keyFilter) {
6300
+ const kf = keyFilter.toLowerCase();
6301
+ filtered = filtered.filter(e => e.key.toLowerCase().includes(kf));
6302
+ }
6303
+ // Filter by tool name (exact match)
6304
+ if (toolFilter) {
6305
+ filtered = filtered.filter(e => e.tool === toolFilter);
6306
+ }
6307
+ // Filter by status
6308
+ if (statusFilter === 'allowed' || statusFilter === 'denied') {
6309
+ filtered = filtered.filter(e => e.status === statusFilter);
6310
+ }
6311
+ // Filter by since timestamp
6312
+ if (sinceFilter) {
6313
+ const sinceTime = new Date(sinceFilter).getTime();
6314
+ if (!isNaN(sinceTime)) {
6315
+ filtered = filtered.filter(e => new Date(e.timestamp).getTime() >= sinceTime);
6316
+ }
6317
+ }
6318
+ const total = filtered.length;
6319
+ // Return newest first
6320
+ const reversed = [...filtered].reverse();
6321
+ const page = reversed.slice(offset, offset + limit);
6322
+ // Compute summary stats
6323
+ const totalAllowed = filtered.filter(e => e.status === 'allowed').length;
6324
+ const totalDenied = filtered.filter(e => e.status === 'denied').length;
6325
+ const totalCredits = filtered.reduce((sum, e) => sum + e.credits, 0);
6326
+ const avgDurationMs = filtered.length > 0
6327
+ ? Math.round(filtered.reduce((sum, e) => sum + e.durationMs, 0) / filtered.length)
6328
+ : 0;
6329
+ res.writeHead(200, { 'Content-Type': 'application/json' });
6330
+ res.end(JSON.stringify({
6331
+ total,
6332
+ offset,
6333
+ limit,
6334
+ summary: {
6335
+ totalAllowed,
6336
+ totalDenied,
6337
+ totalCredits,
6338
+ avgDurationMs,
6339
+ },
6340
+ requests: page,
6341
+ }));
6342
+ }
5964
6343
  }
5965
6344
  exports.PayGateServer = PayGateServer;
5966
6345
  //# sourceMappingURL=server.js.map