@myspec/mcp-server 1.1.1-next.164 → 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 -0
  2. package/dist/index.js +559 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -363,6 +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` (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 |
366
370
 
367
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.
368
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,
@@ -2318,13 +2356,146 @@ function transformSpecSessionResponse(raw) {
2318
2356
  // ../../packages/platform-client/project/types/lock.types.ts
2319
2357
  var SPEC_FILE_LOCK_TTL_MS = 2 * 6e4;
2320
2358
 
2359
+ // ../../packages/platform-client/project/types/task.types.ts
2360
+ var TASK_STATUSES = ["pending", "in_progress", "done"];
2361
+ var TASK_PRIORITIES = [0, 1, 2, 3, 4];
2362
+ var TASK_LIMITS = {
2363
+ maxLabels: 20,
2364
+ maxLabelLength: 40,
2365
+ maxStoryPoints: 9999.99,
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
2372
+ };
2373
+ function parseTimestamp(raw) {
2374
+ if (!raw) return null;
2375
+ const d = new Date(raw);
2376
+ return Number.isNaN(d.getTime()) ? null : d;
2377
+ }
2378
+ function toStatus(raw) {
2379
+ return TASK_STATUSES.includes(raw) ? raw : "pending";
2380
+ }
2381
+ function toPriority(raw) {
2382
+ return TASK_PRIORITIES.includes(raw) ? raw : 0;
2383
+ }
2384
+ function toOutcome(raw) {
2385
+ return raw === "updated" || raw === "unchanged" || raw === "removed" ? raw : "failed";
2386
+ }
2387
+ function transformProjectTask(raw) {
2388
+ return {
2389
+ id: raw.id,
2390
+ projectId: raw.project_id,
2391
+ specFileId: raw.spec_file_id,
2392
+ parentTaskId: raw.parent_task_id ?? null,
2393
+ key: raw.key,
2394
+ title: raw.title,
2395
+ section: raw.section,
2396
+ position: raw.position,
2397
+ depth: raw.depth,
2398
+ status: toStatus(raw.status),
2399
+ body: raw.body,
2400
+ removedAt: parseTimestamp(raw.removed_at),
2401
+ statusChangedAt: parseTimestamp(raw.status_changed_at),
2402
+ completedAt: parseTimestamp(raw.completed_at),
2403
+ assigneeUserId: raw.assignee_user_id ?? null,
2404
+ priority: toPriority(raw.priority),
2405
+ storyPoints: raw.story_points ?? null,
2406
+ cycle: raw.cycle ?? null,
2407
+ startAt: parseTimestamp(raw.start_at),
2408
+ endAt: parseTimestamp(raw.end_at),
2409
+ labels: raw.labels ?? [],
2410
+ updatedBy: raw.updated_by ?? null,
2411
+ createdAt: parseTimestamp(raw.created_at),
2412
+ updatedAt: parseTimestamp(raw.updated_at)
2413
+ };
2414
+ }
2415
+ function transformTaskBoard(raw) {
2416
+ return {
2417
+ projectId: raw.project_id,
2418
+ files: (raw.files ?? []).map((f) => ({
2419
+ specFileId: f.spec_file_id,
2420
+ filePath: f.file_path,
2421
+ contentVersion: f.content_version,
2422
+ stale: f.stale
2423
+ })),
2424
+ tasks: (raw.tasks ?? []).map(transformProjectTask),
2425
+ labels: raw.labels ?? [],
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
+ } : {}
2436
+ };
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
+ }
2451
+ function transformSetTaskStatusResult(raw) {
2452
+ return {
2453
+ results: (raw.results ?? []).map((r) => ({
2454
+ taskId: r.task_id,
2455
+ outcome: toOutcome(r.outcome),
2456
+ ...r.error ? { error: r.error } : {}
2457
+ })),
2458
+ files: (raw.files ?? []).map((f) => ({
2459
+ specFileId: f.spec_file_id,
2460
+ contentVersion: f.content_version,
2461
+ written: f.written,
2462
+ ...f.error ? { error: f.error } : {}
2463
+ })),
2464
+ tasks: (raw.tasks ?? []).map(transformProjectTask)
2465
+ };
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
+ }
2478
+ function toRawUpdateTasksRequest(taskIds, patch) {
2479
+ const set = {};
2480
+ if ("assigneeUserId" in patch) set.assignee_user_id = patch.assigneeUserId ?? null;
2481
+ if ("priority" in patch) set.priority = patch.priority ?? null;
2482
+ if ("storyPoints" in patch) set.story_points = patch.storyPoints ?? null;
2483
+ if ("cycle" in patch) set.cycle = patch.cycle ?? null;
2484
+ if ("startAt" in patch) set.start_at = patch.startAt ? patch.startAt.toISOString() : null;
2485
+ if ("endAt" in patch) set.end_at = patch.endAt ? patch.endAt.toISOString() : null;
2486
+ const body = { task_ids: taskIds, set };
2487
+ if (patch.addLabels?.length) body.add_labels = patch.addLabels;
2488
+ if (patch.removeLabels?.length) body.remove_labels = patch.removeLabels;
2489
+ return body;
2490
+ }
2491
+
2321
2492
  // ../../packages/platform-client/project/types/comment.types.ts
2322
2493
  var COMMENT_THREAD_FILTER_PARAM = "status";
2323
2494
  var COMMENT_UNRESOLVED_ONLY_PARAM = "unresolved_only";
2324
2495
  var MAX_COMMENT_BODY_LENGTH = 1e4;
2325
2496
  var MAX_ANCHOR_QUOTE_LENGTH = 2e3;
2326
2497
  var MAX_ANCHOR_CONTEXT_LENGTH = 64;
2327
- function parseTimestamp(raw) {
2498
+ function parseTimestamp2(raw) {
2328
2499
  if (!raw) {
2329
2500
  return null;
2330
2501
  }
@@ -2368,9 +2539,9 @@ function transformSpecFileCommentResponse(raw) {
2368
2539
  body: raw.body,
2369
2540
  deleted: raw.deleted,
2370
2541
  contentVersion: raw.content_version,
2371
- createdAt: parseTimestamp(raw.created_at),
2372
- updatedAt: parseTimestamp(raw.updated_at),
2373
- deletedAt: parseTimestamp(raw.deleted_at)
2542
+ createdAt: parseTimestamp2(raw.created_at),
2543
+ updatedAt: parseTimestamp2(raw.updated_at),
2544
+ deletedAt: parseTimestamp2(raw.deleted_at)
2374
2545
  };
2375
2546
  }
2376
2547
  function transformSpecFileCommentThreadResponse(raw) {
@@ -2390,10 +2561,10 @@ function transformSpecFileCommentThreadResponse(raw) {
2390
2561
  anchorStatus: toAnchorStatus(raw.anchor_status),
2391
2562
  createdBy: raw.created_by,
2392
2563
  createdByKind: toCommentActorKind(raw.created_by_kind),
2393
- resolvedAt: parseTimestamp(raw.resolved_at),
2564
+ resolvedAt: parseTimestamp2(raw.resolved_at),
2394
2565
  resolvedBy: raw.resolved_by ?? null,
2395
- createdAt: parseTimestamp(raw.created_at),
2396
- updatedAt: parseTimestamp(raw.updated_at),
2566
+ createdAt: parseTimestamp2(raw.created_at),
2567
+ updatedAt: parseTimestamp2(raw.updated_at),
2397
2568
  // Always an array: consumers map over it on every render, so the empty case
2398
2569
  // must not be a value each one has to guard.
2399
2570
  comments: (raw.comments ?? []).map(transformSpecFileCommentResponse)
@@ -3152,6 +3323,55 @@ var HttpSpecFileCommentClient = class {
3152
3323
  }
3153
3324
  };
3154
3325
 
3326
+ // ../../packages/platform-client/project/http/task.http-client.ts
3327
+ var HEADER_ACTOR_KIND3 = "X-Actor-Kind";
3328
+ function tasksPath(projectId) {
3329
+ return `/project/v1/projects/${encodeURIComponent(projectId)}/tasks`;
3330
+ }
3331
+ var HttpProjectTaskClient = class {
3332
+ httpClient;
3333
+ constructor(httpClient) {
3334
+ this.httpClient = httpClient;
3335
+ }
3336
+ async listProjectTasks(projectId, jwtToken, query) {
3337
+ const raw = await this.httpClient.get(
3338
+ `${tasksPath(projectId)}${toTaskListQueryString(query)}`,
3339
+ jwtToken,
3340
+ {}
3341
+ );
3342
+ return transformTaskBoard(raw);
3343
+ }
3344
+ async listProjectTaskAssignees(projectId, jwtToken) {
3345
+ const raw = await this.httpClient.get(`${tasksPath(projectId)}/assignees`, jwtToken, {});
3346
+ return transformTaskAssigneeList(raw);
3347
+ }
3348
+ async updateProjectTasks(projectId, taskIds, patch, jwtToken) {
3349
+ const raw = await this.httpClient.patch(
3350
+ tasksPath(projectId),
3351
+ toRawUpdateTasksRequest(taskIds, patch),
3352
+ jwtToken,
3353
+ {}
3354
+ );
3355
+ return (raw.tasks ?? []).map(transformProjectTask);
3356
+ }
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
+ }
3365
+ const raw = await this.httpClient.post(
3366
+ `${tasksPath(projectId)}/status`,
3367
+ body,
3368
+ jwtToken,
3369
+ opts?.actorKind ? { headers: { [HEADER_ACTOR_KIND3]: opts.actorKind } } : {}
3370
+ );
3371
+ return transformSetTaskStatusResult(raw);
3372
+ }
3373
+ };
3374
+
3155
3375
  // ../../packages/platform-client/artifact/types/artifact.types.ts
3156
3376
  function transformArtifactResponse(raw) {
3157
3377
  return {
@@ -3283,14 +3503,14 @@ function buildUpdateArtifactBody(request) {
3283
3503
  // ../../packages/platform-client/artifact/http/artifact.http-client.ts
3284
3504
  var BASE_PATH = "/artifact/v1";
3285
3505
  var HEADER_EXPECTED_VERSION3 = "X-Expected-Version";
3286
- var HEADER_ACTOR_KIND3 = "X-Actor-Kind";
3506
+ var HEADER_ACTOR_KIND4 = "X-Actor-Kind";
3287
3507
  function writeHeaders(opts) {
3288
3508
  const headers = {};
3289
3509
  if (opts?.expectedVersion !== void 0) {
3290
3510
  headers[HEADER_EXPECTED_VERSION3] = String(opts.expectedVersion);
3291
3511
  }
3292
3512
  if (opts?.actorKind) {
3293
- headers[HEADER_ACTOR_KIND3] = opts.actorKind;
3513
+ headers[HEADER_ACTOR_KIND4] = opts.actorKind;
3294
3514
  }
3295
3515
  return headers;
3296
3516
  }
@@ -3868,7 +4088,8 @@ var PlatformClient = class {
3868
4088
  attachment: new HttpAttachmentClient(httpClient),
3869
4089
  artifact: new HttpArtifactClient(httpClient),
3870
4090
  streamToken: new HttpStreamTokenClient(httpClient),
3871
- comment: new HttpSpecFileCommentClient(httpClient)
4091
+ comment: new HttpSpecFileCommentClient(httpClient),
4092
+ task: new HttpProjectTaskClient(httpClient)
3872
4093
  };
3873
4094
  }
3874
4095
  // ------------------------------------------------------- stream tokens
@@ -3972,6 +4193,41 @@ var PlatformClient = class {
3972
4193
  (jwt, c) => c.comment.deleteComment(fileId, commentId, jwt, { actorKind: "ai" })
3973
4194
  );
3974
4195
  }
4196
+ // ---------------------------------------------------------------- tasks
4197
+ //
4198
+ // The project task board. A status change rewrites tasks.md, so it carries
4199
+ // `actorKind: 'ai'` like every other file write here: the revision is then
4200
+ // recorded as the assistant's, not filed under the user's name. A planning
4201
+ // edit writes no file and the platform records no actor kind for it.
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());
4217
+ }
4218
+ /** Applies one planning patch to up to 200 tasks; all or nothing. */
4219
+ async updateProjectTasks(projectId, taskIds, patch) {
4220
+ return this.withTokenRetry((jwt, c) => c.task.updateProjectTasks(projectId, taskIds, patch, jwt));
4221
+ }
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) {
4227
+ return this.withTokenRetry(
4228
+ (jwt, c) => c.task.setProjectTaskStatus(projectId, taskIds, status, jwt, { actorKind: "ai", expectedVersions })
4229
+ );
4230
+ }
3975
4231
  async withTokenRetry(call) {
3976
4232
  const clients = await this.clients();
3977
4233
  const jwt = await this.tokenManager.getValidAccessToken();
@@ -7180,6 +7436,258 @@ var startSpecSessionTool = {
7180
7436
  }
7181
7437
  };
7182
7438
 
7439
+ // src/server/tools/tasks.ts
7440
+ import { z as z39 } from "zod";
7441
+ var LIST_DEFAULT_TASKS = 100;
7442
+ var LIST_MAX_TASKS = 500;
7443
+ var LIST_MAX_OFFSET = 1e6;
7444
+ var DETAILS_PREVIEW_CHARS = 500;
7445
+ var ME = "me";
7446
+ var NONE = "none";
7447
+ var PRIORITY_NAMES = ["none", "urgent", "high", "medium", "low"];
7448
+ var STATUS = z39.enum(["pending", "in_progress", "done"]);
7449
+ function toDay(date) {
7450
+ return date ? date.toISOString().slice(0, 10) : null;
7451
+ }
7452
+ function fromDay(day) {
7453
+ return /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
7454
+ }
7455
+ function taskSummary(task, opts = {}) {
7456
+ const withDetails = opts.details === true && task.body !== "";
7457
+ const truncated = withDetails && task.body.length > DETAILS_PREVIEW_CHARS;
7458
+ return {
7459
+ task_id: task.id,
7460
+ spec_file_id: task.specFileId,
7461
+ // The id written in the file ("1", "1.1", "T001"); '' when it has none.
7462
+ key: task.key,
7463
+ title: task.title,
7464
+ section: task.section,
7465
+ parent_task_id: task.parentTaskId,
7466
+ status: task.status,
7467
+ assignee_user_id: task.assigneeUserId,
7468
+ priority: task.priority,
7469
+ priority_name: PRIORITY_NAMES[task.priority],
7470
+ story_points: task.storyPoints,
7471
+ cycle: task.cycle,
7472
+ start_date: toDay(task.startAt),
7473
+ end_date: toDay(task.endAt),
7474
+ labels: task.labels,
7475
+ ...withDetails ? { details: truncated ? task.body.slice(0, DETAILS_PREVIEW_CHARS) : task.body } : {},
7476
+ // Only ever present and true.
7477
+ ...truncated ? { details_truncated: true } : {},
7478
+ updated_at: task.updatedAt?.toISOString()
7479
+ };
7480
+ }
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.';
7486
+ var labelField = z39.string().trim().min(1).refine((label) => Array.from(label).length <= TASK_LIMITS.maxLabelLength, {
7487
+ message: `A label is at most ${String(TASK_LIMITS.maxLabelLength)} characters.`
7488
+ });
7489
+ function taskIdsField(what) {
7490
+ return z39.array(z39.uuid()).min(1).max(TASK_LIMITS.maxTasksPerRequest).describe(`${what} At most ${String(TASK_LIMITS.maxTasksPerRequest)} per call.`);
7491
+ }
7492
+ var listInputSchema2 = {
7493
+ project_id: z39.uuid().describe("UUID of the project whose tasks to list."),
7494
+ status: STATUS.optional().describe("Only tasks with this status."),
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."),
7499
+ label: labelField.optional().describe("Only tasks carrying this label."),
7500
+ file_id: z39.uuid().optional().describe("Only tasks from this tasks.md (its spec file id)."),
7501
+ include_details: z39.boolean().optional().describe(
7502
+ "Also return each task's details (its sub-bullets in tasks.md), previewed to 500 characters. Off by default: they make the result several times larger."
7503
+ ),
7504
+ limit: z39.number().int().min(1).max(LIST_MAX_TASKS).optional().describe(
7505
+ `Tasks per page (default ${String(LIST_DEFAULT_TASKS)}, at most ${String(LIST_MAX_TASKS)}).`
7506
+ ),
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.")
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
+ }
7525
+ var listTasksTool = {
7526
+ name: "list_tasks",
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.`,
7528
+ inputSchema: listInputSchema2,
7529
+ handler: async (args, ctx) => {
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);
7552
+ return jsonResult({
7553
+ project_id: board.projectId,
7554
+ files: board.files.map((file) => ({
7555
+ spec_file_id: file.specFileId,
7556
+ file_path: file.filePath,
7557
+ content_version: file.contentVersion,
7558
+ // The file could not be re-read just now: its tasks are from the last read.
7559
+ ...file.stale ? { stale: true } : {}
7560
+ })),
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,
7567
+ // Only while more remain, so its absence means "that was the last page".
7568
+ ...page.nextOffset !== null ? { next_offset: page.nextOffset } : {},
7569
+ labels: board.labels,
7570
+ cycles: board.cycles
7571
+ });
7572
+ }
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
+ };
7595
+ var updateInputSchema2 = {
7596
+ project_id: z39.uuid().describe("UUID of the project the tasks belong to."),
7597
+ task_ids: taskIdsField("The tasks to change (task_id values from list_tasks)."),
7598
+ assignee_user_id: userIdField.nullable().optional().describe(
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.'
7600
+ ),
7601
+ priority: z39.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
7602
+ story_points: z39.number().min(0).max(TASK_LIMITS.maxStoryPoints).nullable().optional().describe("Estimate in story points, 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."),
7604
+ start_date: z39.iso.date().nullable().optional().describe("Start day as YYYY-MM-DD, or null to clear it."),
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."),
7606
+ add_labels: z39.array(labelField).max(TASK_LIMITS.maxLabels).optional().describe(`Labels to add. A task carries at most ${String(TASK_LIMITS.maxLabels)}.`),
7607
+ remove_labels: z39.array(labelField).max(TASK_LIMITS.maxLabels).optional().describe("Labels to remove.")
7608
+ };
7609
+ var updateTasksTool = {
7610
+ name: "update_tasks",
7611
+ description: "Edit the planning fields of one or more tasks: assignee, priority, story points, cycle, start and due dates, and labels. The same change applies to every task given. A field left out is left alone; null clears it. Labels are added and removed, not replaced. Does not change status or the task's text, which live in tasks.md: use set_task_status for status. All or nothing: an unknown task id fails the whole call. Returns the tasks as they are now.",
7612
+ inputSchema: updateInputSchema2,
7613
+ handler: async (args, ctx) => {
7614
+ const patch = {};
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
+ }
7622
+ if (args.priority !== void 0) patch.priority = args.priority;
7623
+ if (args.story_points !== void 0) patch.storyPoints = args.story_points;
7624
+ if (args.cycle !== void 0) patch.cycle = args.cycle;
7625
+ if (args.start_date !== void 0) {
7626
+ patch.startAt = args.start_date === null ? null : fromDay(args.start_date);
7627
+ }
7628
+ if (args.end_date !== void 0) {
7629
+ patch.endAt = args.end_date === null ? null : fromDay(args.end_date);
7630
+ }
7631
+ if (args.add_labels?.length) patch.addLabels = args.add_labels;
7632
+ if (args.remove_labels?.length) patch.removeLabels = args.remove_labels;
7633
+ if (Object.keys(patch).length === 0) {
7634
+ return errorResult(
7635
+ "Nothing to change: pass at least one of assignee_user_id, priority, story_points, cycle, start_date, end_date, add_labels or remove_labels."
7636
+ );
7637
+ }
7638
+ const tasks = await ctx.client.updateProjectTasks(args.project_id, args.task_ids, patch);
7639
+ return jsonResult({ tasks: tasks.map((task) => taskSummary(task)) });
7640
+ }
7641
+ };
7642
+ var statusInputSchema = {
7643
+ project_id: z39.uuid().describe("UUID of the project the tasks belong to."),
7644
+ task_ids: taskIdsField("The tasks to move (task_id values from list_tasks)."),
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
+ )
7654
+ };
7655
+ var setTaskStatusTool = {
7656
+ name: "set_task_status",
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.",
7658
+ inputSchema: statusInputSchema,
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;
7661
+ const result = await ctx.client.setProjectTaskStatus(
7662
+ args.project_id,
7663
+ args.task_ids,
7664
+ args.status,
7665
+ expected
7666
+ );
7667
+ const count = (outcome) => result.results.filter((r) => r.outcome === outcome).length;
7668
+ const failed = count("failed");
7669
+ const body = jsonResult({
7670
+ updated: count("updated"),
7671
+ unchanged: count("unchanged"),
7672
+ removed: count("removed"),
7673
+ failed,
7674
+ results: result.results.map((r) => ({
7675
+ task_id: r.taskId,
7676
+ outcome: r.outcome,
7677
+ ...r.error ? { error: r.error } : {}
7678
+ })),
7679
+ files: result.files.map((file) => ({
7680
+ spec_file_id: file.specFileId,
7681
+ content_version: file.contentVersion,
7682
+ written: file.written,
7683
+ ...file.error ? { error: file.error } : {}
7684
+ })),
7685
+ tasks: result.tasks.map((task) => taskSummary(task))
7686
+ });
7687
+ return failed > 0 && failed === result.results.length ? { ...body, isError: true } : body;
7688
+ }
7689
+ };
7690
+
7183
7691
  // src/server/server.ts
