nexus-ai-pro 2.17.0 → 2.18.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 (80) hide show
  1. package/API_STABILITY.md +35 -0
  2. package/CHANGELOG.md +78 -1
  3. package/README.md +7 -3
  4. package/dist/evaluate/media.d.ts +6 -2
  5. package/dist/evaluate/media.js +7 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/mcp/client.d.ts +25 -2
  8. package/dist/mcp/client.js +82 -47
  9. package/dist/mcp/server.d.ts +1 -1
  10. package/dist/mcp/server.js +4 -1
  11. package/dist/protocols/acp-editor.js +42 -15
  12. package/dist/router/failover.js +19 -1
  13. package/dist/server/index.d.ts +1 -0
  14. package/dist/server/list-page.d.ts +38 -0
  15. package/dist/server/list-page.js +49 -0
  16. package/dist/server/regions.d.ts +15 -1
  17. package/dist/server/regions.js +70 -0
  18. package/dist/server/runs.d.ts +24 -0
  19. package/dist/server/runs.js +18 -4
  20. package/dist/server/server.js +17 -5
  21. package/dist/tracing/index.d.ts +2 -2
  22. package/dist/tracing/instrument.d.ts +7 -2
  23. package/dist/tracing/instrument.js +69 -2
  24. package/dist/tracing/otlp-proto.js +18 -0
  25. package/dist/tracing/otlp.d.ts +26 -1
  26. package/dist/tracing/otlp.js +19 -0
  27. package/dist/tracing/tracer.d.ts +36 -1
  28. package/dist/tracing/tracer.js +68 -2
  29. package/dist/types/response.d.ts +18 -0
  30. package/dist/types/tracing.d.ts +32 -0
  31. package/dist/utils/media-duration.d.ts +20 -0
  32. package/dist/utils/media-duration.js +334 -0
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/dist/video/google.d.ts +8 -0
  36. package/dist/video/google.js +31 -9
  37. package/dist/video/index.d.ts +18 -3
  38. package/dist/video/index.js +46 -3
  39. package/dist/video/mock.js +3 -1
  40. package/dist/video/runway.d.ts +2 -0
  41. package/dist/video/runway.js +45 -11
  42. package/dist-cjs/evaluate/media.d.ts +6 -2
  43. package/dist-cjs/evaluate/media.js +9 -21
  44. package/dist-cjs/index.d.ts +1 -1
  45. package/dist-cjs/mcp/client.d.ts +25 -2
  46. package/dist-cjs/mcp/client.js +82 -47
  47. package/dist-cjs/mcp/server.d.ts +1 -1
  48. package/dist-cjs/mcp/server.js +4 -1
  49. package/dist-cjs/protocols/acp-editor.js +42 -15
  50. package/dist-cjs/router/failover.js +19 -1
  51. package/dist-cjs/server/index.d.ts +1 -0
  52. package/dist-cjs/server/list-page.d.ts +38 -0
  53. package/dist-cjs/server/list-page.js +56 -0
  54. package/dist-cjs/server/regions.d.ts +15 -1
  55. package/dist-cjs/server/regions.js +70 -0
  56. package/dist-cjs/server/runs.d.ts +24 -0
  57. package/dist-cjs/server/runs.js +18 -4
  58. package/dist-cjs/server/server.js +17 -5
  59. package/dist-cjs/tracing/index.d.ts +2 -2
  60. package/dist-cjs/tracing/instrument.d.ts +7 -2
  61. package/dist-cjs/tracing/instrument.js +69 -2
  62. package/dist-cjs/tracing/otlp-proto.js +18 -0
  63. package/dist-cjs/tracing/otlp.d.ts +26 -1
  64. package/dist-cjs/tracing/otlp.js +19 -0
  65. package/dist-cjs/tracing/tracer.d.ts +36 -1
  66. package/dist-cjs/tracing/tracer.js +68 -2
  67. package/dist-cjs/types/response.d.ts +18 -0
  68. package/dist-cjs/types/tracing.d.ts +32 -0
  69. package/dist-cjs/utils/media-duration.d.ts +20 -0
  70. package/dist-cjs/utils/media-duration.js +337 -0
  71. package/dist-cjs/version.d.ts +1 -1
  72. package/dist-cjs/version.js +1 -1
  73. package/dist-cjs/video/google.d.ts +8 -0
  74. package/dist-cjs/video/google.js +31 -9
  75. package/dist-cjs/video/index.d.ts +18 -3
  76. package/dist-cjs/video/index.js +46 -3
  77. package/dist-cjs/video/mock.js +3 -1
  78. package/dist-cjs/video/runway.d.ts +2 -0
  79. package/dist-cjs/video/runway.js +45 -11
  80. package/package.json +1 -1
package/API_STABILITY.md CHANGED
@@ -41,6 +41,41 @@ Both are covered by this policy:
41
41
  Deep imports into `dist`, `dist-cjs`, or `src` are not supported. Import only from `nexus-ai-pro` or
