@slashfi/agents-sdk 0.87.0 → 0.89.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.
@@ -533,6 +533,93 @@ describe("ADK ref.call() full auto-refresh flow", () => {
533
533
  expect((result as any)?.result?.token).toBe("refreshed-token");
534
534
  });
535
535
 
536
+ test("ref.authStatus persists authFields={} in registry-cache for security:none refs (regression: isRefConnected miss-classifies auto-installed no-auth refs)", async () => {
537
+ // Regression: `authStatus` short-circuited at `security.type === "none"`
538
+ // (and at `security == null`) WITHOUT writing the slim `{required,
539
+ // automated}` authFields shape into `registry-cache.json`. Host-side
540
+ // `isRefAuthComplete` then returned `null` for those refs ("no
541
+ // authFields in cache"), and the LLM-facing `isRefConnected` filter
542
+ // in atlas-os-sdk fell back to a coarse `[access_token|api_key|token]`
543
+ // credential presence check — which a security:none ref like
544
+ // web-search/Firecrawl never has by definition. Result: auto-installed
545
+ // no-auth refs silently disappeared from `list_agents` and from
546
+ // `~/.adk/refs/` materialization.
547
+ //
548
+ // The fix: when `inspect` confirmed `security` is absent or
549
+ // `{type:"none"}`, persist `authFields: {}` so `isRefAuthComplete`
550
+ // returns `true` (no required fields to satisfy) and downstream
551
+ // filters treat the ref as connected.
552
+ //
553
+ // The `@math` agent registered above has no `config.security`, so it
554
+ // exercises the "registry returned no security field at all" path.
555
+ const fs = createMemoryFs();
556
+ const adk = createAdk(fs, {
557
+ encryptionKey: "test-key-32-chars-long-enough!!",
558
+ });
559
+
560
+ await adk.registry.add({
561
+ name: "oauth-reg",
562
+ url: `http://localhost:${REG_PORT}`,
563
+ });
564
+ await adk.ref.add({
565
+ ref: "@math",
566
+ name: "math",
567
+ sourceRegistry: {
568
+ url: `http://localhost:${REG_PORT}`,
569
+ agentPath: "@math",
570
+ },
571
+ });
572
+
573
+ const status = await adk.ref.authStatus("math");
574
+ expect(status.complete).toBe(true);
575
+ expect(status.fields).toEqual({});
576
+
577
+ // The cache must now carry authFields={} so isRefAuthComplete can
578
+ // answer "yes, ready to call" without re-fetching the security scheme.
579
+ const cacheRaw = await fs.readFile("registry-cache.json");
580
+ expect(cacheRaw).not.toBeNull();
581
+ const cache = JSON.parse(cacheRaw!);
582
+ expect(cache.refs.math).toBeDefined();
583
+ expect(cache.refs.math.authFields).toEqual({});
584
+ });
585
+
586
+ test("ref.authStatus does NOT persist authFields when inspect fails (registry unreachable)", async () => {
587
+ // Sibling guard: if the registry inspect call throws / returns null
588
+ // (network error, registry doesn't host the ref, etc.), we must NOT
589
+ // cache a false-positive `authFields: {}` — that would let the host
590
+ // treat an unreachable ref as "connected" on the next call.
591
+ const fs = createMemoryFs();
592
+ const adk = createAdk(fs, {
593
+ encryptionKey: "test-key-32-chars-long-enough!!",
594
+ });
595
+
596
+ // Point at a port that nothing is listening on.
597
+ await adk.registry.add({
598
+ name: "dead-reg",
599
+ url: `http://localhost:1`,
600
+ });
601
+ await adk.ref.add({
602
+ ref: "@phantom",
603
+ name: "phantom",
604
+ sourceRegistry: {
605
+ url: `http://localhost:1`,
606
+ agentPath: "@phantom",
607
+ },
608
+ });
609
+
610
+ const status = await adk.ref.authStatus("phantom");
611
+ expect(status.complete).toBe(true);
612
+ expect(status.security).toBeNull();
613
+
614
+ // Registry was unreachable, so we shouldn't have written a cache
615
+ // entry that claims this ref is no-auth.
616
+ const cacheRaw = await fs.readFile("registry-cache.json");
617
+ if (cacheRaw !== null) {
618
+ const cache = JSON.parse(cacheRaw);
619
+ expect(cache.refs?.phantom?.authFields).toBeUndefined();
620
+ }
621
+ });
622
+
536
623
  test("ref.authStatus reports access_token.automated=false for authorizationCode (user must consent)", async () => {
537
624
  // Regression: previously `access_token.automated` was hardcoded to
538
625
  // `true` for every oauth2 scheme. That made cached-authFields
@@ -566,6 +653,225 @@ describe("ADK ref.call() full auto-refresh flow", () => {
566
653
  });
567
654
  });
