@slashfi/agents-sdk 0.76.0 → 0.77.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.
Files changed (59) hide show
  1. package/dist/adk-tools.d.ts +2 -2
  2. package/dist/adk-tools.d.ts.map +1 -1
  3. package/dist/adk-tools.js +9 -18
  4. package/dist/adk-tools.js.map +1 -1
  5. package/dist/adk.js +190 -120
  6. package/dist/adk.js.map +1 -1
  7. package/dist/agent-definitions/config.d.ts.map +1 -1
  8. package/dist/agent-definitions/config.js +12 -14
  9. package/dist/agent-definitions/config.js.map +1 -1
  10. package/dist/cjs/adk-tools.js +9 -18
  11. package/dist/cjs/adk-tools.js.map +1 -1
  12. package/dist/cjs/agent-definitions/config.js +12 -14
  13. package/dist/cjs/agent-definitions/config.js.map +1 -1
  14. package/dist/cjs/config-store.js +527 -30
  15. package/dist/cjs/config-store.js.map +1 -1
  16. package/dist/cjs/define-config.js +5 -7
  17. package/dist/cjs/define-config.js.map +1 -1
  18. package/dist/cjs/index.js.map +1 -1
  19. package/dist/cjs/materialize.js +1 -1
  20. package/dist/cjs/materialize.js.map +1 -1
  21. package/dist/cjs/mcp-client.js +98 -0
  22. package/dist/cjs/mcp-client.js.map +1 -1
  23. package/dist/cjs/registry-consumer.js +69 -11
  24. package/dist/cjs/registry-consumer.js.map +1 -1
  25. package/dist/config-store.d.ts +39 -4
  26. package/dist/config-store.d.ts.map +1 -1
  27. package/dist/config-store.js +528 -31
  28. package/dist/config-store.js.map +1 -1
  29. package/dist/define-config.d.ts +65 -18
  30. package/dist/define-config.d.ts.map +1 -1
  31. package/dist/define-config.js +5 -7
  32. package/dist/define-config.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/materialize.js +1 -1
  37. package/dist/materialize.js.map +1 -1
  38. package/dist/mcp-client.d.ts +44 -0
  39. package/dist/mcp-client.d.ts.map +1 -1
  40. package/dist/mcp-client.js +95 -0
  41. package/dist/mcp-client.js.map +1 -1
  42. package/dist/registry-consumer.d.ts +1 -1
  43. package/dist/registry-consumer.d.ts.map +1 -1
  44. package/dist/registry-consumer.js +69 -11
  45. package/dist/registry-consumer.js.map +1 -1
  46. package/dist/validate.d.ts +8 -8
  47. package/package.json +1 -1
  48. package/src/adk-tools.ts +11 -18
  49. package/src/adk.ts +78 -11
  50. package/src/agent-definitions/config.ts +15 -16
  51. package/src/config-store.test.ts +212 -0
  52. package/src/config-store.ts +615 -37
  53. package/src/consumer.test.ts +7 -7
  54. package/src/define-config.ts +69 -20
  55. package/src/index.ts +1 -0
  56. package/src/materialize.ts +1 -1
  57. package/src/mcp-client.ts +121 -0
  58. package/src/ref-naming.test.ts +115 -90
  59. package/src/registry-consumer.ts +75 -13
@@ -111,7 +111,7 @@ describe("Registry Consumer E2E", () => {
111
111
  registries: [`http://localhost:${PORT}`],
112
112
  refs: [
113
113
  { ref: "@math" },
114
- { ref: "@echo", as: "my-echo", config: { greeting: "hello" } },
114
+ { ref: "@echo", name: "my-echo", config: { greeting: "hello" } },
115
115
  ],
116
116
  };
117
117
 
@@ -151,12 +151,12 @@ describe("Registry Consumer E2E", () => {
151
151
  );
152
152
  });
153
153
 
