@slashfi/agents-sdk 0.75.0 → 0.77.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.
Files changed (43) hide show
  1. package/dist/adk.js +186 -150
  2. package/dist/adk.js.map +1 -1
  3. package/dist/cjs/config-store.js +642 -38
  4. package/dist/cjs/config-store.js.map +1 -1
  5. package/dist/cjs/define-config.js.map +1 -1
  6. package/dist/cjs/index.js.map +1 -1
  7. package/dist/cjs/mcp-client.js +98 -0
  8. package/dist/cjs/mcp-client.js.map +1 -1
  9. package/dist/cjs/registry-consumer.js +76 -10
  10. package/dist/cjs/registry-consumer.js.map +1 -1
  11. package/dist/cjs/server.js +8 -0
  12. package/dist/cjs/server.js.map +1 -1
  13. package/dist/config-store.d.ts +43 -8
  14. package/dist/config-store.d.ts.map +1 -1
  15. package/dist/config-store.js +643 -39
  16. package/dist/config-store.js.map +1 -1
  17. package/dist/define-config.d.ts +83 -17
  18. package/dist/define-config.d.ts.map +1 -1
  19. package/dist/define-config.js.map +1 -1
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/mcp-client.d.ts +44 -0
  24. package/dist/mcp-client.d.ts.map +1 -1
  25. package/dist/mcp-client.js +95 -0
  26. package/dist/mcp-client.js.map +1 -1
  27. package/dist/registry-consumer.d.ts +10 -0
  28. package/dist/registry-consumer.d.ts.map +1 -1
  29. package/dist/registry-consumer.js +76 -10
  30. package/dist/registry-consumer.js.map +1 -1
  31. package/dist/server.d.ts +11 -0
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +8 -0
  34. package/dist/server.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/adk.ts +107 -65
  37. package/src/config-store.test.ts +381 -1
  38. package/src/config-store.ts +750 -55
  39. package/src/define-config.ts +89 -23
  40. package/src/index.ts +0 -2
  41. package/src/mcp-client.ts +121 -0
  42. package/src/registry-consumer.ts +101 -12
  43. package/src/server.ts +19 -0
@@ -1,8 +1,9 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import {
3
+ createAdk,
4
+ createAdkTools,
3
5
  createAgentRegistry,
4
6
  createAgentServer,
5
- createAdk,
6
7
  defineAgent,
7
8
  defineTool,
8
9
  } from "./index";
@@ -458,3 +459,382 @@ describe("ADK ref.call() full auto-refresh flow", () => {
458
459
  expect((result as any)?.result?.token).toBe("refreshed-token");
459
460
  });
460
461
  });
