@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.
- package/dist/adk-check.js +1 -1
- package/dist/adk-check.js.map +1 -1
- package/dist/call-agent-schema.d.ts +224 -0
- package/dist/call-agent-schema.d.ts.map +1 -1
- package/dist/call-agent-schema.js +12 -0
- package/dist/call-agent-schema.js.map +1 -1
- package/dist/cjs/adk-check.js +1 -1
- package/dist/cjs/adk-check.js.map +1 -1
- package/dist/cjs/call-agent-schema.js +12 -0
- package/dist/cjs/call-agent-schema.js.map +1 -1
- package/dist/cjs/config-store.js +33 -2
- package/dist/cjs/config-store.js.map +1 -1
- package/dist/config-store.d.ts +11 -0
- package/dist/config-store.d.ts.map +1 -1
- package/dist/config-store.js +33 -2
- package/dist/config-store.js.map +1 -1
- package/package.json +1 -1
- package/src/adk-check.ts +1 -1
- package/src/call-agent-schema.ts +16 -0
- package/src/config-store.test.ts +219 -0
- package/src/config-store.ts +48 -4
package/src/config-store.test.ts
CHANGED
|
@@ -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", () => {
|
package/src/config-store.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
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 }),
|