melchizedek-agents 1.3.0 → 1.4.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.
@@ -58,7 +58,9 @@
58
58
  * grantedCalls is that preprocessor, check for check, with one difference
59
59
  * in what the answer carries:
60
60
  * 1. The answers: the function responses named `adk_request_credential`
61
- * in the last event with content, which must be the user's. None:
61
+ * in the latest user event, provided this agent has stored nothing
62
+ * with content since (at the top that is the last event with content;
63
+ * in a walk another node's events may come between, ADR 0128). None:
62
64
  * nothing happens.
63
65
  * 2. The requests they answer: `adk_request_credential` calls this agent
64
66
  * made, by id. An answer naming no such request is ignored, as ADK
@@ -102,7 +104,8 @@
102
104
  * workflow under the call's name) makes an approval request an agent node
103
105
  * raised, or an ask_user node's question, the pause, with the node that
104
106
  * asked at the end of the path. The walk stops there: a pause inside a
105
- * workflow node's own delegation does not run (lib/workflow/agentNode.ts).
107
+ * workflow node's own delegation does not run (lib/workflow/agentNode.ts),
108
+ * except an OAuth consent request, which does (ADR 0128, below).
106
109
  *
107
110
  * NESTED WORKFLOWS AS A ROUTE OR A NODE (ADR 0119). A workflow node that is
108
111
  * a nested workflow raises its walk's open requests again on its caller's
@@ -126,6 +129,19 @@
126
129
  * call, the route's pause record or the node's raised request, and the
127
130
  * nested turn reads it as a message at the top.
128
131
  *
132
+ * CONSENT INSIDE A WALK (ADR 0128). An agent node's own consent request
133
+ * is an interrupt of the walk like its approval request: the walk's pause
134
+ * record names it, and walkConsents finds it there for the turn. A node
135
+ * whose request waits below it (in a call its agent left open, in a nested
136
+ * walk, in a nested dispatch syndicate's conversation) raises a copy of it
137
+ * on the walk under the same id (lib/workflow/agentNode.ts,
138
+ * lib/workflow/turn.ts), and consentBelow follows the copy down to the
139
+ * agent that asked; workflowPause reads a credential request as it reads
140
+ * an approval or a question, so a walk nested below a route, a node or a
141
+ * delegated call is followed the same way. grantedCalls reads the latest
142
+ * user event unless the agent has written since, so another node's events
143
+ * between the grant's answer and the node's resume do not hide it.
144
+ *
129
145
  * CONSENT AND SKILL SCRIPTS INSIDE DELEGATED SUBAGENTS (ADR 0118). A skill
130
146
  * script run asks for approval through the same `adk_request_confirmation`
131
147
  * call a gated tool stores, so the walk and the resume above carry it with
@@ -144,7 +160,7 @@ import { nativeToolOf } from '../../models/schemaNormalize.js';
144
160
  import { instructionToolOf, isTool, toolOf } from '../../tools/tool.js';
145
161
  import { inputRequestFrom } from '../../workflowConfig.js';
146
162
  import { approvalRequestOf, pendingApproval } from '../approvals.js';
147
- import { pendingConsent } from '../credentials.js';
163
+ import { consentFrom, pendingConsent } from '../credentials.js';
148
164
  import { getFunctionCalls, getFunctionResponses } from '../events.js';
149
165
  import { ASK_USER, pendingQuestion } from '../questions.js';
150
166
  import { childAppName, entryAppName, legacyChild, legacyEntry } from './delegate.js';
@@ -308,11 +324,17 @@ export async function grantedCalls(agent, scope) {
308
324
  const events = scope.session.events;
309
325
  if (events.length === 0)
310
326
  return undefined;
327
+ // The latest user event, unless this agent has written since (ADR 0128): in a walk, another node's events may
328
+ // follow the person's answer before this node reads it; at the top only this agent writes after the message.
311
329
  let last;
312
- for (let i = events.length - 1; i >= 0 && !last; i--)
313
- if (events[i]?.content !== undefined)
314
- last = events[i];
315
- if (!last || last.author !== 'user')
330
+ for (let i = events.length - 1; i >= 0 && !last; i--) {
331
+ const event = events[i];
332
+ if (event.author === agent.name && event.content !== undefined)
333
+ return undefined;
334
+ if (event.author === 'user' && event.content !== undefined)
335
+ last = event;
336
+ }
337
+ if (!last)
316
338
  return undefined;
317
339
  const answers = new Map();
318
340
  for (const r of getFunctionResponses(last))
@@ -427,6 +449,7 @@ async function pauseBelow(key, caller, call, depth, seen) {
427
449
  callIds: [id],
428
450
  ...(walked.approval ? { approval: { ...walked.approval, path } } : {}),
429
451
  ...(walked.question ? { question: { ...walked.question, path } } : {}),
452
+ ...(walked.consent ? { consent: { ...walked.consent, path } } : {}),
430
453
  };
431
454
  }
432
455
  const approval = pendingApproval(child.events);
@@ -540,8 +563,9 @@ function workflowPause(events, name, id) {
540
563
  const open = new Set((record.longRunningToolIds ?? []).filter((x) => id === undefined || x === id));
541
564
  if (open.size === 0)
542
565
  return undefined;
543
- // The latest open request the record names, an approval first: the walk stores node inputs as user turns, so a later one may follow it.
544
- for (const kind of [REQUEST_CONFIRMATION_CALL, REQUEST_INPUT_CALL]) {
566
+ // The latest open request the record names, an approval first, then a question, then a consent request (ADR 0128):
567
+ // the walk stores node inputs as user turns, so a later one may follow it.
568
+ for (const kind of [REQUEST_CONFIRMATION_CALL, REQUEST_INPUT_CALL, REQUEST_CREDENTIAL_CALL]) {
545
569
  for (let i = events.length - 2; i >= 0; i--) {
546
570
  const event = events[i];
547
571
  if (!event.author || event.author === 'user')
@@ -551,6 +575,12 @@ function workflowPause(events, name, id) {
551
575
  continue;
552
576
  if (kind === REQUEST_CONFIRMATION_CALL)
553
577
  return { asker: event.author, approval: approvalRequestOf(event, call) };
578
+ if (kind === REQUEST_CREDENTIAL_CALL) {
579
+ const consent = consentFrom(event.author, call);
580
+ if (consent)
581
+ return { asker: event.author, consent };
582
+ continue;
583
+ }
554
584
  const question = inputRequestFrom(event.author, call);
555
585
  if (question && question.id === call.id)
556
586
  return { asker: event.author, question };
@@ -559,6 +589,58 @@ function workflowPause(events, name, id) {
559
589
  }
560
590
  return undefined;
561
591
  }
592
+ /** Whether `asker` made the call `functionCallId` itself in `events`: its consent request is its own, not one it raised again. */
593
+ function madeCall(events, asker, functionCallId) {
594
+ return events.some((event) => event.author === asker && getFunctionCalls(event).some((call) => call.id === functionCallId && call.name !== REQUEST_CREDENTIAL_CALL));
595
+ }
596
+ /**
597
+ * The consent request `id` waiting below the node `asker` of a walk whose
598
+ * session holds `events` (filed under `key.appName`), when the node raised
599
+ * it again on the walk (ADR 0128): inside the node's own child session (a
600
+ * nested workflow, or a nested dispatch syndicate's conversation,
601
+ * nestedWorkflowPause), else inside a call the node left open (a delegated
602
+ * subagent, delegatedPauses). Its `path` runs from `asker` down to the
603
+ * agent that asked. Undefined when nothing below holds it.
604
+ */
605
+ async function consentBelow(key, events, asker, id, depth) {
606
+ if (depth >= MAX_DELEGATION_DEPTH)
607
+ return undefined;
608
+ const nested = await nestedWorkflowPause(key, asker, 'node', id, depth + 1);
609
+ if (nested?.consent?.id === id)
610
+ return nested.consent;
611
+ const below = await delegatedPauses({ ...key, delegated: false }, events, asker, depth + 1);
612
+ return below.find((pause) => pause.consent?.id === id)?.consent;
613
+ }
614
+ /**
615
+ * The OAuth consent requests a walk waits on (ADR 0128): the credential
616
+ * requests among the open interrupts its pause record names (the session's
617
+ * last event, authored by the workflow at its own path, no content), each
618
+ * followed down to the agent that asked. A node's own request comes back as
619
+ * it is, `agent` the node and no `path`, as a node's own approval request
620
+ * does; one a node raised again on the walk (a delegated subagent's, a
621
+ * nested walk's, a nested dispatch syndicate's) carries the `path` from the
622
+ * node down. `key.appName` is the walk session's app. Empty when the walk is
623
+ * not paused, or waits on no grant.
624
+ */
625
+ export async function walkConsents(key, events, depth = 0) {
626
+ const record = events[events.length - 1];
627
+ const name = record?.author;
628
+ if (!record || !name || name === 'user' || (record.content?.parts ?? []).length > 0 || record.nodeInfo?.path !== name)
629
+ return [];
630
+ const out = [];
631
+ for (const id of record.longRunningToolIds ?? []) {
632
+ const walked = workflowPause(events, name, id);
633
+ const consent = walked?.consent;
634
+ if (!walked || !consent)
635
+ continue;
636
+ if (madeCall(events, walked.asker, consent.functionCallId)) {
637
+ out.push(consent);
638
+ continue;
639
+ }
640
+ out.push((await consentBelow(key, events, walked.asker, id, depth)) ?? consent);
641
+ }
642
+ return out;
643
+ }
562
644
  /**
563
645
  * The pause the walk of `name` holds in `child` (its child session), with
564
646
  * `id` the only interrupt that counts when given: the node that asked, or,
@@ -569,6 +651,14 @@ async function workflowPauseIn(key, child, name, id, depth = 0) {
569
651
  const walked = workflowPause(child.events, name, id);
570
652
  if (!walked)
571
653
  return undefined;
654
+ if (walked.consent) {
655
+ // A node's own consent request, else one it raised again: followed down to the agent that asked (ADR 0128).
656
+ const below = madeCall(child.events, walked.asker, walked.consent.functionCallId)
657
+ ? undefined
658
+ : await consentBelow({ ...key, appName: child.appName }, child.events, walked.asker, walked.consent.id, depth + 1);
659
+ const path = [name, ...(below?.path ?? [walked.asker])];
660
+ return { path, consent: { ...(below ?? walked.consent), path } };
661
+ }
572
662
  const found = (walked.approval?.id ?? walked.question?.id);
573
663
  const deeper = await nestedWorkflowPause({ ...key, appName: child.appName }, walked.asker, 'node', found, depth + 1);
574
664
  const path = deeper ? [name, ...deeper.path] : [name, walked.asker];
@@ -70,7 +70,7 @@ import { traceAgentRun } from '../observability/tracer.js';
70
70
  import { ProjectedSessionService, renderTranscriptDigest } from '../session/transcript.js';
71
71
  import { approvalDecisionIn, describeApproval, interruptedTurnStart, pendingApproval } from './approvals.js';
72
72
  import { pendingQuestion, questionAnswerPart, questionFrom, turnStartOfCall } from './questions.js';
73
- import { deepestPause, delegatedPauses, nestedWorkflowPause, routePause } from './native/interrupts.js';
73
+ import { deepestPause, delegatedPauses, nestedWorkflowPause, routePause, walkConsents } from './native/interrupts.js';
74
74
  import { consentPinnedTo, entrySession } from './native/delegate.js';
75
75
  import { workflowPauseEvent } from '../workflow/pause.js';
76
76
  import { INPUT_REQUEST } from '../workflowConfig.js';
@@ -485,6 +485,15 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
485
485
  ev.log?.(`⏸ Authorization needed: ${(pending.path ?? [pending.agent]).join(' → ')} → ${pending.provider}`);
486
486
  return finish();
487
487
  };
488
+ /** Whether the grant a consent request waits for is stored (an expired or unreadable one is not). */
489
+ const grantStored = async (open) => {
490
+ try {
491
+ return !!open.provider && !!(await credentialStore?.get({ appName: credentialApp, userId, provider: open.provider }));
492
+ }
493
+ catch {
494
+ return false;
495
+ }
496
+ };
488
497
  let granting;
489
498
  // A call waiting for a grant inside a delegated subagent (ADR 0118): the grant's answer is stored here, and the open call carries it down.
490
499
  let grantingBelow;
@@ -508,14 +517,7 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
508
517
  result.error = { code: 'CONSENT_UNAVAILABLE', message: `A call waits for a ${open.provider} authorization, but this turn has no credential store.` };
509
518
  return finish();
510
519
  }
511
- let granted = false;
512
- try {
513
- granted = !!open.provider && !!(await credentialStore.get({ appName: credentialApp, userId, provider: open.provider }));
514
- }
515
- catch {
516
- // Expired or unreadable: not granted. The request stands.
517
- }
518
- if (!granted)
520
+ if (!(await grantStored(open)))
519
521
  return consentPause(open);
520
522
  // The route's own turn carries the grant's answer down to its call; the message goes to it as it came.
521
523
  if (routeWaits)
@@ -527,6 +529,28 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
527
529
  ev.log?.(`✓ Authorization granted: ${(open.path ?? [open.agent]).join(' → ')} → ${open.provider}`);
528
530
  }
529
531
  }
532
+ // A walk waiting for grants (ADR 0128): a node's own consent request, or one a node raised again from below it. Once every
533
+ // grant is stored, the message becomes their answers and the walk resumes those nodes; until then it repeats the first
534
+ // request still missing and stores nothing, as at the top.
535
+ if (!decision && isWorkflowSyndicate(config)) {
536
+ const waiting = await walkConsents(pauseKey, existing?.events ?? []);
537
+ if (waiting.length > 0) {
538
+ if (!credentialStore) {
539
+ result.status = 'failed';
540
+ result.error = { code: 'CONSENT_UNAVAILABLE', message: `A call waits for a ${waiting[0].provider} authorization, but this turn has no credential store.` };
541
+ return finish();
542
+ }
543
+ for (const open of waiting)
544
+ if (!(await grantStored(open)))
545
+ return consentPause(open);
546
+ granting = waiting[0];
547
+ parts = waiting.map((open) => credentialResponsePart(open.id, open.provider));
548
+ for (const open of waiting)
549
+ ev.log?.(`✓ Authorization granted: ${(open.path ?? [open.agent]).join(' → ')} → ${open.provider}`);
550
+ }
551
+ }
552
+ /** The parts a paused workflow route's walk resumes on once its grant is stored (ADR 0128): the grant's answer to its credential request. */
553
+ const routeGrant = consentRoute?.consent ? [credentialResponsePart(consentRoute.consent.id, consentRoute.consent.provider)] : undefined;
530
554
  // ── Questions (lib/runtime/questions.ts) ───────────────────────────────────
531
555
  // While an agent's `ask_user` call is open, a plain-text message is its
532
556
  // answer: it becomes that call's response, and the agent that asked
@@ -651,6 +675,8 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
651
675
  memory: nativeMemory(opts.memoryService),
652
676
  ...(selfCorrection ? { selfCorrection } : {}),
653
677
  ...(credentialStore ? { credentials: credentialStore } : {}),
678
+ // The consent step, its flows bound to the app the grants are read under, wherever the walk's session is filed (ADR 0128).
679
+ ...(consentStep ? { consent: consentPinnedTo(consentStep, credentialApp) } : {}),
654
680
  ...(compileOpts.log ? { log: compileOpts.log } : {}),
655
681
  });
656
682
  }
