@myspec/mcp-server 1.1.1-next.165 → 1.1.1-next.166

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 (3) hide show
  1. package/README.md +4 -3
  2. package/dist/index.js +224 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -363,9 +363,10 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
363
363
  | `reply_spec_file_comment_thread` | `file_id`, `thread_id`, `body` | Replies to an existing thread. Conflicts on a trashed file |
364
364
  | `update_spec_file_comment` | `file_id`, `comment_id`, `body`, optional `expected_version` | Edits the body of one of YOUR OWN comments; the platform refuses somebody else's. `expected_version` is the **comment's** own `content_version`, not the file's — pairing it with the file would fail an edit because somebody saved the document. Works on a trashed file |
365
365
  | `delete_spec_file_comment` | `file_id`, `comment_id` | Soft-deletes one of YOUR OWN comments. Three outcomes, reported in the result: a reply disappears (`comment: null`); a first comment with live replies becomes a tombstone so the thread keeps a head (`comment` is the tombstone); a first comment with no live replies takes the whole thread (`thread_deleted: true`) |
366
- | `list_tasks` | `project_id`, optional `status`, `assignee_user_id`, `cycle`, `label`, `file_id`, `include_details`, `limit`, `offset` | The project's task board: every checkbox task in its tasks.md files, in file order, each with a stable `task_id`, `status` (`pending`/`in_progress`/`done`), assignee, `priority` (0 none … 4 low, plus `priority_name`), `story_points`, `cycle`, `start_date`/`end_date` (calendar days, `YYYY-MM-DD`), `labels`, and with `include_details` a 500-character preview of its details. Also the files and the labels and cycles in use. Paged: 100 tasks by default (`limit` up to 500), with `total_tasks` and, while more remain, `next_offset` |
367
- | `update_tasks` | `project_id`, `task_ids[]` (≤ 200), any of `assignee_user_id`, `priority`, `story_points`, `cycle`, `start_date`, `end_date`, `add_labels[]`, `remove_labels[]` | Edits planning fields on every task given. A field left out is left alone; `null` clears it. The assignee must be an org member who can open the project. All or nothing: an unknown id fails the call. Returns the tasks as they are now |
368
- | `set_task_status` | `project_id`, `task_ids[]` (≤ 200), `status` | Rewrites the tasks' checkboxes in tasks.md (`[ ]`, `[-]`, `[x]`), one revision per file, recorded as an AI change. Partial: returns `updated`/`unchanged`/`removed`/`failed` counts, an outcome per task (with an error code such as `conflict` or `quota_exceeded`) and per file, and the tasks as they are now; an error result only when every task failed |
366
+ | `list_tasks` | `project_id`, optional `status`, `assignee_user_id` (a user id, `me` or `none`), `cycle`, `label`, `file_id`, `include_details`, `limit`, `offset` | The project's task board: every checkbox task in its tasks.md files, in file order, each with a stable `task_id`, `status` (`pending`/`in_progress`/`done`), assignee, `priority` (0 none … 4 low, plus `priority_name`), `story_points`, `cycle`, `start_date`/`end_date` (calendar days, `YYYY-MM-DD`), `labels`, and with `include_details` a 500-character preview of its details. Also the files (with the `content_version` their tasks reflect) and the labels and cycles in use. Filtered and paged by the platform: 100 tasks by default (`limit` up to 500), with `total_tasks` and, while more remain, `next_offset` |
367
+ | `list_task_assignees` | `project_id` | Who the project's tasks can be assigned to: the org's people who can open the project (exactly who `update_tasks` accepts), each with `user_id`, `username` and `display_name`, plus your own `self_user_id`. May include people who left the org but whose profile remains; at most 1000 (`truncated`) |
368
+ | `update_tasks` | `project_id`, `task_ids[]` (≤ 200), any of `assignee_user_id`, `priority`, `story_points`, `cycle`, `start_date`, `end_date`, `add_labels[]`, `remove_labels[]` | Edits planning fields on every task given. A field left out is left alone; `null` clears it. The assignee must be an org member who can open the project (`list_task_assignees`); `me` means you. All or nothing: an unknown id fails the call. Returns the tasks as they are now |
369
+ | `set_task_status` | `project_id`, `task_ids[]` (≤ 200), `status`, optional `expected_versions[]` (`{spec_file_id, content_version}` from `list_tasks`) | Rewrites the tasks' checkboxes in tasks.md (`[ ]`, `[-]`, `[x]`), one revision per file, recorded as an AI change. A file that changed since the pinned version is not written (`file_changed`). Partial: returns `updated`/`unchanged`/`removed`/`failed` counts, an outcome per task (with an error code such as `conflict` or `quota_exceeded`) and per file, and the tasks as they are now; an error result only when every task failed |
369
370
 
