mcp-server-agentpay 1.0.3 → 1.1.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.
Files changed (3) hide show
  1. package/index.js +226 -5
  2. package/package.json +4 -3
  3. package/server.json +10 -3
package/index.js CHANGED
@@ -10,15 +10,24 @@ const { version } = require("./package.json");
10
10
  // ── Config ──────────────────────────────────────────────────────────
11
11
 
12
12
  const GATEWAY_KEY = process.env.AGENTPAY_GATEWAY_KEY || "";
13
+ const ADMIN_KEY = process.env.AGENTPAY_ADMIN_KEY || "";
13
14
  const BASE_URL = (process.env.AGENTPAY_URL || "https://agentpay.metaltorque.dev").replace(/\/$/, "");
14
15
 
15
16
  // ── HTTP helper ─────────────────────────────────────────────────────
16
17
 
18
+ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB
19
+
17
20
  function request(method, urlPath, body, timeout = 120_000) {
18
21
  return new Promise((resolve, reject) => {
19
22
  const fullUrl = `${BASE_URL}${urlPath}`;
20
- const mod = fullUrl.startsWith("https") ? https : http;
21
23
  const parsed = new URL(fullUrl);
24
+ const isHttps = parsed.protocol === "https:";
25
+
26
+ if (!isHttps && GATEWAY_KEY) {
27
+ return reject(new Error("Refusing to send gateway key over insecure HTTP. Use HTTPS."));
28
+ }
29
+
30
+ const mod = isHttps ? https : http;
22
31
 
23
32
  const headers = {
24
33
  "Content-Type": "application/json",
@@ -28,7 +37,7 @@ function request(method, urlPath, body, timeout = 120_000) {
28
37
 
29
38
  const opts = {
30
39
  hostname: parsed.hostname,
31
- port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
40
+ port: parsed.port || (isHttps ? 443 : 80),
32
41
  path: parsed.pathname + parsed.search,
33
42
  method,
34
43
  headers,
@@ -37,7 +46,12 @@ function request(method, urlPath, body, timeout = 120_000) {
37
46
 
38
47
  const req = mod.request(opts, (res) => {
39
48
  let data = "";
40
- res.on("data", (c) => (data += c));
49
+ let size = 0;
50
+ res.on("data", (c) => {
51
+ size += c.length;
52
+ if (size > MAX_RESPONSE_SIZE) { req.destroy(); return reject(new Error("Response too large")); }
53
+ data += c;
54
+ });
41
55
  res.on("end", () => {
42
56
  try {
43
57
  const json = JSON.parse(data);
@@ -63,6 +77,66 @@ function noKeyError() {
63
77
  };
64
78
  }
65
79
 
80
+ function noAdminKeyError() {
81
+ return {
82
+ content: [{ type: "text", text: "Error: AGENTPAY_ADMIN_KEY environment variable is required for reliability endpoints.\n\nThis is the admin key configured on the AgentPay server." }],
83
+ };
84
+ }
85
+
86
+ function adminRequest(method, urlPath, body, timeout = 30_000) {
87
+ return new Promise((resolve, reject) => {
88
+ const fullUrl = `${BASE_URL}${urlPath}`;
89
+ const parsed = new URL(fullUrl);
90
+ const isHttps = parsed.protocol === "https:";
91
+
92
+ if (!isHttps && ADMIN_KEY) {
93
+ return reject(new Error("Refusing to send admin key over insecure HTTP. Use HTTPS."));
94
+ }
95
+
96
+ const mod = isHttps ? https : http;
97
+
98
+ const headers = {
99
+ "Content-Type": "application/json",
100
+ "User-Agent": `mcp-server-agentpay/${version}`,
101
+ "Authorization": `Bearer ${ADMIN_KEY}`,
102
+ };
103
+
104
+ const opts = {
105
+ hostname: parsed.hostname,
106
+ port: parsed.port || (isHttps ? 443 : 80),
107
+ path: parsed.pathname + parsed.search,
108
+ method,
109
+ headers,
110
+ timeout,
111
+ };
112
+
113
+ const req = mod.request(opts, (res) => {
114
+ let data = "";
115
+ let size = 0;
116
+ res.on("data", (c) => {
117
+ size += c.length;
118
+ if (size > MAX_RESPONSE_SIZE) { req.destroy(); return reject(new Error("Response too large")); }
119
+ data += c;
120
+ });
121
+ res.on("end", () => {
122
+ try {
123
+ const json = JSON.parse(data);
124
+ if (res.statusCode >= 400) return reject(new Error(json.error || `HTTP ${res.statusCode}`));
125
+ resolve(json);
126
+ } catch {
127
+ if (res.statusCode >= 400) return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
128
+ resolve(data);
129
+ }
130
+ });
131
+ });
132
+
133
+ req.on("error", reject);
134
+ req.on("timeout", () => { req.destroy(); reject(new Error("Request timed out")); });
135
+ if (body) req.write(JSON.stringify(body));
136
+ req.end();
137
+ });
138
+ }
139
+
66
140
  // ── MCP Server ──────────────────────────────────────────────────────
67
141
 
68
142
  const server = new McpServer({
@@ -142,7 +216,9 @@ server.tool(
142
216
  try { params = JSON.parse(params_json); } catch { return { content: [{ type: "text", text: "Error: params_json must be valid JSON" }] }; }
143
217
  }
144
218
  const result = await request("POST", "/gateway/call", { tool, method, params }, 600_000);
145
- const meta = `[Cost: $${(result.cost || 0).toFixed(2)} | Balance: $${(result.balance || 0).toFixed(2)} | Time: ${result.elapsed || 0}ms]`;
219
+ const cost = Number(result.cost) || 0;
220
+ const balance = Number(result.balance) || 0;
221
+ const meta = `[Cost: $${cost.toFixed(2)} | Balance: $${balance.toFixed(2)} | Time: ${result.elapsed || 0}ms]`;
146
222
  return {
147
223
  content: [{ type: "text", text: `${meta}\n\n${JSON.stringify(result.result, null, 2)}` }],
148
224
  };
@@ -177,7 +253,7 @@ server.tool(
177
253
  "get_usage",
178
254
  "View your recent tool call history — which tools you called, what methods, how much each cost, and when.",
179
255
  {
180
- limit: z.number().default(20).describe("Number of recent calls to show (default: 20, max: 200)"),
256
+ limit: z.number().int().min(1).max(200).default(20).describe("Number of recent calls to show (default: 20, max: 200)"),
181
257
  },
182
258
  async ({ limit }) => {
183
259
  if (!GATEWAY_KEY) return noKeyError();
@@ -266,6 +342,151 @@ server.tool(
266
342
  }
267
343
  );
268
344
 
345
+ // ── Tool: reliability_dashboard ────────────────────────────────────
346
+
347
+ server.tool(
348
+ "reliability_dashboard",
349
+ "View the full reliability dashboard — circuit breaker states, health metrics (latency p50/p95/p99, success rates), and recent events for all tool backends. Requires admin key.",
350
+ {},
351
+ async () => {
352
+ if (!ADMIN_KEY) return noAdminKeyError();
353
+ try {
354
+ const result = await adminRequest("GET", "/admin/reliability");
355
+ const circuits = result.circuits || {};
356
+ const health = result.health || {};
357
+ const events = result.events || [];
358
+
359
+ let text = "=== Reliability Dashboard ===\n\n";
360
+
361
+ // Circuit states
362
+ text += "CIRCUITS:\n";
363
+ const toolIds = Object.keys(circuits);
364
+ if (toolIds.length === 0) {
365
+ text += " No circuit data yet (no tool calls made since restart)\n";
366
+ } else {
367
+ for (const id of toolIds) {
368
+ const c = circuits[id];
369
+ text += ` ${id}: ${c.state} (failures: ${c.failureCount}, successes: ${c.successCount})\n`;
370
+ }
371
+ }
372
+
373
+ // Health metrics
374
+ text += "\nHEALTH:\n";
375
+ const healthIds = Object.keys(health);
376
+ if (healthIds.length === 0) {
377
+ text += " No health data yet\n";
378
+ } else {
379
+ for (const id of healthIds) {
380
+ const h = health[id];
381
+ text += ` ${id}: ${h.successRate}% success (${h.requests.total} calls) — latency avg ${h.latency.avg}ms, p95 ${h.latency.p95}ms\n`;
382
+ }
383
+ }
384
+
385
+ // Recent events
386
+ if (events.length > 0) {
387
+ text += `\nRECENT EVENTS (last ${events.length}):\n`;
388
+ for (const e of events.slice(-10)) {
389
+ const status = e.success ? "OK" : "FAIL";
390
+ text += ` [${e.timestamp}] ${e.toolId}.${e.method} ${status} ${e.latencyMs}ms${e.error ? " — " + e.error : ""}\n`;
391
+ }
392
+ if (events.length > 10) text += ` ... and ${events.length - 10} more\n`;
393
+ }
394
+
395
+ return { content: [{ type: "text", text }] };
396
+ } catch (e) {
397
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
398
+ }
399
+ }
400
+ );
401
+
402
+ // ── Tool: reliability_tool_detail ─────────────────────────────────
403
+
404
+ server.tool(
405
+ "reliability_tool_detail",
406
+ "Get detailed reliability info for a specific tool — circuit breaker state, config (thresholds, timeouts), health metrics, and recent events.",
407
+ {
408
+ tool_id: z.string().describe("Tool ID (e.g. 'agent-audit', 'indexforge')"),
409
+ },
410
+ async ({ tool_id }) => {
411
+ if (!ADMIN_KEY) return noAdminKeyError();
412
+ try {
413
+ const result = await adminRequest("GET", `/admin/reliability/${encodeURIComponent(tool_id)}`);
414
+ const c = result.circuit || {};
415
+ const cfg = result.config || {};
416
+ const h = result.health || {};
417
+ const events = result.events || [];
418
+
419
+ let text = `=== ${tool_id} Reliability ===\n\n`;
420
+ text += `CIRCUIT: ${c.state} (failures: ${c.failureCount}, successes: ${c.successCount})\n`;
421
+ text += `CONFIG: threshold=${cfg.failureThreshold} failures, recovery=${cfg.recoveryTimeoutMs}ms, timeout=${cfg.requestTimeoutMs}ms\n`;
422
+ text += `HEALTH: ${h.successRate}% success (${h.requests?.total || 0} calls)\n`;
423
+ text += `LATENCY: avg=${h.latency?.avg || 0}ms, p50=${h.latency?.p50 || 0}ms, p95=${h.latency?.p95 || 0}ms, p99=${h.latency?.p99 || 0}ms (${h.latency?.samples || 0} samples)\n`;
424
+ if (h.lastCall) text += `LAST CALL: ${h.lastCall}\n`;
425
+
426
+ if (events.length > 0) {
427
+ text += `\nEVENTS (${events.length}):\n`;
428
+ for (const e of events.slice(-20)) {
429
+ const status = e.success ? "OK" : "FAIL";
430
+ text += ` [${e.timestamp}] ${e.method} ${status} ${e.latencyMs}ms${e.error ? " — " + e.error : ""}\n`;
431
+ }
432
+ }
433
+
434
+ return { content: [{ type: "text", text }] };
435
+ } catch (e) {
436
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
437
+ }
438
+ }
439
+ );
440
+
441
+ // ── Tool: reliability_reset ───────────────────────────────────────
442
+
443
+ server.tool(
444
+ "reliability_reset",
445
+ "Force-close a tripped circuit breaker for a tool, allowing calls to resume immediately. Use when a backend has recovered but the circuit hasn't automatically reset yet.",
446
+ {
447
+ tool_id: z.string().describe("Tool ID to reset (e.g. 'agent-audit', 'indexforge')"),
448
+ },
449
+ async ({ tool_id }) => {
450
+ if (!ADMIN_KEY) return noAdminKeyError();
451
+ try {
452
+ const result = await adminRequest("POST", `/admin/reliability/${encodeURIComponent(tool_id)}/reset`);
453
+ return { content: [{ type: "text", text: `Circuit reset for ${tool_id}: ${result.previousState} → ${result.currentState}` }] };
454
+ } catch (e) {
455
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
456
+ }
457
+ }
458
+ );
459
+
460
+ // ── Tool: reliability_config ──────────────────────────────────────
461
+
462
+ server.tool(
463
+ "reliability_config",
464
+ "Update reliability settings for a specific tool — failure threshold, recovery timeout, and request timeout. Changes take effect immediately.",
465
+ {
466
+ tool_id: z.string().describe("Tool ID to configure (e.g. 'agent-audit', 'indexforge')"),
467
+ failure_threshold: z.number().int().min(1).max(100).optional().describe("Number of consecutive failures before circuit opens (1-100)"),
468
+ recovery_timeout_ms: z.number().int().min(5000).max(600000).optional().describe("Milliseconds before OPEN circuit tries recovery (5000-600000)"),
469
+ request_timeout_ms: z.number().int().min(1000).max(600000).optional().describe("Per-request timeout in milliseconds (1000-600000)"),
470
+ },
471
+ async ({ tool_id, failure_threshold, recovery_timeout_ms, request_timeout_ms }) => {
472
+ if (!ADMIN_KEY) return noAdminKeyError();
473
+ const body = {};
474
+ if (failure_threshold !== undefined) body.failureThreshold = failure_threshold;
475
+ if (recovery_timeout_ms !== undefined) body.recoveryTimeoutMs = recovery_timeout_ms;
476
+ if (request_timeout_ms !== undefined) body.requestTimeoutMs = request_timeout_ms;
477
+ if (Object.keys(body).length === 0) {
478
+ return { content: [{ type: "text", text: "Error: Provide at least one setting to update (failure_threshold, recovery_timeout_ms, request_timeout_ms)" }] };
479
+ }
480
+ try {
481
+ const result = await adminRequest("POST", `/admin/reliability/${encodeURIComponent(tool_id)}/config`, body);
482
+ const cfg = result.config || {};
483
+ return { content: [{ type: "text", text: `Config updated for ${tool_id}:\n failureThreshold: ${cfg.failureThreshold}\n recoveryTimeoutMs: ${cfg.recoveryTimeoutMs}\n requestTimeoutMs: ${cfg.requestTimeoutMs}` }] };
484
+ } catch (e) {
485
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
486
+ }
487
+ }
488
+ );
489
+
269
490
  // ── Start ───────────────────────────────────────────────────────────
270
491
 
271
492
  async function main() {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "mcp-server-agentpay",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "mcpName": "io.github.joepangallo/agent-pay",
5
- "description": "MCP server for AgentPay — the payment gateway for autonomous AI agents. Lets agents discover, provision, and pay for MCP tool APIs with a single gateway key.",
5
+ "description": "MCP server for AgentPay — the payment gateway for autonomous AI agents. Discover, provision, and pay for MCP tool APIs. Includes reliability monitoring with circuit breakers and health metrics.",
6
6
  "bin": {
7
7
  "mcp-server-agentpay": "index.js"
8
8
  },
@@ -44,7 +44,8 @@
44
44
  "url": "https://github.com/joepangallo/agent-pay"
45
45
  },
46
46
  "dependencies": {
47
- "@modelcontextprotocol/sdk": "^1.27.0"
47
+ "@modelcontextprotocol/sdk": "^1.27.0",
48
+ "zod": "^3.23.0"
48
49
  },
49
50
  "engines": {
50
51
  "node": ">=18"
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.joepangallo/agent-pay",
4
- "description": "Payment gateway for AI agents. Tool discovery, provisioning, and pay-per-call metering.",
4
+ "description": "Payment gateway for AI agents. Tool discovery, provisioning, pay-per-call metering, and reliability monitoring with circuit breakers.",
5
5
  "repository": {
6
6
  "url": "https://github.com/joepangallo/agent-pay",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.3",
9
+ "version": "1.1.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "mcp-server-agentpay",
14
- "version": "1.0.3",
14
+ "version": "1.1.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -23,6 +23,13 @@
23
23
  "isSecret": true,
24
24
  "name": "AGENTPAY_GATEWAY_KEY"
25
25
  },
26
+ {
27
+ "description": "Admin key for reliability endpoints (circuit breakers, health metrics, config)",
28
+ "isRequired": false,
29
+ "format": "string",
30
+ "isSecret": true,
31
+ "name": "AGENTPAY_ADMIN_KEY"
32
+ },
26
33
  {
27
34
  "description": "AgentPay API URL",
28
35
  "isRequired": false,