@myspec/mcp-server 0.5.0-next.116 → 0.5.0-next.117

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 +7 -0
  2. package/dist/index.js +615 -114
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -313,6 +313,13 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
313
313
  | `create_artifact` | `project_id`, `name`, `files[]` (`path`, `content`, optional `encoding` utf8/base64, optional `content_type`), optional `slug`, `description`, `entry_path`, `message` | Creates the artifact with revision 1. Limits: 50 files, 2 MiB per file, 10 MiB per revision. A taken slug is a conflict |
314
314
  | `write_artifact_revision` | `artifact_id`, `files[]`, optional `base_revision` (patch mode), `remove[]`, `expected_version`, `message`, `entry_path` | Appends an immutable revision. Full mode replaces the whole bundle; patch mode (with `base_revision`) sends only changed files plus paths to `remove`. A stale `expected_version` is reported as a conflict naming the new head — re-read with `get_artifact` and retry |
315
315
  | `rollback_artifact` | `artifact_id`, `revision_number`, optional `expected_version`, `message` | Restores an earlier revision by APPENDING a revision with its files (`git revert` semantics); history is never rewritten, but only the newest N revisions are retained (50 by default, `ARTIFACT_MAX_REVISIONS` on the platform), so a pruned target answers not found |
316
+ | `list_spec_file_comments` | `file_id`, optional `status` (`open`/`resolved`/`orphaned`) | Lists the review comment threads on a spec file. Each thread carries `anchor_quote`, `anchor_status` (`anchored`, or `orphaned` when the passage can no longer be located), `anchor_start`/`anchor_end`, `resolved`, and its `comments` — each with `content_version`, the token `update_spec_file_comment` takes as `expected_version`. A tombstoned first comment is returned with `deleted: true` and no `body`. Works on a trashed file |
317
+ | `create_spec_file_comment_thread` | `file_id`, `body`, `anchor_quote`, optional `anchor_prefix`, `anchor_suffix` | Opens a review comment on a passage. Quote the passage verbatim — the server searches the current content for it and derives the character offsets itself, so do NOT send them. `anchor_prefix`/`anchor_suffix` are the surrounding text, which disambiguates a passage appearing more than once. Conflicts on a trashed file: a file staged for deletion does not gather new discussion |
318
+ | `reply_spec_file_comment_thread` | `file_id`, `thread_id`, `body` | Replies to an existing thread. Conflicts on a trashed file |
319
+ | `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 |
320
+ | `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`) |
321
+
322
+ 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.
316
323
 
317
324
  ### Brownfield sessions: start the bridge first
318
325
 
package/dist/index.js CHANGED
@@ -1425,6 +1425,9 @@ function conflictOf(err) {
1425
1425
  own || fromMessage ? { ...fromMessage ?? {}, ...own ?? {} } : void 0
1426
1426
  );
1427
1427
  }
1428
+ function isPlatformConflictError(err) {
1429
+ return conflictOf(err) !== void 0;
1430
+ }
1428
1431
  function isPlatformPreconditionRequiredError(err) {
1429
1432
  return isPlatformHttpError(err) && err.status === 428;
1430
1433
  }
@@ -1855,6 +1858,113 @@ function transformSpecSessionResponse(raw) {
1855
1858
  // ../../packages/platform-client/project/types/lock.types.ts
1856
1859
  var SPEC_FILE_LOCK_TTL_MS = 2 * 6e4;
1857
1860
 
1861
+ // ../../packages/platform-client/project/types/comment.types.ts
1862
+ var COMMENT_THREAD_FILTER_PARAM = "status";
1863
+ var MAX_COMMENT_BODY_LENGTH = 1e4;
1864
+ var MAX_ANCHOR_QUOTE_LENGTH = 2e3;
1865
+ var MAX_ANCHOR_CONTEXT_LENGTH = 64;
1866
+ function parseTimestamp(raw) {
1867
+ if (!raw) {
1868
+ return null;
1869
+ }
1870
+ const parsed = new Date(raw);
1871
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
1872
+ }
1873
+ function toCommentActorKind(raw) {
1874
+ return raw === "ai" ? "ai" : "human";
1875
+ }
1876
+ function toAnchorStatus(raw) {
1877
+ return raw === "anchored" ? "anchored" : "orphaned";
1878
+ }
1879
+ function toCreateCommentThreadRequest(input) {
1880
+ return {
1881
+ body: input.body,
1882
+ anchor_quote: input.anchorQuote,
1883
+ anchor_prefix: input.anchorPrefix,
1884
+ anchor_suffix: input.anchorSuffix
1885
+ };
1886
+ }
1887
+ function toCreateCommentReplyRequest(body) {
1888
+ return { body };
1889
+ }
1890
+ function toUpdateCommentRequest(body) {
1891
+ return { body };
1892
+ }
1893
+ function toReanchorCommentThreadRequest(anchor) {
1894
+ return {
1895
+ anchor_quote: anchor.anchorQuote,
1896
+ anchor_prefix: anchor.anchorPrefix,
1897
+ anchor_suffix: anchor.anchorSuffix
1898
+ };
1899
+ }
1900
+ function transformSpecFileCommentResponse(raw) {
1901
+ return {
1902
+ id: raw.id,
1903
+ threadId: raw.thread_id,
1904
+ org: raw.org,
1905
+ authorId: raw.author_id,
1906
+ authorKind: toCommentActorKind(raw.author_kind),
1907
+ body: raw.body,
1908
+ deleted: raw.deleted,
1909
+ contentVersion: raw.content_version,
1910
+ createdAt: parseTimestamp(raw.created_at),
1911
+ updatedAt: parseTimestamp(raw.updated_at),
1912
+ deletedAt: parseTimestamp(raw.deleted_at)
1913
+ };
1914
+ }
1915
+ function transformSpecFileCommentThreadResponse(raw) {
1916
+ return {
1917
+ id: raw.id,
1918
+ specFileId: raw.spec_file_id,
1919
+ projectId: raw.project_id,
1920
+ org: raw.org,
1921
+ rootCommentId: raw.root_comment_id ?? null,
1922
+ anchorContentVersion: raw.anchor_content_version,
1923
+ anchorRevision: raw.anchor_revision,
1924
+ anchorStart: raw.anchor_start,
1925
+ anchorEnd: raw.anchor_end,
1926
+ anchorQuote: raw.anchor_quote,
1927
+ anchorPrefix: raw.anchor_prefix,
1928
+ anchorSuffix: raw.anchor_suffix,
1929
+ anchorStatus: toAnchorStatus(raw.anchor_status),
1930
+ createdBy: raw.created_by,
1931
+ createdByKind: toCommentActorKind(raw.created_by_kind),
1932
+ resolvedAt: parseTimestamp(raw.resolved_at),
1933
+ resolvedBy: raw.resolved_by ?? null,
1934
+ createdAt: parseTimestamp(raw.created_at),
1935
+ updatedAt: parseTimestamp(raw.updated_at),
1936
+ // Always an array: consumers map over it on every render, so the empty case
1937
+ // must not be a value each one has to guard.
1938
+ comments: (raw.comments ?? []).map(transformSpecFileCommentResponse)
1939
+ };
1940
+ }
1941
+ function transformSpecFileCommentCountResponse(raw) {
1942
+ return {
1943
+ specFileId: raw.spec_file_id,
1944
+ openThreadCount: raw.open_thread_count
1945
+ };
1946
+ }
1947
+ function transformSpecFileCommentThreadList(raw) {
1948
+ return (raw.threads ?? []).map(transformSpecFileCommentThreadResponse);
1949
+ }
1950
+ function transformProjectCommentCounts(raw) {
1951
+ return (raw.counts ?? []).map(transformSpecFileCommentCountResponse);
1952
+ }
1953
+ function transformProjectCommentActivity(raw) {
1954
+ return {
1955
+ threads: (raw.threads ?? []).map(transformSpecFileCommentThreadResponse),
1956
+ total: raw.total,
1957
+ limit: raw.limit,
1958
+ offset: raw.offset
1959
+ };
1960
+ }
1961
+ function transformDeleteCommentResult(raw) {
1962
+ return {
1963
+ comment: raw.comment ? transformSpecFileCommentResponse(raw.comment) : null,
1964
+ threadDeleted: raw.thread_deleted
1965
+ };
1966
+ }
1967
+
1858
1968
  // ../../packages/platform-client/project/http/attachment.http-client.ts
1859
1969
  var UPLOAD_TIMEOUT_MS = 3e4;
1860
1970
  var HttpAttachmentClient = class {
@@ -2440,6 +2550,137 @@ var HttpSpecSessionClient = class {
2440
2550
  }
2441
2551
  };
2442
2552
 
2553
+ // ../../packages/platform-client/project/http/comment.http-client.ts
2554
+ var HEADER_EXPECTED_VERSION2 = "X-Expected-Version";
2555
+ var HEADER_ACTOR_KIND2 = "X-Actor-Kind";
2556
+ function segment(value) {
2557
+ return encodeURIComponent(value);
2558
+ }
2559
+ var HttpSpecFileCommentClient = class {
2560
+ httpClient;
2561
+ constructor(httpClient) {
2562
+ this.httpClient = httpClient;
2563
+ }
2564
+ /**
2565
+ * Turns the shared write options into request headers.
2566
+ *
2567
+ * Both halves are forwarded EXACTLY as given and neither is defaulted. That
2568
+ * matters most for what this does NOT do: it never substitutes
2569
+ * `actorKind: 'human'` for an absent value. Resolve, unresolve and re-anchor
2570
+ * are human-only and the platform parses the header fail-closed (AR-PRJ-26),
2571
+ * so a client-side default would turn a 403 the caller should see into a
2572
+ * successful write attributed to a person who never acted.
2573
+ */
2574
+ writeHeaders(opts) {
2575
+ const headers = {};
2576
+ if (opts?.expectedVersion !== void 0) {
2577
+ headers[HEADER_EXPECTED_VERSION2] = String(opts.expectedVersion);
2578
+ }
2579
+ if (opts?.actorKind) {
2580
+ headers[HEADER_ACTOR_KIND2] = opts.actorKind;
2581
+ }
2582
+ return headers;
2583
+ }
2584
+ async listThreads(fileId, jwtToken, opts) {
2585
+ const query = new URLSearchParams();
2586
+ if (opts?.filter) {
2587
+ query.set(COMMENT_THREAD_FILTER_PARAM, opts.filter);
2588
+ }
2589
+ const suffix = query.toString() ? `?${query.toString()}` : "";
2590
+ const raw = await this.httpClient.get(
2591
+ `/project/v1/files/${segment(fileId)}/comments${suffix}`,
2592
+ jwtToken,
2593
+ {}
2594
+ );
2595
+ return transformSpecFileCommentThreadList(raw);
2596
+ }
2597
+ async createThread(fileId, input, jwtToken, opts) {
2598
+ const raw = await this.httpClient.post(
2599
+ `/project/v1/files/${segment(fileId)}/comments`,
2600
+ toCreateCommentThreadRequest(input),
2601
+ jwtToken,
2602
+ { headers: this.writeHeaders(opts) }
2603
+ );
2604
+ return transformSpecFileCommentThreadResponse(raw);
2605
+ }
2606
+ async replyToThread(fileId, threadId, body, jwtToken, opts) {
2607
+ const raw = await this.httpClient.post(
2608
+ `/project/v1/files/${segment(fileId)}/comments/${segment(threadId)}/replies`,
2609
+ toCreateCommentReplyRequest(body),
2610
+ jwtToken,
2611
+ { headers: this.writeHeaders(opts) }
2612
+ );
2613
+ return transformSpecFileCommentResponse(raw);
2614
+ }
2615
+ async editComment(fileId, commentId, body, jwtToken, opts) {
2616
+ const raw = await this.httpClient.patch(
2617
+ `/project/v1/files/${segment(fileId)}/comments/${segment(commentId)}`,
2618
+ toUpdateCommentRequest(body),
2619
+ jwtToken,
2620
+ { headers: this.writeHeaders(opts) }
2621
+ );
2622
+ return transformSpecFileCommentResponse(raw);
2623
+ }
2624
+ async deleteComment(fileId, commentId, jwtToken, opts) {
2625
+ const raw = await this.httpClient.delete(
2626
+ `/project/v1/files/${segment(fileId)}/comments/${segment(commentId)}`,
2627
+ jwtToken,
2628
+ { headers: this.writeHeaders(opts) }
2629
+ );
2630
+ return transformDeleteCommentResult(raw);
2631
+ }
2632
+ async resolveThread(fileId, threadId, jwtToken, opts) {
2633
+ const raw = await this.httpClient.put(
2634
+ `/project/v1/files/${segment(fileId)}/comments/${segment(threadId)}/resolve`,
2635
+ {},
2636
+ jwtToken,
2637
+ { headers: this.writeHeaders(opts) }
2638
+ );
2639
+ return transformSpecFileCommentThreadResponse(raw);
2640
+ }
2641
+ async unresolveThread(fileId, threadId, jwtToken, opts) {
2642
+ const raw = await this.httpClient.delete(
2643
+ `/project/v1/files/${segment(fileId)}/comments/${segment(threadId)}/resolve`,
2644
+ jwtToken,
2645
+ { headers: this.writeHeaders(opts) }
2646
+ );
2647
+ return transformSpecFileCommentThreadResponse(raw);
2648
+ }
2649
+ async reanchorThread(fileId, threadId, anchor, jwtToken, opts) {
2650
+ const raw = await this.httpClient.post(
2651
+ `/project/v1/files/${segment(fileId)}/comments/${segment(threadId)}/reanchor`,
2652
+ toReanchorCommentThreadRequest(anchor),
2653
+ jwtToken,
2654
+ { headers: this.writeHeaders(opts) }
2655
+ );
2656
+ return transformSpecFileCommentThreadResponse(raw);
2657
+ }
2658
+ async getProjectCommentCounts(projectId, jwtToken) {
2659
+ const raw = await this.httpClient.get(
2660
+ `/project/v1/projects/${segment(projectId)}/comment-counts`,
2661
+ jwtToken,
2662
+ {}
2663
+ );
2664
+ return transformProjectCommentCounts(raw);
2665
+ }
2666
+ async listProjectCommentActivity(projectId, jwtToken, opts) {
2667
+ const query = new URLSearchParams();
2668
+ if (opts?.limit !== void 0) {
2669
+ query.set("limit", String(opts.limit));
2670
+ }
2671
+ if (opts?.offset !== void 0) {
2672
+ query.set("offset", String(opts.offset));
2673
+ }
2674
+ const suffix = query.toString() ? `?${query.toString()}` : "";
2675
+ const raw = await this.httpClient.get(
2676
+ `/project/v1/projects/${segment(projectId)}/comments${suffix}`,
2677
+ jwtToken,
2678
+ {}
2679
+ );
2680
+ return transformProjectCommentActivity(raw);
2681
+ }
2682
+ };
2683
+
2443
2684
  // ../../packages/platform-client/artifact/types/artifact.types.ts
2444
2685
  function transformArtifactResponse(raw) {
2445
2686
  return {
@@ -2570,15 +2811,15 @@ function buildUpdateArtifactBody(request) {
2570
2811
 
2571
2812
  // ../../packages/platform-client/artifact/http/artifact.http-client.ts
2572
2813
  var BASE_PATH = "/artifact/v1";
2573
- var HEADER_EXPECTED_VERSION2 = "X-Expected-Version";
2574
- var HEADER_ACTOR_KIND2 = "X-Actor-Kind";
2814
+ var HEADER_EXPECTED_VERSION3 = "X-Expected-Version";
2815
+ var HEADER_ACTOR_KIND3 = "X-Actor-Kind";
2575
2816
  function writeHeaders(opts) {
2576
2817
  const headers = {};
2577
2818
  if (opts?.expectedVersion !== void 0) {
2578
- headers[HEADER_EXPECTED_VERSION2] = String(opts.expectedVersion);
2819
+ headers[HEADER_EXPECTED_VERSION3] = String(opts.expectedVersion);
2579
2820
  }
2580
2821
  if (opts?.actorKind) {
2581
- headers[HEADER_ACTOR_KIND2] = opts.actorKind;
2822
+ headers[HEADER_ACTOR_KIND3] = opts.actorKind;
2582
2823
  }
2583
2824
  return headers;
2584
2825
  }
@@ -3155,7 +3396,8 @@ var PlatformClient = class {
3155
3396
  file: new HttpFileClient(httpClient),
3156
3397
  attachment: new HttpAttachmentClient(httpClient),
3157
3398
  artifact: new HttpArtifactClient(httpClient),
3158
- streamToken: new HttpStreamTokenClient(httpClient)
3399
+ streamToken: new HttpStreamTokenClient(httpClient),
3400
+ comment: new HttpSpecFileCommentClient(httpClient)
3159
3401
  };
3160
3402
  }
3161
3403
  // ------------------------------------------------------- stream tokens
@@ -3205,6 +3447,60 @@ var PlatformClient = class {
3205
3447
  (jwt, c) => c.artifact.rollback(artifactId, request, jwt, { expectedVersion, actorKind: "ai" })
3206
3448
  );
3207
3449
  }
3450
+ // ------------------------------------------------------------- comments
3451
+ //
3452
+ // Review discussion on a spec file. Every WRITE below carries
3453
+ // `actorKind: 'ai'` (AR-MCP-3): an MCP client is an assistant acting on the
3454
+ // user's behalf, and it writes under the USER'S OWN JWT, so the header is the
3455
+ // only thing that can tell a person's comment from a model's. Omitting it
3456
+ // would not fail -- the platform's permissive parse on these paths defaults
3457
+ // to `human` -- it would just file the comment under the user's name.
3458
+ //
3459
+ // There is deliberately no resolve, unresolve or re-anchor method here, and
3460
+ // no tool for one (AR-MCP-2). The platform refuses `ai` on those three routes
3461
+ // with a 403, so a method would only ever produce an error the model then has
3462
+ // to interpret.
3463
+ /** Lists a spec file's comment threads, optionally filtered by status. */
3464
+ async listSpecFileComments(fileId, opts = {}) {
3465
+ return this.withTokenRetry(
3466
+ (jwt, c) => c.comment.listThreads(fileId, jwt, opts.filter ? { filter: opts.filter } : {})
3467
+ );
3468
+ }
3469
+ /** Opens a thread on a passage. The server locates the quote and derives the offsets. */
3470
+ async createSpecFileCommentThread(fileId, input) {
3471
+ return this.withTokenRetry(
3472
+ (jwt, c) => c.comment.createThread(fileId, input, jwt, { actorKind: "ai" })
3473
+ );
3474
+ }
3475
+ /** Replies to an existing thread. */
3476
+ async replyToSpecFileCommentThread(fileId, threadId, body) {
3477
+ return this.withTokenRetry(
3478
+ (jwt, c) => c.comment.replyToThread(fileId, threadId, body, jwt, { actorKind: "ai" })
3479
+ );
3480
+ }
3481
+ /**
3482
+ * Edits one of the caller's own comments.
3483
+ *
3484
+ * `expectedVersion` is the COMMENT's own contentVersion, not the file's:
3485
+ * pairing it with the file would make an edit fail because somebody saved the
3486
+ * document, which has nothing to do with whether this comment moved. Only the
3487
+ * caller's own value is sent -- substituting one read a moment ago would
3488
+ * guard nothing, exactly as in saveSpecFileRevision.
3489
+ */
3490
+ async updateSpecFileComment(fileId, commentId, body, opts = {}) {
3491
+ return this.withTokenRetry(
3492
+ (jwt, c) => c.comment.editComment(fileId, commentId, body, jwt, {
3493
+ ...opts.expectedVersion === void 0 ? {} : { expectedVersion: opts.expectedVersion },
3494
+ actorKind: "ai"
3495
+ })
3496
+ );
3497
+ }
3498
+ /** Soft-deletes one of the caller's own comments. */
3499
+ async deleteSpecFileComment(fileId, commentId) {
3500
+ return this.withTokenRetry(
3501
+ (jwt, c) => c.comment.deleteComment(fileId, commentId, jwt, { actorKind: "ai" })
3502
+ );
3503
+ }
3208
3504
  async withTokenRetry(call) {
3209
3505
  const clients = await this.clients();
3210
3506
  const jwt = await this.tokenManager.getValidAccessToken();
@@ -4790,11 +5086,207 @@ function conflictMessage(filePath, conflict) {
4790
5086
  return `${filePath} was NOT updated: ${who} changed it${when} since you read it.${rev} Their version was kept \u2014 nothing of theirs was overwritten. Re-read the file with get_spec_file (or read_spec_file), decide how your change should combine with theirs, then update again passing the content_version that re-read returns as expected_version.${version} Do not simply resend the same content \u2014 you would undo their work.`;
4791
5087
  }
4792
5088
 
5089
+ // src/server/tools/spec_file_comments.ts
5090
+ import { z as z24 } from "zod";
5091
+ var LIST_BODY_PREVIEW_CHARS = 1e3;
5092
+ var LIST_MAX_THREADS = 100;
5093
+ function commentSummary(comment, opts = {}) {
5094
+ const body = comment.body;
5095
+ const truncated = opts.preview === true && body !== void 0 && body.length > LIST_BODY_PREVIEW_CHARS;
5096
+ return {
5097
+ comment_id: comment.id,
5098
+ thread_id: comment.threadId,
5099
+ author_id: comment.authorId,
5100
+ author_kind: comment.authorKind,
5101
+ // Absent for a tombstone: a soft-deleted root kept so the thread still has
5102
+ // a head to render. `deleted` is the flag to branch on, not a missing body.
5103
+ body: truncated ? body.slice(0, LIST_BODY_PREVIEW_CHARS) : body,
5104
+ // Only ever present and true, so a caller can test it without knowing which
5105
+ // shape produced the comment.
5106
+ ...truncated ? { body_truncated: true } : {},
5107
+ deleted: comment.deleted,
5108
+ content_version: comment.contentVersion,
5109
+ created_at: comment.createdAt?.toISOString(),
5110
+ updated_at: comment.updatedAt?.toISOString()
5111
+ };
5112
+ }
5113
+ function threadSummary(thread, opts = {}) {
5114
+ return {
5115
+ thread_id: thread.id,
5116
+ spec_file_id: thread.specFileId,
5117
+ root_comment_id: thread.rootCommentId,
5118
+ anchor_quote: thread.anchorQuote,
5119
+ // 'anchored' or 'orphaned'. An orphan's passage can no longer be located,
5120
+ // and only a person can repair it — there is no tool here that can.
5121
+ anchor_status: thread.anchorStatus,
5122
+ anchor_start: thread.anchorStart,
5123
+ anchor_end: thread.anchorEnd,
5124
+ // Display only: revision_count is reusable and decrements on revert.
5125
+ anchor_revision: thread.anchorRevision,
5126
+ resolved: thread.resolvedAt !== null,
5127
+ resolved_by: thread.resolvedBy,
5128
+ resolved_at: thread.resolvedAt?.toISOString(),
5129
+ created_by: thread.createdBy,
5130
+ created_by_kind: thread.createdByKind,
5131
+ created_at: thread.createdAt?.toISOString(),
5132
+ comments: thread.comments.map((c) => commentSummary(c, opts))
5133
+ };
5134
+ }
5135
+ function uuidField(what) {
5136
+ return z24.uuid().describe(what);
5137
+ }
5138
+ var listInputSchema = {
5139
+ file_id: uuidField("UUID of the spec file whose comments to list."),
5140
+ status: z24.enum(["open", "resolved", "orphaned"]).optional().describe(
5141
+ "Narrow to open (unresolved) threads, resolved ones, or orphaned ones whose passage can no longer be located. Omit for all of them."
5142
+ )
5143
+ };
5144
+ var listSpecFileCommentsTool = {
5145
+ name: "list_spec_file_comments",
5146
+ description: "List the review comment threads on a spec file. Each thread carries the passage it is anchored to, whether it is resolved, its anchor status, and its comments. Works on a trashed file as well as a live one. Read this before editing a file under review. Returns at most 100 threads (with total_threads and truncated) and previews each comment body to 1000 characters, flagging it with body_truncated; narrow with `status` to see fewer threads in full.",
5147
+ inputSchema: listInputSchema,
5148
+ handler: async (args, ctx) => {
5149
+ const threads = await ctx.client.listSpecFileComments(args.file_id, {
5150
+ ...args.status ? { filter: args.status } : {}
5151
+ });
5152
+ const page = threads.slice(0, LIST_MAX_THREADS);
5153
+ return jsonResult({
5154
+ threads: page.map((t) => threadSummary(t, { preview: true })),
5155
+ total_threads: threads.length,
5156
+ // Only when something was actually withheld, so its absence means "you
5157
+ // have all of it" rather than "nobody thought about it".
5158
+ ...threads.length > page.length ? { truncated: true } : {}
5159
+ });
5160
+ }
5161
+ };
5162
+ var createInputSchema = {
5163
+ file_id: uuidField("UUID of the spec file to comment on."),
5164
+ body: z24.string().min(1).max(MAX_COMMENT_BODY_LENGTH).describe("The comment text, in markdown."),
5165
+ // The three anchor limits are exported by the contract for exactly this, and
5166
+ // the context one is SHORT (64). A description reading "the text immediately
5167
+ // BEFORE the quote" invites a whole preceding sentence, which is the default
5168
+ // reading rather than a corner case — so the ceiling is enforced in the
5169
+ // schema, where the model sees it, rather than discovered as a 400.
5170
+ anchor_quote: z24.string().min(1).max(MAX_ANCHOR_QUOTE_LENGTH).describe(
5171
+ `The exact passage this comment is about, copied verbatim from the file. The server searches the current content for it, so it must match the text as written. Read the file with read_spec_file first. At most ${String(MAX_ANCHOR_QUOTE_LENGTH)} characters.`
5172
+ ),
5173
+ anchor_prefix: z24.string().max(MAX_ANCHOR_CONTEXT_LENGTH).optional().describe(
5174
+ `The text immediately BEFORE the quote, which disambiguates a passage appearing more than once. At most ${String(MAX_ANCHOR_CONTEXT_LENGTH)} characters \u2014 a few words, not a sentence. Send an empty string when the quote starts the file.`
5175
+ ),
5176
+ anchor_suffix: z24.string().max(MAX_ANCHOR_CONTEXT_LENGTH).optional().describe(
5177
+ `The text immediately AFTER the quote. At most ${String(MAX_ANCHOR_CONTEXT_LENGTH)} characters. Empty when the quote ends the file.`
5178
+ )
5179
+ };
5180
+ var createSpecFileCommentThreadTool = {
5181
+ name: "create_spec_file_comment_thread",
5182
+ description: "Open a review comment on a specific passage of a spec file. Quote the passage exactly as it appears. Do NOT send character offsets \u2014 the server locates the quote itself. Refused with a conflict on a trashed file: a file staged for deletion does not gather new discussion.",
5183
+ inputSchema: createInputSchema,
5184
+ handler: async (args, ctx) => {
5185
+ try {
5186
+ const thread = await ctx.client.createSpecFileCommentThread(args.file_id, {
5187
+ body: args.body,
5188
+ anchorQuote: args.anchor_quote,
5189
+ // Always sent, even empty: an omitted key is indistinguishable from a
5190
+ // client that does not know about the field.
5191
+ anchorPrefix: args.anchor_prefix ?? "",
5192
+ anchorSuffix: args.anchor_suffix ?? ""
5193
+ });
5194
+ return jsonResult({ thread: threadSummary(thread) });
5195
+ } catch (err) {
5196
+ if (isPlatformConflictError(err)) {
5197
+ return errorResult(
5198
+ `No thread was created on ${args.file_id}: the file is in the Trash Bin, so it gathers no new discussion. Restore it with restore_spec_file_from_trash first.`
5199
+ );
5200
+ }
5201
+ throw err;
5202
+ }
5203
+ }
5204
+ };
5205
+ var replyInputSchema = {
5206
+ file_id: uuidField("UUID of the spec file the thread belongs to."),
5207
+ thread_id: uuidField("UUID of the thread to reply to, from list_spec_file_comments."),
5208
+ body: z24.string().min(1).max(MAX_COMMENT_BODY_LENGTH).describe("The reply text, in markdown.")
5209
+ };
5210
+ var replySpecFileCommentThreadTool = {
5211
+ name: "reply_spec_file_comment_thread",
5212
+ description: "Reply to an existing review comment thread on a spec file. Use this to answer a reviewer's question or report what you changed. Refused with a conflict on a trashed file.",
5213
+ inputSchema: replyInputSchema,
5214
+ handler: async (args, ctx) => {
5215
+ try {
5216
+ const comment = await ctx.client.replyToSpecFileCommentThread(
5217
+ args.file_id,
5218
+ args.thread_id,
5219
+ args.body
5220
+ );
5221
+ return jsonResult({ comment: commentSummary(comment) });
5222
+ } catch (err) {
5223
+ if (isPlatformConflictError(err)) {
5224
+ return errorResult(
5225
+ `No reply was posted to ${args.thread_id}: the file is in the Trash Bin, so it gathers no new discussion. Restore it with restore_spec_file_from_trash first.`
5226
+ );
5227
+ }
5228
+ throw err;
5229
+ }
5230
+ }
5231
+ };
5232
+ var updateInputSchema = {
5233
+ file_id: uuidField("UUID of the spec file the comment belongs to."),
5234
+ comment_id: uuidField("UUID of the comment to edit. Must be your own."),
5235
+ body: z24.string().min(1).max(MAX_COMMENT_BODY_LENGTH).describe("The replacement text, in markdown. Replaces the comment entirely."),
5236
+ expected_version: z24.number().int().nonnegative().optional().describe(
5237
+ "The COMMENT's own content_version as last read by list_spec_file_comments \u2014 not the file's. Sending it makes the edit fail rather than silently overwrite one made since."
5238
+ )
5239
+ };
5240
+ var updateSpecFileCommentTool = {
5241
+ name: "update_spec_file_comment",
5242
+ description: "Edit the body of one of YOUR OWN comments on a spec file. The platform refuses a comment written by somebody else. Pass the comment's own content_version as expected_version to avoid overwriting an edit made since you read it. Works on a trashed file.",
5243
+ inputSchema: updateInputSchema,
5244
+ handler: async (args, ctx) => {
5245
+ try {
5246
+ const comment = await ctx.client.updateSpecFileComment(
5247
+ args.file_id,
5248
+ args.comment_id,
5249
+ args.body,
5250
+ {
5251
+ // Only the caller's own value is sent. Substituting a version read a
5252
+ // moment ago would guard nothing — it cannot detect an edit made before
5253
+ // that read, which is the edit that matters.
5254
+ ...args.expected_version === void 0 ? {} : { expectedVersion: args.expected_version }
5255
+ }
5256
+ );
5257
+ return jsonResult({ comment: commentSummary(comment) });
5258
+ } catch (err) {
5259
+ if (isPlatformConflictError(err)) {
5260
+ return errorResult(
5261
+ `${args.comment_id} was NOT edited: it has changed since the version you passed. Re-read it with list_spec_file_comments, re-apply your change, and retry with the new content_version.`
5262
+ );
5263
+ }
5264
+ throw err;
5265
+ }
5266
+ }
5267
+ };
5268
+ var deleteInputSchema = {
5269
+ file_id: uuidField("UUID of the spec file the comment belongs to."),
5270
+ comment_id: uuidField("UUID of the comment to delete. Must be your own.")
5271
+ };
5272
+ var deleteSpecFileCommentTool = {
5273
+ name: "delete_spec_file_comment",
5274
+ description: "Soft-delete one of YOUR OWN comments on a spec file. Three outcomes, reported in the result: a reply simply disappears (comment: null); a first comment with live replies becomes a tombstone so the thread keeps a head (comment: the tombstone); and a first comment with no live replies takes the whole thread (thread_deleted: true).",
5275
+ inputSchema: deleteInputSchema,
5276
+ handler: async (args, ctx) => {
5277
+ const result = await ctx.client.deleteSpecFileComment(args.file_id, args.comment_id);
5278
+ return jsonResult({
5279
+ comment: result.comment ? commentSummary(result.comment) : null,
5280
+ thread_deleted: result.threadDeleted
5281
+ });
5282
+ }
5283
+ };
5284
+
4793
5285
  // src/server/tools/upload_attachment.ts
4794
5286
  import { createHash as createHash3 } from "crypto";
4795
5287
  import { lstat, readFile as readFile2 } from "fs/promises";
4796
5288
  import { basename, extname, isAbsolute as isAbsolute2 } from "path";
4797
- import { z as z24 } from "zod";
5289
+ import { z as z25 } from "zod";
4798
5290
  var DEFAULT_MIME = "application/octet-stream";
4799
5291
  var MAX_ATTACHMENT_MB2 = MAX_ATTACHMENT_BYTES / (1024 * 1024);
4800
5292
  var STRUCTURED_EXTENSION_MIME = {
@@ -4900,15 +5392,15 @@ function inferMimeFromName(name) {
4900
5392
  return DEFAULT_MIME;
4901
5393
  }
4902
5394
  var inputSchema24 = {
4903
- project_id: z24.string().min(1).describe("UUID of the project to attach the file to."),
4904
- file_path: z24.string().min(1).describe(
5395
+ project_id: z25.string().min(1).describe("UUID of the project to attach the file to."),
5396
+ file_path: z25.string().min(1).describe(
4905
5397
  "Absolute path to the local file to upload. The MCP server reads this path from its own host."
4906
5398
  ),
4907
- file_name: z24.string().min(1).optional().describe("Override the filename recorded on the attachment. Defaults to the path basename."),
4908
- mime_type: z24.string().min(1).optional().describe(
5399
+ file_name: z25.string().min(1).optional().describe("Override the filename recorded on the attachment. Defaults to the path basename."),
5400
+ mime_type: z25.string().min(1).optional().describe(
4909
5401
  "Override the MIME type sent to the platform. The platform re-detects from content, so this is only a hint. If omitted, inferred from the file extension \u2014 structured (.pdf, .docx, .xlsx) keep their canonical MIME; common UTF-8 text formats (.xml, .json, .yaml, .html, .csv, .md, source code, ...) are sent as text/plain."
4910
5402
  ),
4911
- override: z24.boolean().optional().describe(
5403
+ override: z25.boolean().optional().describe(
4912
5404
  "When true, any existing (non-deleted) attachment on the project whose recorded filename exactly matches `file_name` (or the path basename) is soft-deleted before the new upload, so the new attachment keeps the original name instead of getting an auto-dedup `(1)` suffix. The platform has no in-place content-replace API, so the new upload always gets a new `attachment_id`; any replaced ids are returned as `overridden_attachment_ids` (array, possibly empty) so callers can refresh stored references. Defaults to false (let the platform auto-dedup)."
4913
5405
  )
4914
5406
  };
@@ -5039,7 +5531,7 @@ var uploadAttachmentTool = {
5039
5531
  };
5040
5532
 
5041
5533
  // src/server/tools/upload_spec_file.ts
5042
- import { z as z25 } from "zod";
5534
+ import { z as z26 } from "zod";
5043
5535
 
5044
5536
  // src/server/spec-path.ts
5045
5537
  var MAX_SPEC_DIR_DEPTH = 3;
@@ -5096,17 +5588,17 @@ var SPEC_FILE_TYPES = [
5096
5588
  "openspec-spec"
5097
5589
  ];
5098
5590
  var inputSchema25 = {
5099
- project_id: z25.string().min(1).describe("UUID of the project to upload the file into."),
5100
- file_path: z25.string().min(1).describe(
5591
+ project_id: z26.string().min(1).describe("UUID of the project to upload the file into."),
5592
+ file_path: z26.string().min(1).describe(
5101
5593
  'Path of the new spec file, rooted at "specs" or "openspec" with at most 3 directory levels below the root (e.g. specs/changes/add-oauth-login/proposal.md).'
5102
5594
  ),
5103
- content: z25.string().min(1).optional().describe(
5595
+ content: z26.string().min(1).optional().describe(
5104
5596
  "Full UTF-8 text content of the spec file. Provide this OR local_file_path (exactly one). Use for in-memory / generated content."
5105
5597
  ),
5106
- local_file_path: z25.string().min(1).optional().describe(
5598
+ local_file_path: z26.string().min(1).optional().describe(
5107
5599
  "Path to a local file whose bytes become the spec file body; the server reads it directly. Provide this OR content (exactly one). Absolute paths are most reliable; a relative path resolves against the MCP server working directory. Point this only at an intended spec file \u2014 its bytes are uploaded to the project as-is; never use it for secrets or unrelated files."
5108
5600
  ),
5109
- file_type: z25.enum(SPEC_FILE_TYPES).optional().describe("Spec file type. Omit to derive from the filename (e.g. proposal.md \u2192 proposal).")
5601
+ file_type: z26.enum(SPEC_FILE_TYPES).optional().describe("Spec file type. Omit to derive from the filename (e.g. proposal.md \u2192 proposal).")
5110
5602
  };
5111
5603
  var uploadSpecFileTool = {
5112
5604
  name: "upload_spec_file",
@@ -5142,16 +5634,16 @@ var uploadSpecFileTool = {
5142
5634
  };
5143
5635
 
5144
5636
  // src/server/tools/list_artifacts.ts
5145
- import { z as z27 } from "zod";
5637
+ import { z as z28 } from "zod";
5146
5638
 
5147
5639
  // src/server/tools/artifact-output.ts
5148
- import { z as z26 } from "zod";
5149
- var artifactFilesSchema = z26.array(
5150
- z26.object({
5151
- path: z26.string().min(1).describe('Bundle-relative path, "/"-separated, no leading slash (e.g. assets/logo.svg).'),
5152
- content: z26.string().describe("The file content, in `encoding` (default utf8)."),
5153
- encoding: z26.enum(["utf8", "base64"]).optional().describe("utf8 (default) for text; base64 for binary files such as images."),
5154
- content_type: z26.string().optional().describe("Hint used only for extensions the platform does not recognise.")
5640
+ import { z as z27 } from "zod";
5641
+ var artifactFilesSchema = z27.array(
5642
+ z27.object({
5643
+ path: z27.string().min(1).describe('Bundle-relative path, "/"-separated, no leading slash (e.g. assets/logo.svg).'),
5644
+ content: z27.string().describe("The file content, in `encoding` (default utf8)."),
5645
+ encoding: z27.enum(["utf8", "base64"]).optional().describe("utf8 (default) for text; base64 for binary files such as images."),
5646
+ content_type: z27.string().optional().describe("Hint used only for extensions the platform does not recognise.")
5155
5647
  })
5156
5648
  ).describe("Files of the bundle.");
5157
5649
  function artifactSummary(a) {
@@ -5224,9 +5716,9 @@ function artifactCreateConflictMessage(conflict) {
5224
5716
  var LIST_DEFAULT4 = 50;
5225
5717
  var LIST_MAX4 = 100;
5226
5718
  var inputSchema26 = {
5227
- project_id: z27.string().min(1).describe("UUID of the project whose artifacts to list."),
5228
- limit: z27.number().int().positive().optional().describe(`Max artifacts to return (default ${String(LIST_DEFAULT4)}, capped at ${String(LIST_MAX4)}).`),
5229
- offset: z27.number().int().nonnegative().optional().describe("Pagination offset (default 0).")
5719
+ project_id: z28.string().min(1).describe("UUID of the project whose artifacts to list."),
5720
+ limit: z28.number().int().positive().optional().describe(`Max artifacts to return (default ${String(LIST_DEFAULT4)}, capped at ${String(LIST_MAX4)}).`),
5721
+ offset: z28.number().int().nonnegative().optional().describe("Pagination offset (default 0).")
5230
5722
  };
5231
5723
  var listArtifactsTool = {
5232
5724
  name: "list_artifacts",
@@ -5249,10 +5741,10 @@ var listArtifactsTool = {
5249
5741
  };
5250
5742
 
5251
5743
  // src/server/tools/get_artifact.ts
5252
- import { z as z28 } from "zod";
5744
+ import { z as z29 } from "zod";
5253
5745
  var inputSchema27 = {
5254
- artifact_id: z28.string().min(1).describe("UUID of the artifact."),
5255
- revision: z28.number().int().positive().optional().describe("Revision whose manifest to return (defaults to the head revision).")
5746
+ artifact_id: z29.string().min(1).describe("UUID of the artifact."),
5747
+ revision: z29.number().int().positive().optional().describe("Revision whose manifest to return (defaults to the head revision).")
5256
5748
  };
5257
5749
  var getArtifactTool = {
5258
5750
  name: "get_artifact",
@@ -5274,14 +5766,14 @@ var getArtifactTool = {
5274
5766
  };
5275
5767
 
5276
5768
  // src/server/tools/read_artifact_file.ts
5277
- import { z as z29 } from "zod";
5769
+ import { z as z30 } from "zod";
5278
5770
  var MAX_READABLE_ARTIFACT_FILE_BYTES = 8 * 1024 * 1024;
5279
5771
  var inputSchema28 = {
5280
- artifact_id: z29.string().min(1).describe("UUID of the artifact."),
5281
- path: z29.string().min(1).describe("Bundle-relative path of the file (e.g. index.html, assets/app.js)."),
5282
- revision: z29.number().int().positive().optional().describe("Revision to read from (defaults to the head revision)."),
5283
- offset: z29.number().int().nonnegative().optional().describe("1-based line number to start reading from (default 1; 0 is an alias for 1)."),
5284
- limit: z29.number().int().positive().optional().describe("Max lines to return (default 2000, capped at 2000; larger values are clamped).")
5772
+ artifact_id: z30.string().min(1).describe("UUID of the artifact."),
5773
+ path: z30.string().min(1).describe("Bundle-relative path of the file (e.g. index.html, assets/app.js)."),
5774
+ revision: z30.number().int().positive().optional().describe("Revision to read from (defaults to the head revision)."),
5775
+ offset: z30.number().int().nonnegative().optional().describe("1-based line number to start reading from (default 1; 0 is an alias for 1)."),
5776
+ limit: z30.number().int().positive().optional().describe("Max lines to return (default 2000, capped at 2000; larger values are clamped).")
5285
5777
  };
5286
5778
  var readArtifactFileTool = {
5287
5779
  name: "read_artifact_file",
@@ -5321,14 +5813,14 @@ var readArtifactFileTool = {
5321
5813
  };
5322
5814
 
5323
5815
  // src/server/tools/create_artifact.ts
5324
- import { z as z30 } from "zod";
5816
+ import { z as z31 } from "zod";
5325
5817
  var inputSchema29 = {
5326
- project_id: z30.string().min(1).describe("UUID of the project to create the artifact in."),
5327
- name: z30.string().min(1).describe("Display name (max 200 characters)."),
5328
- slug: z30.string().optional().describe("URL-safe identifier, unique within the project; derived from the name when omitted."),
5329
- description: z30.string().optional().describe("Optional description (max 2000 characters)."),
5330
- entry_path: z30.string().optional().describe("The file a renderer opens first. Defaults to index.html when present, else the only file."),
5331
- message: z30.string().optional().describe("Short summary of this first revision (max 500 characters)."),
5818
+ project_id: z31.string().min(1).describe("UUID of the project to create the artifact in."),
5819
+ name: z31.string().min(1).describe("Display name (max 200 characters)."),
5820
+ slug: z31.string().optional().describe("URL-safe identifier, unique within the project; derived from the name when omitted."),
5821
+ description: z31.string().optional().describe("Optional description (max 2000 characters)."),
5822
+ entry_path: z31.string().optional().describe("The file a renderer opens first. Defaults to index.html when present, else the only file."),
5823
+ message: z31.string().optional().describe("Short summary of this first revision (max 500 characters)."),
5332
5824
  files: artifactFilesSchema
5333
5825
  };
5334
5826
  var createArtifactTool = {
@@ -5362,19 +5854,19 @@ var createArtifactTool = {
5362
5854
  };
5363
5855
 
5364
5856
  // src/server/tools/write_artifact_revision.ts
5365
- import { z as z31 } from "zod";
5857
+ import { z as z32 } from "zod";
5366
5858
  var inputSchema30 = {
5367
- artifact_id: z31.string().min(1).describe("UUID of the artifact."),
5859
+ artifact_id: z32.string().min(1).describe("UUID of the artifact."),
5368
5860
  files: artifactFilesSchema,
5369
- base_revision: z31.number().int().positive().optional().describe(
5861
+ base_revision: z32.number().int().positive().optional().describe(
5370
5862
  "PATCH mode: start from this revision's files, upsert `files` by path and drop `remove` paths. Omit for FULL mode, where `files` is the complete new file set. Patch mode defaults expected_version to base_revision."
5371
5863
  ),
5372
- remove: z31.array(z31.string().min(1)).optional().describe("Patch mode only: paths to drop from the base revision."),
5373
- expected_version: z31.number().int().nonnegative().optional().describe(
5864
+ remove: z32.array(z32.string().min(1)).optional().describe("Patch mode only: paths to drop from the base revision."),
5865
+ expected_version: z32.number().int().nonnegative().optional().describe(
5374
5866
  "The revision_count this write is based on (from get_artifact). The platform refuses the write with a conflict if the head moved. Defaults to base_revision in patch mode, else the current head."
5375
5867
  ),
5376
- message: z31.string().optional().describe("Short summary of the change (max 500 characters)."),
5377
- entry_path: z31.string().optional().describe("Change the file a renderer opens first, from this revision on.")
5868
+ message: z32.string().optional().describe("Short summary of the change (max 500 characters)."),
5869
+ entry_path: z32.string().optional().describe("Change the file a renderer opens first, from this revision on.")
5378
5870
  };
5379
5871
  var writeArtifactRevisionTool = {
5380
5872
  name: "write_artifact_revision",
@@ -5413,12 +5905,12 @@ var writeArtifactRevisionTool = {
5413
5905
  };
5414
5906
 
5415
5907
  // src/server/tools/rollback_artifact.ts
5416
- import { z as z32 } from "zod";
5908
+ import { z as z33 } from "zod";
5417
5909
  var inputSchema31 = {
5418
- artifact_id: z32.string().min(1).describe("UUID of the artifact."),
5419
- revision_number: z32.number().int().positive().describe("The revision whose files to restore."),
5420
- expected_version: z32.number().int().nonnegative().optional().describe("The revision_count this rollback is based on (from get_artifact); defaults to the current head."),
5421
- message: z32.string().optional().describe('Optional summary; defaults to "Rolled back to revision N".')
5910
+ artifact_id: z33.string().min(1).describe("UUID of the artifact."),
5911
+ revision_number: z33.number().int().positive().describe("The revision whose files to restore."),
5912
+ expected_version: z33.number().int().nonnegative().optional().describe("The revision_count this rollback is based on (from get_artifact); defaults to the current head."),
5913
+ message: z33.string().optional().describe('Optional summary; defaults to "Rolled back to revision N".')
5422
5914
  };
5423
5915
  var rollbackArtifactTool = {
5424
5916
  name: "rollback_artifact",
@@ -5443,10 +5935,10 @@ var rollbackArtifactTool = {
5443
5935
  };
5444
5936
 
5445
5937
  // src/server/tools/create_stream_token.ts
5446
- import { z as z34 } from "zod";
5938
+ import { z as z35 } from "zod";
5447
5939
 
5448
5940
  // src/server/tools/stream-token-output.ts
5449
- import { z as z33 } from "zod";
5941
+ import { z as z34 } from "zod";
5450
5942
  var projectStreamEventTypes = [
5451
5943
  "project.updated",
5452
5944
  "project.deleted",
@@ -5454,6 +5946,10 @@ var projectStreamEventTypes = [
5454
5946
  "spec_file.updated",
5455
5947
  "spec_file.deleted",
5456
5948
  "spec_file.lock",
5949
+ "spec_file_comment.created",
5950
+ "spec_file_comment.updated",
5951
+ "spec_file_comment.thread_state_changed",
5952
+ "spec_file_comment.anchors_updated",
5457
5953
  "attachment.created",
5458
5954
  "attachment.deleted",
5459
5955
  "spec_session.created",
@@ -5461,8 +5957,8 @@ var projectStreamEventTypes = [
5461
5957
  "spec_session.deleted",
5462
5958
  "*"
5463
5959
  ];
5464
- var streamEventTypesSchema = z33.array(z33.enum(projectStreamEventTypes)).min(1).describe(
5465
- 'Event types to receive. Required \u2014 pass ["*"] to follow all of them. spec_file.updated covers new revisions, rollbacks, and moves to or from the trash; spec_file.lock covers acquire, renew, release and takeover; spec_session.updated covers completion, archive, unarchive and rename.'
5960
+ var streamEventTypesSchema = z34.array(z34.enum(projectStreamEventTypes)).min(1).describe(
5961
+ 'Event types to receive. Required \u2014 pass ["*"] to follow all of them. spec_file.updated covers new revisions, rollbacks, and moves to or from the trash; spec_file.lock covers acquire, renew, release and takeover; spec_session.updated covers completion, archive, unarchive and rename; spec_file_comment.updated covers a body edit and a soft delete, thread_state_changed covers resolve and unresolve, and anchors_updated is batched across every thread one save moved.'
5466
5962
  );
5467
5963
  function streamTokenSummary(token) {
5468
5964
  return {
@@ -5492,13 +5988,13 @@ function streamTokenMintOutput(result) {
5492
5988
 
5493
5989
  // src/server/tools/create_stream_token.ts
5494
5990
  var inputSchema32 = {
5495
- resource_type: z34.enum(["project"]).default("project").describe("What to follow. Only 'project' is supported today."),
5496
- resource_id: z34.string().min(1).describe("UUID of the project to stream events for."),
5991
+ resource_type: z35.enum(["project"]).default("project").describe("What to follow. Only 'project' is supported today."),
5992
+ resource_id: z35.string().min(1).describe("UUID of the project to stream events for."),
5497
5993
  event_types: streamEventTypesSchema,
5498
- ttl_seconds: z34.number().int().positive().optional().describe(
5994
+ ttl_seconds: z35.number().int().positive().optional().describe(
5499
5995
  "How long the URL stays valid. Defaults to 24 hours, capped at 7 days. Out-of-range values are clamped rather than rejected \u2014 check expires_at in the result."
5500
5996
  ),
5501
- label: z34.string().optional().describe("Short human-readable label, so a person can recognise this token in a listing.")
5997
+ label: z35.string().optional().describe("Short human-readable label, so a person can recognise this token in a listing.")
5502
5998
  };
5503
5999
  var createStreamTokenTool = {
5504
6000
  name: "create_stream_token",
@@ -5517,17 +6013,17 @@ var createStreamTokenTool = {
5517
6013
  };
5518
6014
 
5519
6015
  // src/server/tools/list_stream_tokens.ts
5520
- import { z as z35 } from "zod";
6016
+ import { z as z36 } from "zod";
5521
6017
  var LIST_DEFAULT5 = 50;
5522
6018
  var LIST_MAX5 = 200;
5523
6019
  var inputSchema33 = {
5524
- resource_id: z35.string().optional().describe("Only tokens following this project."),
5525
- live_only: z35.boolean().optional().describe("Hide expired and revoked tokens."),
5526
- mine_only: z35.boolean().optional().describe(
6020
+ resource_id: z36.string().optional().describe("Only tokens following this project."),
6021
+ live_only: z36.boolean().optional().describe("Hide expired and revoked tokens."),
6022
+ mine_only: z36.boolean().optional().describe(
5527
6023
  "Only tokens you created. Off by default, so a leaked URL can be found \u2014 note that revoking one you did not mint needs the organisation owner or admin role."
5528
6024
  ),
5529
- limit: z35.number().int().positive().optional().describe(`Max tokens to return (default ${String(LIST_DEFAULT5)}, capped at ${String(LIST_MAX5)}).`),
5530
- offset: z35.number().int().nonnegative().optional().describe("Pagination offset (default 0).")
6025
+ limit: z36.number().int().positive().optional().describe(`Max tokens to return (default ${String(LIST_DEFAULT5)}, capped at ${String(LIST_MAX5)}).`),
6026
+ offset: z36.number().int().nonnegative().optional().describe("Pagination offset (default 0).")
5531
6027
  };
5532
6028
  var listStreamTokensTool = {
5533
6029
  name: "list_stream_tokens",
@@ -5555,9 +6051,9 @@ var listStreamTokensTool = {
5555
6051
  };
5556
6052
 
5557
6053
  // src/server/tools/revoke_stream_token.ts
5558
- import { z as z36 } from "zod";
6054
+ import { z as z37 } from "zod";
5559
6055
  var inputSchema34 = {
5560
- id: z36.string().min(1).describe("Token id (from create_stream_token or list_stream_tokens) \u2014 NOT the secret itself.")
6056
+ id: z37.string().min(1).describe("Token id (from create_stream_token or list_stream_tokens) \u2014 NOT the secret itself.")
5561
6057
  };
5562
6058
  var revokeStreamTokenTool = {
5563
6059
  name: "revoke_stream_token",
@@ -5570,7 +6066,7 @@ var revokeStreamTokenTool = {
5570
6066
  };
5571
6067
 
5572
6068
  // src/server/tools/start_spec_session.ts
5573
- import { z as z37 } from "zod";
6069
+ import { z as z38 } from "zod";
5574
6070
 
5575
6071
  // src/server/tools/start-session-core.ts
5576
6072
  var START_SESSION_WAIT_MS = 75e3;
@@ -5963,27 +6459,27 @@ var START_SESSION_DESCRIPTION_BASE = "Start a NEW MySpec chat session on the use
5963
6459
  // src/server/tools/start_spec_session.ts
5964
6460
  var BRIDGE_COMMAND = "npx -y @myspec/mcp-server reverse --root <path>";
5965
6461
  var inputSchema35 = {
5966
- project_id: z37.string().min(1).describe("UUID of the project to start the session in."),
5967
- mode: z37.enum(["generate", "edit"]).describe(
6462
+ project_id: z38.string().min(1).describe("UUID of the project to start the session in."),
6463
+ mode: z38.enum(["generate", "edit"]).describe(
5968
6464
  "'generate' writes a new spec bundle from scratch; 'edit' changes the spec files of an existing bundle."
5969
6465
  ),
5970
- prompt: z37.string().min(1).describe(
6466
+ prompt: z38.string().min(1).describe(
5971
6467
  "The opening message. Write it as the user would: what they want specced, and the repository context you have gathered that they would otherwise have to retype."
5972
6468
  ),
5973
- workflow_id: z37.string().optional().describe(
6469
+ workflow_id: z38.string().optional().describe(
5974
6470
  "Workflow variant id, e.g. myspec-greenfield-v1, enterprise-greenfield-v1, speckit-greenfield-v1, openspec-brownfield-v1, myspec-brownfield-v1, myspec-edit-v1, myspec-edit-brownfield-v1. Omit to use the default for the chosen mode. Must agree with mode \u2014 a contradiction is rejected, not silently resolved."
5975
6471
  ),
5976
- language: z37.enum(SPEC_SESSION_LANGUAGES).optional().describe(
6472
+ language: z38.enum(SPEC_SESSION_LANGUAGES).optional().describe(
5977
6473
  "Output language the session locks to on its first turn. Defaults to 'en'. A session's language is immutable once set."
5978
6474
  ),
5979
- model_id: z37.string().optional().describe("Model id to run the session with, e.g. openai:gpt-6-astra."),
5980
- attachment_ids: z37.array(z37.string().min(1)).max(MAX_REFERENCE_IDS).optional().describe(
6475
+ model_id: z38.string().optional().describe("Model id to run the session with, e.g. openai:gpt-6-astra."),
6476
+ attachment_ids: z38.array(z38.string().min(1)).max(MAX_REFERENCE_IDS).optional().describe(
5981
6477
  `Project attachment ids to cite in the opening message, as the composer @-mentions do. At most ${String(MAX_REFERENCE_IDS)}.`
5982
6478
  ),
5983
- spec_file_ids: z37.array(z37.string().min(1)).max(MAX_REFERENCE_IDS).optional().describe(
6479
+ spec_file_ids: z38.array(z38.string().min(1)).max(MAX_REFERENCE_IDS).optional().describe(
5984
6480
  `Existing spec file ids to cite in the opening message. Required in practice for edit sessions. At most ${String(MAX_REFERENCE_IDS)}.`
5985
6481
  ),
5986
- session_title: z37.string().min(1).max(SESSION_TITLE_MAX_LENGTH).optional().describe(
6482
+ session_title: z38.string().min(1).max(SESSION_TITLE_MAX_LENGTH).optional().describe(
5987
6483
  `Human-readable session title, stored as context.sessionSummary (max ${String(SESSION_TITLE_MAX_LENGTH)} characters). A workflow that names the session itself during its interview will overwrite it, exactly as in the webapp.`
5988
6484
  )
5989
6485
  };
@@ -6213,6 +6709,11 @@ function buildServer(deps) {
6213
6709
  registerTool(server, listStreamTokensTool, ctx);
6214
6710
  registerTool(server, revokeStreamTokenTool, ctx);
6215
6711
  registerTool(server, startSpecSessionTool, ctx);
6712
+ registerTool(server, listSpecFileCommentsTool, ctx);
6713
+ registerTool(server, createSpecFileCommentThreadTool, ctx);
6714
+ registerTool(server, replySpecFileCommentThreadTool, ctx);
6715
+ registerTool(server, updateSpecFileCommentTool, ctx);
6716
+ registerTool(server, deleteSpecFileCommentTool, ctx);
6216
6717
  return server;
6217
6718
  }
6218
6719
  async function startStdioServer(deps) {
@@ -6366,7 +6867,7 @@ async function canonicaliseRoot(root) {
6366
6867
  import { promises as fs4 } from "fs";
6367
6868
  import { join as join2, relative } from "path";
6368
6869
  import { spawn } from "child_process";
6369
- import { z as z38 } from "zod";
6870
+ import { z as z39 } from "zod";
6370
6871
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6371
6872
 
6372
6873
  // src/reverse/ignore.ts
@@ -6671,37 +7172,37 @@ var READ_FILE_MAX_LINES = 2e3;
6671
7172
  var LIST_DIR_HARD_CAP = 5e3;
6672
7173
  var LIST_DIR_DEFAULT_CAP = 1e3;
6673
7174
  var GREP_MAX_MATCHES_DEFAULT = 200;
6674
- var listDirInput = z38.object({
6675
- path: z38.string(),
6676
- recursive: z38.boolean().optional(),
6677
- maxEntries: z38.number().int().positive().optional(),
6678
- includeIgnored: z38.boolean().optional()
7175
+ var listDirInput = z39.object({
7176
+ path: z39.string(),
7177
+ recursive: z39.boolean().optional(),
7178
+ maxEntries: z39.number().int().positive().optional(),
7179
+ includeIgnored: z39.boolean().optional()
6679
7180
  });
6680
- var readFileInput = z38.object({
6681
- path: z38.string(),
6682
- offset: z38.number().int().nonnegative().optional(),
6683
- limit: z38.number().int().positive().optional(),
6684
- includeIgnored: z38.boolean().optional()
7181
+ var readFileInput = z39.object({
7182
+ path: z39.string(),
7183
+ offset: z39.number().int().nonnegative().optional(),
7184
+ limit: z39.number().int().positive().optional(),
7185
+ includeIgnored: z39.boolean().optional()
6685
7186
  });
6686
- var grepInput = z38.object({
6687
- pattern: z38.string().describe(
7187
+ var grepInput = z39.object({
7188
+ pattern: z39.string().describe(
6688
7189
  "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."
6689
7190
  ),
6690
- path: z38.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
6691
- isRegex: z38.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
6692
- caseSensitive: z38.boolean().optional(),
6693
- maxMatches: z38.number().int().positive().optional(),
6694
- contextLines: z38.number().int().nonnegative().optional(),
6695
- includeIgnored: z38.boolean().optional()
7191
+ path: z39.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
7192
+ isRegex: z39.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
7193
+ caseSensitive: z39.boolean().optional(),
7194
+ maxMatches: z39.number().int().positive().optional(),
7195
+ contextLines: z39.number().int().nonnegative().optional(),
7196
+ includeIgnored: z39.boolean().optional()
6696
7197
  });
6697
- var packCodebaseInput = z38.object({
6698
- subpath: z38.string().optional(),
6699
- includePatterns: z38.string().optional(),
6700
- ignorePatterns: z38.string().optional()
7198
+ var packCodebaseInput = z39.object({
7199
+ subpath: z39.string().optional(),
7200
+ includePatterns: z39.string().optional(),
7201
+ ignorePatterns: z39.string().optional()
6701
7202
  });
6702
- var packCodebaseReadPageInput = z38.object({
6703
- outputId: z38.string(),
6704
- page: z38.number().int().positive()
7203
+ var packCodebaseReadPageInput = z39.object({
7204
+ outputId: z39.string(),
7205
+ page: z39.number().int().positive()
6705
7206
  });
6706
7207
  function structuredError(error, message, extra) {
6707
7208
  const payload = { error, message, ...extra ?? {} };
@@ -7229,7 +7730,7 @@ function jsonSchemaFromZod(schema) {
7229
7730
  return zodToJson(schema);
7230
7731
  }
7231
7732
  function zodToJson(schema) {
7232
- if (schema instanceof z38.ZodObject) {
7733
+ if (schema instanceof z39.ZodObject) {
7233
7734
  const shape = schema.shape;
7234
7735
  const properties = {};
7235
7736
  const required = [];
@@ -7244,19 +7745,19 @@ function zodToJson(schema) {
7244
7745
  if (required.length > 0) out.required = required;
7245
7746
  return out;
7246
7747
  }
7247
- if (schema instanceof z38.ZodOptional) {
7748
+ if (schema instanceof z39.ZodOptional) {
7248
7749
  return zodToJson(schema.unwrap());
7249
7750
  }
7250
- if (schema instanceof z38.ZodString) {
7751
+ if (schema instanceof z39.ZodString) {
7251
7752
  return { type: "string" };
7252
7753
  }
7253
- if (schema instanceof z38.ZodNumber) {
7754
+ if (schema instanceof z39.ZodNumber) {
7254
7755
  return { type: "number" };
7255
7756
  }
7256
- if (schema instanceof z38.ZodBoolean) {
7757
+ if (schema instanceof z39.ZodBoolean) {
7257
7758
  return { type: "boolean" };
7258
7759
  }
7259
- if (schema instanceof z38.ZodEnum) {
7760
+ if (schema instanceof z39.ZodEnum) {
7260
7761
  return { type: "string", enum: schema.options };
7261
7762
  }
7262
7763
  return { type: "string" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.5.0-next.116",
3
+ "version": "0.5.0-next.117",
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": {