agent-gateway-mcp 0.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.
package/src/index.ts ADDED
@@ -0,0 +1,610 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { discoverByQuery, fetchManifest, fetchCapabilityDetail, clearCache } from "./discovery.js";
7
+ import { authenticate, storeApiKey } from "./auth.js";
8
+ import { callCapability } from "./caller.js";
9
+ import {
10
+ getToken,
11
+ getConnection,
12
+ getAllConnections,
13
+ getAllTokens,
14
+ isInitialized,
15
+ getIdentity,
16
+ setRegistryUrl,
17
+ syncTokensToCloud,
18
+ syncTokensFromCloud,
19
+ clearAllCaches,
20
+ } from "./config.js";
21
+
22
+ // ─── CLI argument parsing ────────────────────────────────────────
23
+
24
+ function parseArgs(): void {
25
+ const args = process.argv.slice(2);
26
+ for (let i = 0; i < args.length; i++) {
27
+ if (args[i] === "--registry" && args[i + 1]) {
28
+ setRegistryUrl(args[i + 1]);
29
+ i++;
30
+ }
31
+ }
32
+ }
33
+
34
+ parseArgs();
35
+
36
+ // ─── MCP Server ──────────────────────────────────────────────────
37
+
38
+ const server = new McpServer(
39
+ {
40
+ name: "agent-gateway",
41
+ version: "0.1.0",
42
+ },
43
+ {
44
+ capabilities: {
45
+ logging: {},
46
+ },
47
+ }
48
+ );
49
+
50
+ // ─── Tool 1: discover ───────────────────────────────────────────────
51
+
52
+ server.registerTool(
53
+ "discover",
54
+ {
55
+ description: "Search for services by intent, explore a specific domain, or drill into a capability. " +
56
+ "Use `query` to search the registry (e.g. 'send email'), `domain` to fetch a specific service's manifest, " +
57
+ "or `domain` + `capability` to get full details on how to call a specific capability.",
58
+ inputSchema: {
59
+ query: z.string().optional().describe("Natural language search (e.g. 'send email', 'create invoice')"),
60
+ domain: z.string().optional().describe("Specific domain to explore (e.g. 'api.mailforge.dev')"),
61
+ capability: z.string().optional().describe("Capability name to drill into (requires domain)"),
62
+ },
63
+ },
64
+ async ({ query, domain, capability }) => {
65
+ try {
66
+ // Mode 1: Search registry by query
67
+ if (query && !domain) {
68
+ const results = await discoverByQuery(query);
69
+ const connections = getAllConnections();
70
+ const connectedDomains = new Set(connections.map((c) => c.domain));
71
+
72
+ const text = results.data
73
+ .map((r) => {
74
+ const connected = connectedDomains.has(r.service.domain);
75
+ const status = connected ? "[CONNECTED]" : "[NOT CONNECTED]";
76
+ const caps = r.matching_capabilities.length > 0
77
+ ? r.matching_capabilities.map((c) => ` - ${c.name}: ${c.description}`).join("\n")
78
+ : r.all_capabilities.map((c) => ` - ${c.name}: ${c.description}`).join("\n");
79
+
80
+ return [
81
+ `${r.service.name} (${r.service.domain}) ${status}`,
82
+ ` ${r.service.description}`,
83
+ ` Auth: ${r.service.auth_type} | Pricing: ${r.service.pricing_type} | Verified: ${r.service.verified}`,
84
+ ` Capabilities:`,
85
+ caps,
86
+ ].join("\n");
87
+ })
88
+ .join("\n\n");
89
+
90
+ return {
91
+ content: [
92
+ {
93
+ type: "text" as const,
94
+ text: results.result_count === 0
95
+ ? `No services found for "${query}". Try different keywords.`
96
+ : `Found ${results.result_count} service(s) for "${query}":\n\n${text}`,
97
+ },
98
+ ],
99
+ };
100
+ }
101
+
102
+ // Mode 2: Fetch specific domain's manifest
103
+ if (domain && !capability) {
104
+ const manifest = await fetchManifest(domain);
105
+ const token = getToken(domain);
106
+ const connected = token && token.access_token ? "[CONNECTED]" : "[NOT CONNECTED]";
107
+
108
+ const caps = manifest.capabilities
109
+ .map((c) => ` - ${c.name}: ${c.description}`)
110
+ .join("\n");
111
+
112
+ const text = [
113
+ `${manifest.name} (${domain}) ${connected}`,
114
+ `${manifest.description}`,
115
+ ``,
116
+ `Base URL: ${manifest.base_url}`,
117
+ `Auth: ${manifest.auth.type}`,
118
+ manifest.pricing ? `Pricing: ${manifest.pricing.type}` : null,
119
+ `Spec version: ${manifest.spec_version}`,
120
+ ``,
121
+ `Capabilities:`,
122
+ caps,
123
+ ``,
124
+ `To use a capability, call the 'call' tool with domain="${domain}" and the capability name.`,
125
+ `To see full details on a capability, call 'discover' with domain="${domain}" and capability="<name>".`,
126
+ ]
127
+ .filter((l) => l !== null)
128
+ .join("\n");
129
+
130
+ return { content: [{ type: "text" as const, text }] };
131
+ }
132
+
133
+ // Mode 3: Drill into a specific capability
134
+ if (domain && capability) {
135
+ const detail = await fetchCapabilityDetail(domain, capability);
136
+ const manifest = await fetchManifest(domain);
137
+ const fullUrl = detail.endpoint.startsWith("http")
138
+ ? detail.endpoint
139
+ : `${manifest.base_url}${detail.endpoint}`;
140
+
141
+ const params = detail.parameters
142
+ .map(
143
+ (p) =>
144
+ ` - ${p.name} (${p.type}${p.required ? ", required" : ", optional"}): ${p.description}${p.example !== undefined ? ` Example: ${JSON.stringify(p.example)}` : ""}`
145
+ )
146
+ .join("\n");
147
+
148
+ const text = [
149
+ `${detail.name} — ${manifest.name}`,
150
+ `${detail.description}`,
151
+ ``,
152
+ `Endpoint: ${detail.method} ${fullUrl}`,
153
+ detail.auth_scopes?.length ? `Scopes needed: ${detail.auth_scopes.join(", ")}` : null,
154
+ detail.rate_limits ? `Rate limits: ${detail.rate_limits.requests_per_minute ?? "?"}/min, ${detail.rate_limits.daily_limit ?? "?"}/day` : null,
155
+ ``,
156
+ `Parameters:`,
157
+ params,
158
+ ``,
159
+ `Example request:`,
160
+ JSON.stringify(detail.request_example, null, 2),
161
+ ``,
162
+ `Example response:`,
163
+ JSON.stringify(detail.response_example, null, 2),
164
+ ``,
165
+ `To call this capability, use the 'call' tool with:`,
166
+ ` domain: "${domain}"`,
167
+ ` capability: "${capability}"`,
168
+ ` params: { ... }`,
169
+ ]
170
+ .filter((l) => l !== null)
171
+ .join("\n");
172
+
173
+ return { content: [{ type: "text" as const, text }] };
174
+ }
175
+
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text" as const,
180
+ text: "Please provide either a 'query' to search services, a 'domain' to explore a specific service, or both 'domain' and 'capability' to see capability details.",
181
+ },
182
+ ],
183
+ };
184
+ } catch (err) {
185
+ return {
186
+ content: [
187
+ {
188
+ type: "text" as const,
189
+ text: `Discovery failed: ${err instanceof Error ? err.message : "unknown error"}`,
190
+ },
191
+ ],
192
+ isError: true,
193
+ };
194
+ }
195
+ }
196
+ );
197
+
198
+ // ─── Tool 2: call ───────────────────────────────────────────────────
199
+
200
+ server.registerTool(
201
+ "call",
202
+ {
203
+ description: "Call a capability on a service. The gateway handles auth, request construction, and execution. " +
204
+ "First use 'discover' to find the service and capability, then call it here.",
205
+ inputSchema: {
206
+ domain: z.string().describe("Service domain (e.g. 'api.mailforge.dev')"),
207
+ capability: z.string().describe("Capability name (e.g. 'send_email')"),
208
+ params: z.record(z.string(), z.unknown()).optional().describe("Parameters for the capability (key-value object)"),
209
+ api_key: z.string().optional().describe("API key if the service requires one and you haven't connected yet"),
210
+ },
211
+ },
212
+ async ({ domain, capability, params, api_key }) => {
213
+ try {
214
+ const result = await callCapability(domain, capability, params ?? {}, api_key);
215
+
216
+ if (!result.success) {
217
+ const text = result.auth_required
218
+ ? `Authentication required for ${domain}.\n\n${result.error}\n\nUse the 'auth' tool to connect first.`
219
+ : `Call failed: ${result.error}${result.data ? `\n\nResponse:\n${JSON.stringify(result.data, null, 2)}` : ""}`;
220
+
221
+ return {
222
+ content: [{ type: "text" as const, text }],
223
+ isError: true,
224
+ };
225
+ }
226
+
227
+ // Sync new connection to cloud (fire and forget)
228
+ syncTokensToCloud().catch(() => { /* silent */ });
229
+
230
+ return {
231
+ content: [
232
+ {
233
+ type: "text" as const,
234
+ text: `${capability} on ${domain} — HTTP ${result.status}\n\n${JSON.stringify(result.data, null, 2)}`,
235
+ },
236
+ ],
237
+ };
238
+ } catch (err) {
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text" as const,
243
+ text: `Call failed: ${err instanceof Error ? err.message : "unknown error"}`,
244
+ },
245
+ ],
246
+ isError: true,
247
+ };
248
+ }
249
+ }
250
+ );
251
+
252
+ // ─── Tool 3: auth ───────────────────────────────────────────────────
253
+
254
+ server.registerTool(
255
+ "auth",
256
+ {
257
+ description: "Connect to a service. For OAuth2 services, opens a browser for authorization. " +
258
+ "For API key services, provide the key. For public APIs, auto-connects. " +
259
+ "Tokens are cloud-synced to your registry account.",
260
+ inputSchema: {
261
+ domain: z.string().describe("Service domain to connect to"),
262
+ api_key: z.string().optional().describe("API key (for api_key auth type services)"),
263
+ },
264
+ },
265
+ async ({ domain, api_key }) => {
266
+ try {
267
+ let result;
268
+ if (api_key) {
269
+ result = await storeApiKey(domain, api_key);
270
+ } else {
271
+ result = await authenticate(domain);
272
+ }
273
+
274
+ // Sync to cloud on successful auth
275
+ if (result.success) {
276
+ syncTokensToCloud().catch(() => { /* silent */ });
277
+ }
278
+
279
+ return {
280
+ content: [{ type: "text" as const, text: result.message }],
281
+ isError: !result.success,
282
+ };
283
+ } catch (err) {
284
+ return {
285
+ content: [
286
+ {
287
+ type: "text" as const,
288
+ text: `Auth failed: ${err instanceof Error ? err.message : "unknown error"}`,
289
+ },
290
+ ],
291
+ isError: true,
292
+ };
293
+ }
294
+ }
295
+ );
296
+
297
+ // ─── Tool 4: subscribe ──────────────────────────────────────────────
298
+
299
+ server.registerTool(
300
+ "subscribe",
301
+ {
302
+ description: "Subscribe to a paid service plan. Shows plan details and asks for user confirmation. " +
303
+ "The agent can NEVER auto-approve payments — always requires human confirmation.",
304
+ inputSchema: {
305
+ domain: z.string().describe("Service domain"),
306
+ plan: z.string().describe("Plan name to subscribe to (from the service's pricing info)"),
307
+ },
308
+ },
309
+ async ({ domain, plan }) => {
310
+ try {
311
+ const manifest = await fetchManifest(domain);
312
+
313
+ if (!manifest.pricing || manifest.pricing.type === "free") {
314
+ return {
315
+ content: [
316
+ {
317
+ type: "text" as const,
318
+ text: `${manifest.name} is free — no subscription needed. Just use the 'call' tool.`,
319
+ },
320
+ ],
321
+ };
322
+ }
323
+
324
+ const matchedPlan = manifest.pricing.plans?.find(
325
+ (p) => p.name.toLowerCase() === plan.toLowerCase()
326
+ );
327
+
328
+ if (!matchedPlan) {
329
+ const available = manifest.pricing.plans
330
+ ?.map((p) => ` - ${p.name}: ${p.price} (${p.limits})`)
331
+ .join("\n");
332
+ return {
333
+ content: [
334
+ {
335
+ type: "text" as const,
336
+ text: `Plan "${plan}" not found for ${manifest.name}.\n\nAvailable plans:\n${available ?? "No plans listed."}${manifest.pricing.plans_url ? `\n\nSee details: ${manifest.pricing.plans_url}` : ""}`,
337
+ },
338
+ ],
339
+ isError: true,
340
+ };
341
+ }
342
+
343
+ const identity = getIdentity();
344
+ const paymentStatus = identity
345
+ ? "Your payment method on file will be used."
346
+ : "Set up a payment method first with `agent-gateway init`.";
347
+
348
+ const text = [
349
+ `Subscription details for ${manifest.name}:`,
350
+ ``,
351
+ ` Plan: ${matchedPlan.name}`,
352
+ ` Price: ${matchedPlan.price}`,
353
+ ` Limits: ${matchedPlan.limits}`,
354
+ manifest.pricing.plans_url ? ` Details: ${manifest.pricing.plans_url}` : null,
355
+ ``,
356
+ ` Payment: ${paymentStatus}`,
357
+ ``,
358
+ ` IMPORTANT: Payment requires user confirmation.`,
359
+ ` The user will receive a confirmation prompt (push notification or browser)`,
360
+ ` to approve this subscription with biometric/PIN confirmation.`,
361
+ ``,
362
+ ` To proceed, the user must confirm via the AgentDNS app or registry website.`,
363
+ ]
364
+ .filter((l) => l !== null)
365
+ .join("\n");
366
+
367
+ return { content: [{ type: "text" as const, text }] };
368
+ } catch (err) {
369
+ return {
370
+ content: [
371
+ {
372
+ type: "text" as const,
373
+ text: `Subscribe failed: ${err instanceof Error ? err.message : "unknown error"}`,
374
+ },
375
+ ],
376
+ isError: true,
377
+ };
378
+ }
379
+ }
380
+ );
381
+
382
+ // ─── Tool 5: manage_subscriptions ───────────────────────────────────
383
+
384
+ server.registerTool(
385
+ "manage_subscriptions",
386
+ {
387
+ description: "List, cancel, upgrade, or downgrade subscriptions. Without arguments, lists all active subscriptions.",
388
+ inputSchema: {
389
+ domain: z.string().optional().describe("Service domain to manage"),
390
+ action: z
391
+ .enum(["cancel", "upgrade", "downgrade"])
392
+ .optional()
393
+ .describe("Action to perform"),
394
+ plan: z.string().optional().describe("Target plan for upgrade/downgrade"),
395
+ },
396
+ },
397
+ async ({ domain, action, plan }) => {
398
+ try {
399
+ // List all subscriptions
400
+ if (!domain) {
401
+ const connections = getAllConnections();
402
+ const withSubs = connections.filter((c) => c.subscription);
403
+
404
+ if (withSubs.length === 0) {
405
+ return {
406
+ content: [
407
+ {
408
+ type: "text" as const,
409
+ text: "No active subscriptions.\n\nUse 'discover' to find services, then 'subscribe' to sign up for a plan.",
410
+ },
411
+ ],
412
+ };
413
+ }
414
+
415
+ const list = withSubs
416
+ .map(
417
+ (c) =>
418
+ ` ${c.service_name} (${c.domain}) — ${c.subscription!.plan} [${c.subscription!.status}]`
419
+ )
420
+ .join("\n");
421
+
422
+ return {
423
+ content: [
424
+ {
425
+ type: "text" as const,
426
+ text: `Active subscriptions:\n\n${list}`,
427
+ },
428
+ ],
429
+ };
430
+ }
431
+
432
+ // Manage specific subscription
433
+ if (!action) {
434
+ const conn = getConnection(domain);
435
+ if (!conn) {
436
+ return {
437
+ content: [
438
+ {
439
+ type: "text" as const,
440
+ text: `Not connected to ${domain}. Use 'auth' to connect first.`,
441
+ },
442
+ ],
443
+ isError: true,
444
+ };
445
+ }
446
+
447
+ const text = conn.subscription
448
+ ? `${conn.service_name} — Plan: ${conn.subscription.plan} [${conn.subscription.status}]`
449
+ : `${conn.service_name} — No active subscription.`;
450
+
451
+ return { content: [{ type: "text" as const, text }] };
452
+ }
453
+
454
+ // Perform action (requires user confirmation)
455
+ const text = [
456
+ `Subscription management for ${domain}:`,
457
+ ``,
458
+ ` Action: ${action}`,
459
+ plan ? ` Target plan: ${plan}` : null,
460
+ ``,
461
+ ` This action requires user confirmation via push notification or browser.`,
462
+ action === "cancel" ? ` Cancel: ends your current plan at end of billing period.` : null,
463
+ action === "upgrade" ? ` Upgrade: moves to ${plan ?? "a higher"} plan (prorated).` : null,
464
+ action === "downgrade" ? ` Downgrade: moves to ${plan ?? "a lower"} plan at next billing cycle.` : null,
465
+ ``,
466
+ ` The user will be prompted to confirm this change.`,
467
+ ]
468
+ .filter((l) => l !== null)
469
+ .join("\n");
470
+
471
+ return { content: [{ type: "text" as const, text }] };
472
+ } catch (err) {
473
+ return {
474
+ content: [
475
+ {
476
+ type: "text" as const,
477
+ text: `Subscription management failed: ${err instanceof Error ? err.message : "unknown error"}`,
478
+ },
479
+ ],
480
+ isError: true,
481
+ };
482
+ }
483
+ }
484
+ );
485
+
486
+ // ─── Tool 6: list_connections ───────────────────────────────────────
487
+
488
+ server.registerTool(
489
+ "list_connections",
490
+ {
491
+ description: "List all connected services with auth status, scopes, and subscription info. " +
492
+ "Provide a domain for detailed info on a specific connection.",
493
+ inputSchema: {
494
+ domain: z.string().optional().describe("Specific domain for detailed info"),
495
+ },
496
+ },
497
+ async ({ domain }) => {
498
+ try {
499
+ if (domain) {
500
+ const conn = getConnection(domain);
501
+ const token = getToken(domain);
502
+
503
+ if (!conn && !token) {
504
+ return {
505
+ content: [
506
+ {
507
+ type: "text" as const,
508
+ text: `Not connected to ${domain}. Use 'auth' to connect.`,
509
+ },
510
+ ],
511
+ };
512
+ }
513
+
514
+ const tokenStatus = token?.access_token
515
+ ? token.expires_at
516
+ ? Date.now() < token.expires_at
517
+ ? `Valid (expires ${new Date(token.expires_at).toISOString()})`
518
+ : "Expired — will auto-refresh on next call"
519
+ : "Valid (no expiry)"
520
+ : "No token";
521
+
522
+ const identity = getIdentity();
523
+
524
+ const text = [
525
+ `Connection: ${conn?.service_name ?? domain}`,
526
+ ` Domain: ${domain}`,
527
+ ` Auth type: ${conn?.auth_type ?? token?.type ?? "unknown"}`,
528
+ ` Token: ${tokenStatus}`,
529
+ token?.scopes?.length ? ` Scopes: ${token.scopes.join(", ")}` : null,
530
+ conn?.subscription
531
+ ? ` Subscription: ${conn.subscription.plan} [${conn.subscription.status}]`
532
+ : ` Subscription: None`,
533
+ conn?.connected_at ? ` Connected since: ${conn.connected_at}` : null,
534
+ identity ? ` Cloud synced: Yes (${identity.email})` : ` Cloud synced: No (run 'agent-gateway init' to enable)`,
535
+ ]
536
+ .filter((l) => l !== null)
537
+ .join("\n");
538
+
539
+ return { content: [{ type: "text" as const, text }] };
540
+ }
541
+
542
+ // List all
543
+ const connections = getAllConnections();
544
+ const tokens = getAllTokens();
545
+ const allDomains = new Set([
546
+ ...connections.map((c) => c.domain),
547
+ ...tokens.map((t) => t.domain),
548
+ ]);
549
+
550
+ if (allDomains.size === 0) {
551
+ return {
552
+ content: [
553
+ {
554
+ type: "text" as const,
555
+ text: "No connections yet.\n\nUse 'discover' to find services, then 'auth' or 'call' to connect.",
556
+ },
557
+ ],
558
+ };
559
+ }
560
+
561
+ const identity = getIdentity();
562
+ const syncLine = identity
563
+ ? `\nCloud sync: Active (${identity.email})`
564
+ : "\nCloud sync: Not configured (run 'agent-gateway init' to enable)";
565
+
566
+ const list = [...allDomains]
567
+ .map((d) => {
568
+ const conn = getConnection(d);
569
+ const token = getToken(d);
570
+ const status = token?.access_token ? "connected" : "expired";
571
+ const sub = conn?.subscription
572
+ ? ` | ${conn.subscription.plan}`
573
+ : "";
574
+ return ` ${conn?.service_name ?? d} (${d}) [${status}]${sub}`;
575
+ })
576
+ .join("\n");
577
+
578
+ return {
579
+ content: [
580
+ {
581
+ type: "text" as const,
582
+ text: `Connected services (${allDomains.size}):\n\n${list}${syncLine}\n\nUse list_connections with a domain for details.`,
583
+ },
584
+ ],
585
+ };
586
+ } catch (err) {
587
+ return {
588
+ content: [
589
+ {
590
+ type: "text" as const,
591
+ text: `Failed to list connections: ${err instanceof Error ? err.message : "unknown error"}`,
592
+ },
593
+ ],
594
+ isError: true,
595
+ };
596
+ }
597
+ }
598
+ );
599
+
600
+ // ─── Start ──────────────────────────────────────────────────────────
601
+
602
+ async function main() {
603
+ const transport = new StdioServerTransport();
604
+ await server.connect(transport);
605
+ }
606
+
607
+ main().catch((err) => {
608
+ process.stderr.write(`Gateway MCP fatal error: ${err}\n`);
609
+ process.exit(1);
610
+ });