462
+
463
+ // ─── ADK Config Store: registry proxy routing ───────────────────
464
+
465
+ describe("ADK registry proxy routing", () => {
466
+ let proxyServer: AgentServer;
467
+ let proxyServerAdk: ReturnType<typeof createAdk>;
468
+ const PROXY_PORT = 19930;
469
+
470
+ beforeAll(async () => {
471
+ // Real server-side adk that the proxy agent operates on. When the
472
+ // local adk forwards ref ops, they land on this backing store via
473
+ // the real @config `ref` tool produced by createAdkTools — no mocks.
474
+ const proxyServerFs = createMemoryFs();
475
+ proxyServerAdk = createAdk(proxyServerFs);
476
+ await proxyServerAdk.writeConfig({
477
+ refs: [
478
+ {
479
+ ref: "@gmail",
480
+ scheme: "mcp",
481
+ url: "https://gmail.example.com/mcp",
482
+ },
483
+ ],
484
+ });
485
+
486
+ // Expose that adk via the same tool surface production uses.
487
+ const adkTools = createAdkTools({ resolveScope: () => proxyServerAdk });
488
+ const configAgent = defineAgent({
489
+ path: "@config",
490
+ entrypoint: "@config agent for proxy routing tests",
491
+ tools: adkTools,
492
+ visibility: "public",
493
+ });
494
+
495
+ const proxyRegistry = createAgentRegistry();
496
+ proxyRegistry.register(configAgent);
497
+ proxyServer = createAgentServer(proxyRegistry, {
498
+ port: PROXY_PORT,
499
+ // Advertise proxy mode in the MCP initialize response so the
500
+ // "registry.add auto-detects proxy" test can verify discovery.
501
+ registry: { version: "1.0", proxy: { mode: "required" } },
502
+ });
503
+ await proxyServer.start();
504
+ });
505
+
506
+ afterAll(async () => {
507
+ await proxyServer.stop();
508
+ });
509
+
510
+ /**
511
+ * Seed the local consumer config with a ref sourced from the proxy
512
+ * registry. We bypass ref.add's reachability check because we're
513
+ * specifically testing how proxying routes around local state.
514
+ */
515
+ async function seedLocalRefFromProxy(fs: FsStore, refName: string) {
516
+ const raw = (await fs.readFile("consumer-config.json")) ?? "{}";
517
+ const config = JSON.parse(raw);
518
+ config.refs = [
519
+ {
520
+ ref: refName,
521
+ scheme: "registry",
522
+ sourceRegistry: {
523
+ url: `http://localhost:${PROXY_PORT}`,
524
+ agentPath: refName,
525
+ },
526
+ },
527
+ ];
528
+ await fs.writeFile("consumer-config.json", JSON.stringify(config));
529
+ }
530
+
531
+ test("ref.authStatus forwards to the real @config on the proxy registry", async () => {
532
+ const fs = createMemoryFs();
533
+ const adk = createAdk(fs);
534
+
535
+ await adk.registry.add({
536
+ url: `http://localhost:${PROXY_PORT}`,
537
+ name: "cloud",
538
+ proxy: { mode: "required" },
539
+ });
540
+ await seedLocalRefFromProxy(fs, "@gmail");
541
+
542
+ // The proxy-side adk owns the @gmail ref (no security declared in
543
+ // its config above) so authStatus should report { complete: true }
544
+ // with security: null.
545
+ const status = await adk.ref.authStatus("@gmail");
546
+ expect(status).toBeDefined();
547
+ // The remote @config returned something — local adk never saw the ref,
548
+ // so this would throw "Ref not found" if proxying wasn't wired.
549
+ expect((status as { name?: string }).name ?? "@gmail").toBe("@gmail");
550
+ });
551
+
552
+ test("ref.auth forwards and returns the remote auth start result", async () => {
553
+ const fs = createMemoryFs();
554
+ const adk = createAdk(fs);
555
+
556
+ await adk.registry.add({
557
+ url: `http://localhost:${PROXY_PORT}`,
558
+ name: "cloud",
559
+ proxy: { mode: "required" },
560
+ });
561
+ await seedLocalRefFromProxy(fs, "@gmail");
562
+
563
+ // @gmail on the proxy side has no security schema, so the real
564
+ // @config.ref tool returns { type: 'none', complete: true }. The
565
+ // assertion here is that we got *something* back from the remote,
566
+ // proving the call made a round trip instead of throwing locally.
567
+ const result = await adk.ref.auth("@gmail");
568
+ expect(result).toBeDefined();
569
+ expect((result as { type?: string }).type).toBeDefined();
570
+ });
571
+
572
+ test("registry.add auto-detects proxy from the server's handshake", async () => {
573
+ const fs = createMemoryFs();
574
+ const adk = createAdk(fs);
575
+
576
+ // Caller passes NO proxy config. The server advertises
577
+ // `capabilities.registry.proxy: { mode: 'required' }` in its MCP
578
+ // initialize response (see beforeAll), so registry.add should
579
+ // auto-populate the RegistryEntry at probe time.
580
+ await adk.registry.add({
581
+ url: `http://localhost:${PROXY_PORT}`,
582
+ name: "cloud-autodetect",
583
+ });
584
+
585
+ const list = await adk.registry.list();
586
+ const entry = list.find((r) => r.name === "cloud-autodetect");
587
+ expect(entry?.proxy?.mode).toBe("required");
588
+ });
589
+
590
+ test("explicit proxy on registry.add is not overwritten by auto-detection", async () => {
591
+ const fs = createMemoryFs();
592
+ const adk = createAdk(fs);
593
+
594
+ // Server advertises required; caller explicitly sets optional. The
595
+ // caller's choice wins — discovery only fills in blanks.
596
+ await adk.registry.add({
597
+ url: `http://localhost:${PROXY_PORT}`,
598
+ name: "cloud-explicit",
599
+ proxy: { mode: "optional", agent: "@custom" },
600
+ });
601
+
602
+ const entry = (await adk.registry.list()).find((r) => r.name === "cloud-explicit");
603
+ expect(entry?.proxy?.mode).toBe("optional");
604
+ expect(entry?.proxy?.agent).toBe("@custom");
605
+ });
606
+
607
+ test("optional proxy honors preferLocal and skips forwarding", async () => {
608
+ const fs = createMemoryFs();
609
+ const adk = createAdk(fs);
610
+
611
+ await adk.registry.add({
612
+ url: `http://localhost:${PROXY_PORT}`,
613
+ name: "cloud",
614
+ proxy: { mode: "optional" },
615
+ });
616
+ await seedLocalRefFromProxy(fs, "@gmail");
617
+
618
+ // With preferLocal:true we should fall through to the local path.
619
+ // The local adk has no usable config for @gmail, so this path
620
+ // throws or returns an empty status — either way, we prove we
621
+ // stayed local by catching and confirming no exception bubbled
622
+ // with "authorizeUrl" (which only the proxy path returns).
623
+ const result = await adk.ref
624
+ .auth("@gmail", { preferLocal: true })
625
+ .catch((err: Error) => ({ _error: err.message }));
626
+ expect((result as { authorizeUrl?: string }).authorizeUrl).toBeUndefined();
627
+ });
628
+ });
629
+
630
+ // ─── Registry auth lifecycle ─────────────────────────────────────
631
+
632
+ describe("ADK registry auth lifecycle", () => {
633
+ const PORT = 19930;
634
+ const MCP_URL = `http://localhost:${PORT}/mcp`;
635
+ const AS_URL = `http://localhost:${PORT}`;
636
+
637
+ let mcpServer: ReturnType<typeof Bun.serve>;
638
+ let activeAccessToken = "access-token-v1";
639
+ let tokenExchangeCount = 0;
640
+ let tokenRefreshCount = 0;
641
+
642
+ beforeAll(() => {
643
+ // Fake registry that speaks MCP when authenticated, emits an RFC 6750
644
+ // challenge pointing at RFC 9728 metadata when not, and doubles as the
645
+ // OAuth authorization server (registration + authorize + token) so the
646
+ // whole adk registry.auth flow can run end-to-end in-process.
647
+ mcpServer = Bun.serve({
648
+ port: PORT,
649
+ async fetch(req) {
650
+ const url = new URL(req.url);
651
+ const path = url.pathname;
652
+
653
+ // RFC 9728 protected-resource metadata
654
+ if (path === "/.well-known/oauth-protected-resource") {
655
+ return Response.json({
656
+ resource: MCP_URL,
657
+ authorization_servers: [AS_URL],
658
+ scopes_supported: ["mcp:full"],
659
+ bearer_methods_supported: ["header"],
660
+ });
661
+ }
662
+
663
+ // RFC 8414 authorization-server metadata
664
+ if (path === "/.well-known/oauth-authorization-server") {
665
+ return Response.json({
666
+ issuer: AS_URL,
667
+ authorization_endpoint: `${AS_URL}/oauth/authorize`,
668
+ token_endpoint: `${AS_URL}/oauth/token`,
669
+ registration_endpoint: `${AS_URL}/oauth/register`,
670
+ });
671
+ }
672
+
673
+ // Dynamic client registration (RFC 7591)
674
+ if (path === "/oauth/register" && req.method === "POST") {
675
+ return Response.json({
676
+ client_id: "test-client-id",
677
+ client_secret: "test-client-secret",
678
+ });
679
+ }
680
+
681
+ // Token endpoint — supports authorization_code + refresh_token grants
682
+ if (path === "/oauth/token" && req.method === "POST") {
683
+ const body = new URLSearchParams(await req.text());
684
+ const grant = body.get("grant_type");
685
+ if (grant === "authorization_code") {
686
+ tokenExchangeCount++;
687
+ return Response.json({
688
+ access_token: activeAccessToken,
689
+ refresh_token: "refresh-token-v1",
690
+ token_type: "Bearer",
691
+ expires_in: 3600,
692
+ });
693
+ }
694
+ if (grant === "refresh_token") {
695
+ tokenRefreshCount++;
696
+ if (body.get("refresh_token") !== "refresh-token-v1") {
697
+ return new Response(
698
+ JSON.stringify({ error: "invalid_grant" }),
699
+ { status: 400 },
700
+ );
701
+ }
702
+ // Rotate to a new access token so the test can tell refresh ran.
703
+ activeAccessToken = "access-token-v2";
704
+ return Response.json({
705
+ access_token: activeAccessToken,
706
+ token_type: "Bearer",
707
+ expires_in: 3600,
708
+ });
709
+ }
710
+ return new Response("unsupported_grant_type", { status: 400 });
711
+ }
712
+
713
+ // MCP endpoint
714
+ if (path === "/mcp" && req.method === "POST") {
715
+ const auth = req.headers.get("authorization") ?? "";
716
+ const expected = `Bearer ${activeAccessToken}`;
717
+ if (auth !== expected) {
718
+ return new Response(
719
+ JSON.stringify({ error: { code: "UNAUTHORIZED", message: "No token" } }),
720
+ {
721
+ status: 401,
722
+ headers: {
723
+ "Content-Type": "application/json",
724
+ "WWW-Authenticate": `Bearer realm="test", resource_metadata="${AS_URL}/.well-known/oauth-protected-resource"`,
725
+ },
726
+ },
727
+ );
728
+ }
729
+ const rpc = (await req.json()) as { id: number; method: string };
730
+ if (rpc.method === "initialize") {
731
+ return Response.json({
732
+ jsonrpc: "2.0",
733
+ id: rpc.id,
734
+ result: { serverInfo: { name: "test-mcp" }, capabilities: {} },
735
+ });
736
+ }
737
+ if (rpc.method === "tools/call") {
738
+ return Response.json({
739
+ jsonrpc: "2.0",
740
+ id: rpc.id,
741
+ result: {
742
+ content: [
743
+ {
744
+ type: "text",
745
+ text: JSON.stringify({
746
+ agents: [
747
+ { path: "@test-agent", description: "An agent", toolCount: 1 },
748
+ ],
749
+ }),
750
+ },
751
+ ],
752
+ },
753
+ });
754
+ }
755
+ return new Response("method not found", { status: 404 });
756
+ }
757
+
758
+ return new Response("not found", { status: 404 });
759
+ },
760
+ });
761
+ });
762
+
763
+ afterAll(() => {
764
+ mcpServer.stop();
765
+ });
766
+
767
+ test("registry.add records auth challenge; browse refuses; auth() unlocks", async () => {
768
+ const fs = createMemoryFs();
769
+ const adk = createAdk(fs, { encryptionKey: "test-key-32-chars-long-enough!!" });
770
+
771
+ const addResult = await adk.registry.add({ name: "test", url: MCP_URL });
772
+
773
+ expect(addResult.authRequirement).toBeDefined();
774
+ expect(addResult.authRequirement?.scheme).toBe("Bearer");
775
+ expect(addResult.authRequirement?.authorizationServers).toEqual([AS_URL]);
776
+ expect(addResult.authRequirement?.scopes).toEqual(["mcp:full"]);
777
+
778
+ await expect(adk.registry.browse("test")).rejects.toMatchObject({
779
+ code: "registry_auth_required",
780
+ });
781
+
782
+ await adk.registry.auth("test", { token: activeAccessToken });
783
+
784
+ // Stored token is encrypted (secret: prefix) — buildConsumer decrypts
785
+ // it transparently so browse should now land the MCP call.
786
+ const stored = await adk.registry.get("test");
787
+ expect(stored?.auth?.type).toBe("bearer");
788
+ expect((stored?.auth as { token: string }).token).toMatch(/^secret:/);
789
+ expect(stored?.authRequirement).toBeUndefined();
790
+
791
+ const agents = await adk.registry.browse("test");
792
+ expect(agents).toHaveLength(1);
793
+ expect(agents[0]?.path).toBe("@test-agent");
794
+ });
795
+
796
+ test("browse 401 triggers refresh via stored refresh_token and retries", async () => {
797
+ const fs = createMemoryFs();
798
+ const adk = createAdk(fs, { encryptionKey: "test-key-32-chars-long-enough!!" });
799
+
800
+ // Reset server-side token so the next refresh rotates predictably.
801
+ activeAccessToken = "access-token-v1";
802
+ tokenRefreshCount = 0;
803
+
804
+ await adk.registry.add({ name: "test", url: MCP_URL });
805
+ await adk.registry.auth("test", { token: activeAccessToken });
806
+
807
+ // Seed the entry with OAuth state as if `authLocal` had completed.
808
+ // Refresh token / endpoint / clientId are written directly so the
809
+ // test isn't dependent on the full browser-redirect flow.
810
+ const config = await adk.readConfig();
811
+ await adk.writeConfig({
812
+ ...config,
813
+ registries: config.registries?.map((r: any) => {
814
+ if (typeof r !== "string" && r.name === "test") {
815
+ return {
816
+ ...r,
817
+ oauth: {
818
+ tokenEndpoint: `${AS_URL}/oauth/token`,
819
+ clientId: "test-client-id",
820
+ refreshToken: "refresh-token-v1",
821
+ },
822
+ };
823
+ }
824
+ return r;
825
+ }),
826
+ });
827
+
828
+ // Rotate the server token — the client's stored token is now stale.
829
+ activeAccessToken = "access-token-v2";
830
+
831
+ const agents = await adk.registry.browse("test");
832
+
833
+ // Refresh was called exactly once; the browse call succeeded on retry.
834
+ expect(tokenRefreshCount).toBe(1);
835
+ expect(agents).toHaveLength(1);
836
+
837
+ const stored = await adk.registry.get("test");
838
+ expect((stored?.auth as { token: string }).token).toMatch(/^secret:/);
839
+ });
840
+ });