@@ -747,7 +773,7 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
747
773
  throw new Error(`Session not found: ${sessionId} (appName=${appName}, userId=${userId})`);
748
774
  const kept = (state) => Object.fromEntries(Object.entries(state ?? {}).filter(([k]) => !k.startsWith(TEMP_STATE_PREFIX)));
749
775
  // Filed under the agent path (ADR 0119), else continued under the route's name alone where ADR 0106 filed it.
750
- const resumingWalk = resumingRoute !== undefined || routeAnswer !== undefined;
776
+ const resumingWalk = resumingRoute !== undefined || routeAnswer !== undefined || consentRoute !== undefined;
751
777
  const child = await entrySession(store, { appName, userId, sessionId, events: shared.events }, route, 'route', kept(shared.state), resumingWalk);
752
778
  const childKey = { appName: child.appName, userId, sessionId };
753
779
  const before = new Map(Object.entries(kept(child.state)).map(([k, v]) => [k, JSON.stringify(v)]));
@@ -760,7 +786,7 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
760
786
  };
761
787
  let walked;
762
788
  try {
763
- walked = await runAgent({ agent, sid: sessionId, appName: child.appName, userParts: routeAnswer ?? parts, sessions: sessionService, stage: 'dispatch', route: resolution, publishToolStatus: true, errorPolicy: 'collect' });
789
+ walked = await runAgent({ agent, sid: sessionId, appName: child.appName, userParts: routeAnswer ?? routeGrant ?? parts, sessions: sessionService, stage: 'dispatch', route: resolution, publishToolStatus: true, errorPolicy: 'collect' });
764
790
  }
