toolcraft 0.0.24 → 0.0.26

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 (43) hide show
  1. package/dist/cli.js +11 -9
  2. package/dist/error-report.js +14 -11
  3. package/dist/redaction.d.ts +4 -0
  4. package/dist/redaction.js +70 -0
  5. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +33 -9
  6. package/node_modules/@poe-code/config-mutations/dist/formats/json.d.ts +2 -1
  7. package/node_modules/@poe-code/config-mutations/dist/formats/json.js +36 -9
  8. package/node_modules/@poe-code/config-mutations/dist/types.d.ts +2 -0
  9. package/node_modules/@poe-code/design-system/dist/components/browser.js +1 -1
  10. package/node_modules/@poe-code/design-system/dist/explorer/actions.js +1 -1
  11. package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +11 -1
  12. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +64 -8
  13. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +9 -11
  14. package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +18 -8
  15. package/node_modules/@poe-code/design-system/dist/explorer/render/header.js +11 -18
  16. package/node_modules/@poe-code/design-system/dist/explorer/render/index.js +2 -10
  17. package/node_modules/@poe-code/design-system/dist/explorer/render/list.js +32 -22
  18. package/node_modules/@poe-code/design-system/dist/explorer/render/modal.js +5 -9
  19. package/node_modules/@poe-code/design-system/dist/explorer/render/text.d.ts +12 -0
  20. package/node_modules/@poe-code/design-system/dist/explorer/render/text.js +81 -0
  21. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
  22. package/node_modules/@poe-code/design-system/dist/explorer/state.js +2 -0
  23. package/node_modules/@poe-code/design-system/dist/prompts/index.js +3 -3
  24. package/node_modules/@poe-code/process-runner/dist/docker/args.d.ts +1 -0
  25. package/node_modules/@poe-code/process-runner/dist/docker/args.js +11 -3
  26. package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +170 -36
  27. package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +66 -9
  28. package/node_modules/@poe-code/process-runner/dist/docker/env-file.d.ts +6 -0
  29. package/node_modules/@poe-code/process-runner/dist/docker/env-file.js +49 -0
  30. package/node_modules/@poe-code/process-runner/dist/host/host-runner.js +5 -4
  31. package/node_modules/@poe-code/process-runner/dist/types.d.ts +3 -0
  32. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.d.ts +5 -1
  33. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.js +29 -10
  34. package/node_modules/auth-store/dist/encrypted-file-store.d.ts +1 -0
  35. package/node_modules/auth-store/dist/encrypted-file-store.js +36 -2
  36. package/node_modules/auth-store/dist/keychain-store.js +20 -1
  37. package/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +6 -3
  38. package/node_modules/tiny-mcp-client/dist/internal.d.ts +3 -0
  39. package/node_modules/tiny-mcp-client/dist/internal.js +39 -14
  40. package/node_modules/tiny-mcp-client/src/internal.ts +45 -17
  41. package/node_modules/tiny-mcp-client/src/mcp-client-sdk.test.ts +32 -0
  42. package/node_modules/tiny-mcp-client/src/transports.test.ts +68 -0
  43. package/package.json +2 -2