42
42
  one of its explicit subpaths.
43
43
 
44
+ ## 2.18 stage (2.18.0)
45
+
46
+ Everything 2.18 adds is stable from the start and additive, unless this section says otherwise.
47
+
48
+ New and stable:
49
+ - in `nexus-ai-pro/tracing`: `RunEvent`, `RunLink`, `RunAttributeValue`, `RunEventInput`, `RunLinkInput`,
50
+ `Run.events`, `droppedEvents`, and `links`, `Tracer.event()`, `RunHandle.event()` and `link()`,
51
+ `StartRunOptions.links`, `FinishRunOptions.events` and `links`, `TracerOptions.maxEventsPerRun`, and
52
+ the event names `retry`, `failover`, `progress`, `guardrail`, `cache`, and `capability_warning`;
53
+ - in `nexus-ai-pro/tracing/otlp`: `OtlpSpanEvent`, `OtlpSpanLink`, and `OtlpSpan.events`,
54
+ `droppedEventsCount`, and `links`;
55
+ - in the root: `ResponseAttempt` and `ResponseMeta.failedAttempts`;
56
+ - in `nexus-ai-pro/evaluate/media`: `readMediaDuration()` and `MediaDuration`;
57
+ - in `nexus-ai-pro/video`, experimental with the family: `VideoModelCapabilities.maxInputVideoSeconds`;
58
+ - in `nexus-ai-pro/server/regions`: `RegionalGatewayOptions.lists`, `RegionListGap`, and a merged
59
+ list's `regions`, `incomplete`, and `nextCursor`;
60
+ - in `nexus-ai-pro/server`: `ListPage`, `RunManager.listThreads()` and `listRuns()`, and the lists'
61
+ `cursor` and `nextCursor`;
62
+ - in `nexus-ai-pro/mcp`: `McpClientOptions.reconnect`, `McpClient.onToolsChanged()`, and
63
+ `SseClientOptions.reopen`;
64
+ - in `nexus-ai-pro/video`, experimental with the family: `VideoGenerateRequest.referenceVideos` and
65
+ `VideoProviderRequest.referenceVideos`, the `reference-videos` input kind,
66
+ `VideoModelCapabilities.maxReferenceVideos` and `referenceVideoSeconds`, and
67
+ `VideoConformanceOptions.referenceVideo`;
68
+ - in `nexus-ai-pro/video/google`, experimental with the family:
69
+ `GoogleVideoProviderConfig.interactionsBaseUrl`, and Gemini Omni Flash on Vertex AI.
70
+
71
+ Changed on purpose:
72
+ - a list through the regional gateway is merged from every region the tenant may reach;
73
+ `lists: 'home'` keeps the earlier answer;
74
+ - an agent server's lists are ordered by last write, then id, and a `limit` that is not a whole
75
+ number above 0 is refused;
76
+ - `McpServer` says `tools.listChanged`, and `register()` sends the notification;
77
+ - `VideoInputKind` widens with `reference-videos`.
78
+
44
79
  ## 2.17 stage (2.17.0)
45
80
 
46
81
  Everything 2.17 adds is stable from the start and additive, unless this section says otherwise.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,82 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.18.0] - 2026-10-22