765
791
  catch (err) {
766
792
  const last = control.stopReason ? undefined : err;
@@ -1087,6 +1113,10 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
1087
1113
  const pending = pendingApproval(after?.events ?? []);
1088
1114
  if (pending)
1089
1115
  return pause(await deepen(pending));
1116
+ // A node waiting for a grant (ADR 0128): its own request, or one from below it, with the path from the node down.
1117
+ const [consent] = await walkConsents(pauseKey, after?.events ?? []);
1118
+ if (consent)
1119
+ return consentPause(consent);
1090
1120
  if (answer.inputRequests.length)
1091
1121
  return question(await deepen(answer.inputRequests[answer.inputRequests.length - 1]));
1092
1122
  result.text = answer.text;
@@ -1153,12 +1153,30 @@ function workflowProblems(raw, subs) {
1153
1153
  if (typeof agent.name === 'string' && mapped.has(agent.name) && isObj(agent.skills) && agent.skills.scripts === 'local') {
1154
1154
  out.push({ path: [...path, 'skills', 'scripts'], message: 'skill scripts (an approval pause) are not supported on an agent a map node runs: a map item cannot pause the walk' });
1155
1155
  }
1156
+ // An authorization_code grant pauses for consent (ADR 0128): allowed on a node, not on a map item.
1157
+ if (typeof agent.name === 'string' && mapped.has(agent.name)) {
1158
+ for (const where of authorizationCodeGrants(agent)) {
1159
+ out.push({ path: [...path, ...where], message: 'an authorization_code grant (a consent pause) is not supported on an agent a map node runs: a map item cannot pause the walk' });
1160
+ }
1161
+ }
1156
1162
  if (typeof agent.a2a_agent_url === 'string') {
1157
1163
  out.push({ path: [...path, 'a2a_agent_url'], message: 'a remote agent cannot be a workflow node yet' });
1158
1164
  }
1159
1165
  }
1160
1166
  return out;
1161
1167
  }