568
655
 
656
+ describe("ADK ref.call() auto-refresh on direct MCP 401", () => {
657
+ // Regression: refs whose entry has a direct `url` and `mode !== "api"`
658
+ // (Linear, Notion, Figma, DoorDash, Houzz, etc.) take the
659
+ // `callMcpDirect` branch instead of the registry-mediated `callRegistry`
660
+ // branch. Before this fix, a 401 from the upstream MCP server was
661
+ // surfaced as `{ success: false, error: "MCP tools/call failed (401):
662
+ // ..." }` with no `httpStatus` field, so `isUnauthorized(result)` never
663
+ // matched and the refresh-on-401 retry path in `ref.call` was silently
664
+ // skipped — even when the ref had a valid `refresh_token` on hand. The
665
+ // fix attaches `httpStatus` to the error envelope, restoring parity
666
+ // with the registry-mediated path (which already gets `_httpStatus`
667
+ // forwarded as structured data).
668
+ let registryServer: AgentServer;
669
+ let mcpServer: ReturnType<typeof Bun.serve>;
670
+ let tokenServer: ReturnType<typeof Bun.serve>;
671
+ const REG_PORT = 19930;
672
+ const MCP_PORT = 19931;
673
+ const TOKEN_PORT = 19932;
674
+ let toolCallCount = 0;
675
+ let tokenRefreshCount = 0;
676
+ let serverActiveToken = "";
677
+
678
+ beforeAll(async () => {
679
+ // Registry exposes the agent with an oauth2 security scheme so
680
+ // `ref.authStatus` can discover the tokenUrl that `refreshToken`
681
+ // POSTs to. The agent has no tools that ever get invoked here —
682
+ // the actual tool call goes direct to mcpServer below.
683
+ const stubTool = defineTool({
684
+ name: "some_tool",
685
+ description: "Never invoked via the registry in this test",
686
+ inputSchema: { type: "object" as const, properties: {} },
687
+ execute: async () => ({ message: "unused" }),
688
+ });
689
+ const agent = defineAgent({
690
+ path: "direct-mcp-agent",
691
+ entrypoint: "Direct-MCP agent (security discovery only)",
692
+ tools: [stubTool],
693
+ visibility: "public",
694
+ config: {
695
+ description: "Direct-MCP test agent (security discovery only)",
696
+ security: {
697
+ type: "oauth2",
698
+ flows: {
699
+ authorizationCode: {
700
+ authorizationUrl: "http://localhost/authorize",
701
+ tokenUrl: `http://localhost:${TOKEN_PORT}`,
702
+ },
703
+ },
704
+ },
705
+ },
706
+ });
707
+ const registry = createAgentRegistry();
708
+ registry.register(agent);
709
+ registryServer = createAgentServer(registry, { port: REG_PORT });
710
+ await registryServer.start();
711
+
712
+ // Direct MCP server — returns 401 unless the bearer token matches
713
+ // `serverActiveToken`. Real MCP servers signal 401 with an HTTP 401
714
+ // (not via httpStatus on the JSON-RPC body); that's exactly what the
715
+ // fix has to recover.
716
+ mcpServer = Bun.serve({
717
+ port: MCP_PORT,
718
+ async fetch(req) {
719
+ const body = (await req.json()) as {
720
+ method?: string;
721
+ id?: number;
722
+ params?: { name?: string };
723
+ };
724
+ const respond = (status: number, payload: unknown) =>
725
+ new Response(JSON.stringify(payload), {
726
+ status,
727
+ headers: { "Content-Type": "application/json" },
728
+ });
729
+
730
+ if (body.method === "initialize") {
731
+ return respond(200, {
732
+ jsonrpc: "2.0",
733
+ id: body.id,
734
+ result: {
735
+ protocolVersion: "2024-11-05",
736
+ capabilities: {},
737
+ serverInfo: { name: "mock-mcp", version: "1.0.0" },
738
+ },
739
+ });
740
+ }
741
+ if (body.method === "notifications/initialized") {
742
+ return respond(200, { jsonrpc: "2.0", id: body.id, result: {} });
743
+ }
744
+ if (body.method === "tools/call") {
745
+ toolCallCount++;
746
+ const auth = req.headers.get("Authorization") ?? "";
747
+ const token = auth.replace(/^Bearer /, "");
748
+ if (token !== serverActiveToken) {
749
+ return respond(401, {
750
+ jsonrpc: "2.0",
751
+ id: body.id,
752
+ error: { code: -32001, message: "Unauthorized" },
753
+ });
754
+ }
755
+ return respond(200, {
756
+ jsonrpc: "2.0",
757
+ id: body.id,
758
+ result: {
759
+ content: [
760
+ {
761
+ type: "text",
762
+ text: JSON.stringify({ ok: true, token }),
763
+ },
764
+ ],
765
+ },
766
+ });
767
+ }
768
+ return respond(404, {
769
+ jsonrpc: "2.0",
770
+ id: body.id,
771
+ error: { code: -32601, message: "Method not found" },
772
+ });
773
+ },
774
+ });
775
+
776
+ // OAuth token endpoint — mints a fresh access token for a known refresh
777
+ // token + client_id. Rejects everything else with 400.
778
+ tokenServer = Bun.serve({
779
+ port: TOKEN_PORT,
780
+ async fetch(req) {
781
+ tokenRefreshCount++;
782
+ const params = new URLSearchParams(await req.text());
783
+ if (
784
+ params.get("grant_type") !== "refresh_token" ||
785
+ params.get("refresh_token") !== "direct-refresh-token" ||
786
+ params.get("client_id") !== "direct-client-id"
787
+ ) {
788
+ return new Response(JSON.stringify({ error: "invalid_request" }), {
789
+ status: 400,
790
+ });
791
+ }
792
+ return new Response(
793
+ JSON.stringify({
794
+ access_token: "refreshed-direct-token",
795
+ token_type: "Bearer",
796
+ expires_in: 3600,
797
+ }),
798
+ { headers: { "Content-Type": "application/json" } },
799
+ );
800
+ },
801
+ });
802
+ });
803
+
804
+ afterAll(async () => {
805
+ await registryServer.stop();
806
+ mcpServer.stop();
807
+ tokenServer.stop();
808
+ });
809
+
810
+ test("401 from direct MCP triggers refresh + retry (parity with registry-mediated refs)", async () => {
811
+ toolCallCount = 0;
812
+ tokenRefreshCount = 0;
813
+ // Server will only accept the refreshed token — the stale one we seed
814
+ // below must round-trip through refresh before the call can succeed.
815
+ serverActiveToken = "refreshed-direct-token";
816
+
817
+ const fs = createMemoryFs();
818
+ const adk = createAdk(fs, {
819
+ encryptionKey: "test-key-32-chars-long-enough!!",
820
+ });
821
+
822
+ // Point the ref at the registry for security discovery, but also set
823
+ // a direct `url` so `ref.call` takes the `callMcpDirect` branch (the
824
+ // exact code path that was broken).
825
+ await adk.registry.add({
826
+ name: "direct-mcp-registry",
827
+ url: `http://localhost:${REG_PORT}`,
828
+ });
829
+ await adk.ref.add({
830
+ ref: "direct-mcp-agent",
831
+ url: `http://localhost:${MCP_PORT}`,
832
+ sourceRegistry: {
833
+ url: `http://localhost:${REG_PORT}`,
834
+ agentPath: "direct-mcp-agent",
835
+ },
836
+ });
837
+
838
+ // Seed credentials directly. access_token is intentionally stale.
839
+ const config = await adk.readConfig();
840
+ await adk.writeConfig({
841
+ ...config,
842
+ refs: config.refs?.map((r: any) => {
843
+ if (r.ref === "direct-mcp-agent") {
844
+ return {
845
+ ...r,
846
+ // Force the direct-MCP branch: any mode that's not "api".
847
+ mode: "redirect",
848
+ config: {
849
+ ...r.config,
850
+ access_token: "stale-direct-token",
851
+ refresh_token: "direct-refresh-token",
852
+ client_id: "direct-client-id",
853
+ },
854
+ };
855
+ }
856
+ return r;
857
+ }),
858
+ });
859
+
860
+ const result = await adk.ref.call("direct-mcp-agent", "some_tool", {});
861
+
862
+ // Without the fix: tokenRefreshCount stays 0, toolCallCount === 1,
863
+ // result.success === false with `MCP tools/call failed (401)` in error.
864
+ // With the fix: 401 → refresh → retry succeeds.
865
+ expect(tokenRefreshCount).toBe(1);
866
+ expect(toolCallCount).toBe(2);
867
+ expect((result as any).success).toBe(true);
868
+ expect((result as any).result).toEqual({
869
+ ok: true,
870
+ token: "refreshed-direct-token",
871
+ });
872
+ });
873
+ });
874
+
569
875
  // ─── Registry auth lifecycle ─────────────────────────────────────