8
+
9
+ Span events and links, media that knows its length, a tenant's threads and runs listed and paged
10
+ across all of its regions, MCP servers that reconnect and change their tools mid-session, and videos
11
+ made after reference videos.
12
+
13
+ ### Added
14
+
15
+ - **Span events and links.** A run records what happened inside it as `RunEvent` values, each at its
16
+ time, and the runs it came from beside its parent as `RunLink` values. `Tracer.event()` records on
17
+ the run in scope, `RunHandle.event()` and `link()` on one run; `StartRunOptions.links` take a link
18
+ or a W3C `traceparent`, and `FinishRunOptions` take the `events` and `links` a run learns as it
19
+ ends. `maxEventsPerRun` (128) keeps a run's events bounded, counting the rest as `droppedEvents`.
20
+ Both reach OTLP as span events and links, in JSON, in protobuf, and over gRPC. The studio shows them
21
+ on a run, each link opening the run it names, and `#/traces/<trace>/<run>` opens one run of a trace.
22
+ - **Events where they happen.** A graph's retry is a `retry` event of its task's run, a tool's
23
+ progress a `progress` event of the tool's, and a task after a step of several, such as a fan-out's
24
+ join, links to each of them. A model run has the attempts that failed before its answer, as `retry`
25
+ and `failover` at the times they failed, and its guardrails, cache answer, and capability warnings.
26
+ - **Failed attempts on the response.** `ResponseMeta.failedAttempts` lists each `ResponseAttempt` that
27
+ failed before the answer: its provider, model, error, category, and time; a stream's last chunk
28
+ carries them too.
29
+ - **Media that knows its length.** `readMediaDuration()`, on `nexus-ai-pro/evaluate/media`, reads the
30
+ length of WAV, MP4 and MOV (fragmented MP4 included), WebM and Matroska, MP3, Ogg Vorbis and Opus,
31
+ and FLAC from their own headers, with no dependency. `mediaChecks()` holds audio and video to a
32
+ `durationMs` range with it, and a video model's `maxInputVideoSeconds` refuses a longer video before
33
+ it is sent: 10 seconds on Gemini Omni Flash, 30 on Veo.
34
+ - **Lists across regions.** The regional gateway answers `GET /threads` and `GET /runs` from every
35
+ region the tenant may reach, merged newest first and cut to the limit, naming the regions that
36
+ answered and, as `RegionListGap` values under `incomplete`, those that were down or failed. A
37
+ refusal every region gives is the answer. `lists: 'home'` keeps the home region's list alone.
38
+ - **Lists that page.** An agent server's `GET /threads`, `GET /runs`, and `GET /threads/:id/runs`
39
+ take a `cursor` and answer with a `nextCursor` while more follow; `RunManager.listThreads()` and
40
+ `listRuns()` return a `ListPage`. A cursor names a place in the order every region lists by, so the
41
+ regional gateway pages a merged list with it too, each record once, in order, however the records
42
+ fall between regions. A region that does not page by it contributes only what comes after it.
43
+ - **MCP that reconnects.** With `McpClientOptions.reconnect`, a client whose connection closed opens
44
+ it again at the next request and greets the server again: an SSE stream that ended, with
45
+ `SseClientOptions.reopen`, or a stdio server that exited, started again. A stream that cannot open,
46
+ or names no endpoint in time, is let go at once, so the next request tries again.
47
+ - **Tools that change.** `McpClient.onToolsChanged()` hears a server's
48
+ `notifications/tools/list_changed`. `McpServer.register()` sends one to a connected client, and the
49
+ server says it will. The editor bridge reconnects an editor's SSE servers within a session and lists
50
+ a server's tools again before the turn after it says they changed, keeping the last list when that
51
+ fails.
52
+ - **Reference videos.** `VideoGenerateRequest.referenceVideos` gives a model videos to take after.
53
+ Models declare `reference-videos` among their inputs, with `maxReferenceVideos` and
54
+ `referenceVideoSeconds`, each and together, read from a video's headers before it is sent. Gemini
55
+ Omni Flash takes three of three seconds each, as does Runway's `gemini_omni_flash_1.1`; Runway's
56
+ Seedance 2 models, `hailuo3`, and `wan3` take 15 seconds together, beside reference images. The
57
+ conformance suite checks them with `referenceVideo`.
58
+ - **Gemini Omni Flash on Vertex AI.** `GoogleVideoProvider` serves `gemini-omni-1.1-flash-preview` on
59
+ Vertex AI's Interactions API, at the global location for the project its `baseUrl` names, or at
60
+ `interactionsBaseUrl`.
61
+
62
+ ### Changed
63
+
64
+ - A list of threads or runs through the regional gateway comes from every region the tenant may
65
+ reach, not its home region alone; `RegionalGatewayOptions.lists: 'home'` keeps the 2.17 answer.
66
+ - An agent server lists threads and runs by when each was last written, then by id, the order its
67
+ cursor pages by. A `limit` that is not a whole number above 0 is refused with a 400.
68
+ - `McpServer` says its tools change, as `tools.listChanged`, and `register()` tells a connected
69
+ client.
70
+ - `VideoInputKind` has `reference-videos`; a `switch` over it that names every kind needs the new one.
71
+ - Tested on Node.js 26 as well as 22 and 24: CI runs the release gate on each, on the newest patch of
72
+ its line, so a Node.js security release, such as 2026-10-21's, is what the package is tested and
73
+ published on the day it ships.
74
+
75
+ ### Fixed
76
+
77
+ - A tenant's threads and runs on a server several tenants share: a list read the newest records of
78
+ every tenant and then kept the tenant's, so another tenant's newer ones could crowd its page out,
79
+ down to an empty one. A list now reads until the tenant's page is full.
80
+ - The studio showed a run's metadata, its feedback, and a tool's progress as
81
+ `[object HTMLHeadingElement]` rather than their content.
82
+
7
83
  ## [2.17.0] - 2026-10-21
8
84
 
9
85
  The editor's MCP servers over every transport, decided by what their servers say they do; and video
@@ -3103,7 +3179,8 @@ shared policy vocabulary is in place for a future release that revisits this.
3103
3179
  - URL fetching rejects unsafe private-network targets and limits response reads.
3104
3180
  - CLI and audit findings no longer disclose detected secret values.
3105
3181
 