1168
+ /** Where an agent declares an authorization_code grant (mcp_auth, mcp_servers[].auth, openapi[].auth), by its path below the agent. */
1169
+ function authorizationCodeGrants(agent) {
1170
+ const out = [];
1171
+ const isCode = (auth) => isObj(auth) && isObj(auth.oauth2) && auth.oauth2.grant === 'authorization_code';
1172
+ if (isCode(agent.mcp_auth))
1173
+ out.push(['mcp_auth', 'oauth2', 'grant']);
1174
+ if (Array.isArray(agent.mcp_servers))
1175
+ agent.mcp_servers.forEach((e, i) => isObj(e) && isCode(e.auth) && out.push(['mcp_servers', i, 'auth', 'oauth2', 'grant']));
1176
+ if (Array.isArray(agent.openapi))
1177
+ agent.openapi.forEach((e, i) => isObj(e) && isCode(e.auth) && out.push(['openapi', i, 'auth', 'oauth2', 'grant']));
1178
+ return out;
1179
+ }
1162
1180
  // ── Public API ───────────────────────────────────────────────────────────────
1163
1181
  /**
1164
1182
  * Thrown by validateSyndicateConfig. `problems` holds one `<file>: <path> —
@@ -5,8 +5,9 @@
5
5
  * An agent's `mcp_server_url:` (or each entry of `mcp_servers:`, ADR 0124)
6
6
  * connects here over Streamable HTTP, falling back to the legacy SSE
7
7
  * transport when the server answers the initialize POST with the spec's
8
- * fallback signal (a 4xx other than 401/403), or over the transport the YAML
9
- * names. Every tool the server lists becomes an own Tool whose declaration is the server's
8
+ * fallback signal (a 4xx other than 401/403), or with a 401 when no OAuth
9
+ * grant is configured for the connection, or over the transport the YAML
10
+ * names; the transport that worked is remembered per URL. Every tool the server lists becomes an own Tool whose declaration is the server's
10
11
  * description (bounded) and input schema, and whose execute calls the
11
12
  * server and returns its text (bounded). loadMcpTools returns them;
12
13
  * createMcpTools is the name the compiler calls.
@@ -134,14 +135,56 @@ export function isSseFallbackSignal(error) {
134
135
  const code = error.code;
135
136
  return typeof code === 'number' && code >= 400 && code < 500 && code !== 401 && code !== 403;
136
137
  }
138
+ /**
139
+ * True when a Streamable HTTP connect failed with a 401: the server refused
140
+ * the request's credential (or its absence). Where the connection carries no
141
+ * OAuth grant, an SSE-only server behind a bearer can answer the initialize
142
+ * POST to its stream URL that way, so connectMcp tries SSE once (ADR 0124's
143
+ * note of 2026-10-11).
144
+ */
145
+ const isUnauthorized = (error) => error instanceof StreamableHTTPError && error.code === 401;
146
+ /** Server URLs whose transport `auto` has learned, at most; the least recently used is forgotten first. */
147
+ const MAX_REMEMBERED_MCP_TRANSPORTS = 256;
148
+ /**
149
+ * The transport that last connected to each server URL under `auto`, for the
150
+ * life of the process, so a later connection skips the probe. It holds a URL
151
+ * and a transport name, never a credential.
152
+ */
153
+ const rememberedTransports = new Map();
154
+ function rememberTransport(href, kind) {
155
+ rememberedTransports.delete(href);
156
+ rememberedTransports.set(href, kind);
157
+ while (rememberedTransports.size > MAX_REMEMBERED_MCP_TRANSPORTS) {
158
+ const oldest = rememberedTransports.keys().next().value;
159
+ rememberedTransports.delete(oldest);
160
+ }
161
+ }
162
+ const STREAMABLE_PREFIX = 'Streamable HTTP error: ';
163
+ /** `message` with the credential header's value, and its token, cut out. */
164
+ function withoutCredential(message, headers) {
165
+ const auth = headers.Authorization;
166
+ if (!auth)
167
+ return message;
168
+ const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : undefined;
169
+ return redact(redact(message, auth), token);
170
+ }
137
171
  /**
138
172
  * A client connected to `url` over `kind`, every request through `fetchFn`
139
173
  * (the redirect rule, and the credential when there is one). A transport
140
174
  * that fails to connect is closed before the error leaves, so nothing keeps
141
- * reconnecting; with `auto`, a failed Streamable HTTP attempt carrying the
142
- * fallback signal is closed and the SSE transport tried once.
175
+ * reconnecting. With `auto`:
176
+ * - a transport remembered for this URL is tried first; if it fails, it is
177
+ * forgotten and the probe runs;
178
+ * - the probe posts over Streamable HTTP. On the spec's fallback signal, or
179
+ * on a 401 when the connection has no OAuth grant (`MCP_BEARER_TOKENS`
180
+ * or no credential), it closes that attempt and tries SSE once with the
181
+ * same fetch and headers. When SSE fails after a 401, the 401 is what is
182
+ * thrown, naming both attempts and no credential. A 401 under an OAuth
183
+ * grant (its consent and discovery path, ADR 0112 and 0124), a 403, a 5xx
184
+ * and a network failure do not fall back;
185
+ * - the transport that connected is remembered for this URL.
143
186
  */