570
876
 
571
877
  describe("ADK registry auth lifecycle", () => {
@@ -1099,6 +1405,33 @@ describe("isRefAuthComplete + cached authFields", () => {
1099
1405
  expect(result).toBeNull();
1100
1406
  });
1101
1407
 
1408
+ test("empty authFields object → true (security:none refs cache an empty map)", async () => {
1409
+ // Companion to the authStatus regression: an explicit `authFields: {}`
1410
+ // in the registry-cache (written by `authStatus` for security:none refs)
1411
+ // means "no required fields to satisfy" — not "cache miss". The
1412
+ // distinction matters because callers (`atlas-os-sdk` `isRefConnected`)
1413
+ // use a different fallback strategy on null vs false. With an empty
1414
+ // map, the required-fields loop runs zero times and we return true.
1415
+ const { isRefAuthComplete } = await import("./config-store");
1416
+ const result = isRefAuthComplete(
1417
+ {
1418
+ ref: "@web-search",
1419
+ name: "@web-search",
1420
+ scheme: "registry",
1421
+ sourceRegistry: {
1422
+ url: "http://localhost",
1423
+ agentPath: "@web-search",
1424
+ },
1425
+ },
1426
+ {
1427
+ ref: "@web-search",
1428
+ fetchedAt: new Date().toISOString(),
1429
+ authFields: {},
1430
+ },
1431
+ );
1432
+ expect(result).toBe(true);
1433
+ });
1434
+
1102
1435
  test("required field present → true", async () => {
1103
1436
  const { isRefAuthComplete } = await import("./config-store");
1104
1437
  const result = isRefAuthComplete(
@@ -959,6 +959,21 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
959
959
  return data;
960
960
  }
961
961
 
962
+ /**
963
+ * Error thrown by `callMcpDirect` when the upstream MCP server returns a
964
+ * non-2xx HTTP response. Carries the numeric `status` so the catch handler
965
+ * can surface it as `httpStatus` on the returned CallAgentResponse, which
966
+ * `isUnauthorized` (and the retry-on-401 path in `ref.call`) relies on.
967
+ */
968
+ class McpHttpError extends Error {
969
+ readonly status: number;
970
+ constructor(status: number, message: string) {
971
+ super(message);
972
+ this.name = "McpHttpError";
973
+ this.status = status;
974
+ }
975
+ }
976
+
962
977
  /** Call an MCP server directly (bypasses registry). */
963
978
  async function callMcpDirect(
964
979
  serverUrl: string,
@@ -993,7 +1008,8 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
993
1008
  }),
994
1009
  });
995
1010
  if (!res.ok) {
996
- throw new Error(
1011
+ throw new McpHttpError(
1012
+ res.status,
997
1013
  `MCP ${method} failed (${res.status}): ${await res.text().catch(() => "unknown")}`,
998
1014
  );
999
1015
  }
@@ -1066,10 +1082,21 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1066
1082
  }
1067
1083
  return { success: true, result } as CallAgentResponse;
1068
1084
  } catch (err) {
1069
- return {
1070
- success: false,
1085
+ // Preserve upstream HTTP status (notably 401) so `isUnauthorized`
1086
+ // can detect it and trigger the auto-refresh-and-retry path in
1087
+ // `ref.call`. Without this, refs that go through callMcpDirect
1088
+ // (mode: redirect/proxy with an MCP url, e.g. Linear, Notion) see
1089
+ // their tokens expire and fail with a raw 401 instead of silently
1090
+ // refreshing the way API-mode refs (Google, etc.) do via the
1091
+ // registry's structured response. We attach httpStatus as an
1092
+ // out-of-band field on the error envelope, matching the shape
1093
+ // `isUnauthorized` already checks for on registry-mediated calls.
1094
+ const errorResponse = {
1095
+ success: false as const,
1071
1096
  error: err instanceof Error ? err.message : String(err),
1072
- } as CallAgentResponse;
1097
+ ...(err instanceof McpHttpError && { httpStatus: err.status }),
1098
+ };
1099
+ return errorResponse as unknown as CallAgentResponse;
1073
1100
  }
