Extensions: first-class advertised-extensions map (Phase 1 of #1633) (#1742)

* Extensions: first-class advertised-extensions map (Phase 1 of #1633)

Collapse the two ad-hoc `capabilities.extensions` spreads in the
InspectorClient constructor (EMA, gated on enterpriseManaged; the modern
Tasks extension, always-on) into a single builder that is the source of
truth for what the client advertises.

- core/mcp/extensions.ts: `ADVERTISABLE_EXTENSIONS` registry (id + label +
  default-on flag), the `EMA_EXTENSION_KEY` constant, and
  `buildClientExtensions()`. The registry is the shared list the Server
  Settings toggle UI (Phase 2, #1739) will drive; EMA stays a conditional
  built-in (auth-mode driven, not a free toggle).
- InspectorClientOptions.advertisedExtensions: per-extension overrides
  (present key wins over the registry default) so a future UI can toggle
  advertised extensions as a debugging knob.
- Behavior preserved: tasks defaults to advertised, so the map is non-empty
  and rides every modern request envelope; EMA advertised iff enterpriseManaged.

The raw modern-task envelope re-stamp is left untouched (it fires only on
the raw tasks channel, which requires the extension anyway).

Tests: extensions.test.ts covers the registry and every builder branch
(defaults, EMA on/off, override enable/disable, unknown key) at 100%.
The tasks-era and EMA integration suites still assert the same wire
advertisement. `advertisedExtensions` is plumbed but not yet wired to the
form (that lands in Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Extensions: address Phase 1 review (EMA-override test + envelope comment)

- extensions.test.ts: add an EMA-specific assertion that an override for
  EMA_EXTENSION_KEY cannot advertise it (auth-mode only) — documents intent
  and guards against EMA being added to ADVERTISABLE_EXTENSIONS by mistake.
- inspectorClient.ts: rewrite the withModernTaskEnvelope tasks-stamp comment
  so it stays accurate once Phase 2 lets a user disable general tasks
  advertisement — the force-stamp is load-bearing (the raw tasks/* channel
  requires the extension), not a redundant re-add.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* TUI: de-flake step-up OAuth frame assertions (raise poll ceiling)

The step-up authorize tests intermittently timed out on the final
"Step-up authorization succeeded" frame. Root cause is a test-infra
timing budget, not product code: waitForFrame/waitUntil polled only 25×
(25ms tick ≈ 625ms), and the step-up flow adds an extra async OAuth-runner
hop plus React commits that can exceed that under CI load with v8 coverage.

Hoist the poll count into a named POLL_TRIES = 100 (~2.5s ceiling). A poll
exits the instant its predicate is true, so a higher ceiling never slows a
passing assertion — it only widens the margin for the slow path. Verified
5× green (3 plain + 2 under --coverage), including the previously-flaky
step-up cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* TUI: prettier-format the wrapped waitForFrame signature

The POLL_TRIES change pushed waitForFrame's signature past the print width;
Prettier wraps it. (Root has no aggregate format script — format is
per-client, so this was missed locally.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-22 15:25:45 -04:00 • committed by GitHub
commit 2f80958d3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -127,6 +127,7 @@ import {
isModernCreateTaskResult,
type ModernDetailedTask,
} from "./modernTaskSchemas.js";
import { buildClientExtensions } from "./extensions.js";
import {
EmptyResultSchema,
CallToolResultSchema,
@ -430,6 +431,9 @@ export class InspectorClient extends InspectorClientEventTarget {
private activeToolCallAbortController?: AbortController;
// Receiver tasks (server-initiated: server sends createMessage/elicit with params.task, server polls us)
private receiverTasks: boolean;
// Per-extension advertise overrides (#1738); undefined key falls back to the
// registry default in ADVERTISABLE_EXTENSIONS.
private advertisedExtensions?: Record<string, boolean>;
private receiverTaskTtlMs: number | (() => number);
private receiverTaskRecords: Map<string, ReceiverTaskRecord> = new Map();
// OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured")
@ -467,6 +471,7 @@ export class InspectorClient extends InspectorClientEventTarget {
this.sample = options.sample ?? true;
this.elicit = options.elicit ?? true;
this.receiverTasks = options.receiverTasks ?? false;
this.advertisedExtensions = options.advertisedExtensions;
this.receiverTaskTtlMs = options.receiverTaskTtlMs ?? 60_000;
this.progress = options.progress ?? true;
this.resetTimeoutOnProgress = options.resetTimeoutOnProgress ?? true;
@ -594,22 +599,24 @@ export class InspectorClient extends InspectorClientEventTarget {
},
};
}
if (options.oauth?.enterpriseManaged) {
// Assemble the advertised-extensions map from one builder (the single
// source of truth), instead of ad-hoc per-extension spreads. It layers the
// registry defaults (with any user overrides from `advertisedExtensions`)
// and the auth-mode-driven EMA extension. The Tasks entry defaults to
// advertised, so the map is non-empty and `capabilities.extensions` is
// always attached — the modern Tasks extension (SEP-2663) must ride every
// modern request envelope for a server to legally return a `CreateTaskResult`
// (harmless on legacy, where extensions are ignored). (#1738)
const advertisedExtensions = buildClientExtensions({
enterpriseManaged: options.oauth?.enterpriseManaged ?? false,
advertised: this.advertisedExtensions,
});
if (Object.keys(advertisedExtensions).length > 0) {
capabilities.extensions = {
...capabilities.extensions,
"io.modelcontextprotocol/enterprise-managed-authorization": {},
...advertisedExtensions,
};
}
// Advertise the modern Tasks extension (SEP-2663) so the SDK stamps it into
// every modern request's `clientCapabilities` envelope — the per-request
// declaration a server requires before it may return a `CreateTaskResult`.
// Harmless on legacy (extensions are ignored there). This is what makes
// server-directed ("unsolicited") task creation legal on modern, and it
// makes `capabilities` always non-empty, so it's always attached.
capabilities.extensions = {
...capabilities.extensions,
[TASKS_EXTENSION_KEY]: {},
};
clientOptions.capabilities = capabilities;
this.clientCapabilities = capabilities;
@ -1806,8 +1813,10 @@ export class InspectorClient extends InspectorClientEventTarget {
(params._meta as Record<string, unknown> | undefined) ?? {};
const clientCapabilities = {
...this.clientCapabilities,
// extensions always carries the tasks extension (advertised at
// construction), so spreading it is never a no-op.
// Force-stamp the tasks extension regardless of what the client
// advertised at construction: the raw `tasks/*` channel requires it, and
// a user may disable general tasks advertisement via `advertisedExtensions`
// (#1738). So this stamp is load-bearing, not a redundant re-add.
extensions: {
...this.clientCapabilities.extensions,
[TASKS_EXTENSION_KEY]: {},