@slashfi/agents-sdk 0.6.0 → 0.8.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,621 @@
1
+ /**
2
+ * Remote Registry Agent
3
+ *
4
+ * Integration agent for connecting to remote agent registries.
5
+ * Uses the IntegrationMethods pattern so @integrations can discover
6
+ * and interact with it uniformly via setup/connect/list/get/update.
7
+ *
8
+ * Each remote registry connection stores:
9
+ * - url: the registry's base URL
10
+ * - tenantId: the tenant created on the remote registry
11
+ * - clientId + clientSecret: credentials for authentication
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { createRemoteRegistryAgent, createAgentRegistry } from '@slashfi/agents-sdk';
16
+ *
17
+ * const registry = createAgentRegistry();
18
+ * registry.register(createRemoteRegistryAgent({ secretStore }));
19
+ *
20
+ * // Then via @integrations:
21
+ * // setup_integration({ provider: 'remote-registry', params: { url: 'https://registry.slash.com', name: 'slash' } })
22
+ * // connect_integration({ provider: 'remote-registry', params: { registryId: 'slash', userId: 'user_123' } })
23
+ * ```
24
+ */
25
+
26
+ import { defineAgent, defineTool } from "../define.js";
27
+ import type {
28
+ AgentDefinition,
29
+ IntegrationMethodContext,
30
+ IntegrationMethodResult,
31
+ ToolContext,
32
+ } from "../types.js";
33
+ import type { SecretStore } from "./secrets.js";
34
+
35
+ // ============================================
36
+ // Types
37
+ // ============================================
38
+
39
+ export interface RemoteRegistryAgentOptions {
40
+ /** Secret store for persisting registry credentials */
41
+ secretStore: SecretStore;
42
+ }
43
+
44
+ /** Stored connection to a remote registry */
45
+ interface RegistryConnection {
46
+ /** Registry identifier (user-chosen name) */
47
+ id: string;
48
+ /** Display name */
49
+ name: string;
50
+ /** Registry base URL */
51
+ url: string;
52
+ /** Tenant ID on the remote registry */
53
+ remoteTenantId: string;
54
+ /** Client ID for authentication */
55
+ clientId: string;
56
+ /** When the connection was created */
57
+ createdAt: number;
58
+ }
59
+
60
+
61
+ // ============================================
62
+ // Helpers
63
+ // ============================================
64
+
65
+
66
+ /**
67
+ * Make an MCP JSON-RPC call to a remote registry.
68
+ */
69
+ async function mcpCall(
70
+ url: string,
71
+ token: string,
72
+ request: {
73
+ action: string;
74
+ path: string;
75
+ tool: string;
76
+ params?: Record<string, unknown>;
77
+ },
78
+ ): Promise<any> {
79
+ const res = await globalThis.fetch(url, {
80
+ method: "POST",
81
+ headers: {
82
+ "Content-Type": "application/json",
83
+ Authorization: `Bearer ${token}`,
84
+ },
85
+ body: JSON.stringify({
86
+ jsonrpc: "2.0",
87
+ id: Date.now(),
88
+ method: "tools/call",
89
+ params: {
90
+ name: "call_agent",
91
+ arguments: {
92
+ request: {
93
+ action: request.action,
94
+ path: request.path,
95
+ tool: request.tool,
96
+ params: request.params ?? {},
97
+ },
98
+ },
99
+ },
100
+ }),
101
+ });
102
+
103
+ if (!res.ok) {
104
+ throw new Error(`Registry call failed: ${res.status} ${res.statusText}`);
105
+ }
106
+
107
+ const json = (await res.json()) as any;
108
+ if (json.error) {
109
+ throw new Error(
110
+ `Registry RPC error: ${json.error.message ?? JSON.stringify(json.error)}`,
111
+ );
112
+ }
113
+
114
+ // Parse the tool result from MCP response
115
+ const text = json?.result?.content?.[0]?.text;
116
+ if (!text) return json?.result;
117
+ try {
118
+ return JSON.parse(text);
119
+ } catch {
120
+ return { raw: text };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Get an access token from a remote registry via /oauth/token.
126
+ */
127
+ async function getRegistryToken(
128
+ url: string,
129
+ clientId: string,
130
+ clientSecret: string,
131
+ ): Promise<string> {
132
+ const tokenUrl = url.replace(/\/$/, "") + "/oauth/token";
133
+ const res = await globalThis.fetch(tokenUrl, {
134
+ method: "POST",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify({
137
+ grant_type: "client_credentials",
138
+ client_id: clientId,
139
+ client_secret: clientSecret,
140
+ }),
141
+ });
142
+
143
+ if (!res.ok) {
144
+ const body = await res.text();
145
+ throw new Error(`Token exchange failed: ${res.status} ${body}`);
146
+ }
147
+
148
+ const json = (await res.json()) as { access_token: string };
149
+ return json.access_token;
150
+ }
151
+
152
+ // ============================================
153
+ // Create Remote Registry Agent
154
+ // ============================================
155
+
156
+ export function createRemoteRegistryAgent(
157
+ options: RemoteRegistryAgentOptions,
158
+ ): AgentDefinition {
159
+ const { secretStore } = options;
160
+
161
+ // We store all registry connections as a single JSON blob per owner.
162
+ // The secret ID is stored via associate/resolveByEntity for lookup.
163
+ const ENTITY_TYPE = "remote-registry-connections";
164
+
165
+ /**
166
+ * Store a registry connection (metadata + credentials).
167
+ */
168
+ async function storeConnection(
169
+ ownerId: string,
170
+ conn: RegistryConnection,
171
+ clientSecret: string,
172
+ ): Promise<void> {
173
+ // Load existing connections, update, and store back
174
+ const all = await loadAllConnections(ownerId);
175
+ all[conn.id] = { ...conn, clientSecret };
176
+ const value = JSON.stringify(all);
177
+
178
+ // Store the blob
179
+ const scope = { tenantId: ownerId };
180
+ const secretId = await secretStore.store(value, ownerId);
181
+
182
+ // Link it so we can find it later
183
+ if (secretStore.associate) {
184
+ await secretStore.associate(secretId, ENTITY_TYPE, ownerId, scope);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Load all connections from the stored blob.
190
+ */
191
+ async function loadAllConnections(
192
+ ownerId: string,
193
+ ): Promise<Record<string, RegistryConnection & { clientSecret: string }>> {
194
+ // Try resolveByEntity first (v0.7.0+)
195
+ if (secretStore.resolveByEntity) {
196
+ const scope = { tenantId: ownerId };
197
+ const secretIds = await secretStore.resolveByEntity(ENTITY_TYPE, ownerId, scope);
198
+ if (secretIds && secretIds.length > 0) {
199
+ // Resolve the latest stored blob
200
+ const latestId = secretIds[secretIds.length - 1];
201
+ const raw = await secretStore.resolve(latestId, ownerId);
202
+ if (raw) {
203
+ try {
204
+ return JSON.parse(raw);
205
+ } catch {
206
+ return {};
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return {};
212
+ }
213
+
214
+ /**
215
+ * Load a registry connection.
216
+ */
217
+ async function loadConnection(
218
+ ownerId: string,
219
+ registryId: string,
220
+ ): Promise<{ conn: RegistryConnection; clientSecret: string } | null> {
221
+ const all = await loadAllConnections(ownerId);
222
+ const entry = all[registryId];
223
+ if (!entry) return null;
224
+ const { clientSecret, ...conn } = entry;
225
+ return { conn, clientSecret };
226
+ }
227
+
228
+ /**
229
+ * List all registry connections for an owner.
230
+ */
231
+ async function listConnectionsList(
232
+ ownerId: string,
233
+ ): Promise<RegistryConnection[]> {
234
+ const all = await loadAllConnections(ownerId);
235
+ return Object.values(all).map(({ clientSecret: _, ...conn }) => conn);
236
+ }
237
+
238
+ /**
239
+ * Get an authenticated token for a registry connection.
240
+ */
241
+ async function getAuthenticatedToken(
242
+ ownerId: string,
243
+ registryId: string,
244
+ ): Promise<{ token: string; conn: RegistryConnection }> {
245
+ const data = await loadConnection(ownerId, registryId);
246
+ if (!data) {
247
+ throw new Error(
248
+ `No registry connection '${registryId}'. Use setup_integration first.`,
249
+ );
250
+ }
251
+ const token = await getRegistryToken(
252
+ data.conn.url,
253
+ data.conn.clientId,
254
+ data.clientSecret,
255
+ );
256
+ return { token, conn: data.conn };
257
+ }
258
+
259
+ // ---- Tools ----
260
+
261
+ const callRemoteTool = defineTool({
262
+ name: "call_remote",
263
+ description:
264
+ "Make an authenticated MCP call to a remote agent registry. " +
265
+ "Proxies the request with the stored tenant credentials.",
266
+ visibility: "public" as const,
267
+ inputSchema: {
268
+ type: "object" as const,
269
+ properties: {
270
+ registryId: {
271
+ type: "string",
272
+ description: "Registry connection ID",
273
+ },
274
+ agentPath: {
275
+ type: "string",
276
+ description: "Agent path on the remote registry (e.g. '@integrations')",
277
+ },
278
+ action: {
279
+ type: "string",
280
+ description: "Action to perform (e.g. 'execute_tool')",
281
+ },
282
+ tool: {
283
+ type: "string",
284
+ description: "Tool name to call",
285
+ },
286
+ params: {
287
+ type: "object",
288
+ description: "Tool parameters",
289
+ },
290
+ },
291
+ required: ["registryId", "agentPath", "action", "tool"],
292
+ },
293
+ execute: async (
294
+ input: {
295
+ registryId: string;
296
+ agentPath: string;
297
+ action: string;
298
+ tool: string;
299
+ params?: Record<string, unknown>;
300
+ },
301
+ ctx: ToolContext,
302
+ ) => {
303
+ const { token, conn } = await getAuthenticatedToken(
304
+ ctx.callerId,
305
+ input.registryId,
306
+ );
307
+ return mcpCall(conn.url, token, {
308
+ action: input.action,
309
+ path: input.agentPath,
310
+ tool: input.tool,
311
+ params: input.params,
312
+ });
313
+ },
314
+ });
315
+
316
+ const listRemoteAgentsTool = defineTool({
317
+ name: "list_remote_agents",
318
+ description: "List agents available on a remote registry.",
319
+ visibility: "public" as const,
320
+ inputSchema: {
321
+ type: "object" as const,
322
+ properties: {
323
+ registryId: {
324
+ type: "string",
325
+ description: "Registry connection ID",
326
+ },
327
+ },
328
+ required: ["registryId"],
329
+ },
330
+ execute: async (
331
+ input: { registryId: string },
332
+ ctx: ToolContext,
333
+ ) => {
334
+ const { token, conn } = await getAuthenticatedToken(
335
+ ctx.callerId,
336
+ input.registryId,
337
+ );
338
+
339
+ const listUrl = conn.url.replace(/\/$/, "") + "/list";
340
+ const res = await globalThis.fetch(listUrl, {
341
+ headers: { Authorization: `Bearer ${token}` },
342
+ });
343
+
344
+ if (!res.ok) {
345
+ throw new Error(`Failed to list agents: ${res.status}`);
346
+ }
347
+
348
+ return res.json();
349
+ },
350
+ });
351
+
352
+ // ---- Agent Definition ----
353
+
354
+ return defineAgent({
355
+ path: "@remote-registry",
356
+ entrypoint:
357
+ "You manage connections to remote agent registries. " +
358
+ "Use setup to connect a new registry, connect to register users, " +
359
+ "and call_remote to proxy authenticated MCP calls.",
360
+ config: {
361
+ name: "Remote Registry",
362
+ description:
363
+ "Connect to remote agent registries (MCP over HTTP) for federated integrations",
364
+ supportedActions: ["execute_tool", "describe_tools", "load"],
365
+ integration: {
366
+ provider: "remote-registry",
367
+ displayName: "Agent Registry",
368
+ icon: "server",
369
+ category: "infrastructure",
370
+ description:
371
+ "Connect to a remote agent registry to access its integrations, databases, and agents.",
372
+ },
373
+ },
374
+ visibility: "public",
375
+ integrationMethods: {
376
+ async setup(
377
+ params: Record<string, unknown>,
378
+ ctx: IntegrationMethodContext,
379
+ ): Promise<IntegrationMethodResult> {
380
+ const url = params.url as string;
381
+ const name = (params.name as string) ?? "registry";
382
+
383
+ if (!url) {
384
+ return { success: false, error: "url is required" };
385
+ }
386
+
387
+ try {
388
+ // 1. Create tenant on remote registry
389
+ const setupUrl = url.replace(/\/$/, "") + "/setup";
390
+ const setupRes = await globalThis.fetch(setupUrl, {
391
+ method: "POST",
392
+ headers: { "Content-Type": "application/json" },
393
+ body: JSON.stringify({ tenant: name }),
394
+ });
395
+
396
+ if (!setupRes.ok) {
397
+ const body = await setupRes.text();
398
+ return {
399
+ success: false,
400
+ error: `Failed to create tenant on registry: ${setupRes.status} ${body}`,
401
+ };
402
+ }
403
+
404
+ const setupResult = (await setupRes.json()) as {
405
+ success: boolean;
406
+ result?: {
407
+ tenantId: string;
408
+ token?: string;
409
+ };
410
+ };
411
+
412
+ if (!setupResult.success || !setupResult.result?.tenantId) {
413
+ return {
414
+ success: false,
415
+ error: "Registry /setup did not return a tenantId",
416
+ };
417
+ }
418
+
419
+ const remoteTenantId = setupResult.result.tenantId;
420
+
421
+ // 2. Register a client for this tenant
422
+ // Use the setup token (or root key) to create client credentials
423
+ const token = setupResult.result.token;
424
+ if (!token) {
425
+ return {
426
+ success: false,
427
+ error: "Registry /setup did not return a token for client creation",
428
+ };
429
+ }
430
+
431
+ const registerResult = await mcpCall(url, token, {
432
+ action: "execute_tool",
433
+ path: "@auth",
434
+ tool: "register",
435
+ params: {
436
+ name: `${name}-client`,
437
+ scopes: ["integrations", "secrets", "users"],
438
+ },
439
+ });
440
+
441
+ const clientId =
442
+ registerResult?.clientId ?? registerResult?.result?.clientId;
443
+ const clientSecret =
444
+ registerResult?.clientSecret?.value ??
445
+ registerResult?.result?.clientSecret?.value ??
446
+ registerResult?.clientSecret;
447
+
448
+ if (!clientId || !clientSecret) {
449
+ return {
450
+ success: false,
451
+ error: `Failed to register client: ${JSON.stringify(registerResult)}`,
452
+ };
453
+ }
454
+
455
+ // 3. Store connection
456
+ const conn: RegistryConnection = {
457
+ id: name,
458
+ name,
459
+ url: url.replace(/\/$/, ""),
460
+ remoteTenantId,
461
+ clientId,
462
+ createdAt: Date.now(),
463
+ };
464
+
465
+ await storeConnection(ctx.callerId, conn, clientSecret);
466
+
467
+ return {
468
+ success: true,
469
+ data: {
470
+ registryId: name,
471
+ url: conn.url,
472
+ remoteTenantId,
473
+ clientId,
474
+ },
475
+ };
476
+ } catch (err) {
477
+ return {
478
+ success: false,
479
+ error: err instanceof Error ? err.message : String(err),
480
+ };
481
+ }
482
+ },
483
+
484
+ async connect(
485
+ params: Record<string, unknown>,
486
+ ctx: IntegrationMethodContext,
487
+ ): Promise<IntegrationMethodResult> {
488
+ const registryId = params.registryId as string;
489
+ const userId = (params.userId as string) ?? ctx.callerId;
490
+
491
+ if (!registryId) {
492
+ return { success: false, error: "registryId is required" };
493
+ }
494
+
495
+ try {
496
+ const { token, conn } = await getAuthenticatedToken(
497
+ ctx.callerId,
498
+ registryId,
499
+ );
500
+
501
+ // Register user on the remote registry
502
+ const result = await mcpCall(conn.url, token, {
503
+ action: "execute_tool",
504
+ path: "@users",
505
+ tool: "create_user",
506
+ params: { name: userId, tenantId: conn.remoteTenantId },
507
+ });
508
+
509
+ return {
510
+ success: true,
511
+ data: {
512
+ registryId,
513
+ userId,
514
+ remoteUser: result,
515
+ },
516
+ };
517
+ } catch (err) {
518
+ return {
519
+ success: false,
520
+ error: err instanceof Error ? err.message : String(err),
521
+ };
522
+ }
523
+ },
524
+
525
+ async list(
526
+ _params: Record<string, unknown>,
527
+ ctx: IntegrationMethodContext,
528
+ ): Promise<IntegrationMethodResult> {
529
+ try {
530
+ const conns = await listConnectionsList(ctx.callerId);
531
+ return {
532
+ success: true,
533
+ data: conns.map((c) => ({
534
+ id: c.id,
535
+ name: c.name,
536
+ url: c.url,
537
+ remoteTenantId: c.remoteTenantId,
538
+ createdAt: c.createdAt,
539
+ })),
540
+ };
541
+ } catch (err) {
542
+ return {
543
+ success: false,
544
+ error: err instanceof Error ? err.message : String(err),
545
+ };
546
+ }
547
+ },
548
+
549
+ async get(
550
+ params: Record<string, unknown>,
551
+ ctx: IntegrationMethodContext,
552
+ ): Promise<IntegrationMethodResult> {
553
+ const registryId = params.registryId as string;
554
+ if (!registryId) {
555
+ return { success: false, error: "registryId is required" };
556
+ }
557
+
558
+ try {
559
+ const data = await loadConnection(ctx.callerId, registryId);
560
+ if (!data) {
561
+ return {
562
+ success: false,
563
+ error: `No registry connection '${registryId}'`,
564
+ };
565
+ }
566
+
567
+ return {
568
+ success: true,
569
+ data: {
570
+ id: data.conn.id,
571
+ name: data.conn.name,
572
+ url: data.conn.url,
573
+ remoteTenantId: data.conn.remoteTenantId,
574
+ clientId: data.conn.clientId,
575
+ createdAt: data.conn.createdAt,
576
+ },
577
+ };
578
+ } catch (err) {
579
+ return {
580
+ success: false,
581
+ error: err instanceof Error ? err.message : String(err),
582
+ };
583
+ }
584
+ },
585
+
586
+ async update(
587
+ params: Record<string, unknown>,
588
+ ctx: IntegrationMethodContext,
589
+ ): Promise<IntegrationMethodResult> {
590
+ const registryId = params.registryId as string;
591
+ if (!registryId) {
592
+ return { success: false, error: "registryId is required" };
593
+ }
594
+
595
+ try {
596
+ const data = await loadConnection(ctx.callerId, registryId);
597
+ if (!data) {
598
+ return {
599
+ success: false,
600
+ error: `No registry connection '${registryId}'`,
601
+ };
602
+ }
603
+
604
+ // Update mutable fields
605
+ if (params.name) data.conn.name = params.name as string;
606
+ if (params.url) data.conn.url = (params.url as string).replace(/\/$/, "");
607
+
608
+ await storeConnection(ctx.callerId, data.conn, data.clientSecret);
609
+
610
+ return { success: true, data: { id: data.conn.id, updated: true } };
611
+ } catch (err) {
612
+ return {
613
+ success: false,
614
+ error: err instanceof Error ? err.message : String(err),
615
+ };
616
+ }
617
+ },
618
+ },
619
+ tools: [callRemoteTool as any, listRemoteAgentsTool as any],
620
+ });
621
+ }