@yawlabs/tailscale-mcp 0.21.1 → 0.21.2

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/README.md CHANGED
@@ -730,13 +730,13 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
730
730
 
731
731
  ## Running on oam.js (optional)
732
732
 
733
- [oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.15.2**. Verified against oam 0.15.2: full MCP handshake, all 97 admin-API tools plus `tailscale_tool_groups`, all 4 resources, identical error responses, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
733
+ [oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.18.0**. Verified against the published oam 0.18.0: full MCP handshake, all 97 admin-API tools plus `tailscale_tool_groups`, all 4 resources, identical error responses, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
734
734
 
735
- **oam 0.15.2 is the minimum.** A floor matters here: releases before 0.9.0 ran `child_process.execFile` arguments through a shell, re-splitting them on whitespace and executing shell metacharacters inside an argument, and this server shells out to the `tailscale` binary across its local-CLI tools, so that was a reachable bug rather than a theoretical one.
735
+ **oam 0.18.0 is the minimum.** A floor matters here: releases before 0.9.0 ran `child_process.execFile` arguments through a shell, re-splitting them on whitespace and executing shell metacharacters inside an argument, and this server shells out to the `tailscale` binary across its local-CLI tools, so that was a reachable bug rather than a theoretical one.
736
736
 
737
737
  How the `tailscale-mcp` command (`bin/tailscale-mcp.mjs`) picks a runtime:
738
738
 
739
- - **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.15.2 or newer, the server runs in that process. Otherwise it uses `OAM_BIN` when that is 0.15.2 or newer, else asks every oam binary it can find — `%LOCALAPPDATA%\oam\bin` then `~/.oam/bin` on Windows, `~/.oam/bin` elsewhere, then `PATH` — for its version and uses the newest at or above the floor (on a tie the installed copy wins). With none, it runs on Node. An oam host older than 0.15.2 never serves the server itself: it hands off to the newest usable oam, or to Node on `PATH`, or exits with an error when there is neither. Whenever it looks for an oam, stderr names an `OAM_BIN` that was passed over and why; the oam binaries it found and passed over are named, each with its reason, only when no usable oam turns up.
739
+ - **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.18.0 or newer, the server runs in that process. Otherwise it uses `OAM_BIN` when that is 0.18.0 or newer, else asks every oam binary it can find — `%LOCALAPPDATA%\oam\bin` then `~/.oam/bin` on Windows, `~/.oam/bin` elsewhere, then `PATH` — for its version and uses the newest at or above the floor (on a tie the installed copy wins). With none, it runs on Node. An oam host older than 0.18.0 never serves the server itself: it hands off to the newest usable oam, or to Node on `PATH`, or exits with an error when there is neither. Whenever it looks for an oam, stderr names an `OAM_BIN` that was passed over and why; the oam binaries it found and passed over are named, each with its reason, only when no usable oam turns up.
740
740
  - **`TAILSCALE_MCP_RUNTIME=oam`** — the same, but exit with an error instead of falling back to Node.
741
741
  - **`TAILSCALE_MCP_RUNTIME=node`** — always Node: in-process under `npx`, handed off to Node on `PATH` when a client launches the command with `oam run`.
742
742
 
@@ -761,7 +761,7 @@ The sandbox is applied by the `tailscale-mcp` command, which spawns a fresh oam
761
761
  }
762
762
  ```
763
763
 
764
- **Measure startup on your own hardware.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid, and on the machine this was measured on node won it — 437ms vs 1554ms for `oam run` over 10 warmed runs (an earlier 5-run round showed 326ms vs 427ms; the box was busy, so treat the magnitude as noisy and the direction as the finding). Those runs used an oam that predates the 0.15.2 floor and have not been repeated since, so do not read them as a current ranking.
764
+ **Measure startup on your own hardware.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid, and on the machine this was measured on node won it — 437ms vs 1554ms for `oam run` over 10 warmed runs (an earlier 5-run round showed 326ms vs 427ms; the box was busy, so treat the magnitude as noisy and the direction as the finding). Those runs used an oam that predates the 0.18.0 floor and have not been repeated since, so do not read them as a current ranking.
765
765
 
766
766
  The published `tailscale-mcp` command prefers the newest usable oam it finds (see above). Without oam that costs almost nothing: discovery is file-existence checks only, never a subprocess, and the fallback runs the server inside the Node process npm already started. With oam installed, though, the command boots Node, runs `--version` on every oam binary it found to pick the newest, and only then boots oam, so it is always slower than pointing your client at a runtime directly — the config above for oam, `node /path/to/tailscale-mcp/dist/index.js` for Node. `TAILSCALE_MCP_RUNTIME=node` skips oam entirely.
767
767
 
@@ -91,7 +91,7 @@
91
91
  * shipped bundle; keep it in step.
92
92
  *
93
93
  * MINIMUM OAM VERSION
94
- * The latest oam release, 0.15.2 -- bump OAM_MIN when oam ships a newer one.
94
+ * The latest oam release, 0.18.0 -- bump OAM_MIN when oam ships a newer one.
95
95
  * Only the current oam is used and verified; an older one is passed over for a
96
96
  * newer oam, or for Node. The floor is not cosmetic: before 0.9.0
97
97
  * `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
@@ -124,7 +124,7 @@ import { delimiter, join } from "node:path";
124
124
  import { fileURLToPath } from "node:url";
125
125
 
126
126
  /** The latest oam release, and the oldest one used. See MINIMUM OAM VERSION above. */
127
- const OAM_MIN = [0, 15, 2];
127
+ const OAM_MIN = [0, 18, 0];
128
128
 
129
129
  /**
130
130
  * The oldest Node this package supports, matching package.json `engines.node`.
package/dist/index.js CHANGED
@@ -4148,13 +4148,14 @@ var require_fast_uri = __commonJS({
4148
4148
  if (!malformedIPLiteral) {
4149
4149
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP2);
4150
4150
  }
4151
- if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4152
- if (uri.indexOf("%") !== -1) {
4153
- if (parsed.host !== void 0 && !malformedIPLiteral) {
4154
- const host = isIP2 ? parsed.host : normalizePercentEncoding(parsed.host, true);
4155
- parsed.host = reescapeHostDelimiters(host, isIP2);
4156
- }
4151
+ if (uri.indexOf("%") !== -1 && parsed.host !== void 0 && !malformedIPLiteral) {
4152
+ let host = isIP2 ? parsed.host : normalizePercentEncoding(parsed.host, true);
4153
+ if (!isIP2) {
4154
+ host = normalizePercentEncoding(host.toLowerCase());
4157
4155
  }
4156
+ parsed.host = reescapeHostDelimiters(host, isIP2);
4157
+ }
4158
+ if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4158
4159
  if (parsed.path) {
4159
4160
  parsed.path = normalizePathEncoding(parsed.path);
4160
4161
  }
@@ -28700,6 +28701,9 @@ var Protocol = class {
28700
28701
  this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => {
28701
28702
  const handleTaskResult = async () => {
28702
28703
  const taskId = request.params.taskId;
28704
+ if (!await this._taskStore.getTask(taskId, extra.sessionId)) {
28705
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
28706
+ }
28703
28707
  if (this._taskMessageQueue) {
28704
28708
  let queuedMessage;
28705
28709
  while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) {
@@ -28730,12 +28734,12 @@ var Protocol = class {
28730
28734
  throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
28731
28735
  }
28732
28736
  if (!isTerminal(task.status)) {
28733
- await this._waitForTaskUpdate(taskId, extra.signal);
28737
+ await this._waitForTaskUpdate(taskId, extra.signal, extra.sessionId);
28734
28738
  return await handleTaskResult();
28735
28739
  }
28736
28740
  if (isTerminal(task.status)) {
28737
28741
  const result = await this._taskStore.getTaskResult(taskId, extra.sessionId);
28738
- this._clearTaskQueue(taskId);
28742
+ this._clearTaskQueue(taskId, extra.sessionId);
28739
28743
  return {
28740
28744
  ...result,
28741
28745
  _meta: {
@@ -28772,7 +28776,7 @@ var Protocol = class {
28772
28776
  throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
28773
28777
  }
28774
28778
  await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId);
28775
- this._clearTaskQueue(request.params.taskId);
28779
+ this._clearTaskQueue(request.params.taskId, extra.sessionId);
28776
28780
  const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId);
28777
28781
  if (!cancelledTask) {
28778
28782
  throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
@@ -28900,6 +28904,19 @@ var Protocol = class {
28900
28904
  const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
28901
28905
  const capturedTransport = this._transport;
28902
28906
  const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId;
28907
+ const sessionId = capturedTransport?.sessionId;
28908
+ const store = this._taskStore;
28909
+ let relatedTaskFound = true;
28910
+ let relatedTaskLookup;
28911
+ if (relatedTaskId && store && this._taskMessageQueue && sessionId !== void 0) {
28912
+ relatedTaskFound = false;
28913
+ relatedTaskLookup = (async () => {
28914
+ if (!await store.getTask(relatedTaskId, sessionId)) {
28915
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${relatedTaskId}`);
28916
+ }
28917
+ relatedTaskFound = true;
28918
+ })();
28919
+ }
28903
28920
  if (handler === void 0) {
28904
28921
  const errorResponse = {
28905
28922
  jsonrpc: "2.0",
@@ -28909,7 +28926,10 @@ var Protocol = class {
28909
28926
  message: "Method not found"
28910
28927
  }
28911
28928
  };
28912
- if (relatedTaskId && this._taskMessageQueue) {
28929
+ if (relatedTaskId && relatedTaskLookup) {
28930
+ const queuedError = { type: "error", message: errorResponse, timestamp: Date.now() };
28931
+ relatedTaskLookup.then(() => this._enqueueTaskMessage(relatedTaskId, queuedError, sessionId), () => capturedTransport?.send(errorResponse)).catch((error51) => this._onerror(new Error(`Failed to send an error response: ${error51}`)));
28932
+ } else if (relatedTaskId && this._taskMessageQueue) {
28913
28933
  this._enqueueTaskMessage(relatedTaskId, {
28914
28934
  type: "error",
28915
28935
  message: errorResponse,
@@ -28960,7 +28980,10 @@ var Protocol = class {
28960
28980
  closeSSEStream: extra?.closeSSEStream,
28961
28981
  closeStandaloneSSEStream: extra?.closeStandaloneSSEStream
28962
28982
  };
28963
- Promise.resolve().then(() => {
28983
+ (relatedTaskLookup ?? Promise.resolve()).then(() => {
28984
+ if (relatedTaskLookup && abortController.signal.aborted) {
28985
+ throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled");
28986
+ }
28964
28987
  if (taskCreationParams) {
28965
28988
  this.assertTaskHandlerCapability(request.method);
28966
28989
  }
@@ -28995,7 +29018,7 @@ var Protocol = class {
28995
29018
  ...error51["data"] !== void 0 && { data: error51["data"] }
28996
29019
  }
28997
29020
  };
28998
- if (relatedTaskId && this._taskMessageQueue) {
29021
+ if (relatedTaskId && this._taskMessageQueue && relatedTaskFound) {
28999
29022
  await this._enqueueTaskMessage(relatedTaskId, {
29000
29023
  type: "error",
29001
29024
  message: errorResponse,
@@ -29475,7 +29498,7 @@ var Protocol = class {
29475
29498
  throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured");
29476
29499
  }
29477
29500
  const maxQueueSize = this._options?.maxTaskQueueSize;
29478
- await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize);
29501
+ await this._taskMessageQueue.enqueue(taskId, message, sessionId ?? this._transport?.sessionId, maxQueueSize);
29479
29502
  }
29480
29503
  /**
29481
29504
  * Clears the message queue for a task and rejects any pending request resolvers.
@@ -29504,12 +29527,13 @@ var Protocol = class {
29504
29527
  * Uses polling to check for updates at the task's configured poll interval.
29505
29528
  * @param taskId The task ID to wait for
29506
29529
  * @param signal Abort signal to cancel the wait
29530
+ * @param sessionId Session of the request that waits, passed to the task store
29507
29531
  * @returns Promise that resolves when an update occurs or rejects if aborted
29508
29532
  */
29509
- async _waitForTaskUpdate(taskId, signal) {
29533
+ async _waitForTaskUpdate(taskId, signal, sessionId) {
29510
29534
  let interval = this._options?.defaultTaskPollInterval ?? 1e3;
29511
29535
  try {
29512
- const task = await this._taskStore?.getTask(taskId);
29536
+ const task = await this._taskStore?.getTask(taskId, sessionId);
29513
29537
  if (task?.pollInterval) {
29514
29538
  interval = task.pollInterval;
29515
29539
  }
@@ -30388,6 +30412,42 @@ var ExperimentalMcpServerTasks = class {
30388
30412
  };
30389
30413
 
30390
30414
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js
30415
+ function toolInputElementCount(value, max) {
30416
+ let count = 0;
30417
+ const stack = [value];
30418
+ while (stack.length > 0) {
30419
+ const node = stack.pop();
30420
+ if (node === null || typeof node !== "object")
30421
+ continue;
30422
+ if (Array.isArray(node)) {
30423
+ for (const child of node) {
30424
+ if (++count > max)
30425
+ return count;
30426
+ if (child !== null && typeof child === "object")
30427
+ stack.push(child);
30428
+ }
30429
+ } else {
30430
+ for (const key in node) {
30431
+ if (!Object.prototype.hasOwnProperty.call(node, key))
30432
+ continue;
30433
+ if (++count > max)
30434
+ return count;
30435
+ const child = node[key];
30436
+ if (child !== null && typeof child === "object")
30437
+ stack.push(child);
30438
+ }
30439
+ }
30440
+ }
30441
+ return count;
30442
+ }
30443
+ function resolveMaxToolInputElements(value) {
30444
+ if (value === void 0 || value === Infinity)
30445
+ return void 0;
30446
+ if (typeof value !== "number" || Number.isNaN(value) || value < 1) {
30447
+ throw new RangeError(`maxToolInputElements must be a number of at least 1, or Infinity, got ${String(value)}`);
30448
+ }
30449
+ return value;
30450
+ }
30391
30451
  var McpServer = class {
30392
30452
  constructor(serverInfo, options) {
30393
30453
  this._registeredResources = {};
@@ -30399,6 +30459,7 @@ var McpServer = class {
30399
30459
  this._resourceHandlersInitialized = false;
30400
30460
  this._promptHandlersInitialized = false;
30401
30461
  this.server = new Server(serverInfo, options);
30462
+ this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements);
30402
30463
  }
30403
30464
  /**
30404
30465
  * Access experimental features.
@@ -30529,12 +30590,15 @@ var McpServer = class {
30529
30590
  * Validates tool input arguments against the tool's input schema.
30530
30591
  */
30531
30592
  async validateToolInput(tool, args, toolName) {
30593
+ if (this._maxToolInputElements !== void 0 && toolInputElementCount(args, this._maxToolInputElements) > this._maxToolInputElements) {
30594
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${this._maxToolInputElements} elements`);
30595
+ }
30532
30596
  if (!tool.inputSchema) {
30533
30597
  return void 0;
30534
30598
  }
30535
30599
  const inputObj = normalizeObjectSchema(tool.inputSchema);
30536
30600
  const schemaToParse = inputObj ?? tool.inputSchema;
30537
- const parseResult = await safeParseAsync2(schemaToParse, args);
30601
+ const parseResult = await safeParseAsync2(schemaToParse, args ?? {});
30538
30602
  if (!parseResult.success) {
30539
30603
  const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
30540
30604
  const errorMessage = getParseErrorMessage(error51);
@@ -30772,7 +30836,7 @@ var McpServer = class {
30772
30836
  }
30773
30837
  if (prompt.argsSchema) {
30774
30838
  const argsObj = normalizeObjectSchema(prompt.argsSchema);
30775
- const parseResult = await safeParseAsync2(argsObj, request.params.arguments);
30839
+ const parseResult = await safeParseAsync2(argsObj, request.params.arguments ?? {});
30776
30840
  if (!parseResult.success) {
30777
30841
  const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
30778
30842
  const errorMessage = getParseErrorMessage(error51);
@@ -35225,7 +35289,7 @@ Install a newer Node (https://nodejs.org/en/download), or point your MCP client'
35225
35289
  );
35226
35290
  process.exit(1);
35227
35291
  }
35228
- var version2 = true ? "0.21.1" : resolveVersionFallback();
35292
+ var version2 = true ? "0.21.2" : resolveVersionFallback();
35229
35293
  var subcommand = process.argv[2];
35230
35294
  var USAGE = `Usage: tailscale-mcp [command]
35231
35295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "mcpName": "io.github.YawLabs/tailscale-mcp",
5
5
  "description": "Tailscale MCP server: admin-API tools for devices, ACLs, DNS, auth keys, users, and audit logs, plus a CLI to validate and deploy ACLs.",
6
6
  "license": "MIT",
@@ -64,13 +64,13 @@
64
64
  "overrides": {
65
65
  "hono": "^4.13.5",
66
66
  "@hono/node-server": "^1.19.15",
67
- "fast-uri": "^3.1.6",
68
- "ip-address": "^10.3.1",
67
+ "fast-uri": "^3.1.8",
68
+ "ip-address": "^10.7.3",
69
69
  "qs": "^6.16.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@biomejs/biome": "^2.4.12",
73
- "@modelcontextprotocol/sdk": "^1.30.0",
73
+ "@modelcontextprotocol/sdk": "^1.32.1",
74
74
  "@types/node": "^26.0.0",
75
75
  "esbuild": "^0.28.1",
76
76
  "postject": "^1.0.0-alpha.6",