@slashfi/agents-sdk 0.67.0 → 0.68.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.
@@ -143,11 +143,29 @@ export interface OAuthResult {
143
143
  clientId: string;
144
144
  }
145
145
 
146
+ /** A field the caller needs to collect from the user */
147
+ export interface AuthChallengeField {
148
+ /** Field key (e.g. "api_key", "token") */
149
+ name: string;
150
+ /** Human-readable label (e.g. "API Key", "DD-API-KEY") */
151
+ label: string;
152
+ /** Whether this is a secret value (should be masked in UI) */
153
+ secret: boolean;
154
+ /** Optional description / help text */
155
+ description?: string;
156
+ }
157
+
146
158
  export interface AuthStartResult {
147
159
  type: string;
148
160
  complete: boolean;
149
161
  /** For OAuth: the URL to open in the browser */
150
162
  authorizeUrl?: string;
163
+ /**
164
+ * When complete=false and type is "apiKey" or "http",
165
+ * these are the fields the caller should collect from the user.
166
+ * The caller can render these as a form (Slack blocks, web modal, CLI prompts).
167
+ */
168
+ fields?: AuthChallengeField[];
151
169
  }
152
170
 
153
171
  export interface AdkRefApi {
@@ -168,8 +186,14 @@ export interface AdkRefApi {
168
186
  * adk.ref.authLocal() to spin up a local server and block.
169
187
  */
170
188
  auth(name: string, opts?: {
171
- /** For API key / bearer auth: the key/token value */
189
+ /** For API key / bearer auth: the key/token value (single-key shorthand) */
172
190
  apiKey?: string;
191
+ /**
192
+ * Credentials map for multi-field auth. Keys match the `name` field
193
+ * from AuthChallengeField (e.g. { "api_key": "xxx", "app_key": "yyy" }).
194
+ * For single-key apiKey or http bearer, `apiKey` shorthand also works.
195
+ */
196
+ credentials?: Record<string, string>;
173
197
  /** Extra context to encode in the OAuth state (e.g., tenant/user IDs for multi-tenant callbacks) */
174
198
  stateContext?: Record<string, unknown>;
175
199
  /** Additional scopes to request (e.g., optional scopes declared by the agent) */
@@ -290,20 +314,18 @@ async function decryptConfigSecrets(
290
314
  // ============================================
291
315
 
292
316
  /**
293
- * Heuristic: does a tool call response look like a 401 Unauthorized?
294
- * Checks both structured error fields and stringified response content.
317
+ * Check if a tool call response indicates a 401 Unauthorized from the upstream API.
318
+ * Primary: httpStatus set by consumer from HTTP res.status
319
+ * Fallback: _httpStatus from tool result body
295
320
  */
296
- function looksLike401(result: unknown): boolean {
321
+ function isUnauthorized(result: unknown): boolean {
297
322
  if (!result || typeof result !== 'object') return false;
298
323
  const r = result as Record<string, unknown>;
299
-
300
- // Structured: { success: true, result: { success: true, result: { content: [{ text: '{"error":"401 ..."} }] } } }
301
- // Or: { error: "401 ..." }
302
- const text = JSON.stringify(r).toLowerCase();
303
- if (text.includes('"401') && (text.includes('unauthorized') || text.includes('unauthenticated') || text.includes('invalid credentials'))) {
304
- return true;
305
- }
306
-
324
+ // Primary: HTTP status forwarded by the registry and set by callRegistry
325
+ if (r.httpStatus === 401) return true;
326
+ // Fallback: _httpStatus in the nested tool result body
327
+ const inner = r.result as Record<string, unknown> | undefined;
328
+ if (inner?._httpStatus === 401) return true;
307
329
  return false;
308
330
  }
309
331
 
@@ -894,7 +916,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
894
916
  const result = await doCall(accessToken);
895
917
 
896
918
  // Check if the response indicates a 401 — try refreshing the token and retry once
897
- if (accessToken && looksLike401(result)) {
919
+ if (accessToken && isUnauthorized(result)) {
898
920
  const refreshed = await ref.refreshToken(name);
899
921
  if (refreshed) {
900
922
  return doCall(refreshed.accessToken);
@@ -1000,12 +1022,32 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1000
1022
  resolvable: false,
1001
1023
  };
1002
1024
  } else if (security.type === "apiKey") {
1003
- fields.api_key = {
1004
- required: true,
1005
- automated: false,
1006
- present: configKeys.includes("api_key"),
1007
- resolvable: await canResolve("api_key"),
1025
+ const apiKeySec = security as {
1026
+ name?: string; headers?: Record<string, { description?: string }>;
1008
1027
  };
1028
+ const toStorageKey = (headerName: string) =>
1029
+ headerName.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
1030
+
1031
+ if (apiKeySec.headers && Object.keys(apiKeySec.headers).length > 0) {
1032
+ // Multi-key mode
1033
+ for (const headerName of Object.keys(apiKeySec.headers)) {
1034
+ const storageKey = toStorageKey(headerName);
1035
+ fields[storageKey] = {
1036
+ required: true,
1037
+ automated: false,
1038
+ present: configKeys.includes(storageKey),
1039
+ resolvable: await canResolve(storageKey),
1040
+ };
1041
+ }
1042
+ } else {
1043
+ // Single-key mode (backwards compat)
1044
+ fields.api_key = {
1045
+ required: true,
1046
+ automated: false,
1047
+ present: configKeys.includes("api_key"),
1048
+ resolvable: await canResolve("api_key"),
1049
+ };
1050
+ }
1009
1051
  } else if (security.type === "http") {
1010
1052
  fields.token = {
1011
1053
  required: true,
@@ -1024,6 +1066,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1024
1066
 
1025
1067
  async auth(name: string, opts?: {
1026
1068
  apiKey?: string;
1069
+ credentials?: Record<string, string>;
1027
1070
  /** Extra context to encode in the OAuth state (e.g., tenant/user IDs for multi-tenant callbacks) */
1028
1071
  stateContext?: Record<string, unknown>;
1029
1072
  /** Additional scopes to request (e.g., optional scopes declared by the agent) */
@@ -1047,15 +1090,92 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1047
1090
  }
1048
1091
 
1049
1092
  if (security.type === "apiKey") {
1050
- const key = opts?.apiKey ?? await tryResolve("api_key");
1051
- if (!key) return { type: "apiKey", complete: false };
1093
+ const apiKeySec = security as {
1094
+ name?: string; prefix?: string;
1095
+ headers?: Record<string, { description?: string }>;
1096
+ };
1097
+
1098
+ const toStorageKey = (headerName: string) =>
1099
+ headerName.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
1100
+
1101
+ if (apiKeySec.headers && Object.keys(apiKeySec.headers).length > 0) {
1102
+ // Multi-key mode: iterate all declared headers
1103
+ const missingFields: AuthChallengeField[] = [];
1104
+ const resolvedKeys: Array<{ storageKey: string; value: string }> = [];
1105
+
1106
+ for (const [headerName, meta] of Object.entries(apiKeySec.headers)) {
1107
+ const storageKey = toStorageKey(headerName);
1108
+ const value = opts?.credentials?.[storageKey] ?? await tryResolve(storageKey);
1109
+
1110
+ if (value) {
1111
+ resolvedKeys.push({ storageKey, value });
1112
+ } else {
1113
+ missingFields.push({
1114
+ name: storageKey,
1115
+ label: headerName,
1116
+ secret: true,
1117
+ description: meta.description,
1118
+ });
1119
+ }
1120
+ }
1121
+
1122
+ if (missingFields.length > 0) {
1123
+ return { type: "apiKey", complete: false, fields: missingFields };
1124
+ }
1125
+ for (const { storageKey, value } of resolvedKeys) {
1126
+ await storeRefSecret(name, storageKey, value);
1127
+ }
1128
+ return { type: "apiKey", complete: true };
1129
+ }
1130
+
1131
+ // Single-key mode (backwards compat)
1132
+ const key = opts?.credentials?.["api_key"] ?? opts?.apiKey ?? await tryResolve("api_key");
1133
+ if (!key) {
1134
+ return {
1135
+ type: "apiKey",
1136
+ complete: false,
1137
+ fields: [{
1138
+ name: "api_key",
1139
+ label: apiKeySec.name ?? "API Key",
1140
+ secret: true,
1141
+ description: apiKeySec.prefix
1142
+ ? `Value sent as "${apiKeySec.prefix} <key>"`
1143
+ : undefined,
1144
+ }],
1145
+ };
1146
+ }
1052
1147
  await storeRefSecret(name, "api_key", key);
1053
1148
  return { type: "apiKey", complete: true };
1054
1149
  }
1055
1150
 
1056
1151
  if (security.type === "http") {
1057
- const token = opts?.apiKey ?? await tryResolve("token");
1058
- if (!token) return { type: "http", complete: false };
1152
+ const httpSec = security as { scheme?: string };
1153
+ const isBasic = httpSec.scheme === "basic";
1154
+
1155
+ if (isBasic) {
1156
+ const username = opts?.credentials?.["username"] ?? await tryResolve("username");
1157
+ const password = opts?.credentials?.["password"] ?? await tryResolve("password");
1158
+ if (!username || !password) {
1159
+ const missingFields: AuthChallengeField[] = [];
1160
+ if (!username) missingFields.push({ name: "username", label: "Username", secret: false });
1161
+ if (!password) missingFields.push({ name: "password", label: "Password", secret: true });
1162
+ return { type: "http", complete: false, fields: missingFields };
1163
+ }
1164
+ // Store as base64 encoded basic auth token
1165
+ const token = btoa(`${username}:${password}`);
1166
+ await storeRefSecret(name, "token", token);
1167
+ return { type: "http", complete: true };
1168
+ }
1169
+
1170
+ // Bearer token
1171
+ const token = opts?.credentials?.["token"] ?? opts?.apiKey ?? await tryResolve("token");
1172
+ if (!token) {
1173
+ return {
1174
+ type: "http",
1175
+ complete: false,
1176
+ fields: [{ name: "token", label: "Bearer Token", secret: true }],
1177
+ };
1178
+ }
1059
1179
  await storeRefSecret(name, "token", token);
1060
1180
  return { type: "http", complete: true };
1061
1181
  }
@@ -1064,7 +1184,14 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1064
1184
  const flows = (security as { flows?: { authorizationCode?: { authorizationUrl?: string; tokenUrl?: string } } }).flows;
1065
1185
  const authCodeFlow = flows?.authorizationCode;
1066
1186
  if (!authCodeFlow?.authorizationUrl) {
1067
- return { type: "oauth2", complete: false };
1187
+ return {
1188
+ type: "oauth2",
1189
+ complete: false,
1190
+ fields: [
1191
+ { name: "client_id", label: "Client ID", secret: false },
1192
+ { name: "client_secret", label: "Client Secret", secret: true },
1193
+ ],
1194
+ };
1068
1195
  }
1069
1196
 
1070
1197
  const authUrl = authCodeFlow.authorizationUrl;
@@ -1118,9 +1245,14 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1118
1245
  }
1119
1246
 
1120
1247
  if (!clientId) {
1121
- throw new Error(
1122
- "Could not obtain client_id. Provide via resolveCredentials callback or store manually.",
1123
- );
1248
+ // Return fields telling the caller what OAuth credentials to provide
1249
+ const missingFields: AuthChallengeField[] = [];
1250
+ if (!clientId) {
1251
+ missingFields.push({ name: "client_id", label: "Client ID", secret: false });
1252
+ }
1253
+ // Always ask for client_secret alongside client_id — most providers need it
1254
+ missingFields.push({ name: "client_secret", label: "Client Secret", secret: true });
1255
+ return { type: "oauth2", complete: false, fields: missingFields };
1124
1256
  }
1125
1257
 
1126
1258
  // State ties the callback back to this ref. Encode as base64 JSON
package/src/index.ts CHANGED
@@ -417,6 +417,7 @@ export type {
417
417
  RefAuthStatus,
418
418
  CredentialField,
419
419
  AuthStartResult,
420
+ AuthChallengeField,
420
421
  OAuthResult,
421
422
  ResolveCredentials,
422
423
  ResolveCredentialsContext,
@@ -727,6 +727,14 @@ export async function createRegistryConsumer(
727
727
  });
728
728
 
729
729
  if (!res.ok) {
730
+ // Upstream 401 — return structured response so ref.call() can refresh + retry
731
+ if (res.status === 401) {
732
+ // Still try to parse the body for context
733
+ const body = await res.text().catch(() => "");
734
+ let parsed: Record<string, unknown> = { success: false, error: "unauthorized" };
735
+ try { parsed = JSON.parse(body); } catch {}
736
+ return { ...parsed, success: false, httpStatus: 401 } as unknown as CallAgentResponse;
737
+ }
730
738
  const text = await res.text().catch(() => "unknown error");
731
739
  throw new Error(
732
740
  `Registry call failed (${registry.url}): ${res.status} ${text}`,
package/src/server.ts CHANGED
@@ -554,6 +554,19 @@ export function createAgentServer(
554
554
  async function handleJsonRpc(
555
555
  request: JsonRpcRequest,
556
556
  auth: ResolvedAuth | null,
557
+ ): Promise<{ rpc: JsonRpcResponse; httpResponse?: { status: number } }> {
558
+ const rpc = await handleJsonRpcInner(request, auth);
559
+ // Extract upstream HTTP status from tool results (set by REST proxy handlers)
560
+ const httpStatus = (rpc as any)?.result?._httpStatus as number | undefined;
561
+ return {
562
+ rpc,
563
+ ...(httpStatus ? { httpResponse: { status: httpStatus } } : {}),
564
+ };
565
+ }
566
+
567
+ async function handleJsonRpcInner(
568
+ request: JsonRpcRequest,
569
+ auth: ResolvedAuth | null,
557
570
  ): Promise<JsonRpcResponse> {
558
571
  switch (request.method) {
559
572
  case "initialize":
@@ -662,7 +675,11 @@ export function createAgentServer(
662
675
  }
663
676
 
664
677
  const result = await registry.call(req);
665
- return mcpResult(result);
678
+ const mcp = mcpResult(result);
679
+ // Preserve upstream HTTP status from tool execution (e.g. REST proxy 401)
680
+ const upstreamStatus = (result as any)?.result?._httpStatus;
681
+ if (upstreamStatus) (mcp as any)._httpStatus = upstreamStatus;
682
+ return mcp;
666
683
  }
667
684
 
668
685
  case "list_agents": {
@@ -1109,8 +1126,10 @@ export function createAgentServer(
1109
1126
  // ── POST / → MCP JSON-RPC ──
1110
1127
  if (path === "/" && req.method === "POST") {
1111
1128
  const body = (await req.json()) as JsonRpcRequest;
1112
- const result = await handleJsonRpc(body, effectiveAuth);
1113
- return cors ? addCors(jsonResponse(result)) : jsonResponse(result);
1129
+ const { rpc, httpResponse } = await handleJsonRpc(body, effectiveAuth);
1130
+ const status = httpResponse?.status ?? 200;
1131
+ const res = jsonResponse(rpc, status);
1132
+ return cors ? addCors(res) : res;
1114
1133
  }
1115
1134
 
1116
1135
  // ── POST /oauth/token → OAuth2 token exchange ──
package/src/types.ts CHANGED
@@ -204,17 +204,35 @@ export interface OAuth2SecurityScheme {
204
204
 
205
205
  /**
206
206
  * API key authentication.
207
- * Used by agents that wrap APIs using a single key
207
+ * Used by agents that wrap APIs using one or more keys
208
208
  * (e.g. OpenAI, Anthropic, Stripe, Datadog).
209
+ *
210
+ * @example
211
+ * // Single key (backwards-compatible)
212
+ * security: { type: 'apiKey', in: 'header', name: 'Authorization', prefix: 'Bearer' }
213
+ *
214
+ * // Multiple keys (e.g. Datadog)
215
+ * security: {
216
+ * type: 'apiKey',
217
+ * headers: {
218
+ * 'DD-API-KEY': { description: 'Your Datadog API key' },
219
+ * 'DD-APPLICATION-KEY': { description: 'Your Datadog application key' },
220
+ * }
221
+ * }
209
222
  */
210
223
  export interface ApiKeySecurityScheme {
211
224
  type: "apiKey";
212
- /** Where the key is sent */
213
- in: "header" | "query";
214
- /** Header or query parameter name (e.g. "X-API-Key", "Authorization") */
215
- name: string;
216
- /** Optional prefix (e.g. "Bearer" for Authorization header) */
225
+ /** Where the key is sent (single-key mode) */
226
+ in?: "header" | "query";
227
+ /** Header or query parameter name (single-key mode, e.g. "X-API-Key") */
228
+ name?: string;
229
+ /** Optional prefix (single-key mode, e.g. "Bearer") */
217
230
  prefix?: string;
231
+ /**
232
+ * Named headers the user must provide values for (multi-key mode).
233
+ * When present, this is the source of truth — `in`/`name`/`prefix` are ignored.
234
+ */
235
+ headers?: Record<string, { description?: string }>;
218
236
  }
219
237
 
220
238
  /**