144
- async function connectMcp(url, kind, fetchFn, headers = {}) {
187
+ async function connectMcp(url, kind, fetchFn, headers = {}, options = {}) {
145
188
  const attempt = async (which) => {
146
189
  const requestInit = Object.keys(headers).length ? { requestInit: { headers } } : {};
147
190
  const transport = which === 'sse' ? new SSEClientTransport(url, { ...requestInit, fetch: fetchFn }) : new StreamableHTTPClientTransport(url, { ...requestInit, fetch: fetchFn });
@@ -160,14 +203,43 @@ async function connectMcp(url, kind, fetchFn, headers = {}) {
160
203
  return attempt('sse');
161
204
  if (kind === 'streamable_http')
162
205
  return attempt('streamable_http');
206
+ const href = url.href;
207
+ const known = rememberedTransports.get(href);
208
+ if (known) {
209
+ try {
210
+ const conn = await attempt(known);
211
+ rememberTransport(href, known);
212
+ return conn;
213
+ }
214
+ catch {
215
+ // The server may have changed: forget it and probe as if new.
216
+ rememberedTransports.delete(href);
217
+ }
218
+ }
219
+ let conn;
163
220
  try {
164
- return await attempt('streamable_http');
221
+ conn = await attempt('streamable_http');
165
222
  }
166
223
  catch (error) {
167
- if (!isSseFallbackSignal(error))
224
+ if (isSseFallbackSignal(error)) {
225
+ conn = await attempt('sse');
226
+ }
227
+ else if (isUnauthorized(error) && !options.oauth) {
228
+ try {
229
+ conn = await attempt('sse');
230
+ }
231
+ catch (sseError) {
232
+ const original = error.message.startsWith(STREAMABLE_PREFIX) ? error.message.slice(STREAMABLE_PREFIX.length) : error.message;
233
+ const sseMessage = sseError instanceof Error ? sseError.message : String(sseError);
234
+ throw new StreamableHTTPError(401, withoutCredential(`${original} (Streamable HTTP answered 401; the SSE transport was tried once after it and failed too: ${sseMessage})`, headers));
235
+ }
236
+ }
237
+ else {
168
238
  throw error;
169
- return attempt('sse');
239
+ }
170
240
  }
241
+ rememberTransport(href, conn.kind);
242
+ return conn;
171
243
  }
172
244
  /**
173
245
  * Close every open MCP connection. A connection otherwise lives as long as
@@ -180,6 +252,7 @@ export async function closeMcpConnections() {
180
252
  openTransports.clear();
181
253
  for (const pool of userPools)
182
254
  pool.clear();
255
+ rememberedTransports.clear();
183
256
  await Promise.all(all.map((t) => t.close().catch(() => { })));
184
257
  }
185
258
  /**
@@ -265,7 +338,7 @@ export async function loadMcpTools(mcpServerUrl, options = {}) {
265
338
  try {
266
339
  const url = await assertSafeMcpUrl(mcpServerUrl);
267
340
  conn = tokenSource
268
- ? await connectMcp(url, kind, bearerFetch(oauth2, (destination) => tokenSource(undefined, destination)))
341
+ ? await connectMcp(url, kind, bearerFetch(oauth2, (destination) => tokenSource(undefined, destination)), {}, { oauth: true })
269
342
  : await connectMcp(url, kind, mcpFetch, mcpAuthHeaders(url));
270
343
  const client = conn.client;
271
344
  // Fetch available tools from the MCP server
@@ -335,7 +408,7 @@ function userGrantMcpTools(mcpServerUrl, oauth2, names, kind) {
335
408
  conn.holder = holder;
336
409
  conn.ready = (async () => {
337
410
  const url = await assertSafeMcpUrl(mcpServerUrl);
338
- const { client, transport } = await connectMcp(url, kind, bearerFetch(oauth2, async () => holder.token));
411
+ const { client, transport } = await connectMcp(url, kind, bearerFetch(oauth2, async () => holder.token), {}, { oauth: true });
339
412
  conn.transport = transport;
340
413
  if (!listed) {
341
414
  const offered = await client.listTools();
@@ -5,8 +5,10 @@
5
5
  *
6
6
  * `auto` (the default) posts the initialize request over Streamable HTTP, the
7
7
  * spec's current transport, and falls back to the legacy HTTP+SSE transport
8
- * on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal);
9
- * `streamable_http` and `sse` use that one transport only.
8
+ * on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal), or
9
+ * on a 401 when the connection carries no OAuth grant, remembering per URL
10
+ * the transport that worked; `streamable_http` and `sse` use that one
11
+ * transport only.
10
12
  */
11
13
  export declare const MCP_TRANSPORTS: readonly ['auto', 'streamable_http', 'sse'];
12
14
  export type McpTransportKind = (typeof MCP_TRANSPORTS)[number];
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * `auto` (the default) posts the initialize request over Streamable HTTP, the
7
7
  * spec's current transport, and falls back to the legacy HTTP+SSE transport
8
- * on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal);
9
- * `streamable_http` and `sse` use that one transport only.
8
+ * on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal), or
9
+ * on a 401 when the connection carries no OAuth grant, remembering per URL
10
+ * the transport that worked; `streamable_http` and `sse` use that one
11
+ * transport only.
10
12
  */
11
13
  export const MCP_TRANSPORTS = ['auto', 'streamable_http', 'sse'];
@@ -44,7 +44,8 @@
44
44
  * run's `error`, and the scheduler fails the attempt with
45
45
  * NodeReportedError, ADK's message, retried or stopping the walk as
46
46
  * ADK's does, and reported once (by the node's own event). A run the turn stopped throws. A run that pauses on
