@slashfi/agents-sdk 0.88.0 → 0.89.1

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.
@@ -653,6 +653,225 @@ describe("ADK ref.call() full auto-refresh flow", () => {
653
653
  });
654
654
  });
655
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
+
656
875
  // ─── Registry auth lifecycle ─────────────────────────────────────
657
876
 
658
877
  describe("ADK registry auth lifecycle", () => {
@@ -214,6 +214,17 @@ export interface AdkOptions {
214
214
  encryptionKey?: string;
215
215
  /** Bearer token for authenticated registries */
216
216
  token?: string;
217
+ /**
218
+ * Default result token limit to pass through for `ref.call` registry requests.
219
+ * Use `null` to explicitly disable remote result limiting for in-process
220
+ * scripting boundaries like `adk run`; omit to use the registry default.
221
+ */
222
+ refCallMaxResultTokens?: number | null;
223
+ /**
224
+ * Default overflow behavior to pass through for `ref.call` registry requests.
225
+ * Only applies when the remote registry enforces a result limit.
226
+ */
227
+ refCallOverflow?: "error" | "truncate" | null;
217
228
  /**
218
229
  * OAuth callback URL. Defaults to http://localhost:8919/callback.
219
230
  * Set this to your server's callback endpoint in non-local environments
@@ -959,6 +970,21 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
959
970
  return data;
960
971
  }
961
972
 
973
+ /**
974
+ * Error thrown by `callMcpDirect` when the upstream MCP server returns a
975
+ * non-2xx HTTP response. Carries the numeric `status` so the catch handler
976
+ * can surface it as `httpStatus` on the returned CallAgentResponse, which
977
+ * `isUnauthorized` (and the retry-on-401 path in `ref.call`) relies on.
978
+ */
979
+ class McpHttpError extends Error {
980
+ readonly status: number;
981
+ constructor(status: number, message: string) {
982
+ super(message);
983
+ this.name = "McpHttpError";
984
+ this.status = status;
985
+ }
986
+ }
987
+
962
988
  /** Call an MCP server directly (bypasses registry). */
963
989
  async function callMcpDirect(
964
990
  serverUrl: string,
@@ -993,7 +1019,8 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
993
1019
  }),
994
1020
  });
995
1021
  if (!res.ok) {
996
- throw new Error(
1022
+ throw new McpHttpError(
1023
+ res.status,
997
1024
  `MCP ${method} failed (${res.status}): ${await res.text().catch(() => "unknown")}`,
998
1025
  );
999
1026
  }
@@ -1066,10 +1093,21 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1066
1093
  }
1067
1094
  return { success: true, result } as CallAgentResponse;
1068
1095
  } catch (err) {
1069
- return {
1070
- success: false,
1096
+ // Preserve upstream HTTP status (notably 401) so `isUnauthorized`
1097
+ // can detect it and trigger the auto-refresh-and-retry path in
1098
+ // `ref.call`. Without this, refs that go through callMcpDirect
1099
+ // (mode: redirect/proxy with an MCP url, e.g. Linear, Notion) see
1100
+ // their tokens expire and fail with a raw 401 instead of silently
1101
+ // refreshing the way API-mode refs (Google, etc.) do via the
1102
+ // registry's structured response. We attach httpStatus as an
1103
+ // out-of-band field on the error envelope, matching the shape
1104
+ // `isUnauthorized` already checks for on registry-mediated calls.
1105
+ const errorResponse = {
1106
+ success: false as const,
1071
1107
  error: err instanceof Error ? err.message : String(err),
1072
- } as CallAgentResponse;
1108
+ ...(err instanceof McpHttpError && { httpStatus: err.status }),
1109
+ };
1110
+ return errorResponse as unknown as CallAgentResponse;
1073
1111
  }
1074
1112
  }
1075
1113
 
@@ -2173,6 +2211,12 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2173
2211
  action: "execute_tool",
2174
2212
  path: entry.sourceRegistry?.agentPath ?? entry.ref,
2175
2213
  tool,
2214
+ ...("refCallMaxResultTokens" in options && {
2215
+ maxResultTokens: options.refCallMaxResultTokens,
2216
+ }),
2217
+ ...("refCallOverflow" in options && {
2218
+ overflow: options.refCallOverflow,
2219
+ }),
2176
2220
  params: {
2177
2221
  ...(params ?? {}),
2178
2222
  ...(token && { accessToken: token }),