@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/dist/cjs/config-store.js +11 -9
- package/dist/cjs/config-store.js.map +1 -1
- package/dist/cjs/registry-consumer.js +11 -0
- package/dist/cjs/registry-consumer.js.map +1 -1
- package/dist/cjs/server.js +19 -3
- package/dist/cjs/server.js.map +1 -1
- package/dist/config-store.d.ts.map +1 -1
- package/dist/config-store.js +11 -9
- package/dist/config-store.js.map +1 -1
- package/dist/registry-consumer.d.ts.map +1 -1
- package/dist/registry-consumer.js +11 -0
- package/dist/registry-consumer.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +19 -3
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/config-store.test.ts +223 -0
- package/src/config-store.ts +10 -12
- package/src/registry-consumer.ts +8 -0
- package/src/server.ts +22 -3
package/package.json
CHANGED
package/src/config-store.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/config-store.ts
CHANGED
|
@@ -290,20 +290,18 @@ async function decryptConfigSecrets(
|
|
|
290
290
|
// ============================================
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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
|
|
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
|
-
|
|
301
|
-
//
|
|
302
|
-
const
|
|
303
|
-
if (
|
|
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 &&
|
|
895
|
+
if (accessToken && isUnauthorized(result)) {
|
|
898
896
|
const refreshed = await ref.refreshToken(name);
|
|
899
897
|
if (refreshed) {
|
|
900
898
|
return doCall(refreshed.accessToken);
|
package/src/registry-consumer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1113
|
-
|
|
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 ──
|