47
- * an ask_user tool call is refused by name.
47
+ * an ask_user tool call, or on an approval or a question inside its
48
+ * delegation, is refused by name.
48
49
  * 8. APPROVALS (ADR 0098). A run that pauses on approval requests (a tool
49
50
  * in the agent's `require_approval`) resolves with their ids as the
50
51
  * node's interrupts: the node waits and the walk ends paused, as an
@@ -62,6 +63,20 @@
62
63
  * from its input on. ADK's runLlmAgentAsNode stores the input again and
63
64
  * starts the agent afresh, so the pinned call never runs there; ADK
64
65
  * turns refuse gates on workflow nodes instead.
66
+ * 9. OAUTH CONSENT (ADR 0128). The node's loop gets the turn's consent
67
+ * step, its flows bound to the turn's app. A tool whose grant is
68
+ * missing stores its `adk_request_credential` call as the node's event
69
+ * (stamped, its id in longRunningToolIds) and the run ends paused: that
70
+ * id is the node's interrupt, as an approval request's is. A delegated
71
+ * call whose child waits for a grant (ADR 0118) leaves the call open;
72
+ * the node raises a copy of the child's request on one event of its own
73
+ * (`pausedOn`), under the same id. On the resume, a grant's answer
74
+ * (`{ credentialKey, granted: true }`, never plain text) lets the run
75
+ * continue as rule 8 lets an approval's: the loop's auth preprocessor
76
+ * (interrupts.ts grantedCalls) runs the paused call again, or its
77
+ * delegation resume carries the answer down the open call. Without one
78
+ * the node waits again, its request raised again (`waitAgain`). A map
79
+ * item runs without the consent step: it cannot pause the walk.
65
80
  *
66
81
  * `agentNodeRuntime` puts it together for the scheduler: a `runNode` for
67
82
  * agent nodes and map items, and an `onEvent` that stores the event ADK
@@ -69,7 +84,8 @@
69
84
  * (lib/workflow/nodeEvents.ts), so a session the native walk writes holds
70
85
  * what ADK's holds, in the same order.
71
86
  *
72
- * NOT HERE: an ask_user tool call inside a node, a pause inside a map item,
87
+ * NOT HERE: an ask_user tool call inside a node, an approval or a question
88
+ * inside a node's own delegation, a pause inside a map item,
73
89
  * tool nodes (lib/workflow/toolNode.ts) and ask_user nodes
74
90
  * (lib/workflow/pause.ts). It imports nothing from ADK.
75
91
  */
@@ -119,7 +135,7 @@ export interface AgentNodeContext {
119
135
  * one with no output. Throws NodeStoppedError when the turn stopped it, and
120
136
  * an Error when it paused on a person.
121
137
  */
122
- export declare function runAgentNode(agent: NativeAgent, run: Pick<NodeRun, 'input' | 'path' | 'branch' | 'signal' | 'resumeInputs' | 'resumedInterruptIds'>, ctx: AgentNodeContext): Promise<NodeResult>;
138
+ export declare function runAgentNode(agent: NativeAgent, run: Pick<NodeRun, 'input' | 'path' | 'branch' | 'signal' | 'resumeInputs' | 'resumedInterruptIds'> & Partial<Pick<NodeRun, 'target'>>, ctx: AgentNodeContext): Promise<NodeResult>;
123
139
  export interface AgentNodeRuntimeOptions extends Omit<AgentNodeContext, 'appendInput'> {
124
140
  /** Every agent of the syndicate, compiled for native, by YAML name. */
125
141
  agents: ReadonlyMap<string, NativeAgent>;