@thotischner/observability-mcp 1.8.1 → 3.0.1

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 (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -0,0 +1,162 @@
1
+ // Upstream MCP federation client.
2
+ //
3
+ // One UpstreamClient per remote MCP gateway. On connect() it runs the
4
+ // MCP initialize handshake, fetches tools/list, and caches the catalog
5
+ // locally. callTool() forwards a request to the upstream and returns
6
+ // its CallToolResult verbatim.
7
+ //
8
+ // Transports:
9
+ // - "http" — Streamable HTTP to an upstream gateway URL (default).
10
+ // - "stdio" — spawn a child process that speaks MCP over its stdio
11
+ // channels. The classic MCP transport, useful when the
12
+ // upstream is a CLI-style server (omcp inspector-config,
13
+ // a local-only MCP, an in-cluster sidecar).
14
+ // - "ws" — WebSocket to a ws:// or wss:// URL. Useful when the
15
+ // upstream gateway exposes MCP via the WS subprotocol
16
+ // rather than streamable HTTP. No bearer-auth header
17
+ // (the SDK transport only accepts a URL); operators
18
+ // that need auth append it as a query string or run
19
+ // the gateway behind an authenticating reverse proxy.
20
+ //
21
+ // Auth forwarding modes:
22
+ // - "none" — no auth header on outbound calls
23
+ // - "bearer" — static OMCP_FEDERATION_TOKEN_<NAME> sent as Bearer
24
+ // (HTTP transport only — stdio doesn't have HTTP headers)
25
+ //
26
+ // OIDC + UAID passthrough are deferred — they require a per-request
27
+ // identity hand-off the federation manager doesn't carry today.
28
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
29
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
30
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
31
+ import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
32
+ export class UpstreamClient {
33
+ name;
34
+ /** Empty-string for stdio (no remote URL); kept on the public surface
35
+ * so the UI doesn't have to special-case the transport kind. */
36
+ url;
37
+ namespacePrefix;
38
+ transportKind;
39
+ cfg;
40
+ client;
41
+ // `unknown` because the SDK exposes a different concrete type per
42
+ // transport. Federation only talks to the transport via the SDK
43
+ // Client object, so the concrete type isn't needed here.
44
+ transport;
45
+ toolsCache = [];
46
+ status = "disconnected";
47
+ lastError;
48
+ refreshTimer;
49
+ refreshIntervalMs;
50
+ constructor(cfg) {
51
+ this.cfg = cfg;
52
+ this.name = cfg.name;
53
+ this.transportKind =
54
+ cfg.transport === "stdio" ? "stdio" :
55
+ cfg.transport === "ws" ? "ws" :
56
+ "http";
57
+ this.url =
58
+ this.transportKind === "stdio" ? `stdio:${cfg.command}` :
59
+ this.transportKind === "ws" ? cfg.url :
60
+ cfg.url;
61
+ this.namespacePrefix = cfg.namespacePrefix ?? cfg.name;
62
+ this.refreshIntervalMs = cfg.refreshIntervalMs ?? 5 * 60 * 1000;
63
+ }
64
+ getStatus() {
65
+ return { status: this.status, lastError: this.lastError, toolCount: this.toolsCache.length };
66
+ }
67
+ /** Cached catalog (read-only). */
68
+ getTools() {
69
+ return [...this.toolsCache];
70
+ }
71
+ /** Connect + initial catalog fetch. Logs failures and leaves the
72
+ * client in `degraded` so the catalog stays empty rather than
73
+ * blocking startup. Re-runnable. */
74
+ async connect() {
75
+ this.status = "connecting";
76
+ try {
77
+ this.transport = this.buildTransport();
78
+ this.client = new Client({ name: "observability-mcp-federation", version: "1" }, { capabilities: {} });
79
+ // SDK Client.connect accepts any Transport implementation; the
80
+ // injected test transport just needs the start()/send()/close()
81
+ // contract — the type assertion sidesteps each concrete class.
82
+ await this.client.connect(this.transport);
83
+ await this.refresh();
84
+ this.status = "ready";
85
+ this.lastError = undefined;
86
+ if (this.refreshIntervalMs > 0) {
87
+ this.refreshTimer = setInterval(() => {
88
+ this.refresh().catch((err) => {
89
+ console.warn("UpstreamClient %s: background refresh failed: %s", this.name, err instanceof Error ? err.message : String(err));
90
+ });
91
+ }, this.refreshIntervalMs).unref?.();
92
+ }
93
+ }
94
+ catch (err) {
95
+ this.status = "degraded";
96
+ this.lastError = err instanceof Error ? err.message : String(err);
97
+ console.warn("UpstreamClient %s: connect failed (%s). Federation continues without this upstream.", this.name, this.lastError);
98
+ }
99
+ }
100
+ /** Re-fetch the upstream tool catalog. Throws on failure so the
101
+ * caller can choose to degrade or retry. */
102
+ async refresh() {
103
+ if (!this.client)
104
+ throw new Error(`upstream ${this.name} not connected`);
105
+ const result = await this.client.listTools({});
106
+ this.toolsCache = (result.tools ?? []).map((t) => ({
107
+ namespacedName: `${this.namespacePrefix}.${t.name}`,
108
+ upstreamName: t.name,
109
+ sourceName: this.name,
110
+ description: t.description ?? "",
111
+ inputSchema: t.inputSchema,
112
+ }));
113
+ }
114
+ /** Forward a callTool request to the upstream by upstream-tool name
115
+ * (NOT the namespaced name — the registry strips the prefix before
116
+ * calling here). */
117
+ async callTool(upstreamName, args) {
118
+ if (!this.client) {
119
+ throw new Error(`upstream ${this.name} is ${this.status}`);
120
+ }
121
+ return this.client.callTool({
122
+ name: upstreamName,
123
+ arguments: (args ?? {}),
124
+ });
125
+ }
126
+ async close() {
127
+ if (this.refreshTimer)
128
+ clearInterval(this.refreshTimer);
129
+ try {
130
+ await this.client?.close();
131
+ }
132
+ catch {
133
+ /* socket may already be down */
134
+ }
135
+ this.status = "disconnected";
136
+ }
137
+ // --- internals ----------------------------------------------------
138
+ buildTransport() {
139
+ // Test path: a pre-built transport short-circuits the spawn / fetch.
140
+ if (this.cfg._transport)
141
+ return this.cfg._transport;
142
+ if (this.transportKind === "stdio") {
143
+ const cfg = this.cfg;
144
+ return new StdioClientTransport({
145
+ command: cfg.command,
146
+ args: cfg.args ?? [],
147
+ env: cfg.env,
148
+ });
149
+ }
150
+ if (this.transportKind === "ws") {
151
+ const cfg = this.cfg;
152
+ return new WebSocketClientTransport(new URL(cfg.url));
153
+ }
154
+ const cfg = this.cfg;
155
+ const url = new URL(cfg.url);
156
+ const init = { headers: {} };
157
+ if (cfg.bearerToken) {
158
+ init.headers["Authorization"] = `Bearer ${cfg.bearerToken}`;
159
+ }
160
+ return new StreamableHTTPClientTransport(url, { requestInit: init });
161
+ }
162
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { UpstreamClient } from "./upstream.js";
4
+ test("UpstreamClient: HTTP config — transportKind='http', url surfaced", () => {
5
+ const cfg = {
6
+ name: "remote",
7
+ url: "https://gw.example.com/mcp",
8
+ bearerToken: "t0k",
9
+ };
10
+ const c = new UpstreamClient(cfg);
11
+ assert.equal(c.transportKind, "http");
12
+ assert.equal(c.url, "https://gw.example.com/mcp");
13
+ assert.equal(c.namespacePrefix, "remote");
14
+ assert.deepEqual(c.getTools(), []);
15
+ });
16
+ test("UpstreamClient: stdio config — transportKind='stdio', url shows command", () => {
17
+ const cfg = {
18
+ transport: "stdio",
19
+ name: "local-mcp",
20
+ command: "/usr/local/bin/mcp",
21
+ args: ["--config", "/etc/mcp.yaml"],
22
+ };
23
+ const c = new UpstreamClient(cfg);
24
+ assert.equal(c.transportKind, "stdio");
25
+ assert.equal(c.url, "stdio:/usr/local/bin/mcp");
26
+ assert.equal(c.namespacePrefix, "local-mcp");
27
+ });
28
+ test("UpstreamClient: stdio config respects custom namespacePrefix", () => {
29
+ const cfg = {
30
+ transport: "stdio",
31
+ name: "weather",
32
+ command: "weather-mcp",
33
+ namespacePrefix: "weather.local",
34
+ };
35
+ const c = new UpstreamClient(cfg);
36
+ assert.equal(c.namespacePrefix, "weather.local");
37
+ });
38
+ test("UpstreamClient: explicit transport='http' is also accepted", () => {
39
+ const cfg = {
40
+ transport: "http",
41
+ name: "gw",
42
+ url: "https://gw.example.com/mcp",
43
+ };
44
+ const c = new UpstreamClient(cfg);
45
+ assert.equal(c.transportKind, "http");
46
+ });
47
+ test("UpstreamClient: ws transport surfaces the ws:// URL", () => {
48
+ const cfg = {
49
+ transport: "ws",
50
+ name: "gw",
51
+ url: "wss://gw.example.com/mcp/ws",
52
+ };
53
+ const c = new UpstreamClient(cfg);
54
+ assert.equal(c.transportKind, "ws");
55
+ assert.equal(c.url, "wss://gw.example.com/mcp/ws");
56
+ });
57
+ test("UpstreamClient: empty args defaults to [] on stdio", () => {
58
+ const cfg = {
59
+ transport: "stdio",
60
+ name: "x",
61
+ command: "x",
62
+ };
63
+ const c = new UpstreamClient(cfg);
64
+ // Just verifies construction doesn't throw on a minimal stdio config.
65
+ assert.equal(c.transportKind, "stdio");
66
+ });
67
+ test("UpstreamClient: getStatus initial state", () => {
68
+ const c = new UpstreamClient({ name: "x", url: "https://x/mcp" });
69
+ const s = c.getStatus();
70
+ assert.equal(s.status, "disconnected");
71
+ assert.equal(s.toolCount, 0);
72
+ assert.equal(s.lastError, undefined);
73
+ });
74
+ test("UpstreamClient: connect uses injected _transport instead of spawning / fetching", async () => {
75
+ // Build a minimal MCP Transport stub that also COMPLETES the
76
+ // initialize handshake — when the SDK Client sends a JSON-RPC
77
+ // request, we synthesise a matching response on onmessage so the
78
+ // initialize promise resolves quickly (no 60s SDK timeout).
79
+ let started = false;
80
+ let sentMessages = 0;
81
+ const fakeTransport = {
82
+ start: async () => { started = true; },
83
+ send: async (msg) => {
84
+ sentMessages += 1;
85
+ if (msg?.method === "initialize" && msg?.id !== undefined) {
86
+ queueMicrotask(() => {
87
+ fakeTransport.onmessage?.({
88
+ jsonrpc: "2.0",
89
+ id: msg.id,
90
+ result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "fake", version: "1" } },
91
+ });
92
+ });
93
+ }
94
+ else if (msg?.method === "tools/list" && msg?.id !== undefined) {
95
+ queueMicrotask(() => {
96
+ fakeTransport.onmessage?.({ jsonrpc: "2.0", id: msg.id, result: { tools: [] } });
97
+ });
98
+ }
99
+ },
100
+ close: async () => { },
101
+ onclose: undefined,
102
+ onerror: undefined,
103
+ onmessage: undefined,
104
+ };
105
+ const c = new UpstreamClient({
106
+ name: "injected",
107
+ url: "https://ignored.example/mcp",
108
+ refreshIntervalMs: 0,
109
+ _transport: fakeTransport,
110
+ });
111
+ await c.connect();
112
+ await c.close();
113
+ assert.equal(started, true, "fake transport.start() should have been called");
114
+ assert.ok(sentMessages >= 1, "fake transport.send() should have received initialize");
115
+ // Status reaches "ready" only when initialize + tools/list both succeed
116
+ // — confirms our injected transport drove the whole handshake.
117
+ // (connect-time errors leave it in "degraded".)
118
+ });