370
371
  There is deliberately **no tool to resolve, unresolve or re-anchor a thread**. Those three are human-only: resolving declares a review finished, and re-anchoring is the repair a person makes after the automatic pass has already failed to locate the passage. The platform refuses them for an AI caller with a 403, so a tool would only ever return an error.
371
372
 
package/dist/index.js CHANGED
@@ -1841,6 +1841,24 @@ function isProjectPermissionDeniedError(err) {
1841
1841
  const message = err.message;
1842
1842
  return typeof message === "string" && message.includes("PROJECT_PERMISSION_DENIED:");
1843
1843
  }
1844
+ var TASK_BOARD_DISABLED_CODE = "task_board_disabled";
1845
+ var PlatformTaskBoardDisabledError = class extends PlatformHttpError {
1846
+ code = TASK_BOARD_DISABLED_CODE;
1847
+ constructor(message, details) {
1848
+ super(message, 403, details);
1849
+ this.name = "PlatformTaskBoardDisabledError";
1850
+ }
1851
+ };
1852
+ function isTaskBoardDisabledError(err) {
1853
+ if (err instanceof PlatformTaskBoardDisabledError) {
1854
+ return true;
1855
+ }
1856
+ if (typeof err !== "object" || err === null) {
1857
+ return false;
1858
+ }
1859
+ const code = err.code;
1860
+ return typeof code === "string" && code.toLowerCase() === TASK_BOARD_DISABLED_CODE;
1861
+ }
1844
1862
  function isPlatformHttpError(err) {
1845
1863
  return typeof err === "object" && err !== null && err[PLATFORM_ERROR_BRAND] === true;
1846
1864
  }
@@ -1933,6 +1951,9 @@ function createPlatformError(message, status, details, code) {
1933
1951
  if (status === 403 && typeof code === "string" && code.toLowerCase() === PROJECT_PERMISSION_DENIED_CODE) {
1934
1952
  return new PlatformPermissionError(message, details);
1935
1953
  }
1954
+ if (status === 403 && typeof code === "string" && code.toLowerCase() === TASK_BOARD_DISABLED_CODE) {
1955
+ return new PlatformTaskBoardDisabledError(message, details);
1956
+ }
1936
1957
  return new PlatformHttpError(message, status, details);
1937
1958
  }
1938
1959
  function truncate(text) {
@@ -1958,6 +1979,23 @@ async function throwFromResponse(response, fallbackMessage) {
1958
1979
  throw createPlatformError(`HTTP ${response.status}: ${message}`, response.status, details, code);
1959
1980
  }
1960
1981
 
1982
+ // ../../packages/platform-client/shared/jwt.ts
1983
+ function jwtSubject(token) {
1984
+ const raw = token.startsWith("Bearer ") ? token.slice("Bearer ".length) : token;
1985
+ const payloadSegment = raw.split(".")[1];
1986
+ if (!payloadSegment) {
1987
+ return null;
1988
+ }
1989
+ try {
1990
+ const base64 = payloadSegment.replace(/-/g, "+").replace(/_/g, "/");
1991
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
1992
+ const payload = JSON.parse(atob(padded));
1993
+ return typeof payload.sub === "string" && payload.sub !== "" ? payload.sub : null;
1994
+ } catch {
1995
+ return null;
1996
+ }
1997
+ }
1998
+
1961
1999
  // ../../packages/shared/logger.ts
1962
2000
  var LOG_LEVEL_PRIORITY = {
1963
2001
  debug: 0,
@@ -2325,7 +2363,12 @@ var TASK_LIMITS = {
2325
2363
  maxLabels: 20,
2326
2364
  maxLabelLength: 40,
2327
2365
  maxStoryPoints: 9999.99,
2328
- maxTasksPerRequest: 200
2366
+ maxCycle: 1e5,
2367
+ maxTasksPerRequest: 200,
2368
+ /** The most tasks one page of a listing holds. */
2369
+ maxPageSize: 500,
2370
+ /** The most tasks.md files one board lists (and the most expected_versions a status change takes). */
2371
+ maxBoardFiles: 500
2329
2372
  };
2330
2373
  function parseTimestamp(raw) {
2331
2374
  if (!raw) return null;
@@ -2380,9 +2423,31 @@ function transformTaskBoard(raw) {
2380
2423
  })),
2381
2424
  tasks: (raw.tasks ?? []).map(transformProjectTask),
2382
2425
  labels: raw.labels ?? [],
2383
- cycles: raw.cycles ?? []
2426
+ cycles: raw.cycles ?? [],
2427
+ filesTruncated: raw.files_truncated === true,
2428
+ ...typeof raw.total === "number" ? {
2429
+ page: {
2430
+ total: raw.total,
2431
+ offset: raw.offset ?? 0,
2432
+ limit: raw.limit ?? 0,
2433
+ nextOffset: typeof raw.next_offset === "number" ? raw.next_offset : null
2434
+ }
2435
+ } : {}
2384
2436
  };
