@wrongstack/mcp 0.306.4 → 0.307.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.
package/dist/index.js CHANGED
@@ -3469,7 +3469,7 @@ function percentile(sorted, ratio) {
3469
3469
  }
3470
3470
 
3471
3471
  // src/registry.ts
3472
- import { expectDefined } from "@wrongstack/core/utils";
3472
+ import { expectDefined as expectDefined2 } from "@wrongstack/core/utils";
3473
3473
 
3474
3474
  // src/registry-catalog.ts
3475
3475
  var MAX_CATALOG_PAGES = 100;
@@ -3695,6 +3695,9 @@ function scheduleRegistryReconnect({
3695
3695
  }, delay);
3696
3696
  }
3697
3697
 
3698
+ // src/registry-connect-loop.ts
3699
+ import { expectDefined } from "@wrongstack/core/utils";
3700
+
3698
3701
  // src/wrap-tool.ts
3699
3702
  import { ToolCapabilities } from "@wrongstack/core/security";
3700
3703
  import { mcpQualifiedToolName } from "@wrongstack/core/utils";
@@ -3761,6 +3764,274 @@ function stringify(c) {
3761
3764
  return String(c ?? "");
3762
3765
  }
3763
3766
 
3767
+ // src/registry-connect-loop.ts
3768
+ function applySlotTools(ctx, slot, tools, client) {
3769
+ if (slot.lazy && slot.registeredLazy && !ctx.lazyMode) return;
3770
+ const allowed = slot.cfg.allowedTools;
3771
+ const filtered = tools.filter((t) => !allowed || allowed.includes(t.name));
3772
+ const clientArg = slot.lazy ? () => ctx.ensureConnected(slot.cfg.name) : expectDefined(client);
3773
+ const wrapped = filtered.map(
3774
+ (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
3775
+ onStart: () => {
3776
+ slot.operations.inFlightCalls++;
3777
+ slot.operations.peakInFlightCalls = Math.max(
3778
+ slot.operations.peakInFlightCalls,
3779
+ slot.operations.inFlightCalls
3780
+ );
3781
+ ctx.recordOperation(slot, "call", "started", void 0, void 0, false);
3782
+ },
3783
+ onFinish: ({ durationMs, ok }) => {
3784
+ slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
3785
+ pushBounded(
3786
+ slot.operations.callSamples,
3787
+ durationMs,
3788
+ MCP_OPERATION_LIMITS.LATENCY_SAMPLES
3789
+ );
3790
+ if (ok) {
3791
+ ctx.recordSuccess(slot);
3792
+ ctx.recordOperation(slot, "call", "ok", void 0, durationMs, false);
3793
+ } else {
3794
+ ctx.recordFailure(slot, "tool", "tool-call-failed", durationMs);
3795
+ }
3796
+ }
3797
+ })
3798
+ );
3799
+ if (ctx.lazyMode) {
3800
+ slot.lazyTools = wrapped;
3801
+ return;
3802
+ }
3803
+ for (const tool of wrapped) {
3804
+ try {
3805
+ ctx.toolRegistry.register(tool, `mcp:${slot.cfg.name}`);
3806
+ slot.toolNames.push(tool.name);
3807
+ } catch (err) {
3808
+ ctx.log.warn(`MCP tool "${tool.name}" not registered`, err);
3809
+ }
3810
+ }
3811
+ if (slot.lazy && wrapped.length > 0) slot.registeredLazy = true;
3812
+ }
3813
+ async function discoverSlotCapabilities(ctx, slot, client) {
3814
+ const startedAt = Date.now();
3815
+ slot.serverMetadata = client.getServerMetadata();
3816
+ const capabilities = slot.serverMetadata?.capabilities;
3817
+ if (capabilities?.resources) {
3818
+ try {
3819
+ slot.resources = await collectCatalogPages(
3820
+ (cursor) => client.listResources(cursor ? { cursor } : {}),
3821
+ (page) => page.resources
3822
+ );
3823
+ } catch (err) {
3824
+ slot.resources = void 0;
3825
+ ctx.recordFailure(slot, "protocol", "resource-discovery-failed");
3826
+ ctx.log.warn(`MCP server "${slot.cfg.name}" resource discovery failed`, err);
3827
+ }
3828
+ try {
3829
+ slot.resourceTemplates = await collectCatalogPages(
3830
+ (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
3831
+ (page) => page.resourceTemplates
3832
+ );
3833
+ } catch (err) {
3834
+ slot.resourceTemplates = void 0;
3835
+ ctx.recordFailure(slot, "protocol", "resource-template-discovery-failed");
3836
+ ctx.log.warn(`MCP server "${slot.cfg.name}" resource template discovery failed`, err);
3837
+ }
3838
+ } else {
3839
+ slot.resources = void 0;
3840
+ slot.resourceTemplates = void 0;
3841
+ }
3842
+ if (capabilities?.prompts) {
3843
+ try {
3844
+ slot.prompts = await collectCatalogPages(
3845
+ (cursor) => client.listPrompts(cursor ? { cursor } : {}),
3846
+ (page) => page.prompts
3847
+ );
3848
+ } catch (err) {
3849
+ slot.prompts = void 0;
3850
+ ctx.recordFailure(slot, "protocol", "prompt-discovery-failed");
3851
+ ctx.log.warn(`MCP server "${slot.cfg.name}" prompt discovery failed`, err);
3852
+ }
3853
+ } else {
3854
+ slot.prompts = void 0;
3855
+ }
3856
+ const durationMs = Date.now() - startedAt;
3857
+ pushBounded(slot.operations.discoverySamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
3858
+ ctx.recordOperation(slot, "discover", "complete", void 0, durationMs, false);
3859
+ }
3860
+ async function persistSlotCapabilityManifest(cacheDir, slot) {
3861
+ if (!slot.lazy || !cacheDir) return;
3862
+ const previous = slot.manifestWrite ?? Promise.resolve();
3863
+ const pending = previous.then(
3864
+ () => writeCapabilityManifest(cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), {
3865
+ tools: slot.client?.listTools() ?? [],
3866
+ serverMetadata: slot.serverMetadata,
3867
+ resources: slot.resources,
3868
+ resourceTemplates: slot.resourceTemplates,
3869
+ prompts: slot.prompts
3870
+ })
3871
+ );
3872
+ slot.manifestWrite = pending;
3873
+ await pending;
3874
+ if (slot.manifestWrite === pending) slot.manifestWrite = void 0;
3875
+ }
3876
+ async function attemptConnectSlot(ctx, slot) {
3877
+ const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
3878
+ let attempt = 0;
3879
+ while (attempt < MAX_ATTEMPTS) {
3880
+ if (ctx.servers.has(slot.cfg.name) && ctx.servers.get(slot.cfg.name) !== slot) {
3881
+ return;
3882
+ }
3883
+ attempt++;
3884
+ const startedAt = Date.now();
3885
+ slot.state = attempt === 1 ? "connecting" : "reconnecting";
3886
+ slot.attempts = attempt;
3887
+ let client;
3888
+ let boundDisconnect;
3889
+ try {
3890
+ client = new MCPClient({
3891
+ name: slot.cfg.name,
3892
+ transport: slot.cfg.transport,
3893
+ command: slot.cfg.command,
3894
+ args: slot.cfg.args,
3895
+ env: slot.cfg.env,
3896
+ url: slot.cfg.url,
3897
+ headers: slot.cfg.headers,
3898
+ startupTimeoutMs: slot.cfg.startupTimeoutMs,
3899
+ requestTimeoutMs: slot.cfg.requestTimeoutMs,
3900
+ passthroughEnv: slot.cfg.passthroughEnv,
3901
+ authorizationProvider: ctx.authorizationProviderFactory?.(slot.cfg)
3902
+ });
3903
+ if (slot.cfg.transport === "stdio") {
3904
+ client.addExitListener(ctx.onChildExit);
3905
+ } else {
3906
+ boundDisconnect = () => ctx.onTransportDisconnect(slot.cfg.name);
3907
+ client.addDisconnectListener(boundDisconnect);
3908
+ }
3909
+ client.addToolsChangedListener(ctx.onToolsChanged);
3910
+ ctx.addCatalogListeners(client);
3911
+ await client.connect();
3912
+ if (slot.state === "disconnected" || ctx.servers.has(slot.cfg.name) && ctx.servers.get(slot.cfg.name) !== slot) {
3913
+ client.removeExitListener(ctx.onChildExit);
3914
+ if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
3915
+ client.removeToolsChangedListener(ctx.onToolsChanged);
3916
+ ctx.removeCatalogListeners(client);
3917
+ await client.close().catch(() => {
3918
+ });
3919
+ return;
3920
+ }
3921
+ if (slot.client && slot.client !== client) {
3922
+ const prior = slot.client;
3923
+ const priorDisconnect = slot.onDisconnect;
3924
+ slot.client.removeExitListener(ctx.onChildExit);
3925
+ if (priorDisconnect) prior.removeDisconnectListener(priorDisconnect);
3926
+ prior.removeToolsChangedListener(ctx.onToolsChanged);
3927
+ ctx.removeCatalogListeners(prior);
3928
+ prior.close().catch(() => {
3929
+ });
3930
+ }
3931
+ slot.client = client;
3932
+ slot.onDisconnect = boundDisconnect;
3933
+ const isReconnect = slot.reconnectCycles > 0 || attempt > 1;
3934
+ slot.state = "connected";
3935
+ slot.reconnectCycles = 0;
3936
+ const mc = client;
3937
+ const discovered = mc.listTools();
3938
+ await discoverSlotCapabilities(ctx, slot, mc);
3939
+ await persistSlotCapabilityManifest(ctx.cacheDir, slot);
3940
+ applySlotTools(ctx, slot, discovered, mc);
3941
+ const durationMs = Date.now() - startedAt;
3942
+ pushBounded(
3943
+ slot.operations.connectionSamples,
3944
+ durationMs,
3945
+ MCP_OPERATION_LIMITS.LATENCY_SAMPLES
3946
+ );
3947
+ ctx.recordSuccess(slot, (slot.operations.lastFailureAt ?? 0) < startedAt);
3948
+ ctx.recordOperation(
3949
+ slot,
3950
+ isReconnect ? "reconnect" : "connect",
3951
+ "connected",
3952
+ void 0,
3953
+ durationMs
3954
+ );
3955
+ slot.lastUsed = Date.now();
3956
+ if (slot.lazy) ctx.ensureIdleSweep();
3957
+ ctx.events.emit(isReconnect ? "mcp.server.reconnected" : "mcp.server.connected", {
3958
+ name: slot.cfg.name,
3959
+ toolCount: slot.toolNames.length
3960
+ });
3961
+ return;
3962
+ } catch (err) {
3963
+ ctx.recordFailure(slot, "transport", "connect-attempt-failed", Date.now() - startedAt);
3964
+ ctx.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
3965
+ if (client) {
3966
+ client.removeExitListener(ctx.onChildExit);
3967
+ if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
3968
+ client.removeToolsChangedListener(ctx.onToolsChanged);
3969
+ ctx.removeCatalogListeners(client);
3970
+ await client.close().catch(() => {
3971
+ });
3972
+ }
3973
+ if (attempt >= MAX_ATTEMPTS) {
3974
+ ctx.log.error(
3975
+ `MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`,
3976
+ err
3977
+ );
3978
+ slot.state = "failed";
3979
+ slot.client = void 0;
3980
+ if (slot.reconnectTimer) {
3981
+ clearTimeout(slot.reconnectTimer);
3982
+ slot.reconnectTimer = void 0;
3983
+ }
3984
+ slot.reconnectPending = false;
3985
+ ctx.events.emit("mcp.server.disconnected", {
3986
+ name: slot.cfg.name,
3987
+ reason: err instanceof Error ? err.message : "unknown"
3988
+ });
3989
+ return;
3990
+ }
3991
+ const delay = 500 * 2 ** attempt;
3992
+ await new Promise((r) => setTimeout(r, delay));
3993
+ if (slot.state === "disconnected" || ctx.servers.has(slot.cfg.name) && ctx.servers.get(slot.cfg.name) !== slot) {
3994
+ return;
3995
+ }
3996
+ }
3997
+ }
3998
+ }
3999
+
4000
+ // src/registry-idle.ts
4001
+ async function sleepIdleSlot(ctx, slot) {
4002
+ slot.reconnectPending = false;
4003
+ if (slot.reconnectTimer) {
4004
+ clearTimeout(slot.reconnectTimer);
4005
+ slot.reconnectTimer = void 0;
4006
+ }
4007
+ if (slot.client) {
4008
+ slot.client.removeExitListener(ctx.onChildExit);
4009
+ if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
4010
+ slot.client.removeToolsChangedListener(ctx.onToolsChanged);
4011
+ ctx.removeCatalogListeners(slot.client);
4012
+ await slot.client.close();
4013
+ slot.client = void 0;
4014
+ }
4015
+ slot.onDisconnect = void 0;
4016
+ slot.state = "dormant";
4017
+ slot.operations.sleepCount++;
4018
+ ctx.recordOperation(slot, "sleep", "idle-timeout");
4019
+ ctx.log.info(`MCP server "${slot.cfg.name}" idle \u2014 sleeping (tools stay registered)`);
4020
+ ctx.events.emit("mcp.server.disconnected", { name: slot.cfg.name, reason: "idle-sleep" });
4021
+ }
4022
+ async function sweepIdleSlots(ctx) {
4023
+ if (ctx.idleTimeoutMs <= 0) return false;
4024
+ const now = Date.now();
4025
+ for (const slot of ctx.servers.values()) {
4026
+ if (slot.lazy && slot.state === "connected" && slot.client && now - slot.lastUsed > ctx.idleTimeoutMs) {
4027
+ await sleepIdleSlot(ctx, slot);
4028
+ }
4029
+ }
4030
+ return [...ctx.servers.values()].some(
4031
+ (slot) => slot.lazy && slot.state === "connected" && slot.client
4032
+ );
4033
+ }
4034
+
3764
4035
  // src/registry.ts
3765
4036
  var MCPRegistry = class _MCPRegistry {
3766
4037
  servers = /* @__PURE__ */ new Map();
@@ -3871,7 +4142,7 @@ var MCPRegistry = class _MCPRegistry {
3871
4142
  * cache yet, do a one-time cold discovery connect to learn + cache the tools.
3872
4143
  */
3873
4144
  async startLazy(slot) {
3874
- const cacheDir = expectDefined(this.cacheDir);
4145
+ const cacheDir = expectDefined2(this.cacheDir);
3875
4146
  const hash = manifestConfigHash(slot.cfg);
3876
4147
  const cached = await readCapabilityManifest(cacheDir, slot.cfg.name, hash);
3877
4148
  if (cached) {
@@ -4118,121 +4389,45 @@ var MCPRegistry = class _MCPRegistry {
4118
4389
  toolNamesForSlot(s) {
4119
4390
  return s.toolNames.length > 0 ? s.toolNames.slice() : (s.lazyTools ?? []).map((t) => t.name);
4120
4391
  }
4121
- /**
4122
- * Wrap + register (or cache) a server's tools. Lazy servers get resolver-backed
4123
- * wrappers that spawn the process on first use; eager servers bind the live
4124
- * client directly. Honours token-saving `lazyMode` (cache, don't register) and
4125
- * a register-once guard for lazy resolver wrappers (so a wake/reconnect reuses
4126
- * the existing registrations rather than churning the tool list).
4127
- */
4128
- applyTools(slot, tools, client) {
4129
- if (slot.lazy && slot.registeredLazy && !this.lazyMode) return;
4130
- const allowed = slot.cfg.allowedTools;
4131
- const filtered = tools.filter((t) => !allowed || allowed.includes(t.name));
4132
- const clientArg = slot.lazy ? () => this.ensureConnected(slot.cfg.name) : expectDefined(client);
4133
- const wrapped = filtered.map(
4134
- (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
4135
- onStart: () => {
4136
- slot.operations.inFlightCalls++;
4137
- slot.operations.peakInFlightCalls = Math.max(
4138
- slot.operations.peakInFlightCalls,
4139
- slot.operations.inFlightCalls
4140
- );
4141
- this.recordOperation(slot, "call", "started", void 0, void 0, false);
4142
- },
4143
- onFinish: ({ durationMs, ok }) => {
4144
- slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
4145
- pushBounded(
4146
- slot.operations.callSamples,
4147
- durationMs,
4148
- MCP_OPERATION_LIMITS.LATENCY_SAMPLES
4149
- );
4150
- if (ok) {
4151
- this.recordSuccess(slot);
4152
- this.recordOperation(slot, "call", "ok", void 0, durationMs, false);
4153
- } else {
4154
- this.recordFailure(slot, "tool", "tool-call-failed", durationMs);
4155
- }
4156
- }
4157
- })
4158
- );
4159
- if (this.lazyMode) {
4160
- slot.lazyTools = wrapped;
4161
- return;
4162
- }
4163
- for (const tool of wrapped) {
4164
- try {
4165
- this.toolRegistry.register(tool, `mcp:${slot.cfg.name}`);
4166
- slot.toolNames.push(tool.name);
4167
- } catch (err) {
4168
- this.log.warn(`MCP tool "${tool.name}" not registered`, err);
4169
- }
4170
- }
4171
- if (slot.lazy && wrapped.length > 0) slot.registeredLazy = true;
4392
+ connectContext() {
4393
+ return {
4394
+ servers: this.servers,
4395
+ toolRegistry: this.toolRegistry,
4396
+ events: this.events,
4397
+ log: this.log,
4398
+ lazyMode: this.lazyMode,
4399
+ cacheDir: this.cacheDir,
4400
+ authorizationProviderFactory: this.authorizationProviderFactory,
4401
+ operationListeners: this.operationListeners,
4402
+ ensureConnected: (name) => this.ensureConnected(name),
4403
+ recordOperation: (slot, kind, reason, failureKind, durationMs, retain) => this.recordOperation(slot, kind, reason, failureKind, durationMs, retain),
4404
+ recordSuccess: (slot, resetFailures) => this.recordSuccess(slot, resetFailures),
4405
+ recordFailure: (slot, failureKind, reason, durationMs) => this.recordFailure(slot, failureKind, reason, durationMs),
4406
+ onChildExit: this.onChildExit,
4407
+ onTransportDisconnect: this.onTransportDisconnect,
4408
+ onToolsChanged: this.onToolsChanged,
4409
+ addCatalogListeners: (client) => this.addCatalogListeners(client),
4410
+ removeCatalogListeners: (client) => this.removeCatalogListeners(client),
4411
+ ensureIdleSweep: () => this.ensureIdleSweep()
4412
+ };
4172
4413
  }
4173
- async discoverCapabilities(slot, client) {
4174
- const startedAt = Date.now();
4175
- slot.serverMetadata = client.getServerMetadata();
4176
- const capabilities = slot.serverMetadata?.capabilities;
4177
- if (capabilities?.resources) {
4178
- try {
4179
- slot.resources = await collectCatalogPages(
4180
- (cursor) => client.listResources(cursor ? { cursor } : {}),
4181
- (page) => page.resources
4182
- );
4183
- } catch (err) {
4184
- slot.resources = void 0;
4185
- this.recordFailure(slot, "protocol", "resource-discovery-failed");
4186
- this.log.warn(`MCP server "${slot.cfg.name}" resource discovery failed`, err);
4187
- }
4188
- try {
4189
- slot.resourceTemplates = await collectCatalogPages(
4190
- (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
4191
- (page) => page.resourceTemplates
4192
- );
4193
- } catch (err) {
4194
- slot.resourceTemplates = void 0;
4195
- this.recordFailure(slot, "protocol", "resource-template-discovery-failed");
4196
- this.log.warn(`MCP server "${slot.cfg.name}" resource template discovery failed`, err);
4197
- }
4198
- } else {
4199
- slot.resources = void 0;
4200
- slot.resourceTemplates = void 0;
4201
- }
4202
- if (capabilities?.prompts) {
4203
- try {
4204
- slot.prompts = await collectCatalogPages(
4205
- (cursor) => client.listPrompts(cursor ? { cursor } : {}),
4206
- (page) => page.prompts
4207
- );
4208
- } catch (err) {
4209
- slot.prompts = void 0;
4210
- this.recordFailure(slot, "protocol", "prompt-discovery-failed");
4211
- this.log.warn(`MCP server "${slot.cfg.name}" prompt discovery failed`, err);
4212
- }
4213
- } else {
4214
- slot.prompts = void 0;
4215
- }
4216
- const durationMs = Date.now() - startedAt;
4217
- pushBounded(slot.operations.discoverySamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
4218
- this.recordOperation(slot, "discover", "complete", void 0, durationMs, false);
4414
+ idleContext() {
4415
+ return {
4416
+ servers: this.servers,
4417
+ idleTimeoutMs: this.idleTimeoutMs,
4418
+ events: this.events,
4419
+ log: this.log,
4420
+ recordOperation: (slot, kind, reason) => this.recordOperation(slot, kind, reason),
4421
+ onChildExit: this.onChildExit,
4422
+ onToolsChanged: this.onToolsChanged,
4423
+ removeCatalogListeners: (client) => this.removeCatalogListeners(client)
4424
+ };
4425
+ }
4426
+ applyTools(slot, tools, client) {
4427
+ applySlotTools(this.connectContext(), slot, tools, client);
4219
4428
  }
4220
4429
  async persistCapabilityManifest(slot) {
4221
- if (!slot.lazy || !this.cacheDir) return;
4222
- const cacheDir = this.cacheDir;
4223
- const previous = slot.manifestWrite ?? Promise.resolve();
4224
- const pending = previous.then(
4225
- () => writeCapabilityManifest(cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), {
4226
- tools: slot.client?.listTools() ?? [],
4227
- serverMetadata: slot.serverMetadata,
4228
- resources: slot.resources,
4229
- resourceTemplates: slot.resourceTemplates,
4230
- prompts: slot.prompts
4231
- })
4232
- );
4233
- slot.manifestWrite = pending;
4234
- await pending;
4235
- if (slot.manifestWrite === pending) slot.manifestWrite = void 0;
4430
+ return persistSlotCapabilityManifest(this.cacheDir, slot);
4236
4431
  }
4237
4432
  /** Start the shared idle sweep timer once (unref'd so it never holds the process). */
4238
4433
  ensureIdleSweep() {
@@ -4242,49 +4437,14 @@ var MCPRegistry = class _MCPRegistry {
4242
4437
  }, MCP_CONSTANTS.IDLE.SWEEP_INTERVAL_MS);
4243
4438
  this.idleTimer.unref?.();
4244
4439
  }
4245
- /** Auto-sleep connected lazy servers that have been idle past the timeout. */
4246
4440
  async sweepIdle() {
4247
4441
  if (this.idleTimeoutMs <= 0) return;
4248
- const now = Date.now();
4249
- for (const slot of this.servers.values()) {
4250
- if (slot.lazy && slot.state === "connected" && slot.client && now - slot.lastUsed > this.idleTimeoutMs) {
4251
- await this.sleepIdle(slot);
4252
- }
4253
- }
4254
- const hasConnectedLazyServer = [...this.servers.values()].some(
4255
- (slot) => slot.lazy && slot.state === "connected" && slot.client
4256
- );
4257
- if (!hasConnectedLazyServer && this.idleTimer) {
4442
+ const hasConnectedLazy = await sweepIdleSlots(this.idleContext());
4443
+ if (!hasConnectedLazy && this.idleTimer) {
4258
4444
  clearInterval(this.idleTimer);
4259
4445
  this.idleTimer = void 0;
4260
4446
  }
4261
4447
  }
4262
- /**
4263
- * Soft stop: close the server process but KEEP its resolver wrappers and
4264
- * cached manifest registered, so the next tool call transparently re-wakes it.
4265
- * Distinct from {@link stop} (full teardown for disable/remove).
4266
- */
4267
- async sleepIdle(slot) {
4268
- slot.reconnectPending = false;
4269
- if (slot.reconnectTimer) {
4270
- clearTimeout(slot.reconnectTimer);
4271
- slot.reconnectTimer = void 0;
4272
- }
4273
- if (slot.client) {
4274
- slot.client.removeExitListener(this.onChildExit);
4275
- if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
4276
- slot.client.removeToolsChangedListener(this.onToolsChanged);
4277
- this.removeCatalogListeners(slot.client);
4278
- await slot.client.close();
4279
- slot.client = void 0;
4280
- }
4281
- slot.onDisconnect = void 0;
4282
- slot.state = "dormant";
4283
- slot.operations.sleepCount++;
4284
- this.recordOperation(slot, "sleep", "idle-timeout");
4285
- this.log.info(`MCP server "${slot.cfg.name}" idle \u2014 sleeping (tools stay registered)`);
4286
- this.events.emit("mcp.server.disconnected", { name: slot.cfg.name, reason: "idle-sleep" });
4287
- }
4288
4448
  /**
4289
4449
  * Catalog of every server ever registered with this registry — includes
4290
4450
  * servers that are stopped, failed, or not yet started.
@@ -4452,134 +4612,14 @@ var MCPRegistry = class _MCPRegistry {
4452
4612
  );
4453
4613
  }
4454
4614
  async attemptConnect(slot) {
4455
- const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
4456
- let attempt = 0;
4457
- while (attempt < MAX_ATTEMPTS) {
4458
- if (this.servers.has(slot.cfg.name) && this.servers.get(slot.cfg.name) !== slot) {
4459
- return;
4460
- }
4461
- attempt++;
4462
- const startedAt = Date.now();
4463
- slot.state = attempt === 1 ? "connecting" : "reconnecting";
4464
- slot.attempts = attempt;
4465
- let client;
4466
- let boundDisconnect;
4467
- try {
4468
- client = new MCPClient({
4469
- name: slot.cfg.name,
4470
- transport: slot.cfg.transport,
4471
- command: slot.cfg.command,
4472
- args: slot.cfg.args,
4473
- env: slot.cfg.env,
4474
- url: slot.cfg.url,
4475
- headers: slot.cfg.headers,
4476
- startupTimeoutMs: slot.cfg.startupTimeoutMs,
4477
- requestTimeoutMs: slot.cfg.requestTimeoutMs,
4478
- passthroughEnv: slot.cfg.passthroughEnv,
4479
- authorizationProvider: this.authorizationProviderFactory?.(slot.cfg)
4480
- });
4481
- if (slot.cfg.transport === "stdio") {
4482
- client.addExitListener(this.onChildExit);
4483
- } else {
4484
- boundDisconnect = () => this.onTransportDisconnect(slot.cfg.name);
4485
- client.addDisconnectListener(boundDisconnect);
4486
- }
4487
- client.addToolsChangedListener(this.onToolsChanged);
4488
- this.addCatalogListeners(client);
4489
- await client.connect();
4490
- if (slot.state === "disconnected" || this.servers.has(slot.cfg.name) && this.servers.get(slot.cfg.name) !== slot) {
4491
- client.removeExitListener(this.onChildExit);
4492
- if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
4493
- client.removeToolsChangedListener(this.onToolsChanged);
4494
- this.removeCatalogListeners(client);
4495
- await client.close().catch(() => {
4496
- });
4497
- return;
4498
- }
4499
- if (slot.client && slot.client !== client) {
4500
- const prior = slot.client;
4501
- const priorDisconnect = slot.onDisconnect;
4502
- slot.client.removeExitListener(this.onChildExit);
4503
- if (priorDisconnect) prior.removeDisconnectListener(priorDisconnect);
4504
- prior.removeToolsChangedListener(this.onToolsChanged);
4505
- this.removeCatalogListeners(prior);
4506
- prior.close().catch(() => {
4507
- });
4508
- }
4509
- slot.client = client;
4510
- slot.onDisconnect = boundDisconnect;
4511
- const isReconnect = slot.reconnectCycles > 0 || attempt > 1;
4512
- slot.state = "connected";
4513
- slot.reconnectCycles = 0;
4514
- const mc = client;
4515
- const discovered = mc.listTools();
4516
- await this.discoverCapabilities(slot, mc);
4517
- await this.persistCapabilityManifest(slot);
4518
- this.applyTools(slot, discovered, mc);
4519
- const durationMs = Date.now() - startedAt;
4520
- pushBounded(
4521
- slot.operations.connectionSamples,
4522
- durationMs,
4523
- MCP_OPERATION_LIMITS.LATENCY_SAMPLES
4524
- );
4525
- this.recordSuccess(slot, (slot.operations.lastFailureAt ?? 0) < startedAt);
4526
- this.recordOperation(
4527
- slot,
4528
- isReconnect ? "reconnect" : "connect",
4529
- "connected",
4530
- void 0,
4531
- durationMs
4532
- );
4533
- slot.lastUsed = Date.now();
4534
- if (slot.lazy) this.ensureIdleSweep();
4535
- this.events.emit(isReconnect ? "mcp.server.reconnected" : "mcp.server.connected", {
4536
- name: slot.cfg.name,
4537
- toolCount: slot.toolNames.length
4538
- });
4539
- return;
4540
- } catch (err) {
4541
- this.recordFailure(slot, "transport", "connect-attempt-failed", Date.now() - startedAt);
4542
- this.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
4543
- if (client) {
4544
- client.removeExitListener(this.onChildExit);
4545
- if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
4546
- client.removeToolsChangedListener(this.onToolsChanged);
4547
- this.removeCatalogListeners(client);
4548
- await client.close().catch(() => {
4549
- });
4550
- }
4551
- if (attempt >= MAX_ATTEMPTS) {
4552
- this.log.error(
4553
- `MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`,
4554
- err
4555
- );
4556
- slot.state = "failed";
4557
- slot.client = void 0;
4558
- if (slot.reconnectTimer) {
4559
- clearTimeout(slot.reconnectTimer);
4560
- slot.reconnectTimer = void 0;
4561
- }
4562
- slot.reconnectPending = false;
4563
- this.events.emit("mcp.server.disconnected", {
4564
- name: slot.cfg.name,
4565
- reason: err instanceof Error ? err.message : "unknown"
4566
- });
4567
- return;
4568
- }
4569
- const delay = 500 * 2 ** attempt;
4570
- await new Promise((r) => setTimeout(r, delay));
4571
- if (slot.state === "disconnected" || this.servers.has(slot.cfg.name) && this.servers.get(slot.cfg.name) !== slot) {
4572
- return;
4573
- }
4574
- }
4575
- }
4615
+ return attemptConnectSlot(this.connectContext(), slot);
4576
4616
  }
4577
4617
  };
4578
4618
 
4579
4619
  // src/server.ts
4580
4620
  import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
4581
4621
  import { createServer } from "node:http";
4582
- import { expectDefined as expectDefined2, toErrorMessage as toErrorMessage2, validateAgainstSchema } from "@wrongstack/core/utils";
4622
+ import { expectDefined as expectDefined3, toErrorMessage as toErrorMessage2, validateAgainstSchema } from "@wrongstack/core/utils";
4583
4623
  var PARSE_ERROR = -32700;
4584
4624
  var INVALID_REQUEST = -32600;
4585
4625
  var METHOD_NOT_FOUND = -32601;
@@ -4627,7 +4667,7 @@ var MCPServer = class {
4627
4667
  const result = await this.dispatch(msg.method, msg.params);
4628
4668
  if (result === METHOD_NOT_FOUND_SENTINEL) {
4629
4669
  return this.encodeError(
4630
- expectDefined2(msg.id),
4670
+ expectDefined3(msg.id),
4631
4671
  METHOD_NOT_FOUND,
4632
4672
  `Method not found: ${msg.method}`
4633
4673
  );
@@ -4637,7 +4677,7 @@ var MCPServer = class {
4637
4677
  const message = toErrorMessage2(err);
4638
4678
  this.logger?.warn?.(`MCP server: method "${msg.method}" threw: ${message}`);
4639
4679
  const code = err instanceof InvalidToolArgumentsError ? INVALID_PARAMS : INTERNAL_ERROR;
4640
- return this.encodeError(expectDefined2(msg.id), code, message);
4680
+ return this.encodeError(expectDefined3(msg.id), code, message);
4641
4681
  }
4642
4682
  }
4643
4683
  async dispatch(method, params) {
@@ -0,0 +1,34 @@
1
+ import type { EventBus } from '@wrongstack/core/kernel';
2
+ import type { ToolRegistry } from '@wrongstack/core/registry';
3
+ import type { Logger } from '@wrongstack/core/types';
4
+ import { MCPClient, type MCPTool } from './client.js';
5
+ import { type MCPFailureKind, type MCPOperationKind, type MCPOperationListener } from './operations.js';
6
+ import type { ServerSlot } from './registry-slots.js';
7
+ import type { MCPRegistryOptions } from './registry-types.js';
8
+ export interface RegistryConnectContext {
9
+ servers: Map<string, ServerSlot>;
10
+ toolRegistry: ToolRegistry;
11
+ events: EventBus;
12
+ log: Logger;
13
+ lazyMode: boolean;
14
+ cacheDir?: string | undefined;
15
+ authorizationProviderFactory?: MCPRegistryOptions['authorizationProviderFactory'] | undefined;
16
+ operationListeners: Set<MCPOperationListener>;
17
+ ensureConnected: (name: string) => Promise<MCPClient>;
18
+ recordOperation: (slot: ServerSlot, kind: MCPOperationKind, reason?: string, failureKind?: MCPFailureKind, durationMs?: number, retain?: boolean) => void;
19
+ recordSuccess: (slot: ServerSlot, resetFailures?: boolean) => void;
20
+ recordFailure: (slot: ServerSlot, failureKind: MCPFailureKind, reason: string, durationMs?: number) => void;
21
+ onChildExit: (name: string, code: number | null, signal: string | null) => void;
22
+ onTransportDisconnect: (name: string) => void;
23
+ onToolsChanged: (name: string, tools: {
24
+ name: string;
25
+ }[]) => void;
26
+ addCatalogListeners: (client: MCPClient) => void;
27
+ removeCatalogListeners: (client: MCPClient) => void;
28
+ ensureIdleSweep: () => void;
29
+ }
30
+ export declare function applySlotTools(ctx: RegistryConnectContext, slot: ServerSlot, tools: MCPTool[], client?: MCPClient | undefined): void;
31
+ export declare function discoverSlotCapabilities(ctx: RegistryConnectContext, slot: ServerSlot, client: MCPClient): Promise<void>;
32
+ export declare function persistSlotCapabilityManifest(cacheDir: string | undefined, slot: ServerSlot): Promise<void>;
33
+ export declare function attemptConnectSlot(ctx: RegistryConnectContext, slot: ServerSlot): Promise<void>;
34
+ //# sourceMappingURL=registry-connect-loop.d.ts.map
@@ -0,0 +1,20 @@
1
+ import type { EventBus } from '@wrongstack/core/kernel';
2
+ import type { Logger } from '@wrongstack/core/types';
3
+ import type { MCPClient } from './client.js';
4
+ import type { MCPOperationKind } from './operations.js';
5
+ import type { ServerSlot } from './registry-slots.js';
6
+ export interface RegistryIdleContext {
7
+ servers: Map<string, ServerSlot>;
8
+ idleTimeoutMs: number;
9
+ events: EventBus;
10
+ log: Logger;
11
+ recordOperation: (slot: ServerSlot, kind: MCPOperationKind, reason?: string) => void;
12
+ onChildExit: (name: string, code: number | null, signal: string | null) => void;
13
+ onToolsChanged: (name: string, tools: {
14
+ name: string;
15
+ }[]) => void;
16
+ removeCatalogListeners: (client: MCPClient) => void;
17
+ }
18
+ export declare function sleepIdleSlot(ctx: RegistryIdleContext, slot: ServerSlot): Promise<void>;
19
+ export declare function sweepIdleSlots(ctx: RegistryIdleContext): Promise<boolean>;
20
+ //# sourceMappingURL=registry-idle.d.ts.map
@@ -105,26 +105,13 @@ export declare class MCPRegistry {
105
105
  * tools are connected but intentionally not registered).
106
106
  */
107
107
  private toolNamesForSlot;
108
- /**
109
- * Wrap + register (or cache) a server's tools. Lazy servers get resolver-backed
110
- * wrappers that spawn the process on first use; eager servers bind the live
111
- * client directly. Honours token-saving `lazyMode` (cache, don't register) and
112
- * a register-once guard for lazy resolver wrappers (so a wake/reconnect reuses
113
- * the existing registrations rather than churning the tool list).
114
- */
108
+ private connectContext;
109
+ private idleContext;
115
110
  private applyTools;
116
- private discoverCapabilities;
117
111
  private persistCapabilityManifest;
118
112
  /** Start the shared idle sweep timer once (unref'd so it never holds the process). */
119
113
  private ensureIdleSweep;
120
- /** Auto-sleep connected lazy servers that have been idle past the timeout. */
121
114
  private sweepIdle;
122
- /**
123
- * Soft stop: close the server process but KEEP its resolver wrappers and
124
- * cached manifest registered, so the next tool call transparently re-wakes it.
125
- * Distinct from {@link stop} (full teardown for disable/remove).
126
- */
127
- private sleepIdle;
128
115
  /**
129
116
  * Catalog of every server ever registered with this registry — includes
130
117
  * servers that are stopped, failed, or not yet started.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/mcp",
3
- "version": "0.306.4",
3
+ "version": "0.307.0",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack Model Context Protocol client and registry: stdio, SSE, and streamable HTTP transports.",
6
6
  "repository": {
@@ -26,12 +26,12 @@
26
26
  "!dist/**/*.map"
27
27
  ],
28
28
  "dependencies": {
29
- "@wrongstack/core": "0.306.4"
29
+ "@wrongstack/core": "0.307.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/node": "^26.1.2",
32
+ "@types/node": "^26.2.0",
33
33
  "typescript": "^7.0.2",
34
- "undici-types": "^8.9.0",
34
+ "undici-types": "^8.10.0",
35
35
  "vitest": "^4.1.10"
36
36
  },
37
37
  "publishConfig": {