3106
- [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.17.0...HEAD
3182
+ [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.18.0...HEAD
3183
+ [2.18.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.17.0...v2.18.0
3107
3184
  [2.17.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.16.0...v2.17.0
3108
3185
  [2.16.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.15.1...v2.16.0
3109
3186
  [2.15.1]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.15.0...v2.15.1
package/README.md CHANGED
@@ -327,12 +327,16 @@ Shipped so far:
327
327
  as it is written;
328
328
  - 2.17 the editor's MCP servers over every transport, and video you can extend or edit: an ACP agent
329
329
  connects stdio, HTTP, and SSE servers and decides each tool by what its server says it does, and a
330
- video is extended from its own job or edited on Gemini Omni Flash, Veo, and Runway.
330
+ video is extended from its own job or edited on Gemini Omni Flash, Veo, and Runway;
331
+ - 2.18 span events and links, as OpenTelemetry has them, media that knows its length from its own
332
+ headers, a tenant's threads and runs listed and paged across all of its regions, MCP servers that
333
+ reconnect and change their tools within a session, and videos made after reference videos.
331
334
 
332
335
  Upgrading from 1.x: `npx nexus migrate src --write` moves your imports, and
333
336
  [MIGRATING.md](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/MIGRATING.md) covers the rest.
334
- Next, 2.18 records events and links on spans, as OpenTelemetry has them, and reads the length of
335
- compressed audio and video, so an evaluation holds a clip to its duration. Pinecone, images, the Gemini
337
+ Next, 2.19 reads a service's own API description into emulated tools, and lets an emulator hold
338
+ what a whole evaluation did, so examples that build on each other are evaluated with nothing sent.
339
+ Pinecone, images, the Gemini
336
340
  embedding batch tier, media understanding, video, Gemini Live, SIP, and both phone providers
337
341
  graduate once the live workflow passes against each. The
338
342
  [roadmap](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/ROADMAP.md) lists what comes after, in the order
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { type JudgeClient } from '../evals/judge.js';
7
7
  import type { EvaluationContext, Evaluator, MediaPart } from '../types/evaluate.js';
8
+ export { type MediaDuration, readMediaDuration } from '../utils/media-duration.js';
8
9
  /** What kind of medium something is. */
9
10
  export type MediaSubjectKind = 'image' | 'audio' | 'video' | 'document' | 'other';
10
11
  /** A medium read for judging: what it is, and as much of it as could be read. */
@@ -27,7 +28,10 @@ export interface ResolvedMedia {
27
28
  width?: number;
28
29
  /** Height in pixels. */
29
30
  height?: number;
30
- /** Duration in milliseconds, read from a WAV header. */
31
+ /**
32
+ * Duration in milliseconds, read from the file's own headers: WAV, MP4 and MOV, WebM and Matroska,
33
+ * MP3, Ogg (Vorbis and Opus), and FLAC.
34
+ */
31
35
  durationMs?: number;
32
36
  /** Why its bytes could not be read, when they could not. */
33
37
  unreadable?: string;
@@ -93,7 +97,7 @@ export interface MediaCheckOptions extends MediaResolveOptions, MediaSelection {
93
97
  ratio: number;
94
98
  tolerance?: number;
95
99
  };
96
- /** The duration in milliseconds allowed, for audio whose header says it. */
100
+ /** The duration in milliseconds allowed, for audio or video whose headers say it. */
97
101
  durationMs?: MediaRange;
98
102
  /** A medium whose measurement could not be read passes or fails that check. Defaults to `fail`. */
99
103
  unknown?: 'pass' | 'fail';
@@ -4,6 +4,8 @@ import { decodeBase64, encodeBase64 } from '../utils/base64.js';
4
4
  import { sha256Hex } from '../utils/sha256.js';
5
5
  import { withProvenance } from './provenance.js';
6
6
  import { asMediaPart, mediaParts } from './trace.js';
7
+ import { readMediaDuration } from '../utils/media-duration.js';
8
+ export { readMediaDuration } from '../utils/media-duration.js';
7
9
  export function mediaOf(context, from = 'output') {
8
10
  const traced = (direction) => (context.trace?.media ?? []).filter((item) => item.direction === direction).map((item) => item.part);
9
11
  const output = () => {
@@ -65,11 +67,11 @@ export async function resolveMedia(part, options = {}) {
65
67
  resolved.kind = 'document';
66
68
  }
67
69
  else {
68
- const wav = wavDuration(bytes);
69
- if (wav !== undefined) {
70
- resolved.mimeType = 'audio/wav';
71
- resolved.kind = 'audio';
72
- resolved.durationMs = wav;
70
+ const read = readMediaDuration(bytes);
71
+ if (read) {
72
+ resolved.mimeType = read.mimeType;
73
+ resolved.kind = read.kind;
74
+ resolved.durationMs = read.durationMs;
73
75
  }
74
76
  }
75
77
  }
@@ -410,22 +412,6 @@ function mimeMatches(pattern, mimeType) {
410
412
  return mimeType.startsWith(pattern.slice(0, -1));
411
413
  return pattern === mimeType;
412
414
  }
413
- function wavDuration(bytes) {
414
- if (!startsWith(bytes, 'RIFF') || ascii(bytes, 8, 4) !== 'WAVE')
415
- return undefined;
416
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
417
- let byteRate;
418
- for (let at = 12; at + 8 <= bytes.length;) {
419
- const id = ascii(bytes, at, 4);
420
- const size = view.getUint32(at + 4, true);
421
- if (id === 'fmt ' && at + 16 <= bytes.length)
422
- byteRate = view.getUint32(at + 16, true);
423
- if (id === 'data')
424
- return byteRate ? Math.round((size / byteRate) * 1000) : undefined;
425
- at += 8 + size + (size % 2);
426
- }
427
- return undefined;
428
- }
429
415
  function startsWith(bytes, text) {
430
416
  return ascii(bytes, 0, text.length) === text;
431
417
  }
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export { tool, toolOutput } from './agent/tool.js';
20
20
  export type { CacheHint, CompletionRequest, Message, MessageRole, ContentPart, PromptCacheConfig, ReasoningConfig, TextContent, ImageContent, AudioContent, BinaryBuffer, VideoContent, AssetContent, ToolChoice, ToolDefinition, ToolCallResult, ToolOutput, } from './types/messages.js';
21
21
  export type { CapabilityConfig, CapabilityPolicy, CapabilityWarning, CapabilityWarningAction, } from './types/capabilities.js';
22
22
  export type { CostEstimate, NexusPlan, } from './types/planning.js';
23
- export type { NexusResponse, NexusStream, ResponseCost, ResponseMeta, StreamChunk, TokenUsage, ToolCall, } from './types/response.js';
23
+ export type { NexusResponse, NexusStream, ResponseAttempt, ResponseCost, ResponseMeta, StreamChunk, TokenUsage, ToolCall, } from './types/response.js';
24
24
  export type { NexusAIConfig, ProvidersConfig, OpenAIProviderConfig, AnthropicProviderConfig, GoogleProviderConfig, OllamaProviderConfig, GroqProviderConfig, MistralProviderConfig, CohereProviderConfig, CustomProviderConfig, DeepSeekProviderConfig, AzureOpenAIProviderConfig, LMStudioProviderConfig, LlamaCppProviderConfig, RoutingConfig, RoutingRule, RoutingStrategy, FallbackConfig, ResponseFormatConfig, RetryConfig, CostBudgetConfig, AuditLogConfig, AuditLogEvent, LoggerConfig, LogEvent, LogLevel, RateLimitConfig, } from './types/config.js';
25
25
  export type { ContextSummaryConfig, ContextSummaryInput, ContextSummaryMode, ContextSummarizer, ContextWindowConfig, ContextWindowResult, ContextWindowStrategy, ContextWindowUsage, } from './types/context-window.js';
26
26
  export type { AliasMetadata, AliasStage, CacheTtl, InputModality, ModelCapabilities, ModelEndpoint, ModelStatus, OutputModality, PromptCachingCapability, ProviderCapabilities, ReasoningEffort, RoutingModelPreference, } from './types/providers.js';
@@ -91,6 +91,14 @@ export interface McpClientOptions {
91
91
  };
92
92
  /** How long a request waits before failing. Defaults to 30 seconds. */
93
93
  timeoutMs?: number;
94
+ /**
95
+ * When the transport closes, as an SSE stream that ends or a server process that exits does, the
96
+ * next request opens it again and repeats the handshake, since the server's session went with it.
97
+ * Requests in flight still fail, and a client closed with `close()` stays closed. Needs a transport
98
+ * that can open again: `createStdioTransport` starts a new process, `createSseTransport` with
99
+ * `reopen` a new stream.
100
+ */
101
+ reconnect?: boolean;
94
102
  }
95
103
  /**
96
104
  * Talks to an MCP server.
@@ -109,7 +117,15 @@ export declare class McpClient {
109
117
  private initialized;
110
118
  private connecting;
111
119
  private serverInfo;
120
+ /** Who hears that the server's tools changed. */
121
+ private readonly toolsChanged;
122
+ private stopped;
112
123
  constructor(transport: McpTransport, options?: McpClientOptions);
124
+ /**
125
+ * Hears each `notifications/tools/list_changed` the server sends: its tools are no longer what
126
+ * `listTools()` returned. Returns a function that stops hearing.
127
+ */
128
+ onToolsChanged(handler: () => void): () => void;
113
129
  /**
114
130
  * Performs the handshake and returns the server's name and version. Called automatically by the
115
131
  * first request that needs it; requests made together share one handshake.
@@ -190,7 +206,8 @@ export declare function serverEnvironment(source: Record<string, string | undefi
190
206
  * Runs an MCP server as a child process and speaks to it over its stdin and stdout.
191
207
  *
192
208
  * The transport most MCP servers ship with. `node:child_process` is imported lazily, so a browser
193
- * or edge build that only uses the HTTP transport never reaches for it.
209
+ * or edge build that only uses the HTTP transport never reaches for it. When the process exits, the
210
+ * transport closes; started again, it runs a new one.
194
211
  */
195
212
  export declare function createStdioTransport(options: StdioClientOptions): McpTransport;
196
213
  /** Connects to a remote MCP server over HTTP. */
@@ -219,11 +236,17 @@ export interface SseClientOptions {
219
236
  fetch?: typeof globalThis.fetch;
220
237
  /** How long to wait for the server to name where messages go, in milliseconds. Defaults to 10 seconds. */
221
238
  timeoutMs?: number;
239
+ /**
240
+ * Opens a new stream when the next message is sent after the stream ended, rather than staying
241
+ * closed. The server starts a new session on it, so an `McpClient` with `reconnect` greets it again.
242
+ */
243
+ reopen?: boolean;
222
244
  }
223
245
  /**
224
246
  * Speaks to an MCP server over HTTP with Server-Sent Events, as MCP's 2024-11-05 transport: one
225
247
  * long `GET` the server answers on, and a `POST` per message to the endpoint the stream names
226
248
  * first. The endpoint must be on the stream's own origin, so a server cannot send the messages, and
227
- * their headers, anywhere else. When the stream ends, the transport closes.
249
+ * their headers, anywhere else. When the stream ends, the transport closes; with `reopen`, the next
250
+ * message opens a new one.
228
251
  */
229
252
  export declare function createSseTransport(options: SseClientOptions): McpTransport;
@@ -15,11 +15,23 @@ export class McpClient {
15
15
  initialized = false;
16
16
  connecting;
17
17
  serverInfo = {};
18
+ toolsChanged = new Set();
19
+ stopped = false;
18
20
  constructor(transport, options = {}) {
19
21
  this.transport = transport;
20
22
  this.options = options;
21
23
  transport.onMessage((message) => this.receive(message));
22
- transport.onClose?.(() => this.failAll(new McpError('The MCP connection closed')));
24
+ transport.onClose?.(() => {
25
+ this.failAll(new McpError('The MCP connection closed'));
26
+ if (this.options.reconnect && !this.stopped)
27
+ this.initialized = false;
28
+ });
29
+ }
30
+ onToolsChanged(handler) {
31
+ this.toolsChanged.add(handler);
32
+ return () => {
33
+ this.toolsChanged.delete(handler);
34
+ };
23
35
  }
24
36
  async connect() {
25
37
  if (this.initialized)
@@ -116,6 +128,7 @@ export class McpClient {
116
128
  });
117
129
  }
118
130
  async close() {
131
+ this.stopped = true;
119
132
  this.failAll(new McpError('The MCP client was closed'));
120
133
  await this.transport.close();
121
134
  }
@@ -141,6 +154,16 @@ export class McpClient {
141
154
  this.settle(message.id, message.result, message.error ? new McpError(message.error.message, message.error.code, message.error.data) : undefined);
142
155
  return;
143
156
  }
157
+ if (isNotification(message) && message.method === 'notifications/tools/list_changed') {
158
+ for (const handler of this.toolsChanged) {
159
+ try {
160
+ handler();
161
+ }
162
+ catch {
163
+ }
164
+ }
165
+ return;
166
+ }
144
167
  if (isNotification(message) && message.method === 'notifications/progress') {
145
168
  const params = (message.params ?? {});
146
169
  const report = this.progress.get(String(params.progressToken));
@@ -235,11 +258,11 @@ export function serverEnvironment(source, options = {}) {
235
258
  }
236
259
  export function createStdioTransport(options) {
237
260
  let child;
238
- const decoder = new LineDecoder();
239
261
  let onMessage = () => undefined;
240
262
  let onClose = () => undefined;
241
263
  return {
242
264
  async start() {
265
+ const decoder = new LineDecoder();
243
266
  const { spawn } = await import('node:child_process');
244
267
  const spawned = spawn(options.command, options.args ?? [], {
245
268
  cwd: options.cwd,
@@ -323,62 +346,73 @@ export function createSseTransport(options) {
323
346
  let endpoint;
324
347
  let opening;
325
348
  let closed = false;
326
- const stream = new AbortController();
327
- const ended = () => {
328
- if (closed)
349
+ let stopped = false;
350
+ let stream = new AbortController();
351
+ const ended = (from) => {
352
+ if (closed || from !== stream)
329
353
  return;
330
354
  closed = true;
331
355
  for (const handler of onClose)
332
356
  handler();
333
357
  };
334
358
  const open = async () => {
335
- const response = await fetchImplementation(options.url, {
336
- headers: { accept: 'text/event-stream', ...options.headers },
337
- signal: stream.signal,
338
- });
339
- if (!response.ok || !response.body)
340
- throw new McpError(`MCP SSE transport failed with ${response.status}`);
341
- const reader = response.body.getReader();
342
- await new Promise((resolve, reject) => {
343
- const timer = setTimeout(() => {
344
- reject(new McpError(`The MCP server named no endpoint within ${options.timeoutMs ?? 10_000}ms`));
345
- stream.abort();
346
- }, options.timeoutMs ?? 10_000);
347
- const event = (name, data) => {
348
- if (name === 'endpoint') {
349
- if (endpoint)
350
- return;
351
- const target = new URL(data.trim(), options.url);
352
- if (target.origin !== new URL(options.url).origin) {
359
+ const own = new AbortController();
360
+ stream = own;
361
+ try {
362
+ const response = await fetchImplementation(options.url, {
363
+ headers: { accept: 'text/event-stream', ...options.headers },
364
+ signal: own.signal,
365
+ });
366
+ if (!response.ok || !response.body)
367
+ throw new McpError(`MCP SSE transport failed with ${response.status}`);
368
+ const reader = response.body.getReader();
369
+ await new Promise((resolve, reject) => {
370
+ const timer = setTimeout(() => reject(new McpError(`The MCP server named no endpoint within ${options.timeoutMs ?? 10_000}ms`)), options.timeoutMs ?? 10_000);
371
+ const event = (name, data) => {
372
+ if (name === 'endpoint') {
373
+ if (endpoint)
374
+ return;
375
+ const target = new URL(data.trim(), options.url);
376
+ if (target.origin !== new URL(options.url).origin) {
377
+ clearTimeout(timer);
378
+ reject(new McpError(`The MCP server named an endpoint on another origin, ${target.origin}`));
379
+ return;
380
+ }
381
+ endpoint = target.href;
353
382
  clearTimeout(timer);
354
- reject(new McpError(`The MCP server named an endpoint on another origin, ${target.origin}`));
355
- stream.abort();
356
- return;
383
+ resolve();
357
384
  }
358
- endpoint = target.href;
359
- clearTimeout(timer);
360
- resolve();
361
- }
362
- else if (name === 'message') {
363
- try {
364
- onMessage(JSON.parse(data));
385
+ else if (name === 'message') {
386
+ try {
387
+ onMessage(JSON.parse(data));
388
+ }
389
+ catch {
390
+ }
365
391
  }
366
- catch {
367
- }
368
- }
369
- };
370
- void readEvents(reader, event).then(() => {
371
- clearTimeout(timer);
372
- reject(new McpError('The MCP event stream ended'));
373
- ended();
374
- }, (error) => {
375
- clearTimeout(timer);
376
- reject(error instanceof McpError ? error : new McpError(`The MCP event stream failed: ${String(error)}`));
377
- ended();
392
+ };
393
+ void readEvents(reader, event).then(() => {
394
+ clearTimeout(timer);
395
+ reject(new McpError('The MCP event stream ended'));
396
+ ended(own);
397
+ }, (error) => {
398
+ clearTimeout(timer);
399
+ reject(error instanceof McpError ? error : new McpError(`The MCP event stream failed: ${String(error)}`));
400
+ ended(own);
401
+ });
378
402
  });
379
- });
403
+ }
404
+ catch (error) {
405
+ own.abort();
406
+ ended(own);
407
+ throw error;
408
+ }
380
409
  };
381
410
  const started = () => {
411
+ if (closed && options.reopen && !stopped) {
412
+ closed = false;
413
+ endpoint = undefined;
414
+ opening = undefined;
415
+ }
382
416
  opening ??= open();
383
417
  return opening;
384
418
  };
@@ -404,8 +438,9 @@ export function createSseTransport(options) {
404
438
  onClose.push(handler);
405
439
  },
406
440
  close() {
441
+ stopped = true;
407
442
  stream.abort();
408
- ended();
443
+ ended(stream);
409
444
  },
410
445
  };
411
446
  }
@@ -41,7 +41,7 @@ export declare class McpServer {
41
41
  private readonly tools;
42
42
  private transport;
43
43
  constructor(options?: McpServerOptions);
44
- /** Adds a tool. Returns the server, for chaining. */
44
+ /** Adds a tool, and tells a connected client its tools changed. Returns the server, for chaining. */
45
45
  register(tool: ToolDefinition): this;
46
46
  /** Starts serving requests over a transport. */
47
47
  connect(transport: McpTransport): Promise<void>;
@@ -25,6 +25,9 @@ export class McpServer {
25
25
  }
26
26
  register(tool) {
27
27
  this.tools.set(tool.name, tool);
28
+ if (this.transport) {
29
+ void Promise.resolve(this.transport.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' })).catch(() => undefined);
30
+ }
28
31
  return this;
29
32
  }
30
33
  async connect(transport) {
@@ -63,7 +66,7 @@ export class McpServer {
63
66
  return {
64
67
  protocolVersion: MCP_PROTOCOL_VERSION,
65
68
  capabilities: {
66
- tools: { listChanged: false },
69
+ tools: { listChanged: true },
67
70
  ...(this.options.resources?.length ? { resources: { listChanged: false } } : {}),
68
71
  },
69
72
  serverInfo: { name: this.options.name ?? 'nexus-ai-pro', version: this.options.version ?? '1.x' },
@@ -7,8 +7,7 @@ export function acpEditor(options = {}) {
7
7
  let connected = servers.get(session.id);
8
8
  if (!connected) {
9
9
  connected = (async () => {
10
- const clients = [];
11
- const tools = [];
10
+ const entries = [];
12
11
  const declare = options.mcpToolCapabilities ?? true;
13
12
  for (const offered of session.mcpServers) {
14
13
  const name = String(offered.name ?? 'mcp');
@@ -21,6 +20,7 @@ export function acpEditor(options = {}) {
21
20
  ...(options.mcpFetch ? { fetch: options.mcpFetch } : {}),
22
21
  }
23
22
  : undefined;
23
+ const sse = remote && offered.type === 'sse';
24
24
  const transport = offered.command
25
25
  ? createStdioTransport({
26
26
  command: offered.command,
@@ -28,26 +28,40 @@ export function acpEditor(options = {}) {
28
28
  env: Object.fromEntries((offered.env ?? []).map((item) => [item.name, item.value])),
29
29
  ...(session.cwd ? { cwd: session.cwd } : {}),
30
30
  })
31
- : remote && offered.type === 'sse'
32
- ? createSseTransport({ ...remote, timeoutMs: options.mcpTimeoutMs ?? 30_000 })
31
+ : sse
32
+ ?
33
+ createSseTransport({ ...remote, timeoutMs: options.mcpTimeoutMs ?? 30_000, reopen: true })
33
34
  : remote && (offered.type === 'http' || offered.type === undefined)
34
35
  ? createHttpTransport(remote)
35
36
  : undefined;
36
37
  if (!transport)
37
38
  throw new Error(`MCP server "${name}" uses a transport this agent does not speak`);
38
- const client = new McpClient(transport, { timeoutMs: options.mcpTimeoutMs ?? 30_000 });
39
- clients.push(client);
40
- tools.push(...(await client.toNexusTools({
41
- prefix,
42
- server: prefix,
43
- capabilities: typeof declare === 'function' ? (tool) => declare(tool, prefix) : declare,
44
- })));
39
+ const client = new McpClient(transport, {
40
+ timeoutMs: options.mcpTimeoutMs ?? 30_000,
41
+ ...(sse ? { reconnect: true } : {}),
42
+ });
43
+ const entry = {
44
+ name,
45
+ client,
46
+ tools: [],
47
+ stale: false,
48
+ list: () => client.toNexusTools({
49
+ prefix,
50
+ server: prefix,
51
+ capabilities: typeof declare === 'function' ? (tool) => declare(tool, prefix) : declare,
52
+ }),
53
+ };
54
+ entries.push(entry);
55
+ client.onToolsChanged(() => {
56
+ entry.stale = true;
57
+ });
58
+ entry.tools = await entry.list();
45
59
  }
46
60
  catch (error) {
47
61
  options.onError?.(error, name);
48
62
  }
49
63
  }
50
- return { clients, tools };
64
+ return entries;
51
65
  })();
52
66
  servers.set(session.id, connected);
53
67
  }
@@ -155,13 +169,26 @@ export function acpEditor(options = {}) {
155
169
  const own = tools(session);
156
170
  if (options.mcp === false || session.mcpServers.length === 0)
157
171
  return own;
158
- return [...own, ...(await mcpTools(session)).tools];
172
+ const entries = await mcpTools(session);
173
+ await Promise.all(entries
174
+ .filter((entry) => entry.stale)
175
+ .map(async (entry) => {
176
+ entry.stale = false;
177
+ try {
178
+ entry.tools = await entry.list();
179
+ }
180
+ catch (error) {
181
+ entry.stale = true;
182
+ options.onError?.(error, entry.name);
183
+ }
184
+ }));
185
+ return [...own, ...entries.flatMap((entry) => entry.tools)];
159
186
  },
160
187
  async close() {
161
188
  const connected = [...servers.values()];
162
189
  servers.clear();
163
- for (const { clients } of await Promise.all(connected)) {
164
- await Promise.all(clients.map((client) => client.close().catch(() => undefined)));
190
+ for (const entries of await Promise.all(connected)) {
191
+ await Promise.all(entries.map((entry) => entry.client.close().catch(() => undefined)));
165
192
  }
166
193
  },
167
194
  };