2385
2437
  }
2438
+ function toTaskListQueryString(query) {
2439
+ if (!query) return "";
2440
+ const params = new URLSearchParams();
2441
+ if (query.status) params.set("status", query.status);
2442
+ if (query.assigneeUserId) params.set("assignee_user_id", query.assigneeUserId);
2443
+ if (query.cycle !== void 0) params.set("cycle", String(query.cycle));
2444
+ if (query.label) params.set("label", query.label);
2445
+ if (query.specFileId) params.set("spec_file_id", query.specFileId);
2446
+ if (query.limit !== void 0) params.set("limit", String(query.limit));
2447
+ if (query.offset !== void 0) params.set("offset", String(query.offset));
2448
+ const qs = params.toString();
2449
+ return qs ? `?${qs}` : "";
2450
+ }
2386
2451
  function transformSetTaskStatusResult(raw) {
2387
2452
  return {
2388
2453
  results: (raw.results ?? []).map((r) => ({
@@ -2399,6 +2464,17 @@ function transformSetTaskStatusResult(raw) {
2399
2464
  tasks: (raw.tasks ?? []).map(transformProjectTask)
2400
2465
  };
2401
2466
  }
2467
+ function transformTaskAssigneeList(raw) {
2468
+ return {
2469
+ assignees: (raw.assignees ?? []).map((a) => ({
2470
+ userId: a.user_id,
2471
+ username: a.username ?? "",
2472
+ displayName: a.display_name ?? ""
2473
+ })),
2474
+ selfUserId: raw.self_user_id ?? "",
2475
+ truncated: raw.truncated === true
2476
+ };
2477
+ }
2402
2478
  function toRawUpdateTasksRequest(taskIds, patch) {
2403
2479
  const set = {};
2404
2480
  if ("assigneeUserId" in patch) set.assignee_user_id = patch.assigneeUserId ?? null;
@@ -3257,10 +3333,18 @@ var HttpProjectTaskClient = class {
3257
3333
  constructor(httpClient) {
3258
3334
  this.httpClient = httpClient;
3259
3335
  }
3260
- async listProjectTasks(projectId, jwtToken) {
3261
- const raw = await this.httpClient.get(tasksPath(projectId), jwtToken, {});
3336
+ async listProjectTasks(projectId, jwtToken, query) {
3337
+ const raw = await this.httpClient.get(
3338
+ `${tasksPath(projectId)}${toTaskListQueryString(query)}`,
3339
+ jwtToken,
3340
+ {}
3341
+ );
3262
3342
  return transformTaskBoard(raw);
3263
3343
  }
3344
+ async listProjectTaskAssignees(projectId, jwtToken) {
3345
+ const raw = await this.httpClient.get(`${tasksPath(projectId)}/assignees`, jwtToken, {});
3346
+ return transformTaskAssigneeList(raw);
3347
+ }
3264
3348
  async updateProjectTasks(projectId, taskIds, patch, jwtToken) {
3265
3349
  const raw = await this.httpClient.patch(
3266
3350
  tasksPath(projectId),
@@ -3271,9 +3355,16 @@ var HttpProjectTaskClient = class {
3271
3355
  return (raw.tasks ?? []).map(transformProjectTask);
3272
3356
  }
3273
3357
  async setProjectTaskStatus(projectId, taskIds, status, jwtToken, opts) {
3358
+ const body = {
3359
+ task_ids: taskIds,
3360
+ status
3361
+ };
3362
+ if (opts?.expectedVersions && Object.keys(opts.expectedVersions).length > 0) {
3363
+ body.expected_versions = opts.expectedVersions;
3364
+ }
3274
3365
  const raw = await this.httpClient.post(
3275
3366
  `${tasksPath(projectId)}/status`,
3276
- { task_ids: taskIds, status },
3367
+ body,
3277
3368
  jwtToken,
3278
3369
  opts?.actorKind ? { headers: { [HEADER_ACTOR_KIND3]: opts.actorKind } } : {}
3279
3370
  );
@@ -4108,18 +4199,33 @@ var PlatformClient = class {
4108
4199
  // `actorKind: 'ai'` like every other file write here: the revision is then
4109
4200
  // recorded as the assistant's, not filed under the user's name. A planning
4110
4201
  // edit writes no file and the platform records no actor kind for it.
4111
- /** Every live task of a project's tasks.md files. */
4112
- async listProjectTasks(projectId) {
4113
- return this.withTokenRetry((jwt, c) => c.task.listProjectTasks(projectId, jwt));
4202
+ /** The live tasks of a project's tasks.md files: all, or those `query` keeps. */
4203
+ async listProjectTasks(projectId, query) {
4204
+ return this.withTokenRetry((jwt, c) => c.task.listProjectTasks(projectId, jwt, query));
4205
+ }
4206
+ /** Who the project's tasks can be assigned to, and the caller's own user id. */
4207
+ async listProjectTaskAssignees(projectId) {
4208
+ return this.withTokenRetry((jwt, c) => c.task.listProjectTaskAssignees(projectId, jwt));
4209
+ }
4210
+ /**
4211
+ * The caller's own user id, read from the access token (unverified: only to
4212
+ * turn "me" into an id the platform then checks). Null when the token does
4213
+ * not carry one.
4214
+ */
4215
+ async currentUserId() {
4216
+ return jwtSubject(await this.tokenManager.getValidAccessToken());
4114
4217
  }
4115
4218
  /** Applies one planning patch to up to 200 tasks; all or nothing. */
4116
4219
  async updateProjectTasks(projectId, taskIds, patch) {
4117
4220
  return this.withTokenRetry((jwt, c) => c.task.updateProjectTasks(projectId, taskIds, patch, jwt));
4118
4221
  }
4119
- /** Sets the status of up to 200 tasks by rewriting their checkboxes. */
4120
- async setProjectTaskStatus(projectId, taskIds, status) {
4222
+ /**
4223
+ * Sets the status of up to 200 tasks by rewriting their checkboxes.
4224
+ * `expectedVersions` pins the file versions the caller's view came from.
4225
+ */
4226
+ async setProjectTaskStatus(projectId, taskIds, status, expectedVersions) {
4121
4227
  return this.withTokenRetry(
4122
- (jwt, c) => c.task.setProjectTaskStatus(projectId, taskIds, status, jwt, { actorKind: "ai" })
4228
+ (jwt, c) => c.task.setProjectTaskStatus(projectId, taskIds, status, jwt, { actorKind: "ai", expectedVersions })
4123
4229
  );
4124
4230
  }
4125
4231
  async withTokenRetry(call) {
@@ -7334,8 +7440,10 @@ var startSpecSessionTool = {
7334
7440
  import { z as z39 } from "zod";
7335
7441
  var LIST_DEFAULT_TASKS = 100;
7336
7442
  var LIST_MAX_TASKS = 500;
7443
+ var LIST_MAX_OFFSET = 1e6;
7337
7444
  var DETAILS_PREVIEW_CHARS = 500;
7338
- var MAX_CYCLE = 1e5;
7445
+ var ME = "me";
7446
+ var NONE = "none";
7339
7447
  var PRIORITY_NAMES = ["none", "urgent", "high", "medium", "low"];
7340
7448
  var STATUS = z39.enum(["pending", "in_progress", "done"]);
7341
7449
  function toDay(date) {
@@ -7371,6 +7479,10 @@ function taskSummary(task, opts = {}) {
7371
7479
  };
7372
7480
  }
7373
7481
  var userIdField = z39.string().trim().min(1);
7482
+ async function resolveMe(id, ctx) {
7483
+ return id === ME ? ctx.client.currentUserId() : id;
7484
+ }
7485
+ var UNKNOWN_ME = 'Could not tell who you are from this connection, so "me" cannot be used. Pass your user id instead: list_task_assignees returns it as self_user_id.';
7374
7486
  var labelField = z39.string().trim().min(1).refine((label) => Array.from(label).length <= TASK_LIMITS.maxLabelLength, {
7375
7487
  message: `A label is at most ${String(TASK_LIMITS.maxLabelLength)} characters.`
7376
7488
  });
@@ -7380,8 +7492,10 @@ function taskIdsField(what) {
7380
7492
  var listInputSchema2 = {
7381
7493
  project_id: z39.uuid().describe("UUID of the project whose tasks to list."),
7382
7494
  status: STATUS.optional().describe("Only tasks with this status."),
7383
- assignee_user_id: userIdField.optional().describe("Only tasks assigned to this user id (as returned in assignee_user_id)."),
7384
- cycle: z39.number().int().min(1).optional().describe("Only tasks in this cycle (sprint) number."),
7495
+ assignee_user_id: userIdField.optional().describe(
7496
+ 'Only tasks assigned to this user id (as returned in assignee_user_id or by list_task_assignees). "me" means you; "none" means unassigned tasks.'
7497
+ ),
7498
+ cycle: z39.number().int().min(1).max(TASK_LIMITS.maxCycle).optional().describe("Only tasks in this cycle (sprint) number."),
7385
7499
  label: labelField.optional().describe("Only tasks carrying this label."),
7386
7500
  file_id: z39.uuid().optional().describe("Only tasks from this tasks.md (its spec file id)."),
7387
7501
  include_details: z39.boolean().optional().describe(
@@ -7390,20 +7504,51 @@ var listInputSchema2 = {
7390
7504
  limit: z39.number().int().min(1).max(LIST_MAX_TASKS).optional().describe(
7391
7505
  `Tasks per page (default ${String(LIST_DEFAULT_TASKS)}, at most ${String(LIST_MAX_TASKS)}).`
7392
7506
  ),
7393
- offset: z39.number().int().min(0).optional().describe("How many matching tasks to skip; pass next_offset from the previous page.")
7507
+ offset: z39.number().int().min(0).max(LIST_MAX_OFFSET).optional().describe("How many matching tasks to skip; pass next_offset from the previous page.")
7394
7508
  };
7509
+ function pageLocally(board, query) {
7510
+ const assignee = query.assigneeUserId;
7511
+ const label = query.label?.split(/\s+/).filter(Boolean).join(" ");
7512
+ const matching = board.tasks.filter(
7513
+ (task) => (query.status === void 0 || task.status === query.status) && (assignee === void 0 || (assignee === NONE ? task.assigneeUserId === null : task.assigneeUserId === assignee)) && (query.cycle === void 0 || task.cycle === query.cycle) && (label === void 0 || task.labels.includes(label)) && (query.specFileId === void 0 || task.specFileId === query.specFileId)
7514
+ );
7515
+ const offset = query.offset ?? 0;
7516
+ const tasks = matching.slice(offset, offset + (query.limit ?? LIST_DEFAULT_TASKS));
7517
+ const next = offset + tasks.length;
7518
+ return {
7519
+ tasks,
7520
+ total: matching.length,
7521
+ offset,
7522
+ nextOffset: next < matching.length ? next : null
7523
+ };
7524
+ }
7395
7525
  var listTasksTool = {
7396
7526
  name: "list_tasks",
7397
- description: "List a project's tasks: every checkbox task in its tasks.md files, in file order, each with a stable task_id, status (pending, in_progress, done), assignee, priority (0 none, 1 urgent, 2 high, 3 medium, 4 low), story points, cycle, start/end dates (YYYY-MM-DD) and labels. Also returns the files, and the labels and cycles in use. Filter by status, assignee, cycle, label or file. Paged: 100 tasks by default (limit up to 500), with total_tasks and, while more remain, next_offset. Details (sub-bullets) only with include_details. Use the task_id values with update_tasks and set_task_status.",
7527
+ description: `List a project's tasks: every checkbox task in its tasks.md files, in file order, each with a stable task_id, status (pending, in_progress, done), assignee, priority (0 none, 1 urgent, 2 high, 3 medium, 4 low), story points, cycle, start/end dates (YYYY-MM-DD) and labels. Also returns the files (with the content_version their tasks reflect), and the labels and cycles in use. Filter by status, assignee ("me" for yourself, "none" for unassigned), cycle, label or file. Paged: 100 tasks by default (limit up to 500), with total_tasks and, while more remain, next_offset. Details (sub-bullets) only with include_details. Use the task_id values with update_tasks and set_task_status.`,
7398
7528
  inputSchema: listInputSchema2,
7399
7529
  handler: async (args, ctx) => {
7400
- const board = await ctx.client.listProjectTasks(args.project_id);
7401
- const matching = board.tasks.filter(
7402
- (task) => (args.status === void 0 || task.status === args.status) && (args.assignee_user_id === void 0 || task.assigneeUserId === args.assignee_user_id) && (args.cycle === void 0 || task.cycle === args.cycle) && (args.label === void 0 || task.labels.includes(args.label)) && (args.file_id === void 0 || task.specFileId === args.file_id)
7403
- );
7404
- const offset = args.offset ?? 0;
7405
- const page = matching.slice(offset, offset + (args.limit ?? LIST_DEFAULT_TASKS));
7406
- const next = offset + page.length;
7530
+ let assignee;
7531
+ if (args.assignee_user_id !== void 0) {
7532
+ const resolved = await resolveMe(args.assignee_user_id, ctx);
7533
+ if (resolved === null) return errorResult(UNKNOWN_ME);
7534
+ assignee = resolved;
7535
+ }
7536
+ const query = {
7537
+ ...args.status !== void 0 ? { status: args.status } : {},
7538
+ ...assignee !== void 0 ? { assigneeUserId: assignee } : {},
7539
+ ...args.cycle !== void 0 ? { cycle: args.cycle } : {},
7540
+ ...args.label !== void 0 ? { label: args.label } : {},
7541
+ ...args.file_id !== void 0 ? { specFileId: args.file_id } : {},
7542
+ limit: args.limit ?? LIST_DEFAULT_TASKS,
7543
+ offset: args.offset ?? 0
7544
+ };
7545
+ const board = await ctx.client.listProjectTasks(args.project_id, query);
7546
+ const page = board.page ? {
7547
+ tasks: board.tasks,
7548
+ total: board.page.total,
7549
+ offset: board.page.offset,
7550
+ nextOffset: board.page.nextOffset
7551
+ } : pageLocally(board, query);
7407
7552
  return jsonResult({
7408
7553
  project_id: board.projectId,
7409
7554
  files: board.files.map((file) => ({
@@ -7413,25 +7558,49 @@ var listTasksTool = {
7413
7558
  // The file could not be re-read just now: its tasks are from the last read.
7414
7559
  ...file.stale ? { stale: true } : {}
7415
7560
  })),
7416
- tasks: page.map((task) => taskSummary(task, { details: args.include_details })),
7417
- total_tasks: matching.length,
7418
- offset,
7561
+ // Only ever present and true: the project has more than 500 tasks.md
7562
+ // files and the rest are left out.
7563
+ ...board.filesTruncated ? { files_truncated: true } : {},
7564
+ tasks: page.tasks.map((task) => taskSummary(task, { details: args.include_details })),
7565
+ total_tasks: page.total,
7566
+ offset: page.offset,
7419
7567
  // Only while more remain, so its absence means "that was the last page".
7420
- ...next < matching.length ? { next_offset: next } : {},
7568
+ ...page.nextOffset !== null ? { next_offset: page.nextOffset } : {},
7421
7569
  labels: board.labels,
7422
7570
  cycles: board.cycles
7423
7571
  });
7424
7572
  }
7425
7573
  };
7574
+ var assigneesInputSchema = {
7575
+ project_id: z39.uuid().describe("UUID of the project whose tasks are being assigned.")
7576
+ };
7577
+ var listTaskAssigneesTool = {
7578
+ name: "list_task_assignees",
7579
+ description: `List who a project's tasks can be assigned to: the organisation's people who can open the project, which is exactly who update_tasks accepts as assignee_user_id. Each has a user_id, a username (handle) and a display_name ("" when unset). Also returns your own user id as self_user_id. May include people who have left the organisation but whose profile remains. At most 1000 people; truncated is true when the list was cut.`,
7580
+ inputSchema: assigneesInputSchema,
7581
+ handler: async (args, ctx) => {
7582
+ const list = await ctx.client.listProjectTaskAssignees(args.project_id);
7583
+ return jsonResult({
7584
+ assignees: list.assignees.map((a) => ({
7585
+ user_id: a.userId,
7586
+ username: a.username,
7587
+ display_name: a.displayName
7588
+ })),
7589
+ self_user_id: list.selfUserId,
7590
+ // Only ever present and true.
7591
+ ...list.truncated ? { truncated: true } : {}
7592
+ });
7593
+ }
7594
+ };
7426
7595
  var updateInputSchema2 = {
7427
7596
  project_id: z39.uuid().describe("UUID of the project the tasks belong to."),
7428
7597
  task_ids: taskIdsField("The tasks to change (task_id values from list_tasks)."),
7429
7598
  assignee_user_id: userIdField.nullable().optional().describe(
7430
- "User id to assign, or null to unassign. The person must be a member of the organisation who can open the project."
7599
+ 'User id to assign ("me" for yourself), or null to unassign. The person must be a member of the organisation who can open the project: list_task_assignees lists them.'
7431
7600
  ),
7432
7601
  priority: z39.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
7433
7602
  story_points: z39.number().min(0).max(TASK_LIMITS.maxStoryPoints).nullable().optional().describe("Estimate in story points, or null to clear it."),
7434
- cycle: z39.number().int().min(1).max(MAX_CYCLE).nullable().optional().describe("Cycle (sprint) number, or null to clear it."),
7603
+ cycle: z39.number().int().min(1).max(TASK_LIMITS.maxCycle).nullable().optional().describe("Cycle (sprint) number, or null to clear it."),
7435
7604
  start_date: z39.iso.date().nullable().optional().describe("Start day as YYYY-MM-DD, or null to clear it."),
7436
7605
  end_date: z39.iso.date().nullable().optional().describe("Due day as YYYY-MM-DD, or null to clear it. Not before the start day."),
7437
7606
  add_labels: z39.array(labelField).max(TASK_LIMITS.maxLabels).optional().describe(`Labels to add. A task carries at most ${String(TASK_LIMITS.maxLabels)}.`),
@@ -7443,7 +7612,13 @@ var updateTasksTool = {
7443
7612
  inputSchema: updateInputSchema2,
7444
7613
  handler: async (args, ctx) => {
7445
7614
  const patch = {};
7446
- if (args.assignee_user_id !== void 0) patch.assigneeUserId = args.assignee_user_id;
7615
+ if (args.assignee_user_id === null) {
7616
+ patch.assigneeUserId = null;
7617
+ } else if (args.assignee_user_id !== void 0) {
7618
+ const assignee = await resolveMe(args.assignee_user_id, ctx);
7619
+ if (assignee === null) return errorResult(UNKNOWN_ME);
7620
+ patch.assigneeUserId = assignee;
7621
+ }
7447
7622
  if (args.priority !== void 0) patch.priority = args.priority;
7448
7623
  if (args.story_points !== void 0) patch.storyPoints = args.story_points;
7449
7624
  if (args.cycle !== void 0) patch.cycle = args.cycle;
@@ -7467,17 +7642,27 @@ var updateTasksTool = {
7467
7642
  var statusInputSchema = {
7468
7643
  project_id: z39.uuid().describe("UUID of the project the tasks belong to."),
7469
7644
  task_ids: taskIdsField("The tasks to move (task_id values from list_tasks)."),
7470
- status: STATUS.describe("The new status: pending, in_progress or done.")
7645
+ status: STATUS.describe("The new status: pending, in_progress or done."),
7646
+ expected_versions: z39.array(
7647
+ z39.object({
7648
+ spec_file_id: z39.uuid().describe("A tasks.md file (spec_file_id from list_tasks)."),
7649
+ content_version: z39.number().int().min(0).describe("The content_version list_tasks returned for that file.")
7650
+ })
7651
+ ).max(TASK_LIMITS.maxBoardFiles).optional().describe(
7652
+ "Optional. The file versions your view of these tasks came from (from list_tasks); only the files these tasks are in are checked, so passing just those is enough. A file that has changed since is not written and its tasks fail with file_changed, so a change never lands on a line you did not see; list again and retry."
7653
+ )
7471
7654
  };
7472
7655
  var setTaskStatusTool = {
7473
7656
  name: "set_task_status",
7474
- description: "Change the status of one or more tasks by rewriting their checkbox in the project's tasks.md files: `[ ]` pending, `[-]` in progress, `[x]` done. Each affected file gets one new revision, recorded as an AI change; nothing else in the file changes. Partial by nature: a file that cannot be written (it kept changing, the organisation is out of storage, or it was moved to the trash) fails only its own tasks. Returns an outcome per task (updated, unchanged, removed: no longer in its file, failed: with an error code), an outcome per file, and the tasks as they are now. Marked as an error only when every task failed.",
7657
+ description: "Change the status of one or more tasks by rewriting their checkbox in the project's tasks.md files: `[ ]` pending, `[-]` in progress, `[x]` done. Each affected file gets one new revision, recorded as an AI change; nothing else in the file changes. Partial by nature: a file that cannot be written (it kept changing, it changed since the expected_versions you passed, the organisation is out of storage, or it was moved to the trash) fails only its own tasks. Returns an outcome per task (updated, unchanged, removed: no longer in its file, failed: with an error code), an outcome per file, and the tasks as they are now. Marked as an error only when every task failed.",
7475
7658
  inputSchema: statusInputSchema,
7476
7659
  handler: async (args, ctx) => {
7660
+ const expected = args.expected_versions?.length ? Object.fromEntries(args.expected_versions.map((v) => [v.spec_file_id, v.content_version])) : void 0;
7477
7661
  const result = await ctx.client.setProjectTaskStatus(
7478
7662
  args.project_id,
7479
7663
  args.task_ids,
7480
- args.status
7664
+ args.status,
7665
+ expected
7481
7666
  );
7482
7667
  const count = (outcome) => result.results.filter((r) => r.outcome === outcome).length;
7483
7668
  const failed = count("failed");
@@ -7555,6 +7740,7 @@ function buildServer(deps) {
7555
7740
  registerTool(server, updateSpecFileCommentTool, ctx);
7556
7741
  registerTool(server, deleteSpecFileCommentTool, ctx);
7557
7742
  registerTool(server, listTasksTool, ctx);
7743
+ registerTool(server, listTaskAssigneesTool, ctx);
7558
7744
  registerTool(server, updateTasksTool, ctx);
7559
7745
  registerTool(server, setTaskStatusTool, ctx);
7560
7746
  return server;
@@ -7575,6 +7761,11 @@ function wrapToolHandler(tool, ctx) {
7575
7761
  if (isProjectPermissionDeniedError(err)) {
7576
7762
  return errorResult(`${tool.name} failed: ${permissionDeniedMessage(err)}`);
7577
7763
  }
7764
+ if (isTaskBoardDisabledError(err)) {
7765
+ return errorResult(
7766
+ `${tool.name} failed: the task board is turned off in this MySpec environment, so the task tools are unavailable here until it is turned on.`
7767
+ );
7768
+ }
7578
7769
  const message = err instanceof Error ? err.message : String(err);
7579
7770
  return errorResult(`${tool.name} failed: ${message}`);
7580
7771
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "1.1.1-next.165",
3
+ "version": "1.1.1-next.166",
4
4
  "description": "MySpec MCP server — exposes MySpec platform projects, files and attachments to MCP-aware clients via OAuth-authenticated access tokens.",
5
5
  "type": "module",
6
6
  "repository": {