7184
7692
  function buildServer(deps) {
7185
7693
  const server = new McpServer({
@@ -7231,6 +7739,10 @@ function buildServer(deps) {
7231
7739
  registerTool(server, replySpecFileCommentThreadTool, ctx);
7232
7740
  registerTool(server, updateSpecFileCommentTool, ctx);
7233
7741
  registerTool(server, deleteSpecFileCommentTool, ctx);
7742
+ registerTool(server, listTasksTool, ctx);
7743
+ registerTool(server, listTaskAssigneesTool, ctx);
7744
+ registerTool(server, updateTasksTool, ctx);
7745
+ registerTool(server, setTaskStatusTool, ctx);
7234
7746
  return server;
7235
7747
  }
7236
7748
  async function startStdioServer(deps) {
@@ -7249,6 +7761,11 @@ function wrapToolHandler(tool, ctx) {
7249
7761
  if (isProjectPermissionDeniedError(err)) {
7250
7762
  return errorResult(`${tool.name} failed: ${permissionDeniedMessage(err)}`);
7251
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
+ }
7252
7769
  const message = err instanceof Error ? err.message : String(err);
7253
7770
  return errorResult(`${tool.name} failed: ${message}`);
7254
7771
  }
@@ -7402,7 +7919,7 @@ async function canonicaliseRoot(root) {
7402
7919
  import { promises as fs4 } from "fs";
7403
7920
  import { join as join2, relative } from "path";
7404
7921
  import { spawn } from "child_process";
7405
- import { z as z39 } from "zod";
7922
+ import { z as z40 } from "zod";
7406
7923
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7407
7924
 
7408
7925
  // src/reverse/ignore.ts
@@ -7707,37 +8224,37 @@ var READ_FILE_MAX_LINES = 2e3;
7707
8224
  var LIST_DIR_HARD_CAP = 5e3;
7708
8225
  var LIST_DIR_DEFAULT_CAP = 1e3;
7709
8226
  var GREP_MAX_MATCHES_DEFAULT = 200;
7710
- var listDirInput = z39.object({
7711
- path: z39.string(),
7712
- recursive: z39.boolean().optional(),
7713
- maxEntries: z39.number().int().positive().optional(),
7714
- includeIgnored: z39.boolean().optional()
8227
+ var listDirInput = z40.object({
8228
+ path: z40.string(),
8229
+ recursive: z40.boolean().optional(),
8230
+ maxEntries: z40.number().int().positive().optional(),
8231
+ includeIgnored: z40.boolean().optional()
7715
8232
  });
7716
- var readFileInput = z39.object({
7717
- path: z39.string(),
7718
- offset: z39.number().int().nonnegative().optional(),
7719
- limit: z39.number().int().positive().optional(),
7720
- includeIgnored: z39.boolean().optional()
8233
+ var readFileInput = z40.object({
8234
+ path: z40.string(),
8235
+ offset: z40.number().int().nonnegative().optional(),
8236
+ limit: z40.number().int().positive().optional(),
8237
+ includeIgnored: z40.boolean().optional()
7721
8238
  });
7722
- var grepInput = z39.object({
7723
- pattern: z39.string().describe(
8239
+ var grepInput = z40.object({
8240
+ pattern: z40.string().describe(
7724
8241
  "Search pattern. Interpreted as a regular expression by default (ripgrep syntax), e.g. `foo|bar` or `[Rr]ole`. Set `isRegex: false` to match the pattern as an exact literal string instead."
7725
8242
  ),
7726
- path: z39.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
7727
- isRegex: z39.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
7728
- caseSensitive: z39.boolean().optional(),
7729
- maxMatches: z39.number().int().positive().optional(),
7730
- contextLines: z39.number().int().nonnegative().optional(),
7731
- includeIgnored: z39.boolean().optional()
8243
+ path: z40.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
8244
+ isRegex: z40.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
8245
+ caseSensitive: z40.boolean().optional(),
8246
+ maxMatches: z40.number().int().positive().optional(),
8247
+ contextLines: z40.number().int().nonnegative().optional(),
8248
+ includeIgnored: z40.boolean().optional()
7732
8249
  });
7733
- var packCodebaseInput = z39.object({
7734
- subpath: z39.string().optional(),
7735
- includePatterns: z39.string().optional(),
7736
- ignorePatterns: z39.string().optional()
8250
+ var packCodebaseInput = z40.object({
8251
+ subpath: z40.string().optional(),
8252
+ includePatterns: z40.string().optional(),
8253
+ ignorePatterns: z40.string().optional()
7737
8254
  });
7738
- var packCodebaseReadPageInput = z39.object({
7739
- outputId: z39.string(),
7740
- page: z39.number().int().positive()
8255
+ var packCodebaseReadPageInput = z40.object({
8256
+ outputId: z40.string(),
8257
+ page: z40.number().int().positive()
7741
8258
  });
7742
8259
  function structuredError(error, message, extra) {
7743
8260
  const payload = { error, message, ...extra ?? {} };
@@ -8265,7 +8782,7 @@ function jsonSchemaFromZod(schema) {
8265
8782
  return zodToJson(schema);
8266
8783
  }
8267
8784
  function zodToJson(schema) {
8268
- if (schema instanceof z39.ZodObject) {
8785
+ if (schema instanceof z40.ZodObject) {
8269
8786
  const shape = schema.shape;
8270
8787
  const properties = {};
8271
8788
  const required = [];
@@ -8280,19 +8797,19 @@ function zodToJson(schema) {
8280
8797
  if (required.length > 0) out.required = required;
8281
8798
  return out;
8282
8799
  }
8283
- if (schema instanceof z39.ZodOptional) {
8800
+ if (schema instanceof z40.ZodOptional) {
8284
8801
  return zodToJson(schema.unwrap());
8285
8802
  }
8286
- if (schema instanceof z39.ZodString) {
8803
+ if (schema instanceof z40.ZodString) {
8287
8804
  return { type: "string" };
8288
8805
  }
8289
- if (schema instanceof z39.ZodNumber) {
8806
+ if (schema instanceof z40.ZodNumber) {
8290
8807
  return { type: "number" };
8291
8808
  }
8292
- if (schema instanceof z39.ZodBoolean) {
8809
+ if (schema instanceof z40.ZodBoolean) {
8293
8810
  return { type: "boolean" };
8294
8811
  }
8295
- if (schema instanceof z39.ZodEnum) {
8812
+ if (schema instanceof z40.ZodEnum) {
8296
8813
  return { type: "string", enum: schema.options };
8297
8814
  }
8298
8815
  return { type: "string" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "1.1.1-next.164",
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": {