1074
1101
  }
1075
1102
 
@@ -2229,6 +2256,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2229
2256
  if (!entry) throw new Error(`Ref "${name}" not found`);
2230
2257
 
2231
2258
  let security: SecuritySchemeSummary | null = null;
2259
+ let inspectSucceeded = false;
2232
2260
  try {
2233
2261
  const consumer = await buildConsumerForRef(entry);
2234
2262
  // Pass `sourceRegistry.url` so inspect targets the registry the ref
@@ -2241,12 +2269,32 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2241
2269
  entry.sourceRegistry?.agentPath ?? entry.ref,
2242
2270
  entry.sourceRegistry?.url,
2243
2271
  );
2244
- if (info?.security) security = info.security;
2272
+ if (info) {
2273
+ inspectSucceeded = true;
2274
+ if (info.security) security = info.security;
2275
+ }
2245
2276
  } catch {
2246
2277
  // Can't reach registry
2247
2278
  }
2248
2279
 
2249
2280
  if (!security || security.type === "none") {
2281
+ // Persist an empty authFields map when the registry confirmed the
2282
+ // ref needs no auth — either an explicit `security.type === "none"`
2283
+ // or no `security` field on the agent at all. Host-side filters
2284
+ // that consult the registry-cache (e.g. atlas-os-sdk
2285
+ // `isRefConnected`) need this to distinguish "registry says this
2286
+ // ref needs no auth" from "we never warmed the cache". Without
2287
+ // it, auto-installed no-auth refs (e.g. web-search/Firecrawl)
2288
+ // look identical to never-inspected refs and get filtered out
2289
+ // of LLM-facing surfaces as "not connected" even though they
2290
+ // have nothing to connect.
2291
+ //
2292
+ // Gate on `inspectSucceeded` so we don't cache a false positive
2293
+ // when the registry was unreachable (network failure / consumer
2294
+ // error — `inspect` returned null/threw).
2295
+ if (inspectSucceeded) {
2296
+ await upsertRegistryCacheAuthFields(name, entry.ref, {});
2297
+ }
2250
2298
  return { name, security, complete: true, fields: {} };
2251
2299
  }
2252
2300