@slashfi/agents-sdk 0.67.0 → 0.67.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slashfi/agents-sdk",
3
- "version": "0.67.0",
3
+ "version": "0.67.2",
4
4
  "author": "Slash Financial",
5
5
  "repository": {
6
6
  "type": "git",
@@ -235,3 +235,226 @@ describe("ADK ref.add validation", () => {
235
235
  expect(result).toBeDefined();
236
236
  });
237
237
  });
238
+
239
+ // ─── ADK ref.call() 401 → refresh → retry ────────────────────────
240
+
241
+ describe("ADK ref.call() auto-refresh on 401", () => {
242
+ let server: AgentServer;
243
+ const PORT = 19910;
244
+ let callCount = 0;
245
+
246
+ beforeAll(async () => {
247
+ callCount = 0;
248
+
249
+ const failOnceTool = defineTool({
250
+ name: "get_data",
251
+ description: "Returns 401 on first call, succeeds on retry",
252
+ inputSchema: { type: "object" as const, properties: {} },
253
+ execute: async () => {
254
+ callCount++;
255
+ if (callCount === 1) {
256
+ return { content: [{ type: "text", text: '{"error":"401 Unauthorized"}' }], _httpStatus: 401 };
257
+ }
258
+ return { data: "success", callNumber: callCount };
259
+ },
260
+ });
261
+
262
+ const agent = defineAgent({
263
+ path: "test-api",
264
+ entrypoint: "Test API agent",
265
+ tools: [failOnceTool],
266
+ visibility: "public",
267
+ });
268
+
269
+ const registry = createAgentRegistry();
270
+ registry.register(agent);
271
+ server = createAgentServer(registry, { port: PORT });
272
+ await server.start();
273
+ });
274
+
275
+ afterAll(async () => {
276
+ await server.stop();
277
+ });
278
+
279
+ test("server forwards _httpStatus as HTTP 401", async () => {
280
+ callCount = 0;
281
+ const res = await fetch(`http://localhost:${PORT}`, {
282
+ method: "POST",
283
+ headers: { "Content-Type": "application/json" },
284
+ body: JSON.stringify({
285
+ jsonrpc: "2.0",
286
+ id: "test-401",
287
+ method: "tools/call",
288
+ params: {
289
+ name: "call_agent",
290
+ arguments: {
291
+ request: {
292
+ action: "execute_tool",
293
+ path: "test-api",
294
+ tool: "get_data",
295
+ params: {},
296
+ },
297
+ },
298
+ },
299
+ }),
300
+ });
301
+
302
+ // Server should forward the 401 HTTP status from the tool result
303
+ expect(res.status).toBe(401);
304
+
305
+ // Body should still be valid JSON-RPC
306
+ const body = await res.json();
307
+ expect(body.jsonrpc).toBe("2.0");
308
+ expect(body.id).toBe("test-401");
309
+ });
310
+
311
+ test("second call succeeds with 200", async () => {
312
+ const res = await fetch(`http://localhost:${PORT}`, {
313
+ method: "POST",
314
+ headers: { "Content-Type": "application/json" },
315
+ body: JSON.stringify({
316
+ jsonrpc: "2.0",
317
+ id: "test-200",
318
+ method: "tools/call",
319
+ params: {
320
+ name: "call_agent",
321
+ arguments: {
322
+ request: {
323
+ action: "execute_tool",
324
+ path: "test-api",
325
+ tool: "get_data",
326
+ params: {},
327
+ },
328
+ },
329
+ },
330
+ }),
331
+ });
332
+
333
+ expect(res.status).toBe(200);
334
+ });
335
+ });
336
+
337
+ describe("ADK ref.call() full auto-refresh flow", () => {
338
+ let registryServer: AgentServer;
339
+ let tokenServer: ReturnType<typeof Bun.serve>;
340
+ const REG_PORT = 19920;
341
+ const TOKEN_PORT = 19921;
342
+ let toolCallCount = 0;
343
+ let tokenRefreshCount = 0;
344
+
345
+ beforeAll(async () => {
346
+ // Mock token endpoint that validates refresh_token
347
+ tokenServer = Bun.serve({
348
+ port: TOKEN_PORT,
349
+ async fetch(req) {
350
+ tokenRefreshCount++;
351
+ const body = await req.text();
352
+ const params = new URLSearchParams(body);
353
+ if (params.get("grant_type") !== "refresh_token") {
354
+ return new Response(JSON.stringify({ error: "unsupported_grant_type" }), { status: 400 });
355
+ }
356
+ if (params.get("refresh_token") !== "my-refresh-token") {
357
+ return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 });
358
+ }
359
+ if (params.get("client_id") !== "my-client-id") {
360
+ return new Response(JSON.stringify({ error: "invalid_client" }), { status: 401 });
361
+ }
362
+ return new Response(JSON.stringify({
363
+ access_token: "refreshed-token",
364
+ token_type: "Bearer",
365
+ expires_in: 3600,
366
+ }), { headers: { "Content-Type": "application/json" } });
367
+ },
368
+ });
369
+
370
+ // Agent that validates the access token
371
+ const apiTool = defineTool({
372
+ name: "get_data",
373
+ description: "Validates token and returns 401 if expired",
374
+ inputSchema: { type: "object" as const, properties: {} },
375
+ execute: async (input: any) => {
376
+ toolCallCount++;
377
+ const token = input?.accessToken;
378
+ if (token === "expired-token" || !token) {
379
+ return { content: [{ type: "text", text: '{"error":"401 Unauthorized"}' }], _httpStatus: 401 };
380
+ }
381
+ if (token === "refreshed-token") {
382
+ return { message: "success", token };
383
+ }
384
+ return { content: [{ type: "text", text: '{"error":"403 Forbidden"}' }], _httpStatus: 403 };
385
+ },
386
+ });
387
+
388
+ const agent = defineAgent({
389
+ path: "oauth-api",
390
+ entrypoint: "OAuth API agent",
391
+ tools: [apiTool],
392
+ visibility: "public",
393
+ config: {
394
+ security: {
395
+ type: "oauth2",
396
+ flows: {
397
+ authorizationCode: {
398
+ authorizationUrl: "http://localhost/authorize",
399
+ tokenUrl: `http://localhost:${TOKEN_PORT}`,
400
+ },
401
+ },
402
+ },
403
+ },
404
+ });
405
+
406
+ const registry = createAgentRegistry();
407
+ registry.register(agent);
408
+ registryServer = createAgentServer(registry, { port: REG_PORT });
409
+ await registryServer.start();
410
+ });
411
+
412
+ afterAll(async () => {
413
+ await registryServer.stop();
414
+ tokenServer.stop();
415
+ });
416
+
417
+ test("ref.call() detects 401, refreshes token, retries, and succeeds", async () => {
418
+ toolCallCount = 0;
419
+ tokenRefreshCount = 0;
420
+
421
+ const fs = createMemoryFs();
422
+ const adk = createAdk(fs, { encryptionKey: "test-key-32-chars-long-enough!!" });
423
+
424
+ await adk.registry.add({ name: "oauth-reg", url: `http://localhost:${REG_PORT}` });
425
+ await adk.ref.add({
426
+ ref: "oauth-api",
427
+ sourceRegistry: { url: `http://localhost:${REG_PORT}`, agentPath: "oauth-api" },
428
+ });
429
+
430
+ // Store credentials directly
431
+ const config = await adk.readConfig();
432
+ await adk.writeConfig({
433
+ ...config,
434
+ refs: config.refs?.map((r: any) => {
435
+ if (r.ref === "oauth-api" || r.name === "oauth-api") {
436
+ return {
437
+ ...r,
438
+ config: {
439
+ ...r.config,
440
+ access_token: "expired-token",
441
+ refresh_token: "my-refresh-token",
442
+ client_id: "my-client-id",
443
+ },
444
+ };
445
+ }
446
+ return r;
447
+ }),
448
+ });
449
+
450
+ const result = await adk.ref.call("oauth-api", "get_data");
451
+
452
+ // Tool called twice: first with expired token (401), then with refreshed token (success)
453
+ expect(toolCallCount).toBe(2);
454
+ // Token endpoint called once with correct refresh_token + client_id
455
+ expect(tokenRefreshCount).toBe(1);
456
+ // Final result should be success, proving the refreshed token was used
457
+ expect((result as any)?.result?.message).toBe("success");
458
+ expect((result as any)?.result?.token).toBe("refreshed-token");
459
+ });
460
+ });
@@ -290,20 +290,18 @@ async function decryptConfigSecrets(
290
290
  // ============================================
291
291
 
292
292
  /**
293
- * Heuristic: does a tool call response look like a 401 Unauthorized?
294
- * Checks both structured error fields and stringified response content.
293
+ * Check if a tool call response indicates a 401 Unauthorized from the upstream API.
294
+ * Primary: httpStatus set by consumer from HTTP res.status
295
+ * Fallback: _httpStatus from tool result body
295
296
  */
296
- function looksLike401(result: unknown): boolean {
297
+ function isUnauthorized(result: unknown): boolean {
297
298
  if (!result || typeof result !== 'object') return false;
298
299
  const r = result as Record<string, unknown>;
299
-
300
- // Structured: { success: true, result: { success: true, result: { content: [{ text: '{"error":"401 ..."} }] } } }
301
- // Or: { error: "401 ..." }
302
- const text = JSON.stringify(r).toLowerCase();
303
- if (text.includes('"401') && (text.includes('unauthorized') || text.includes('unauthenticated') || text.includes('invalid credentials'))) {
304
- return true;
305
- }
306
-
300
+ // Primary: HTTP status forwarded by the registry and set by callRegistry
301
+ if (r.httpStatus === 401) return true;
302
+ // Fallback: _httpStatus in the nested tool result body
303
+ const inner = r.result as Record<string, unknown> | undefined;
304
+ if (inner?._httpStatus === 401) return true;
307
305
  return false;
308
306
  }
309
307
 
@@ -894,7 +892,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
894
892
  const result = await doCall(accessToken);
895
893
 
896
894
  // Check if the response indicates a 401 — try refreshing the token and retry once
897
- if (accessToken && looksLike401(result)) {
895
+ if (accessToken && isUnauthorized(result)) {
898
896
  const refreshed = await ref.refreshToken(name);
899
897
  if (refreshed) {
900
898
  return doCall(refreshed.accessToken);
@@ -727,6 +727,14 @@ export async function createRegistryConsumer(
727
727
  });
728
728
 
729
729
  if (!res.ok) {
730
+ // Upstream 401 — return structured response so ref.call() can refresh + retry
731
+ if (res.status === 401) {
732
+ // Still try to parse the body for context
733
+ const body = await res.text().catch(() => "");
734
+ let parsed: Record<string, unknown> = { success: false, error: "unauthorized" };
735
+ try { parsed = JSON.parse(body); } catch {}
736
+ return { ...parsed, success: false, httpStatus: 401 } as unknown as CallAgentResponse;
737
+ }
730
738
  const text = await res.text().catch(() => "unknown error");
731
739
  throw new Error(
732
740
  `Registry call failed (${registry.url}): ${res.status} ${text}`,
package/src/server.ts CHANGED
@@ -554,6 +554,19 @@ export function createAgentServer(
554
554
  async function handleJsonRpc(
555
555
  request: JsonRpcRequest,
556
556
  auth: ResolvedAuth | null,
557
+ ): Promise<{ rpc: JsonRpcResponse; httpResponse?: { status: number } }> {
558
+ const rpc = await handleJsonRpcInner(request, auth);
559
+ // Extract upstream HTTP status from tool results (set by REST proxy handlers)
560
+ const httpStatus = (rpc as any)?.result?._httpStatus as number | undefined;
561
+ return {
562
+ rpc,
563
+ ...(httpStatus ? { httpResponse: { status: httpStatus } } : {}),
564
+ };
565
+ }
566
+
567
+ async function handleJsonRpcInner(
568
+ request: JsonRpcRequest,
569
+ auth: ResolvedAuth | null,
557
570
  ): Promise<JsonRpcResponse> {
558
571
  switch (request.method) {
559
572
  case "initialize":
@@ -662,7 +675,11 @@ export function createAgentServer(
662
675
  }
663
676
 
664
677
  const result = await registry.call(req);
665
- return mcpResult(result);
678
+ const mcp = mcpResult(result);
679
+ // Preserve upstream HTTP status from tool execution (e.g. REST proxy 401)
680
+ const upstreamStatus = (result as any)?.result?._httpStatus;
681
+ if (upstreamStatus) (mcp as any)._httpStatus = upstreamStatus;
682
+ return mcp;
666
683
  }
667
684
 
668
685
  case "list_agents": {
@@ -1109,8 +1126,10 @@ export function createAgentServer(
1109
1126
  // ── POST / → MCP JSON-RPC ──
1110
1127
  if (path === "/" && req.method === "POST") {
1111
1128
  const body = (await req.json()) as JsonRpcRequest;
1112
- const result = await handleJsonRpc(body, effectiveAuth);
1113
- return cors ? addCors(jsonResponse(result)) : jsonResponse(result);
1129
+ const { rpc, httpResponse } = await handleJsonRpc(body, effectiveAuth);
1130
+ const status = httpResponse?.status ?? 200;
1131
+ const res = jsonResponse(rpc, status);
1132
+ return cors ? addCors(res) : res;
1114
1133
  }
1115
1134
 
1116
1135
  // ── POST /oauth/token → OAuth2 token exchange ──