154
- test("multi-instance refs with as: alias", async () => {
154
+ test("multi-instance refs with name aliases", async () => {
155
155
  const config = {
156
156
  registries: [`http://localhost:${PORT}`],
157
157
  refs: [
158
- { ref: "@echo", as: "echo-1", config: { prefix: "first" } },
159
- { ref: "@echo", as: "echo-2", config: { prefix: "second" } },
158
+ { ref: "@echo", name: "echo-1", config: { prefix: "first" } },
159
+ { ref: "@echo", name: "echo-2", config: { prefix: "second" } },
160
160
  ],
161
161
  };
162
162
 
@@ -240,10 +240,10 @@ describe("normalizeRef", () => {
240
240
  });
241
241
  });
242
242
 
243
- test("object ref with alias", () => {
243
+ test("object ref with name", () => {
244
244
  const result = normalizeRef({
245
245
  ref: "postgres",
246
- as: "prod-db",
246
+ name: "prod-db",
247
247
  config: { url: "https://twin.slash.com/secrets/db" },
248
248
  });
249
249
  expect(result.ref).toBe("postgres");
@@ -253,7 +253,7 @@ describe("normalizeRef", () => {
253
253
  });
254
254
  });
255
255
 
256
- test("object ref without alias uses ref as name", () => {
256
+ test("object ref without name uses ref as name", () => {
257
257
  const result = normalizeRef({ ref: "github" });
258
258
  expect(result.name).toBe("github");
259
259
  });
@@ -16,8 +16,8 @@
16
16
  * ],
17
17
  * refs: [
18
18
  * 'notion',
19
- * { ref: 'postgres', as: 'prod-db', config: { url: 'https://twin.slash.com/secrets/crdb-url' } },
20
- * { ref: 'postgres', as: 'staging', config: { url: 'https://twin.slash.com/secrets/staging-url' } },
19
+ * { ref: 'postgres', name: 'prod-db', config: { url: 'https://twin.slash.com/secrets/crdb-url' } },
20
+ * { ref: 'postgres', name: 'staging', config: { url: 'https://twin.slash.com/secrets/staging-url' } },
21
21
  * ],
22
22
  * });
23
23
  * ```
@@ -58,6 +58,49 @@ export interface RegistryProxy {
58
58
  agent?: string;
59
59
  }
60
60
 
61
+ /**
62
+ * OAuth state captured after `adk registry auth` completes a dynamic-client
63
+ * registration + authorization-code flow against a registry. Stored alongside
64
+ * `auth.token` so the access token can be refreshed without re-prompting the
65
+ * user. The `auth.token` slot holds the current access token; everything
66
+ * needed to refresh it lives here.
67
+ */
68
+ export interface RegistryOAuthState {
69
+ /** Token endpoint used for code exchange / refresh. */
70
+ tokenEndpoint: string;
71
+ /** Client ID issued by dynamic client registration (RFC 7591). */
72
+ clientId: string;
73
+ /** Client secret from dynamic registration, when the server issued one. */
74
+ clientSecret?: string;
75
+ /** Refresh token returned by the token endpoint, if any. */
76
+ refreshToken?: string;
77
+ /** Absolute expiry (ISO 8601) derived from `expires_in` at exchange time. */
78
+ expiresAt?: string;
79
+ /** Scopes the access token was granted for. */
80
+ scopes?: string[];
81
+ }
82
+
83
+ /**
84
+ * Captured auth challenge from a registry that rejected an unauthenticated
85
+ * probe (RFC 6750 `WWW-Authenticate` + RFC 9728 protected-resource metadata).
86
+ * When present on a `RegistryEntry`, the registry has been seen to require
87
+ * credentials and ref ops will fail until `adk registry auth` is run.
88
+ */
89
+ export interface RegistryAuthRequirement {
90
+ /** Auth scheme advertised in `WWW-Authenticate` (e.g. `Bearer`). */
91
+ scheme?: string;
92
+ /** Realm advertised in `WWW-Authenticate`. */
93
+ realm?: string;
94
+ /** RFC 9728 `resource_metadata` URL parsed from the challenge. */
95
+ resourceMetadataUrl?: string;
96
+ /** Authorization servers from the protected-resource metadata. */
97
+ authorizationServers?: string[];
98
+ /** Scopes supported by the resource. */
99
+ scopes?: string[];
100
+ /** Bearer-methods advertised by the resource (`header`, `body`, `query`). */
101
+ bearerMethodsSupported?: string[];
102
+ }
103
+
61
104
  /** A registry endpoint the consumer connects to */
62
105
  export interface RegistryEntry {
63
106
  /** Registry URL (e.g., 'https://registry.slash.com') */
@@ -84,6 +127,21 @@ export interface RegistryEntry {
84
127
  * running locally. See {@link RegistryProxy}.
85
128
  */
86
129
  proxy?: RegistryProxy;
130
+
131
+ /**
132
+ * Populated by `adk registry add` when the probe returned 401. Cleared
133
+ * by `adk registry auth`. Registry ops refuse to run while this is set
134
+ * and no usable auth credentials are configured.
135
+ */
136
+ authRequirement?: RegistryAuthRequirement;
137
+
138
+ /**
139
+ * OAuth lifecycle state — refresh token, client credentials from dynamic
140
+ * registration, token endpoint, expiry. Populated by `adk registry auth`
141
+ * when the flow went through OAuth; absent for manually-supplied bearer
142
+ * tokens.
143
+ */
144
+ oauth?: RegistryOAuthState;
87
145
  }
88
146
 
89
147
  // ============================================
@@ -100,14 +158,10 @@ export type RefEntry = {
100
158
 
101
159
  /**
102
160
  * Local identifier for this ref. Used by all operations
103
- * (call/remove/auth/update/…) to look up the entry. If omitted,
104
- * the canonical `ref` string is used as the identifier — the
105
- * common case "one local instance per agent" requires only
106
- * `{ ref: 'notion', ... }`. Set `name` to a different value only
107
- * when you need multiple local instances of the same remote
108
- * agent (e.g. `{ ref: 'notion', name: 'work-notion' }`).
161
+ * (call/remove/auth/update/…) to look up the entry. Add paths
162
+ * default this to `ref` when omitted.
109
163
  */
110
- name?: string;
164
+ name: string;
111
165
 
112
166
  /** Connection scheme */
113
167
  scheme?: 'mcp' | 'https' | 'registry';
@@ -115,12 +169,6 @@ export type RefEntry = {
115
169
  /** Direct URL to the agent (e.g. https://mcp.notion.com/mcp) */
116
170
  url?: string;
117
171
 
118
- /**
119
- * @deprecated Use `name` instead. `as` is preserved for reading
120
- * old consumer-config.json files; new writes emit `name`.
121
- */
122
- as?: string;
123
-
124
172
  /** Per-instance config (headers, secrets, etc. — values support {{secret-uri}} templates) */
125
173
  config?: RefConfig;
126
174
 
@@ -131,6 +179,9 @@ export type RefEntry = {
131
179
  status?: 'active' | 'inactive' | 'error';
132
180
  };
133
181
 
182
+ /** Input accepted by add paths. `name` defaults to `ref` when omitted. */
183
+ export type RefAddInput = Omit<RefEntry, "name"> & { name?: string };
184
+
134
185
  // ============================================
135
186
  // Consumer Config
136
187
  // ============================================
@@ -201,15 +252,13 @@ export interface ResolvedConfig {
201
252
  /**
202
253
  * Normalize a ref entry to its full form.
203
254
  *
204
- * Local identifier resolution order: `entry.name` `entry.as` (legacy)
205
- * `entry.ref` (canonical). This order makes the tool/API surface
206
- * consistent with the `ref.add({ ref, name })` contract while still
207
- * reading old `{ ref, as }` entries from pre-0.74 consumer-config.json.
255
+ * Add paths default `name` to `ref` before writing. `normalizeRef` keeps the
256
+ * same invariant for in-memory/test configs that omit `name`.
208
257
  */
209
258
  export function normalizeRef(entry: RefEntry): ResolvedRef {
210
259
  return {
211
260
  ...entry,
212
- name: entry.name ?? entry.as ?? entry.ref,
261
+ name: entry.name ?? entry.ref,
213
262
  config: entry.config ?? {},
214
263
  };
215
264
  }
package/src/index.ts CHANGED
@@ -307,6 +307,7 @@ export type {
307
307
  RegistryAuth,
308
308
  RegistryEntry,
309
309
  RefConfig,
310
+ RefAddInput,
310
311
  RefEntry,
311
312
  ConsumerConfig,
312
313
  ResolvedRegistry,
@@ -326,7 +326,7 @@ export async function syncAllRefs(
326
326
  for (const refEntry of refs) {
327
327
  const name = typeof refEntry === "string"
328
328
  ? refEntry
329
- : (refEntry as any).as ?? (refEntry as any).ref ?? (refEntry as any).name;
329
+ : (refEntry as any).name ?? (refEntry as any).ref;
330
330
  if (!name) continue;
331
331
  if (opts?.filter && name !== opts.filter) continue;
332
332
  names.push(name);
package/src/mcp-client.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { generatePkcePair } from "./pkce.js";
17
+ import type { RegistryAuthRequirement } from "./define-config.js";
18
+ import type { FetchFn } from "./fetch-types.js";
17
19
 
18
20
  // ============================================
19
21
  // Types
@@ -239,3 +241,122 @@ export async function refreshAccessToken(
239
241
  expiresIn: data.expires_in as number | undefined,
240
242
  };
241
243
  }
244
+
245
+ // ============================================
246
+ // Registry Auth Probe (RFC 6750 + RFC 9728)
247
+ // ============================================
248
+
249
+ /**
250
+ * Parse a `WWW-Authenticate` header of the form
251
+ * `Bearer realm="x", resource_metadata="https://..."`
252
+ * Returns the scheme and any `key="value"` params. Tolerant of
253
+ * single-value headers and missing params.
254
+ */
255
+ export function parseWwwAuthenticate(
256
+ header: string,
257
+ ): { scheme: string; params: Record<string, string> } {
258
+ const spaceIdx = header.indexOf(" ");
259
+ const scheme = (spaceIdx === -1 ? header : header.slice(0, spaceIdx)).trim();
260
+ const rest = spaceIdx === -1 ? "" : header.slice(spaceIdx + 1);
261
+ const params: Record<string, string> = {};
262
+ for (const match of rest.matchAll(/([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*"([^"]*)"/g)) {
263
+ params[match[1]!.toLowerCase()] = match[2]!;
264
+ }
265
+ return { scheme, params };
266
+ }
267
+
268
+ /** RFC 9728 protected-resource metadata. */
269
+ export interface ProtectedResourceMetadata {
270
+ resource: string;
271
+ authorization_servers?: string[];
272
+ bearer_methods_supported?: string[];
273
+ scopes_supported?: string[];
274
+ resource_documentation?: string;
275
+ }
276
+
277
+ /** Fetch RFC 9728 metadata. Returns null on any failure. */
278
+ export async function discoverProtectedResourceMetadata(
279
+ metadataUrl: string,
280
+ fetchFn: FetchFn = globalThis.fetch,
281
+ ): Promise<ProtectedResourceMetadata | null> {
282
+ try {
283
+ const res = await fetchFn(metadataUrl);
284
+ if (!res.ok) return null;
285
+ return (await res.json()) as ProtectedResourceMetadata;
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Probe an MCP URL to see whether it requires authentication.
293
+ *
294
+ * Sends a minimal `initialize` request. On 200 the server accepts anonymous
295
+ * connections; on 401 we parse the `WWW-Authenticate` header and, if it
296
+ * points at RFC 9728 resource metadata, fetch it so the caller can record
297
+ * the authorization servers and scopes.
298
+ *
299
+ * Returns `{ ok: true }` when no auth is required, or
300
+ * `{ ok: false, requirement }` when the server challenged the request.
301
+ * Other failures (DNS, TLS, unexpected status) surface as `{ ok: null }`
302
+ * — the caller should treat those as probe-inconclusive rather than
303
+ * asserting auth is required.
304
+ */
305
+ export async function probeRegistryAuth(
306
+ registryUrl: string,
307
+ fetchFn: FetchFn = globalThis.fetch,
308
+ ): Promise<
309
+ | { ok: true }
310
+ | { ok: false; requirement: RegistryAuthRequirement }
311
+ | { ok: null }
312
+ > {
313
+ const url = registryUrl.replace(/\/$/, "");
314
+ let res: Response;
315
+ try {
316
+ res = await fetchFn(url, {
317
+ method: "POST",
318
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
319
+ body: JSON.stringify({
320
+ jsonrpc: "2.0",
321
+ id: 1,
322
+ method: "initialize",
323
+ params: {
324
+ protocolVersion: "2024-11-05",
325
+ capabilities: {},
326
+ clientInfo: { name: "agents-sdk-probe", version: "1.0.0" },
327
+ },
328
+ }),
329
+ });
330
+ } catch {
331
+ return { ok: null };
332
+ }
333
+
334
+ if (res.status !== 401) {
335
+ return res.ok ? { ok: true } : { ok: null };
336
+ }
337
+
338
+ const wwwAuth = res.headers.get("www-authenticate") ?? "";
339
+ const { scheme, params } = parseWwwAuthenticate(wwwAuth);
340
+ const requirement: RegistryAuthRequirement = {};
341
+ if (scheme) requirement.scheme = scheme;
342
+ if (params.realm) requirement.realm = params.realm;
343
+
344
+ const metadataUrl = params.resource_metadata;
345
+ if (metadataUrl) {
346
+ requirement.resourceMetadataUrl = metadataUrl;
347
+ const metadata = await discoverProtectedResourceMetadata(metadataUrl, fetchFn);
348
+ if (metadata) {
349
+ if (metadata.authorization_servers?.length) {
350
+ requirement.authorizationServers = metadata.authorization_servers;
351
+ }
352
+ if (metadata.scopes_supported?.length) {
353
+ requirement.scopes = metadata.scopes_supported;
354
+ }
355
+ if (metadata.bearer_methods_supported?.length) {
356
+ requirement.bearerMethodsSupported = metadata.bearer_methods_supported;
357
+ }
358
+ }
359
+ }
360
+
361
+ return { ok: false, requirement };
362
+ }
@@ -1,15 +1,10 @@
1
1
  /**
2
2
  * Tests for the `ref` naming contract introduced in 0.74:
3
3
  *
4
- * - `RefEntry` gains an optional `name?` field for the local identifier.
5
- * The legacy `as?` field still parses for backward compat.
6
- * - `normalizeRef` resolves the identifier as `name ?? as ?? ref`, so
7
- * all lookup paths (get, list, update, remove) accept entries written
8
- * in either shape.
9
- * - `createRefTool` (the adk-tools.ts MCP tool) drops `as` from its
10
- * schema and defaults `ref` to `name` on add, so "Add a ref called X"
11
- * via an LLM that picks either field lands on the same stored
12
- * `{ ref: 'X' }` entry.
4
+ * - `name` is the local identifier for every stored ref entry.
5
+ * - Add paths default `name` to `ref` when omitted.
6
+ * - `as` is not part of the stored or public ref shape.
7
+ * - Adding a duplicate `name` is a loud error, not a replace.
13
8
  */
14
9
 
15
10
  import { describe, expect, test } from "bun:test";
@@ -42,7 +37,7 @@ async function readJson<T = unknown>(fs: FsStore, path: string): Promise<T> {
42
37
  // ─── RefEntry: name field on write ───────────────────────────────────
43
38
 
44
39
  describe("ref.add — identifier field", () => {
45
- test("single-instance case stores only `ref` (no `name`/`as`)", async () => {
40
+ test("single-instance case stores explicit `name` equal to `ref`", async () => {
46
41
  const fs = createMemoryFs();
47
42
  const adk = createAdk(fs);
48
43
 
@@ -58,8 +53,7 @@ describe("ref.add — identifier field", () => {
58
53
  );
59
54
  expect(parsed.refs).toHaveLength(1);
60
55
  expect(parsed.refs[0].ref).toBe("test-ref");
61
- expect(parsed.refs[0].name).toBeUndefined();
62
- expect(parsed.refs[0].as).toBeUndefined();
56
+ expect(parsed.refs[0].name).toBe("test-ref");
63
57
  });
64
58
 
65
59
  test("aliasing case stores both `ref` and `name`", async () => {
@@ -79,14 +73,40 @@ describe("ref.add — identifier field", () => {
79
73
  );
80
74
  expect(parsed.refs[0].ref).toBe("notion");
81
75
  expect(parsed.refs[0].name).toBe("work-notion");
82
- // Legacy `as` field is never emitted on new writes.
83
- expect(parsed.refs[0].as).toBeUndefined();
76
+ });
77
+
78
+ test("adding a duplicate name rejects instead of replacing", async () => {
79
+ const fs = createMemoryFs();
80
+ const adk = createAdk(fs);
81
+
82
+ await adk.ref.add({
83
+ ref: "notion",
84
+ name: "work",
85
+ scheme: "mcp",
86
+ url: "http://localhost:12345",
87
+ });
88
+
89
+ await expect(
90
+ adk.ref.add({
91
+ ref: "linear",
92
+ name: "work",
93
+ scheme: "mcp",
94
+ url: "http://localhost:12346",
95
+ }),
96
+ ).rejects.toThrow(/already exists/);
97
+
98
+ const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
99
+ fs,
100
+ "consumer-config.json",
101
+ );
102
+ expect(parsed.refs).toHaveLength(1);
103
+ expect(parsed.refs[0].ref).toBe("notion");
84
104
  });
85
105
  });
86
106
 
87
107
  // ─── Lookup compatibility: new `name` field works end-to-end ─────────
88
108
 
89
- describe("ref lookup — name/as/ref resolution", () => {
109
+ describe("ref lookup — name/ref resolution", () => {
90
110
  test("entries written with `name` are findable by `name`", async () => {
91
111
  const fs = createMemoryFs();
92
112
  const adk = createAdk(fs);
@@ -119,9 +139,7 @@ describe("ref lookup — name/as/ref resolution", () => {
119
139
  expect(refs[0]?.name).toBe("work-notion");
120
140
  });
121
141
 
122
- test("legacy entries written with `as` remain findable by `as`", async () => {
123
- // Simulate a consumer-config.json produced by a pre-0.74 client that
124
- // still writes the `as` field. The read path must still resolve it.
142
+ test("entries without `name` normalize to `ref`", async () => {
125
143
  const fs = createMemoryFs();
126
144
  await fs.writeFile(
127
145
  "consumer-config.json",
@@ -129,7 +147,6 @@ describe("ref lookup — name/as/ref resolution", () => {
129
147
  refs: [
130
148
  {
131
149
  ref: "notion",
132
- as: "work-notion",
133
150
  scheme: "mcp",
134
151
  url: "http://localhost:12345",
135
152
  },
@@ -138,56 +155,26 @@ describe("ref lookup — name/as/ref resolution", () => {
138
155
  );
139
156
  const adk = createAdk(fs);
140
157
 
141
- const entry = await adk.ref.get("work-notion");
142
- expect(entry).not.toBeNull();
143
- expect(entry?.ref).toBe("notion");
144
- expect(entry?.as).toBe("work-notion");
145
- });
146
-
147
- test("when both `name` and `as` are present, `name` wins", async () => {
148
- const fs = createMemoryFs();
149
- await fs.writeFile(
150
- "consumer-config.json",
151
- JSON.stringify({
152
- refs: [
153
- {
154
- ref: "notion",
155
- name: "new-identifier",
156
- as: "legacy-identifier",
157
- scheme: "mcp",
158
- url: "http://localhost:12345",
159
- },
160
- ],
161
- }),
162
- );
163
- const adk = createAdk(fs);
164
-
165
- const byNew = await adk.ref.get("new-identifier");
166
- expect(byNew).not.toBeNull();
167
- expect(byNew?.ref).toBe("notion");
158
+ const refs = await adk.ref.list();
159
+ expect(refs[0]?.name).toBe("notion");
160
+ expect(await adk.ref.get("notion")).not.toBeNull();
168
161
  });
169
162
  });
170
163
 
171
- // ─── ref.update: renaming clears the legacy `as` field ───────────────
164
+ // ─── ref.update: name is the only rename field ───────────────────────
172
165
 
173
- describe("ref.update — name/as handling", () => {
174
- test("passing `name` in updates sets name and clears legacy `as`", async () => {
166
+ describe("ref.update — name handling", () => {
167
+ test("passing `name` in updates sets name", async () => {
175
168
  const fs = createMemoryFs();
176
- await fs.writeFile(
177
- "consumer-config.json",
178
- JSON.stringify({
179
- refs: [
180
- {
181
- ref: "notion",
182
- as: "old-alias",
183
- scheme: "mcp",
184
- url: "http://localhost:12345",
185
- },
186
- ],
187
- }),
188
- );
189
169
  const adk = createAdk(fs);
190
170
 
171
+ await adk.ref.add({
172
+ ref: "notion",
173
+ name: "old-alias",
174
+ scheme: "mcp",
175
+ url: "http://localhost:12345",
176
+ });
177
+
191
178
  const ok = await adk.ref.update("old-alias", { name: "new-alias" });
192
179
  expect(ok).toBe(true);
193
180
 
@@ -196,35 +183,29 @@ describe("ref.update — name/as handling", () => {
196
183
  "consumer-config.json",
197
184
  );
198
185
  expect(parsed.refs[0].name).toBe("new-alias");
199
- expect(parsed.refs[0].as).toBeUndefined();
200
186
  expect(parsed.refs[0].ref).toBe("notion");
201
187
  });
202
188
 
203
- test("passing only `as` updates the legacy field (pre-0.74 callers)", async () => {
189
+ test("renaming to an existing name rejects", async () => {
204
190
  const fs = createMemoryFs();
205
- await fs.writeFile(
206
- "consumer-config.json",
207
- JSON.stringify({
208
- refs: [
209
- {
210
- ref: "notion",
211
- as: "first",
212
- scheme: "mcp",
213
- url: "http://localhost:12345",
214
- },
215
- ],
216
- }),
217
- );
218
191
  const adk = createAdk(fs);
219
192
 
220
- const ok = await adk.ref.update("first", { as: "second" });
221
- expect(ok).toBe(true);
193
+ await adk.ref.add({
194
+ ref: "notion",
195
+ name: "first",
196
+ scheme: "mcp",
197
+ url: "http://localhost:12345",
198
+ });
199
+ await adk.ref.add({
200
+ ref: "linear",
201
+ name: "second",
202
+ scheme: "mcp",
203
+ url: "http://localhost:12346",
204
+ });
222
205
 
223
- const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
224
- fs,
225
- "consumer-config.json",
206
+ await expect(adk.ref.update("first", { name: "second" })).rejects.toThrow(
207
+ /already exists/,
226
208
  );
227
- expect(parsed.refs[0].as).toBe("second");
228
209
  });
229
210
  });
230
211
 
@@ -232,10 +213,8 @@ describe("ref.update — name/as handling", () => {
232
213
  //
233
214
  // When an LLM is prompted with "Add a ref called X", its tool-call
234
215
  // arguments can land on either `{ ref: 'X', … }` or `{ name: 'X', … }`
235
- // depending on sampling. Pre-0.74, the former stored `{ ref: 'X' }`
236
- // and the latter stored `{ ref: undefined }` (broken lookup). The
237
- // `add` handler now defaults `ref ??= name` so both paths converge on
238
- // the same stored entry.
216
+ // depending on sampling. The `add` handler defaults the missing field so
217
+ // both paths converge on the same stored `{ ref: 'X', name: 'X' }` entry.
239
218
 
240
219
  describe("ref tool — add operation defaults ref to name", () => {
241
220
  function makeRefTool(adk: ReturnType<typeof createAdk>) {
@@ -267,8 +246,7 @@ describe("ref tool — add operation defaults ref to name", () => {
267
246
  );
268
247
  expect(parsed.refs).toHaveLength(1);
269
248
  expect(parsed.refs[0].ref).toBe("test-identity-ref");
270
- // name was not stored because it equals ref (single-instance case)
271
- expect(parsed.refs[0].name).toBeUndefined();
249
+ expect(parsed.refs[0].name).toBe("test-identity-ref");
272
250
 
273
251
  const entry = await adk.ref.get("test-identity-ref");
274
252
  expect(entry).not.toBeNull();
@@ -295,7 +273,7 @@ describe("ref tool — add operation defaults ref to name", () => {
295
273
  "consumer-config.json",
296
274
  );
297
275
  expect(parsed.refs[0].ref).toBe("test-identity-ref");
298
- expect(parsed.refs[0].name).toBeUndefined();
276
+ expect(parsed.refs[0].name).toBe("test-identity-ref");
299
277
  });
300
278
 
301
279
  test("LLM sends `ref` + different `name` → stored as canonical + alias", async () => {
@@ -349,3 +327,50 @@ describe("ref tool — add operation defaults ref to name", () => {
349
327
  expect(raw).toBeNull();
350
328
  });
351
329
  });
330
+
331
+ describe("ref tool — auth state hook", () => {
332
+ test("passes tool input to getAuthStateContext", async () => {
333
+ const authCalls: Array<{
334
+ name: string;
335
+ opts: { stateContext?: Record<string, unknown> };
336
+ }> = [];
337
+ const adk = {
338
+ ref: {
339
+ auth: async (
340
+ name: string,
341
+ opts: { stateContext?: Record<string, unknown> },
342
+ ) => {
343
+ authCalls.push({ name, opts });
344
+ return { complete: true };
345
+ },
346
+ },
347
+ } as unknown as ReturnType<typeof createAdk>;
348
+ const tools = createAdkTools({
349
+ resolveScope: () => adk,
350
+ hooks: {
351
+ getAuthStateContext: async (input) => ({
352
+ name: input.name,
353
+ ref: input.ref,
354
+ }),
355
+ },
356
+ });
357
+ const refTool = tools.find((t) => t.name === "ref");
358
+ if (!refTool) throw new Error("ref tool not found");
359
+
360
+ await refTool.execute(
361
+ {
362
+ operation: "auth",
363
+ ref: "google-gmail",
364
+ name: "work2",
365
+ },
366
+ {} as ToolContext,
367
+ );
368
+
369
+ expect(authCalls).toHaveLength(1);
370
+ expect(authCalls[0]?.name).toBe("work2");
371
+ expect(authCalls[0]?.opts.stateContext).toEqual({
372
+ ref: "google-gmail",
373
+ name: "work2",
374
+ });
375
+ });
376
+ });