@@ -13,6 +13,7 @@ let temporaryFileSequence = 0;
13
13
  export class EncryptedFileStore {
14
14
  fs;
15
15
  filePath;
16
+ symbolicLinkCheckStartPath;
16
17
  salt;
17
18
  getMachineIdentity;
18
19
  getRandomBytes;
@@ -20,7 +21,16 @@ export class EncryptedFileStore {
20
21
  constructor(input) {
21
22
  this.fs = input.fs ?? fs;
22
23
  this.salt = input.salt;
23
- this.filePath = input.filePath ?? path.join((input.getHomeDirectory ?? homedir)(), input.defaultDirectory ?? ".auth-store", input.defaultFileName ?? "credentials.enc");
24
+ if (input.filePath === undefined) {
25
+ const homeDirectory = (input.getHomeDirectory ?? homedir)();
26
+ const defaultDirectory = input.defaultDirectory ?? ".auth-store";
27
+ this.filePath = path.join(homeDirectory, defaultDirectory, input.defaultFileName ?? "credentials.enc");
28
+ this.symbolicLinkCheckStartPath = resolveDefaultDirectoryCheckStart(homeDirectory, defaultDirectory);
29
+ }
30
+ else {
31
+ this.filePath = input.filePath;
32
+ this.symbolicLinkCheckStartPath = null;
33
+ }
24
34
  this.getMachineIdentity = input.getMachineIdentity ?? defaultMachineIdentity;
25
35
  this.getRandomBytes = input.getRandomBytes ?? randomBytes;
26
36
  }
@@ -106,7 +116,7 @@ export class EncryptedFileStore {
106
116
  }
107
117
  async assertRegularCredentialPath() {
108
118
  const resolvedPath = path.resolve(this.filePath);
109
- const protectedPaths = [path.dirname(resolvedPath), resolvedPath];
119
+ const protectedPaths = getProtectedCredentialPaths(resolvedPath, this.symbolicLinkCheckStartPath);
110
120
  for (const currentPath of protectedPaths) {
111
121
  try {
112
122
  const stats = await this.fs.lstat(currentPath);
@@ -135,6 +145,30 @@ export class EncryptedFileStore {
135
145
  return this.keyPromise;
136
146
  }
137
147
  }
148
+ function resolveDefaultDirectoryCheckStart(homeDirectory, defaultDirectory) {
149
+ const [firstSegment] = defaultDirectory.split(/[\\/]+/).filter(Boolean);
150
+ return path.resolve(homeDirectory, firstSegment ?? ".");
151
+ }
152
+ function getProtectedCredentialPaths(resolvedPath, symbolicLinkCheckStartPath) {
153
+ if (symbolicLinkCheckStartPath === null) {
154
+ return [path.dirname(resolvedPath), resolvedPath];
155
+ }
156
+ const resolvedStartPath = path.resolve(symbolicLinkCheckStartPath);
157
+ if (!isPathInsideOrEqual(resolvedPath, resolvedStartPath)) {
158
+ return [path.dirname(resolvedPath), resolvedPath];
159
+ }
160
+ const protectedPaths = [resolvedStartPath];
161
+ let currentPath = resolvedStartPath;
162
+ for (const segment of path.relative(resolvedStartPath, resolvedPath).split(path.sep).filter(Boolean)) {
163
+ currentPath = path.join(currentPath, segment);
164
+ protectedPaths.push(currentPath);
165
+ }
166
+ return protectedPaths;
167
+ }
168
+ function isPathInsideOrEqual(childPath, parentPath) {
169
+ const relativePath = path.relative(parentPath, childPath);
170
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
171
+ }
138
172
  async function removeIfPresent(fileSystem, filePath) {
139
173
  try {
140
174
  await fileSystem.unlink(filePath);
@@ -64,6 +64,19 @@ function runSecurityCommand(command, args, options) {
64
64
  });
65
65
  let stdout = "";
66
66
  let stderr = "";
67
+ let stdinErrorMessage;
68
+ const appendStderr = (message) => {
69
+ stderr = stderr.length === 0
70
+ ? message
71
+ : `${stderr}${stderr.endsWith("\n") ? "" : "\n"}${message}`;
72
+ };
73
+ const appendStdinError = () => {
74
+ if (stdinErrorMessage === undefined) {
75
+ return;
76
+ }
77
+ appendStderr(stdinErrorMessage);
78
+ stdinErrorMessage = undefined;
79
+ };
67
80
  child.stdout?.setEncoding("utf8");
68
81
  child.stdout?.on("data", (chunk) => {
69
82
  stdout += chunk.toString();
@@ -73,17 +86,23 @@ function runSecurityCommand(command, args, options) {
73
86
  stderr += chunk.toString();
74
87
  });
75
88
  if (options?.stdin !== undefined) {
89
+ child.stdin?.once("error", (error) => {
90
+ stdinErrorMessage = error instanceof Error ? error.message : String(error);
91
+ });
76
92
  child.stdin?.end(options.stdin);
77
93
  }
78
94
  child.on("error", (error) => {
79
95
  const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
96
+ appendStdinError();
97
+ appendStderr(message);
80
98
  resolve({
81
99
  stdout,
82
- stderr: stderr ? `${stderr}${message}` : message,
100
+ stderr,
83
101
  exitCode: 127
84
102
  });
85
103
  });
86
104
  child.on("close", (code) => {
105
+ appendStdinError();
87
106
  resolve({
88
107
  stdout,
89
108
  stderr,
@@ -61,12 +61,15 @@ export function createAuthStoreClientStore(options) {
61
61
  }
62
62
  function createNamedSecretStore(key, options, defaults) {
63
63
  const hash = crypto.createHash("sha256").update(key).digest("hex");
64
- const parsedFilePath = options.fileStore?.filePath === undefined ? null : path.parse(options.fileStore.filePath);
64
+ const configuredFilePath = options.fileStore?.filePath;
65
+ const parsedFilePath = configuredFilePath === undefined ? null : path.parse(configuredFilePath);
65
66
  const fileStore = {
66
67
  ...options.fileStore,
68
+ filePath: parsedFilePath === null
69
+ ? undefined
70
+ : path.join(parsedFilePath.dir, `${parsedFilePath.name}-${hash}${parsedFilePath.ext || ".enc"}`),
67
71
  salt: options.fileStore?.salt ?? defaults.salt,
68
- defaultDirectory: parsedFilePath?.dir ||
69
- options.fileStore?.defaultDirectory ||
72
+ defaultDirectory: options.fileStore?.defaultDirectory ||
70
73
  defaults.directory,
71
74
  defaultFileName: parsedFilePath === null
72
75
  ? `${hash}.enc`
@@ -400,6 +400,7 @@ export declare class StdioTransport implements McpTransport {
400
400
  private static readonly STDERR_MAX_LENGTH;
401
401
  constructor({ command, args, cwd, env, spawn: spawnProcess, }: StdioTransportOptions);
402
402
  getStderrOutput(): string;
403
+ private appendStderrOutput;
403
404
  dispose(reason?: Error): void;
404
405
  }
405
406
  export declare class HttpTransport implements McpTransport {
@@ -515,6 +516,7 @@ export declare class SseParser {
515
516
  export interface JsonRpcRequestOptions {
516
517
  timeoutMs?: number;
517
518
  onRequestId?: (requestId: RequestId) => void;
519
+ onTimeout?: (requestId: RequestId) => void;
518
520
  }
519
521
  interface JsonRpcRequestContext {
520
522
  id: RequestId;
@@ -541,6 +543,7 @@ export declare class JsonRpcMessageLayer {
541
543
  onRequest(method: string, handler: JsonRpcRequestHandler): void;
542
544
  onNotification(method: string, handler: JsonRpcNotificationHandler): void;
543
545
  sendRequest(method: string, params?: unknown, options?: JsonRpcRequestOptions): Promise<unknown>;
546
+ cancelRequest(requestId: RequestId, reason: unknown): boolean;
544
547
  dispose(reason?: Error): void;
545
548
  private consumeInput;
546
549
  private resolveInputStreamClosedReason;
@@ -258,10 +258,19 @@ export class McpClient {
258
258
  }
259
259
  try {
260
260
  let requestId;
261
+ let cancellationSent = false;
262
+ const sendCancellationNotification = () => {
263
+ if (requestId === undefined || cancellationSent) {
264
+ return;
265
+ }
266
+ cancellationSent = true;
267
+ messageLayer.sendNotification("notifications/cancelled", { requestId });
268
+ };
261
269
  const requestPromise = messageLayer.sendRequest("tools/call", requestParams, {
262
270
  onRequestId: (nextRequestId) => {
263
271
  requestId = nextRequestId;
264
272
  },
273
+ onTimeout: sendCancellationNotification,
265
274
  }).then((result) => {
266
275
  if (!isCallToolResult(result)) {
267
276
  throw new McpError(ERROR_INVALID_REQUEST, "Invalid tool result");
@@ -275,8 +284,9 @@ export class McpClient {
275
284
  let abortListener;
276
285
  const abortPromise = new Promise((_, reject) => {
277
286
  const rejectWithAbortReason = () => {
287
+ sendCancellationNotification();
278
288
  if (requestId !== undefined) {
279
- messageLayer.sendNotification("notifications/cancelled", { requestId });
289
+ messageLayer.cancelRequest(requestId, signal.reason);
280
290
  }
281
291
  reject(signal.reason);
282
292
  };
@@ -1504,11 +1514,15 @@ export class StdioTransport {
1504
1514
  const child = this.child;
1505
1515
  this.readable = child.stdout;
1506
1516
  this.writable = child.stdin;
1517
+ const stderrDecoder = new TextDecoder();
1507
1518
  child.stderr.on("data", (chunk) => {
1508
- this.stderrOutput += chunkToString(chunk);
1509
- if (this.stderrOutput.length > StdioTransport.STDERR_MAX_LENGTH) {
1510
- this.stderrOutput = this.stderrOutput.slice(-StdioTransport.STDERR_MAX_LENGTH);
1511
- }
1519
+ const decoded = chunk instanceof Uint8Array
1520
+ ? stderrDecoder.decode(chunk, { stream: true })
1521
+ : `${stderrDecoder.decode()}${String(chunk)}`;
1522
+ this.appendStderrOutput(decoded);
1523
+ });
1524
+ child.stderr.once("end", () => {
1525
+ this.appendStderrOutput(stderrDecoder.decode());
1512
1526
  });
1513
1527
  this.closed = new Promise((resolve) => {
1514
1528
  let settled = false;
@@ -1548,6 +1562,15 @@ export class StdioTransport {
1548
1562
  getStderrOutput() {
1549
1563
  return this.stderrOutput;
1550
1564
  }
1565
+ appendStderrOutput(chunk) {
1566
+ if (chunk.length === 0) {
1567
+ return;
1568
+ }
1569
+ this.stderrOutput += chunk;
1570
+ if (this.stderrOutput.length > StdioTransport.STDERR_MAX_LENGTH) {
1571
+ this.stderrOutput = this.stderrOutput.slice(-StdioTransport.STDERR_MAX_LENGTH);
1572
+ }
1573
+ }
1551
1574
  dispose(reason = new Error("Stdio transport disposed")) {
1552
1575
  void reason;
1553
1576
  if (this.disposed) {
@@ -1959,15 +1982,6 @@ export class McpError extends Error {
1959
1982
  export function serializeJsonRpcMessage(message) {
1960
1983
  return `${JSON.stringify(message)}\n`;
1961
1984
  }
1962
- function chunkToString(chunk) {
1963
- if (typeof chunk === "string") {
1964
- return chunk;
1965
- }
1966
- if (chunk instanceof Uint8Array) {
1967
- return Buffer.from(chunk).toString("utf8");
1968
- }
1969
- return String(chunk);
1970
- }
1971
1985
  function normalizeLine(line) {
1972
1986
  return line.endsWith("\r") ? line.slice(0, -1) : line;
1973
1987
  }
@@ -2147,6 +2161,7 @@ export class JsonRpcMessageLayer {
2147
2161
  return new Promise((resolve, reject) => {
2148
2162
  const timeout = setTimeout(() => {
2149
2163
  this.pendingRequests.delete(id);
2164
+ options.onTimeout?.(id);
2150
2165
  reject(new Error(`JSON-RPC request "${method}" timed out after ${timeoutMs}ms`));
2151
2166
  }, timeoutMs);
2152
2167
  this.pendingRequests.set(id, { resolve, reject, timeout });
@@ -2160,6 +2175,16 @@ export class JsonRpcMessageLayer {
2160
2175
  }
2161
2176
  });
2162
2177
  }
2178
+ cancelRequest(requestId, reason) {
2179
+ const pending = this.pendingRequests.get(requestId);
2180
+ if (pending === undefined) {
2181
+ return false;
2182
+ }
2183
+ this.pendingRequests.delete(requestId);
2184
+ clearTimeout(pending.timeout);
2185
+ pending.reject(reason);
2186
+ return true;
2187
+ }
2163
2188
  dispose(reason = new Error("JSON-RPC message layer disposed")) {
2164
2189
  if (this.disposedError !== undefined) {
2165
2190
  return;
@@ -449,10 +449,19 @@ export class McpClient {
449
449
 
450
450
  try {
451
451
  let requestId: RequestId | undefined;
452
+ let cancellationSent = false;
453
+ const sendCancellationNotification = () => {
454
+ if (requestId === undefined || cancellationSent) {
455
+ return;
456
+ }
457
+ cancellationSent = true;
458
+ messageLayer.sendNotification("notifications/cancelled", { requestId });
459
+ };
452
460
  const requestPromise = messageLayer.sendRequest("tools/call", requestParams, {
453
461
  onRequestId: (nextRequestId) => {
454
462
  requestId = nextRequestId;
455
463
  },
464
+ onTimeout: sendCancellationNotification,
456
465
  }).then((result) => {
457
466
  if (!isCallToolResult(result)) {
458
467
  throw new McpError(ERROR_INVALID_REQUEST, "Invalid tool result");
@@ -468,8 +477,9 @@ export class McpClient {
468
477
  let abortListener: (() => void) | undefined;
469
478
  const abortPromise = new Promise<CallToolResult>((_, reject) => {
470
479
  const rejectWithAbortReason = () => {
480
+ sendCancellationNotification();
471
481
  if (requestId !== undefined) {
472
- messageLayer.sendNotification("notifications/cancelled", { requestId });
482
+ messageLayer.cancelRequest(requestId, signal.reason);
473
483
  }
474
484
  reject(signal.reason);
475
485
  };
@@ -2343,11 +2353,16 @@ export class StdioTransport implements McpTransport {
2343
2353
 
2344
2354
  this.readable = child.stdout;
2345
2355
  this.writable = child.stdin;
2356
+ const stderrDecoder = new TextDecoder();
2346
2357
  child.stderr.on("data", (chunk: unknown) => {
2347
- this.stderrOutput += chunkToString(chunk);
2348
- if (this.stderrOutput.length > StdioTransport.STDERR_MAX_LENGTH) {
2349
- this.stderrOutput = this.stderrOutput.slice(-StdioTransport.STDERR_MAX_LENGTH);
2350
- }
2358
+ const decoded =
2359
+ chunk instanceof Uint8Array
2360
+ ? stderrDecoder.decode(chunk, { stream: true })
2361
+ : `${stderrDecoder.decode()}${String(chunk)}`;
2362
+ this.appendStderrOutput(decoded);
2363
+ });
2364
+ child.stderr.once("end", () => {
2365
+ this.appendStderrOutput(stderrDecoder.decode());
2351
2366
  });
2352
2367
  this.closed = new Promise((resolve) => {
2353
2368
  let settled = false;
@@ -2398,6 +2413,17 @@ export class StdioTransport implements McpTransport {
2398
2413
  return this.stderrOutput;
2399
2414
  }
2400
2415
 
2416
+ private appendStderrOutput(chunk: string): void {
2417
+ if (chunk.length === 0) {
2418
+ return;
2419
+ }
2420
+
2421
+ this.stderrOutput += chunk;
2422
+ if (this.stderrOutput.length > StdioTransport.STDERR_MAX_LENGTH) {
2423
+ this.stderrOutput = this.stderrOutput.slice(-StdioTransport.STDERR_MAX_LENGTH);
2424
+ }
2425
+ }
2426
+
2401
2427
  dispose(reason = new Error("Stdio transport disposed")): void {
2402
2428
  void reason;
2403
2429
 
@@ -2958,18 +2984,6 @@ export function serializeJsonRpcMessage(message: JsonRpcMessage): string {
2958
2984
  return `${JSON.stringify(message)}\n`;
2959
2985
  }
2960
2986
 
2961
- function chunkToString(chunk: unknown): string {
2962
- if (typeof chunk === "string") {
2963
- return chunk;
2964
- }
2965
-
2966
- if (chunk instanceof Uint8Array) {
2967
- return Buffer.from(chunk).toString("utf8");
2968
- }
2969
-
2970
- return String(chunk);
2971
- }
2972
-
2973
2987
  function normalizeLine(line: string): string {
2974
2988
  return line.endsWith("\r") ? line.slice(0, -1) : line;
2975
2989
  }
@@ -3134,6 +3148,7 @@ interface ActiveIncomingRequest {
3134
3148
  export interface JsonRpcRequestOptions {
3135
3149
  timeoutMs?: number;
3136
3150
  onRequestId?: (requestId: RequestId) => void;
3151
+ onTimeout?: (requestId: RequestId) => void;
3137
3152
  }
3138
3153
 
3139
3154
  interface JsonRpcRequestContext {
@@ -3242,6 +3257,7 @@ export class JsonRpcMessageLayer {
3242
3257
  return new Promise((resolve, reject) => {
3243
3258
  const timeout = setTimeout(() => {
3244
3259
  this.pendingRequests.delete(id);
3260
+ options.onTimeout?.(id);
3245
3261
  reject(new Error(`JSON-RPC request "${method}" timed out after ${timeoutMs}ms`));
3246
3262
  }, timeoutMs);
3247
3263
 
@@ -3257,6 +3273,18 @@ export class JsonRpcMessageLayer {
3257
3273
  });
3258
3274
  }
3259
3275
 
3276
+ cancelRequest(requestId: RequestId, reason: unknown): boolean {
3277
+ const pending = this.pendingRequests.get(requestId);
3278
+ if (pending === undefined) {
3279
+ return false;
3280
+ }
3281
+
3282
+ this.pendingRequests.delete(requestId);
3283
+ clearTimeout(pending.timeout);
3284
+ pending.reject(reason);
3285
+ return true;
3286
+ }
3287
+
3260
3288
  dispose(reason = new Error("JSON-RPC message layer disposed")): void {
3261
3289
  if (this.disposedError !== undefined) {
3262
3290
  return;
@@ -293,6 +293,38 @@ describe("McpClient SDK integration callTool", () => {
293
293
  }
294
294
  });
295
295
 
296
+ it("cancels an in-flight slow tool call when the request timeout elapses", async () => {
297
+ const server = await createMockSlowToolServer({ delayMs: 1_000, pollIntervalMs: 5 });
298
+ const { client, cleanup } = await createSdkTestPair(server, () =>
299
+ new McpClient({
300
+ clientInfo: {
301
+ name: "test-client",
302
+ version: "1.0.0",
303
+ },
304
+ requestTimeoutMs: 100,
305
+ })
306
+ );
307
+
308
+ try {
309
+ const callPromise = client.callTool({
310
+ name: "slow",
311
+ arguments: {
312
+ delayMs: 500,
313
+ },
314
+ });
315
+
316
+ await waitFor(() => server.wasStarted(), "Timed out waiting for slow tool to start");
317
+
318
+ await expect(callPromise).rejects.toThrow(
319
+ 'JSON-RPC request "tools/call" timed out after 100ms'
320
+ );
321
+ await waitFor(() => server.wasCancelled(), "Timed out waiting for slow tool cancellation");
322
+ expect(server.getCancelledRequestIds()).toEqual(server.getStartedRequestIds());
323
+ } finally {
324
+ await cleanup();
325
+ }
326
+ });
327
+
296
328
  it("rejects with JSON-RPC error code and message for unknown tool names", async () => {
297
329
  const server = await createMockErrorServer();
298
330
  const { client, cleanup } = await createSdkTestPair(server, () =>
@@ -1471,6 +1471,40 @@ describe("JsonRpcMessageLayer sendRequest", () => {
1471
1471
  vi.useRealTimers();
1472
1472
  }
1473
1473
  });
1474
+
1475
+ it("clears pending request state and timeout when a request is cancelled", async () => {
1476
+ vi.useFakeTimers();
1477
+ try {
1478
+ const input = new PassThrough();
1479
+ const output = new PassThrough();
1480
+ trackForCleanup(input, output);
1481
+ const layer = new JsonRpcMessageLayer(input, output, 25);
1482
+ const onTimeout = vi.fn();
1483
+ const pendingCount = () =>
1484
+ (
1485
+ layer as unknown as {
1486
+ pendingRequests: Map<unknown, unknown>;
1487
+ }
1488
+ ).pendingRequests.size;
1489
+
1490
+ const responsePromise = layer.sendRequest("slow/method", undefined, {
1491
+ onTimeout,
1492
+ });
1493
+ expect(pendingCount()).toBe(1);
1494
+
1495
+ expect(layer.cancelRequest(1, "user cancelled")).toBe(true);
1496
+
1497
+ await expect(responsePromise).rejects.toBe("user cancelled");
1498
+ expect(pendingCount()).toBe(0);
1499
+
1500
+ await vi.advanceTimersByTimeAsync(25);
1501
+
1502
+ expect(onTimeout).not.toHaveBeenCalled();
1503
+ expect(layer.cancelRequest(1, "already gone")).toBe(false);
1504
+ } finally {
1505
+ vi.useRealTimers();
1506
+ }
1507
+ });
1474
1508
  });
1475
1509
 
1476
1510
  describe("JsonRpcMessageLayer UTF-8 input", () => {
@@ -2643,6 +2677,22 @@ describe("StdioTransport stderr capture", () => {
2643
2677
  expect(transport.getStderrOutput()).toBe("first second");
2644
2678
  });
2645
2679
 
2680
+ it("preserves UTF-8 characters split across stderr chunks", () => {
2681
+ const child = createMockChildProcess();
2682
+ const spawn = vi.fn<StdioSpawn>(() => child);
2683
+
2684
+ const transport = new StdioTransport({
2685
+ command: "node",
2686
+ spawn,
2687
+ });
2688
+
2689
+ const encoded = Buffer.from("é", "utf8");
2690
+ child.stderr.write(encoded.subarray(0, 1));
2691
+ child.stderr.write(encoded.subarray(1));
2692
+
2693
+ expect(transport.getStderrOutput()).toBe("é");
2694
+ });
2695
+
2646
2696
  it("caps stderr at 64KB keeping the tail", () => {
2647
2697
  const child = createMockChildProcess();
2648
2698
  const spawn = vi.fn<StdioSpawn>(() => child);
@@ -7123,6 +7173,21 @@ describe("McpClient callTool", () => {
7123
7173
  throw new Error("Expected initialized notification line to be written");
7124
7174
  }
7125
7175
 
7176
+ const activeMessageLayer = (
7177
+ client as unknown as {
7178
+ messageLayer: JsonRpcMessageLayer | null;
7179
+ }
7180
+ ).messageLayer;
7181
+ if (activeMessageLayer === null) {
7182
+ throw new Error("Expected message layer to exist after connect");
7183
+ }
7184
+ const pendingCount = () =>
7185
+ (
7186
+ activeMessageLayer as unknown as {
7187
+ pendingRequests: Map<unknown, unknown>;
7188
+ }
7189
+ ).pendingRequests.size;
7190
+
7126
7191
  const abortController = new AbortController();
7127
7192
  const callToolPromise = client.callTool(
7128
7193
  {
@@ -7142,6 +7207,8 @@ describe("McpClient callTool", () => {
7142
7207
  const callToolRequest = JSON.parse(callToolLineResult.value) as {
7143
7208
  id: number;
7144
7209
  };
7210
+ expect(pendingCount()).toBe(1);
7211
+
7145
7212
  abortController.abort("user cancelled");
7146
7213
 
7147
7214
  const cancelledLineResult = await iterator.next();
@@ -7158,6 +7225,7 @@ describe("McpClient callTool", () => {
7158
7225
  });
7159
7226
 
7160
7227
  await callToolRejection;
7228
+ expect(pendingCount()).toBe(0);
7161
7229
  await client.close();
7162
7230
  });
7163
7231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -45,7 +45,7 @@
45
45
  "dependencies": {
46
46
  "@clack/core": "^1.0.0",
47
47
  "@clack/prompts": "^1.0.0",
48
- "toolcraft-schema": "0.0.24",
48
+ "toolcraft-schema": "0.0.26",
49
49
  "commander": "^14.0.3",
50
50
  "jose": "^6.1.2",
51
51
  "jsonc-parser": "^3.3.1",