@myspec/mcp-server 0.4.0-next.95 → 0.4.0-next.97

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 +8 -0
  2. package/dist/index.js +733 -136
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -298,6 +298,14 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
298
298
  | `restore_spec_file_from_trash` | `file_id` | Restores a trashed spec file back to the active file list, reversing `move_spec_file_to_trash` |
299
299
  | `get_attachment` | `attachment_id`, optional `include_download_url` | Attachment metadata (looked up org-scoped by id); with `include_download_url>0` also returns a signed download URL |
300
300
  | `upload_attachment` | `project_id`, `file_path` (absolute), optional `file_name`, optional `mime_type`, optional `override` | Uploads the local file as a new attachment on the project. Returns `attachment_id`, `file_name` (the **effective** name the platform stored — may differ from the requested one when auto-dedup adds a `(N)` suffix), `has_file_name_conflict` (`true` when stored name ≠ requested name), `mime_type`, `file_size_bytes`, `checksum` (format: `sha256:<hex>`), `file_uri`, `reused_existing_attachment` (`true` when an existing attachment with the same name AND same checksum was found and returned without re-uploading; `file_uri` is then omitted because the list endpoint does not surface it — call `get_attachment` for a download URL). With `override=true`, any existing attachment whose recorded name matches is soft-deleted first (returned as `overridden_attachment_ids` — empty array means nothing matched) and the new upload keeps the requested name; the same-checksum short-circuit still applies, so a no-op re-upload never deletes anything. Max 10 MB. Supported content: PDF, DOCX, XLSX, and any UTF-8 text file (XML, JSON, YAML, HTML, Markdown, CSV, source code, ...) |
301
+ | `list_attachments` | `project_id`, optional `limit`, optional `offset` | Paginated attachment metadata (`id`, `name`, `mime_type`, `file_size_bytes`, `checksum`, `created_at`) — `file_size_bytes` is always present so a caller can size-check before `read_attachment`. Default 50 per page, capped at 200; returns `total` and `next_offset` while records remain |
302
+ | `read_attachment` | `attachment_id`, optional `offset`, optional `limit` | The attachment's content inline. UTF-8 text is paginated by line (`offset` is 1-based, default/cap 2000 lines, 1 MiB per response) with `content`, `start_line`, `end_line`, `read_lines`, `total_lines`, `truncated`, `next_offset`. Images (PNG, JPEG, GIF, WEBP) are returned whole as an MCP image block (never paginated, up to 10 MiB). Other binary types (PDF, DOCX, XLSX, ...) error with a pointer to the webapp and to `get_attachment include_download_url` as the local alternative |
303
+ | `list_spec_sessions` | `project_id`, optional `archive_filter` (`active`/`archived`/`all`), optional `status`, optional `query`, optional `limit`, optional `offset` | Paginated session metadata plus `session_summary` and `context_size_bytes` — never the context itself or the chat transcript. Default 10 per page, capped at 20 |
304
+ | `get_spec_session` | `session_id`, optional `include_context` | Session metadata (status, counters, timestamps, `archived_at`), `session_summary`, `context_size_bytes` and `context_keys`. `include_context=true` returns the full context under a 1 MiB ceiling (above it the tool errors with the actual size). The chat transcript is never returned |
305
+ | `rename_spec_session` | `session_id`, `session_summary` | Sets `context.sessionSummary` (max 500 chars) via read-merge-write, preserving every other context key; works on archived sessions; refuses when the context is not a JSON object. Returns `renamed`, `session_id`, `session_summary` |
306
+ | `archive_spec_session` | `session_id` | Archives the session (idempotent — re-archiving keeps the original `archived_at`); the required confirmation step before `delete_spec_session` |
307
+ | `unarchive_spec_session` | `session_id` | Returns an archived session to the active list |
308
+ | `delete_spec_session` | `session_id` | Soft-deletes an ARCHIVED session. Refuses non-archived sessions with an archive-first error and never archives on the caller's behalf |
301
309
 
302
310
  ### Download destinations
303
311
 
package/dist/index.js CHANGED
@@ -2471,6 +2471,32 @@ var PlatformClient = class {
2471
2471
  };
2472
2472
  return this.withTokenRetry((jwt, c) => c.session.listSpecSessions(projectId, jwt, merged));
2473
2473
  }
2474
+ async getSpecSession(sessionId) {
2475
+ return this.withTokenRetry((jwt, c) => c.session.getSpecSession(sessionId, jwt));
2476
+ }
2477
+ /**
2478
+ * Writes ONLY the session's `context` field (rename's read-merge-write).
2479
+ * Deliberately narrow: status, chatHistory and the counters stay
2480
+ * unreachable from the MCP surface. The platform permits context-only
2481
+ * updates on archived sessions, so rename works there too.
2482
+ */
2483
+ async updateSpecSessionContext(sessionId, context) {
2484
+ return this.withTokenRetry((jwt, c) => c.session.updateSpecSession(sessionId, { context }, jwt));
2485
+ }
2486
+ /** Idempotent: re-archiving keeps the original archivedAt. */
2487
+ async archiveSpecSession(sessionId) {
2488
+ return this.withTokenRetry((jwt, c) => c.session.archiveSpecSession(sessionId, jwt));
2489
+ }
2490
+ async unarchiveSpecSession(sessionId) {
2491
+ return this.withTokenRetry((jwt, c) => c.session.unarchiveSpecSession(sessionId, jwt));
2492
+ }
2493
+ /**
2494
+ * Soft-deletes a spec session. The platform rejects this unless the session
2495
+ * is already archived, surfacing a validation error the caller reports.
2496
+ */
2497
+ async deleteSpecSession(sessionId) {
2498
+ await this.withTokenRetry((jwt, c) => c.session.deleteSpecSession(sessionId, jwt));
2499
+ }
2474
2500
  async listFiles(projectId, opts = {}) {
2475
2501
  return this.withTokenRetry((jwt, c) => c.file.listFiles(projectId, jwt, opts));
2476
2502
  }
@@ -2744,6 +2770,14 @@ function jsonResult(value) {
2744
2770
  function errorResult(message) {
2745
2771
  return { content: [{ type: "text", text: message }], isError: true };
2746
2772
  }
2773
+ function imageResult(dataBase64, mimeType, metadata) {
2774
+ return {
2775
+ content: [
2776
+ { type: "text", text: JSON.stringify(metadata, null, 2) },
2777
+ { type: "image", data: dataBase64, mimeType }
2778
+ ]
2779
+ };
2780
+ }
2747
2781
 
2748
2782
  // src/server/tools/archive_project.ts
2749
2783
  import { z } from "zod";
@@ -2775,16 +2809,63 @@ var archiveProjectTool = {
2775
2809
  }
2776
2810
  };
2777
2811
 
2778
- // src/server/tools/create_project.ts
2812
+ // src/server/tools/archive_spec_session.ts
2779
2813
  import { z as z2 } from "zod";
2814
+
2815
+ // src/server/tools/session-output.ts
2816
+ function sessionNotFound(err, sessionId) {
2817
+ return statusOf(err) === 404 ? errorResult(`Session ${sessionId} not found.`) : null;
2818
+ }
2819
+ function sessionMetadata(s) {
2820
+ return {
2821
+ id: s.id,
2822
+ project_id: s.projectId,
2823
+ org: s.org,
2824
+ user_id: s.userId,
2825
+ status: s.status,
2826
+ message_count: s.messageCount,
2827
+ token_count: s.tokenCount,
2828
+ started_at: s.startedAt,
2829
+ completed_at: s.completedAt ?? null,
2830
+ archived_at: s.archivedAt ?? null,
2831
+ created_at: s.createdAt,
2832
+ updated_at: s.updatedAt
2833
+ };
2834
+ }
2835
+
2836
+ // src/server/tools/archive_spec_session.ts
2780
2837
  var inputSchema2 = {
2781
- name: z2.string().describe("Name of the project to create."),
2782
- description: z2.string().optional().describe("Optional project description.")
2838
+ session_id: z2.string().min(1).describe("UUID of the spec session to archive")
2839
+ };
2840
+ var archiveSpecSessionTool = {
2841
+ name: "archive_spec_session",
2842
+ description: "Archive a spec session. Archiving is the confirmation step required before delete_spec_session, and is reversible via unarchive_spec_session. Idempotent: archiving an already-archived session succeeds and keeps the original archived_at.",
2843
+ inputSchema: inputSchema2,
2844
+ handler: async (args, ctx) => {
2845
+ let session;
2846
+ try {
2847
+ session = await ctx.client.archiveSpecSession(args.session_id);
2848
+ } catch (err) {
2849
+ const notFound = sessionNotFound(err, args.session_id);
2850
+ if (notFound) {
2851
+ return notFound;
2852
+ }
2853
+ throw err;
2854
+ }
2855
+ return jsonResult({ session: sessionMetadata(session) });
2856
+ }
2857
+ };
2858
+
2859
+ // src/server/tools/create_project.ts
2860
+ import { z as z3 } from "zod";
2861
+ var inputSchema3 = {
2862
+ name: z3.string().describe("Name of the project to create."),
2863
+ description: z3.string().optional().describe("Optional project description.")
2783
2864
  };
2784
2865
  var createProjectTool = {
2785
2866
  name: "create_project",
2786
2867
  description: "Create a new project. Returns the created project id and metadata.",
2787
- inputSchema: inputSchema2,
2868
+ inputSchema: inputSchema3,
2788
2869
  handler: async (args, ctx) => {
2789
2870
  const name = args.name.trim();
2790
2871
  if (name === "") {
@@ -2799,14 +2880,14 @@ var createProjectTool = {
2799
2880
  };
2800
2881
 
2801
2882
  // src/server/tools/delete_project.ts
2802
- import { z as z3 } from "zod";
2803
- var inputSchema3 = {
2804
- project_id: z3.string().min(1).describe("UUID of the project to delete.")
2883
+ import { z as z4 } from "zod";
2884
+ var inputSchema4 = {
2885
+ project_id: z4.string().min(1).describe("UUID of the project to delete.")
2805
2886
  };
2806
2887
  var deleteProjectTool = {
2807
2888
  name: "delete_project",
2808
2889
  description: "Soft-delete a project. Only a project that is already archived can be deleted. If the project is still active, this tool refuses and you must NOT archive it on the user's behalf to make the deletion go through \u2014 instead, reconfirm with the user whether deleting the project is truly intended.",
2809
- inputSchema: inputSchema3,
2890
+ inputSchema: inputSchema4,
2810
2891
  handler: async (args, ctx) => {
2811
2892
  let status;
2812
2893
  try {
@@ -2831,9 +2912,38 @@ var deleteProjectTool = {
2831
2912
  }
2832
2913
  };
2833
2914
 
2915
+ // src/server/tools/delete_spec_session.ts
2916
+ import { z as z5 } from "zod";
2917
+ var inputSchema5 = {
2918
+ session_id: z5.string().min(1).describe("UUID of the spec session to delete")
2919
+ };
2920
+ var deleteSpecSessionTool = {
2921
+ name: "delete_spec_session",
2922
+ description: "Soft-delete a spec session. Only an ARCHIVED session can be deleted \u2014 archive it first with archive_spec_session. This tool never archives on your behalf: archiving is the user's confirmation step, and collapsing the two-step destructive flow would remove the only checkpoint the platform provides. Check archived_at via get_spec_session to see whether delete is permitted.",
2923
+ inputSchema: inputSchema5,
2924
+ handler: async (args, ctx) => {
2925
+ try {
2926
+ await ctx.client.deleteSpecSession(args.session_id);
2927
+ } catch (err) {
2928
+ const message = err instanceof Error ? err.message : String(err);
2929
+ if (/must be archived/i.test(message)) {
2930
+ return errorResult(
2931
+ `Session ${args.session_id} was NOT deleted: session must be archived before it can be deleted. Archive it first with archive_spec_session \u2014 after reconfirming with the user that deleting this session is intended.`
2932
+ );
2933
+ }
2934
+ const notFound = sessionNotFound(err, args.session_id);
2935
+ if (notFound) {
2936
+ return notFound;
2937
+ }
2938
+ throw err;
2939
+ }
2940
+ return jsonResult({ deleted: true, session_id: args.session_id });
2941
+ }
2942
+ };
2943
+
2834
2944
  // src/server/tools/download_spec_file.ts
2835
2945
  import path3 from "path";
2836
- import { z as z4 } from "zod";
2946
+ import { z as z6 } from "zod";
2837
2947
 
2838
2948
  // src/server/download-path.ts
2839
2949
  import { randomBytes as randomBytes2 } from "crypto";
@@ -2967,18 +3077,18 @@ async function writeBytesAtomically(target, bytes) {
2967
3077
 
2968
3078
  // src/server/tools/download_spec_file.ts
2969
3079
  var MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
2970
- var inputSchema4 = {
2971
- file_id: z4.string().min(1).describe("UUID of the file"),
2972
- revision: z4.number().int().positive().optional().describe("Specific revision number to download (defaults to the latest revision)."),
2973
- destination_path: z4.string().min(1).optional().describe(
3080
+ var inputSchema6 = {
3081
+ file_id: z6.string().min(1).describe("UUID of the file"),
3082
+ revision: z6.number().int().positive().optional().describe("Specific revision number to download (defaults to the latest revision)."),
3083
+ destination_path: z6.string().min(1).optional().describe(
2974
3084
  "Local path to save the file to. Optional: omit it to save the file under `.specs/` in the MCP server's working directory, mirroring its remote path (`specs/add-oauth/proposal.md` \u2192 `.specs/add-oauth/proposal.md`). When given, it is absolute or relative to that working directory; a relative path rooted at `specs/` is redirected to `.specs/`, so pass an absolute path to save into a literal `specs/` directory. If it points to an existing directory (or ends with a path separator), the spec file's basename is appended."
2975
3085
  ),
2976
- overwrite: z4.boolean().optional().describe("Overwrite the destination file if it already exists (default: false).")
3086
+ overwrite: z6.boolean().optional().describe("Overwrite the destination file if it already exists (default: false).")
2977
3087
  };
2978
3088
  var downloadSpecFileTool = {
2979
3089
  name: "download_spec_file",
2980
3090
  description: "Download a spec file's raw bytes to a local file. By default it lands under `.specs/` in the MCP server's working directory, mirroring the file's remote path \u2014 pass `destination_path` only to save it somewhere else. Unlike read_spec_file, this handles binary files and files up to 50 MiB, and the response returns only metadata (saved path, size, revision) \u2014 not the content. Use it to save a large or binary file locally, then search or read it in chunks with other tools.",
2981
- inputSchema: inputSchema4,
3091
+ inputSchema: inputSchema6,
2982
3092
  handler: async (args, ctx) => {
2983
3093
  const meta = await ctx.client.getFileMetadata(args.file_id);
2984
3094
  const targetRevision = args.revision ?? meta.revisionCount;
@@ -3076,15 +3186,15 @@ function buildOversizeMessage(fileId, byteLength) {
3076
3186
  }
3077
3187
 
3078
3188
  // src/server/tools/get_attachment.ts
3079
- import { z as z5 } from "zod";
3080
- var inputSchema5 = {
3081
- attachment_id: z5.string().min(1).describe("UUID of the attachment"),
3082
- include_download_url: z5.number().int().nonnegative().optional().describe("Set to any positive value to include a signed download URL. Omit or 0 to skip.")
3189
+ import { z as z7 } from "zod";
3190
+ var inputSchema7 = {
3191
+ attachment_id: z7.string().min(1).describe("UUID of the attachment"),
3192
+ include_download_url: z7.number().int().nonnegative().optional().describe("Set to any positive value to include a signed download URL. Omit or 0 to skip.")
3083
3193
  };
3084
3194
  var getAttachmentTool = {
3085
3195
  name: "get_attachment",
3086
3196
  description: "Get attachment metadata. With `include_download_url>0`, also returns a signed `download_url`.",
3087
- inputSchema: inputSchema5,
3197
+ inputSchema: inputSchema7,
3088
3198
  handler: async (args, ctx) => {
3089
3199
  const attachment = await ctx.client.getAttachmentMetadata(args.attachment_id);
3090
3200
  if (!attachment) {
@@ -3113,14 +3223,14 @@ var getAttachmentTool = {
3113
3223
  };
3114
3224
 
3115
3225
  // src/server/tools/get_project.ts
3116
- import { z as z6 } from "zod";
3117
- var inputSchema6 = {
3118
- project_id: z6.string().min(1).describe("UUID of the project to fetch")
3226
+ import { z as z8 } from "zod";
3227
+ var inputSchema8 = {
3228
+ project_id: z8.string().min(1).describe("UUID of the project to fetch")
3119
3229
  };
3120
3230
  var getProjectTool = {
3121
3231
  name: "get_project",
3122
3232
  description: "Get details for a project including all active spec sessions, files (id + metadata), and attachments (id + metadata).",
3123
- inputSchema: inputSchema6,
3233
+ inputSchema: inputSchema8,
3124
3234
  handler: async (args, ctx) => {
3125
3235
  const [project, sessions, files, attachments] = await Promise.all([
3126
3236
  ctx.client.getProject(args.project_id),
@@ -3166,7 +3276,7 @@ var getProjectTool = {
3166
3276
  };
3167
3277
 
3168
3278
  // src/server/tools/get_spec_file.ts
3169
- import { z as z7 } from "zod";
3279
+ import { z as z9 } from "zod";
3170
3280
 
3171
3281
  // src/server/tools/spec-file-output.ts
3172
3282
  function specFileSummary(file) {
@@ -3186,16 +3296,16 @@ function specFileSummary(file) {
3186
3296
  }
3187
3297
 
3188
3298
  // src/server/tools/get_spec_file.ts
3189
- var inputSchema7 = {
3190
- file_id: z7.string().min(1).describe("UUID of the file"),
3191
- include_download_url: z7.number().int().nonnegative().optional().describe(
3299
+ var inputSchema9 = {
3300
+ file_id: z9.string().min(1).describe("UUID of the file"),
3301
+ include_download_url: z9.number().int().nonnegative().optional().describe(
3192
3302
  "Revision number to include a download URL for. Returns a signed, time-limited URL for that revision (latest or historical) that needs no credentials. Omit or 0 to skip."
3193
3303
  )
3194
3304
  };
3195
3305
  var getSpecFileTool = {
3196
3306
  name: "get_spec_file",
3197
3307
  description: "Get spec file metadata. With `include_download_url=N`, also returns a signed, time-limited download URL for revision N (latest or historical). The URL is self-authenticating, so no credentials are returned.",
3198
- inputSchema: inputSchema7,
3308
+ inputSchema: inputSchema9,
3199
3309
  handler: async (args, ctx) => {
3200
3310
  const meta = await ctx.client.getFileMetadata(args.file_id);
3201
3311
  const base = specFileSummary(meta);
@@ -3219,19 +3329,209 @@ var getSpecFileTool = {
3219
3329
  }
3220
3330
  };
3221
3331
 
3332
+ // src/server/tools/get_spec_session.ts
3333
+ import { z as z10 } from "zod";
3334
+
3335
+ // src/util/bytes.ts
3336
+ function encodeBase64(bytes) {
3337
+ const CHUNK = 32768;
3338
+ let binary = "";
3339
+ for (let i = 0; i < bytes.length; i += CHUNK) {
3340
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
3341
+ }
3342
+ return btoa(binary);
3343
+ }
3344
+ function isBinary(bytes) {
3345
+ return bytes.includes(0);
3346
+ }
3347
+ function decodeUtf8Strict(bytes) {
3348
+ try {
3349
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
3350
+ } catch {
3351
+ return null;
3352
+ }
3353
+ }
3354
+ function utf8ByteLength(value) {
3355
+ return new TextEncoder().encode(value).byteLength;
3356
+ }
3357
+
3358
+ // src/server/session-context.ts
3359
+ var MAX_CONTEXT_RESPONSE_BYTES = 1 * 1024 * 1024;
3360
+ var SESSION_SUMMARY_MAX_LENGTH = 500;
3361
+ function jsonTypeOf(value) {
3362
+ if (value === null) {
3363
+ return "null";
3364
+ }
3365
+ if (Array.isArray(value)) {
3366
+ return "array";
3367
+ }
3368
+ const t = typeof value;
3369
+ if (t === "string" || t === "number" || t === "boolean") {
3370
+ return t;
3371
+ }
3372
+ return "object";
3373
+ }
3374
+ function isPlainObject(value) {
3375
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3376
+ }
3377
+ function introspectContext(context) {
3378
+ if (context === null || context === void 0) {
3379
+ return { session_summary: null, context_size_bytes: 0, context_keys: [] };
3380
+ }
3381
+ const sizeBytes = contextSizeBytes(context);
3382
+ if (!isPlainObject(context)) {
3383
+ const type = jsonTypeOf(context);
3384
+ return {
3385
+ session_summary: null,
3386
+ context_size_bytes: sizeBytes,
3387
+ context_keys: [],
3388
+ ...type !== "object" && type !== "null" ? { context_type: type } : {}
3389
+ };
3390
+ }
3391
+ const summary = context.sessionSummary;
3392
+ return {
3393
+ session_summary: typeof summary === "string" ? summary : null,
3394
+ context_size_bytes: sizeBytes,
3395
+ context_keys: Object.entries(context).map(([key, value]) => ({
3396
+ key,
3397
+ size_bytes: value === void 0 ? 0 : utf8ByteLength(JSON.stringify(value))
3398
+ }))
3399
+ };
3400
+ }
3401
+ function contextSizeBytes(context) {
3402
+ if (context === null || context === void 0) {
3403
+ return 0;
3404
+ }
3405
+ return utf8ByteLength(JSON.stringify(context));
3406
+ }
3407
+ function mergeSessionSummary(context, sessionSummary) {
3408
+ if (context === null || context === void 0) {
3409
+ return { ok: true, context: { sessionSummary } };
3410
+ }
3411
+ if (isPlainObject(context)) {
3412
+ return { ok: true, context: { ...context, sessionSummary } };
3413
+ }
3414
+ return { ok: false, actualType: jsonTypeOf(context) };
3415
+ }
3416
+
3417
+ // src/server/tools/get_spec_session.ts
3418
+ var inputSchema10 = {
3419
+ session_id: z10.string().min(1).describe("UUID of the spec session"),
3420
+ include_context: z10.boolean().optional().describe(
3421
+ "When true, include the whole context JSONB (subject to a 1 MiB ceiling \u2014 above it the tool errors with the actual size). Default false: the response instead carries context_size_bytes and context_keys so you can see the size and shape before asking."
3422
+ )
3423
+ };
3424
+ var getSpecSessionTool = {
3425
+ name: "get_spec_session",
3426
+ description: "Get one spec session's metadata: status, counters, timestamps, archived_at (delete eligibility), session_summary, plus context_size_bytes and context_keys describing the context without returning it. Set include_context=true for the full context (1 MiB ceiling). The chat transcript is never returned.",
3427
+ inputSchema: inputSchema10,
3428
+ handler: async (args, ctx) => {
3429
+ let session;
3430
+ try {
3431
+ session = await ctx.client.getSpecSession(args.session_id);
3432
+ } catch (err) {
3433
+ const notFound = sessionNotFound(err, args.session_id);
3434
+ if (notFound) {
3435
+ return notFound;
3436
+ }
3437
+ throw err;
3438
+ }
3439
+ const summary = introspectContext(session.context);
3440
+ const base = {
3441
+ ...sessionMetadata(session),
3442
+ session_summary: summary.session_summary,
3443
+ context_size_bytes: summary.context_size_bytes,
3444
+ context_keys: summary.context_keys,
3445
+ ...summary.context_type === void 0 ? {} : { context_type: summary.context_type }
3446
+ };
3447
+ if (args.include_context !== true) {
3448
+ return jsonResult(base);
3449
+ }
3450
+ const size = contextSizeBytes(session.context);
3451
+ if (size > MAX_CONTEXT_RESPONSE_BYTES) {
3452
+ return errorResult(
3453
+ `Session ${session.id} context is ${String(size)} bytes, above the ${String(MAX_CONTEXT_RESPONSE_BYTES)}-byte include_context ceiling. Structured JSON is not truncated; use context_keys to locate what you need, or read the session in the MySpec webapp.`
3454
+ );
3455
+ }
3456
+ return jsonResult({ ...base, context: session.context ?? null });
3457
+ }
3458
+ };
3459
+
3460
+ // src/server/tools/list_attachments.ts
3461
+ import { z as z11 } from "zod";
3462
+
3463
+ // src/util/list-paging.ts
3464
+ function clampLimit(limit, defaultLimit, maxLimit) {
3465
+ const requested = limit ?? defaultLimit;
3466
+ return Math.min(Math.max(1, requested), maxLimit);
3467
+ }
3468
+ function sliceWindow(items, limit, offset) {
3469
+ const total = items.length;
3470
+ const start = Math.min(Math.max(0, offset), total);
3471
+ const slice = items.slice(start, start + limit);
3472
+ const end = start + slice.length;
3473
+ return {
3474
+ items: [...slice],
3475
+ total,
3476
+ limit,
3477
+ offset: start,
3478
+ ...end < total ? { next_offset: end } : {}
3479
+ };
3480
+ }
3481
+ function nextOffsetOf(offset, returned, total) {
3482
+ const end = offset + returned;
3483
+ return end < total ? end : void 0;
3484
+ }
3485
+
3486
+ // src/server/tools/list_attachments.ts
3487
+ var LIST_DEFAULT = 50;
3488
+ var LIST_MAX = 200;
3489
+ var inputSchema11 = {
3490
+ project_id: z11.string().min(1).describe("UUID of the project whose attachments to list."),
3491
+ limit: z11.number().int().positive().optional().describe(
3492
+ `Max attachments to return (default ${String(LIST_DEFAULT)}, capped at ${String(LIST_MAX)}).`
3493
+ ),
3494
+ offset: z11.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3495
+ };
3496
+ var listAttachmentsTool = {
3497
+ name: "list_attachments",
3498
+ description: "List a project's attachments (metadata only, always including file_size_bytes so a caller can check the size before read_attachment). Returns `total` and, when more records remain, `next_offset`.",
3499
+ inputSchema: inputSchema11,
3500
+ handler: async (args, ctx) => {
3501
+ const all = await ctx.client.listAttachments(args.project_id);
3502
+ const limit = clampLimit(args.limit, LIST_DEFAULT, LIST_MAX);
3503
+ const window = sliceWindow(all, limit, args.offset ?? 0);
3504
+ return jsonResult({
3505
+ project_id: args.project_id,
3506
+ attachments: window.items.map((a) => ({
3507
+ id: a.id,
3508
+ name: a.name,
3509
+ mime_type: a.mimeType,
3510
+ file_size_bytes: a.fileSizeBytes,
3511
+ checksum: a.checksum,
3512
+ created_at: a.createdAt
3513
+ })),
3514
+ total: window.total,
3515
+ limit: window.limit,
3516
+ offset: window.offset,
3517
+ ...window.next_offset === void 0 ? {} : { next_offset: window.next_offset }
3518
+ });
3519
+ }
3520
+ };
3521
+
3222
3522
  // src/server/tools/list_projects.ts
3223
- import { z as z8 } from "zod";
3224
- var inputSchema8 = {
3225
- query: z8.string().optional().describe(
3523
+ import { z as z12 } from "zod";
3524
+ var inputSchema12 = {
3525
+ query: z12.string().optional().describe(
3226
3526
  "Search term to filter projects by name. Omit to list all projects."
3227
3527
  ),
3228
- limit: z8.number().int().positive().max(100).optional().describe("Max projects to return (default 20)"),
3229
- offset: z8.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3528
+ limit: z12.number().int().positive().max(100).optional().describe("Max projects to return (default 20)"),
3529
+ offset: z12.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3230
3530
  };
3231
3531
  var listProjectsTool = {
3232
3532
  name: "list_projects",
3233
3533
  description: "List or search MySpec projects accessible to the authenticated user.",
3234
- inputSchema: inputSchema8,
3534
+ inputSchema: inputSchema12,
3235
3535
  handler: async (args, ctx) => {
3236
3536
  const result = await ctx.client.listProjects({
3237
3537
  query: args.query,
@@ -3254,21 +3554,21 @@ var listProjectsTool = {
3254
3554
  };
3255
3555
 
3256
3556
  // src/server/tools/list_spec_file.ts
3257
- import { z as z9 } from "zod";
3557
+ import { z as z13 } from "zod";
3258
3558
  var PAGE_SIZE = 100;
3259
- var inputSchema9 = {
3260
- project_id: z9.string().min(1).describe("UUID of the project whose spec files to list."),
3261
- path: z9.string().optional().describe(
3559
+ var inputSchema13 = {
3560
+ project_id: z13.string().min(1).describe("UUID of the project whose spec files to list."),
3561
+ path: z13.string().optional().describe(
3262
3562
  'Directory prefix to filter by, e.g. ".spec" or ".spec/design". Matches the exact file and everything under that directory. Pass "", "/", or omit to list all files.'
3263
3563
  ),
3264
- trashed: z9.boolean().optional().describe(
3564
+ trashed: z13.boolean().optional().describe(
3265
3565
  "When true, list files in the project Trash Bin instead. When false or omitted, list only live files (trashed files excluded)."
3266
3566
  )
3267
3567
  };
3268
3568
  var listSpecFileTool = {
3269
3569
  name: "list_spec_file",
3270
3570
  description: 'List a project\'s spec files with id, path, and metadata (revision count, file size of the latest revision, file type, owning session, last update). Filter to a directory with `path` (exact file or everything under it; pass "", "/", or omit for all). Set `trashed=true` to list files in the Trash Bin instead of live files.',
3271
- inputSchema: inputSchema9,
3571
+ inputSchema: inputSchema13,
3272
3572
  handler: async (args, ctx) => {
3273
3573
  const trashed = args.trashed ?? false;
3274
3574
  const files = trashed ? await ctx.client.listTrashedFiles(args.project_id) : await listAllLiveFiles(ctx.client, args.project_id);
@@ -3324,34 +3624,255 @@ function isUnderPath(filePath, prefix) {
3324
3624
  return filePath === prefix || filePath.startsWith(prefix + "/");
3325
3625
  }
3326
3626
 
3627
+ // src/server/tools/list_spec_sessions.ts
3628
+ import { z as z14 } from "zod";
3629
+ var LIST_DEFAULT2 = 10;
3630
+ var LIST_MAX2 = 20;
3631
+ var inputSchema14 = {
3632
+ project_id: z14.string().min(1).describe("UUID of the project whose spec sessions to list."),
3633
+ archive_filter: z14.enum(["active", "archived", "all"]).optional().describe("Which sessions to list: 'active' (default), 'archived', or 'all'."),
3634
+ status: z14.enum(["generating", "pending_input", "completed"]).optional().describe("Filter by session status."),
3635
+ query: z14.string().optional().describe("Search term matched against the session summary."),
3636
+ limit: z14.number().int().positive().optional().describe(
3637
+ `Max sessions to return (default ${String(LIST_DEFAULT2)}, capped at ${String(LIST_MAX2)}).`
3638
+ ),
3639
+ offset: z14.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3640
+ };
3641
+ var listSpecSessionsTool = {
3642
+ name: "list_spec_sessions",
3643
+ description: `List a project's spec sessions (metadata plus session_summary and context_size_bytes \u2014 never the context itself or the chat transcript). Filter with archive_filter ('active'/'archived'/'all'), status, and query (searches the session summary). Capped at ${String(LIST_MAX2)} per page; returns \`total\` and \`next_offset\`.`,
3644
+ inputSchema: inputSchema14,
3645
+ handler: async (args, ctx) => {
3646
+ const limit = clampLimit(args.limit, LIST_DEFAULT2, LIST_MAX2);
3647
+ const offset = args.offset ?? 0;
3648
+ const result = await ctx.client.listProjectSessions(args.project_id, {
3649
+ archiveFilter: args.archive_filter ?? "active",
3650
+ ...args.status === void 0 ? {} : { status: args.status },
3651
+ ...args.query === void 0 ? {} : { query: args.query },
3652
+ limit,
3653
+ offset
3654
+ });
3655
+ const next = nextOffsetOf(offset, result.sessions.length, result.total);
3656
+ return jsonResult({
3657
+ project_id: args.project_id,
3658
+ sessions: result.sessions.map((s) => {
3659
+ const summary = introspectContext(s.context);
3660
+ return {
3661
+ ...sessionMetadata(s),
3662
+ session_summary: summary.session_summary,
3663
+ context_size_bytes: summary.context_size_bytes
3664
+ };
3665
+ }),
3666
+ total: result.total,
3667
+ limit,
3668
+ offset,
3669
+ ...next === void 0 ? {} : { next_offset: next }
3670
+ });
3671
+ }
3672
+ };
3673
+
3327
3674
  // src/server/tools/move_spec_file_to_trash.ts
3328
- import { z as z10 } from "zod";
3329
- var inputSchema10 = {
3330
- file_id: z10.string().min(1).describe("UUID of the spec file to move to the Trash Bin.")
3675
+ import { z as z15 } from "zod";
3676
+ var inputSchema15 = {
3677
+ file_id: z15.string().min(1).describe("UUID of the spec file to move to the Trash Bin.")
3331
3678
  };
3332
3679
  var moveSpecFileToTrashTool = {
3333
3680
  name: "move_spec_file_to_trash",
3334
3681
  description: "Move a spec file to the Trash Bin. This is recoverable \u2014 the file is hidden from the default file list and AI-agent tools but kept restorable indefinitely via `restore_spec_file_from_trash`. It is NOT a permanent delete.",
3335
- inputSchema: inputSchema10,
3682
+ inputSchema: inputSchema15,
3336
3683
  handler: async (args, ctx) => {
3337
3684
  await ctx.client.trashFile(args.file_id);
3338
3685
  return jsonResult({ trashed: true, file_id: args.file_id });
3339
3686
  }
3340
3687
  };
3341
3688
 
3689
+ // src/server/tools/read_attachment.ts
3690
+ import { z as z16 } from "zod";
3691
+
3692
+ // src/util/paginate-lines.ts
3693
+ var MAX_LINES_PER_CALL = 2e3;
3694
+ var MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
3695
+ function normalizeNewlines(text) {
3696
+ const withoutBom = text.startsWith("\uFEFF") ? text.slice(1) : text;
3697
+ return withoutBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3698
+ }
3699
+ function splitIntoLines(text) {
3700
+ const parts = text.split("\n");
3701
+ if (parts.length > 0 && parts[parts.length - 1] === "") {
3702
+ parts.pop();
3703
+ }
3704
+ return parts;
3705
+ }
3706
+ function truncateToBytes(value, maxBytes) {
3707
+ const encoded = new TextEncoder().encode(value);
3708
+ if (encoded.byteLength <= maxBytes) {
3709
+ return value;
3710
+ }
3711
+ const decoded = new TextDecoder("utf-8").decode(encoded.subarray(0, maxBytes));
3712
+ return decoded.replace(/�+$/, "");
3713
+ }
3714
+ function paginateText(text, offsetArg, limitArg) {
3715
+ const lines = splitIntoLines(normalizeNewlines(text));
3716
+ const totalLines = lines.length;
3717
+ const rawOffset = offsetArg ?? 1;
3718
+ const offset = rawOffset === 0 ? 1 : rawOffset;
3719
+ if (offset < 1) {
3720
+ return {
3721
+ ok: false,
3722
+ error: `Invalid offset ${String(rawOffset)}; offset is 1-based and must be >= 1.`
3723
+ };
3724
+ }
3725
+ if (totalLines === 0) {
3726
+ return {
3727
+ ok: true,
3728
+ window: {
3729
+ content: "",
3730
+ start_line: 1,
3731
+ end_line: 0,
3732
+ read_lines: 0,
3733
+ total_lines: 0,
3734
+ truncated: false
3735
+ }
3736
+ };
3737
+ }
3738
+ if (offset > totalLines) {
3739
+ return {
3740
+ ok: false,
3741
+ error: `Offset ${String(rawOffset)} is past the end of the file (total ${String(totalLines)} lines).`
3742
+ };
3743
+ }
3744
+ const effectiveLimit = Math.min(Math.max(1, limitArg ?? MAX_LINES_PER_CALL), MAX_LINES_PER_CALL);
3745
+ const startIdx = offset - 1;
3746
+ const lineWindowEnd = Math.min(startIdx + effectiveLimit, totalLines);
3747
+ const out = [];
3748
+ let usedBytes = 0;
3749
+ let index = startIdx;
3750
+ let byteCeilingHit = false;
3751
+ for (; index < lineWindowEnd; index++) {
3752
+ const line = lines[index] ?? "";
3753
+ const lineBytes = utf8ByteLength(line);
3754
+ const separatorBytes = out.length > 0 ? 1 : 0;
3755
+ if (usedBytes + separatorBytes + lineBytes > MAX_RESPONSE_BYTES) {
3756
+ if (out.length === 0) {
3757
+ out.push(
3758
+ `${truncateToBytes(line, MAX_RESPONSE_BYTES)}\u2026 [truncated, ${String(lineBytes)} bytes total]`
3759
+ );
3760
+ }
3761
+ byteCeilingHit = true;
3762
+ break;
3763
+ }
3764
+ usedBytes += separatorBytes + lineBytes;
3765
+ out.push(line);
3766
+ }
3767
+ const endLine = startIdx + out.length;
3768
+ const truncated = byteCeilingHit || endLine < totalLines;
3769
+ return {
3770
+ ok: true,
3771
+ window: {
3772
+ content: out.join("\n"),
3773
+ start_line: offset,
3774
+ end_line: endLine,
3775
+ read_lines: out.length,
3776
+ total_lines: totalLines,
3777
+ truncated,
3778
+ ...truncated && endLine < totalLines ? { next_offset: endLine + 1 } : {}
3779
+ }
3780
+ };
3781
+ }
3782
+
3783
+ // src/server/tools/read_attachment.ts
3784
+ var MAX_ATTACHMENT_MB = MAX_ATTACHMENT_BYTES / (1024 * 1024);
3785
+ var IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
3786
+ "image/png",
3787
+ "image/jpeg",
3788
+ "image/gif",
3789
+ "image/webp"
3790
+ ]);
3791
+ var TEXT_MIME_TYPES = /* @__PURE__ */ new Set([
3792
+ "application/json",
3793
+ "application/xml",
3794
+ "application/x-yaml",
3795
+ "application/yaml",
3796
+ "image/svg+xml"
3797
+ ]);
3798
+ var GENERIC_MIME_TYPES = /* @__PURE__ */ new Set(["application/octet-stream", ""]);
3799
+ var inputSchema16 = {
3800
+ attachment_id: z16.string().min(1).describe("UUID of the attachment to read"),
3801
+ offset: z16.number().int().nonnegative().optional().describe(
3802
+ "1-based line number to start reading from (text attachments only; default 1; 0 is an alias for 1)."
3803
+ ),
3804
+ limit: z16.number().int().positive().optional().describe("Max lines to return (text attachments only; default 2000, capped at 2000).")
3805
+ };
3806
+ var readAttachmentTool = {
3807
+ name: "read_attachment",
3808
+ description: `Return an attachment's content inline. UTF-8 text is paginated by line (offset/limit, 1 MiB per response). Images (png, jpeg, gif, webp) are returned whole as an MCP image block \u2014 never paginated \u2014 up to the ${String(MAX_ATTACHMENT_MB)} MiB attachment limit; a 10 MiB image is roughly 13.4 MiB of base64, so check file_size_bytes (from get_attachment or list_attachments) before reading a large one. Other binary types (pdf, docx, xlsx, \u2026) cannot be read here; view them in the MySpec webapp.`,
3809
+ inputSchema: inputSchema16,
3810
+ handler: async (args, ctx) => {
3811
+ const meta = await ctx.client.getAttachmentMetadata(args.attachment_id);
3812
+ if (!meta) {
3813
+ return errorResult(`Attachment ${args.attachment_id} not found.`);
3814
+ }
3815
+ const mime = meta.mimeType;
3816
+ if (IMAGE_MIME_TYPES.has(mime)) {
3817
+ if (meta.fileSizeBytes > MAX_ATTACHMENT_BYTES) {
3818
+ return errorResult(
3819
+ `Attachment ${meta.id} is ${String(meta.fileSizeBytes)} bytes, above the ${String(MAX_ATTACHMENT_MB)} MiB image ceiling.`
3820
+ );
3821
+ }
3822
+ const bytes2 = await ctx.client.downloadAttachment(meta.id);
3823
+ if (bytes2.byteLength > MAX_ATTACHMENT_BYTES) {
3824
+ return errorResult(
3825
+ `Attachment ${meta.id} is ${String(bytes2.byteLength)} bytes, above the ${String(MAX_ATTACHMENT_MB)} MiB image ceiling.`
3826
+ );
3827
+ }
3828
+ return imageResult(encodeBase64(bytes2), mime, {
3829
+ attachment_id: meta.id,
3830
+ file_name: meta.name,
3831
+ mime_type: mime,
3832
+ file_size_bytes: bytes2.byteLength
3833
+ });
3834
+ }
3835
+ const declaredText = mime.startsWith("text/") || TEXT_MIME_TYPES.has(mime);
3836
+ const generic = GENERIC_MIME_TYPES.has(mime);
3837
+ if (!declaredText && !generic) {
3838
+ return errorResult(
3839
+ `Attachment ${meta.id} has MIME type ${mime}, which is neither UTF-8 text nor a supported image type (png, jpeg, gif, webp). View it in the MySpec webapp, or use get_attachment with include_download_url=1 for a signed URL to download it locally.`
3840
+ );
3841
+ }
3842
+ const bytes = await ctx.client.downloadAttachment(meta.id);
3843
+ const text = isBinary(bytes) ? null : decodeUtf8Strict(bytes);
3844
+ if (text === null) {
3845
+ return errorResult(
3846
+ `Attachment ${meta.id} (MIME type ${mime}) is not valid UTF-8 text and is not a supported image type. View it in the MySpec webapp, or use get_attachment with include_download_url=1 for a signed URL to download it locally.`
3847
+ );
3848
+ }
3849
+ const paginated = paginateText(text, args.offset, args.limit);
3850
+ if (!paginated.ok) {
3851
+ return errorResult(paginated.error);
3852
+ }
3853
+ return jsonResult({
3854
+ attachment_id: meta.id,
3855
+ file_name: meta.name,
3856
+ mime_type: mime,
3857
+ file_size_bytes: meta.fileSizeBytes,
3858
+ ...paginated.window
3859
+ });
3860
+ }
3861
+ };
3862
+
3342
3863
  // src/server/tools/read_spec_file.ts
3343
3864
  import { promises as fs3 } from "fs";
3344
3865
  import { createHash as createHash2 } from "crypto";
3345
- import { z as z11 } from "zod";
3866
+ import { z as z17 } from "zod";
3346
3867
  var MAX_READ_BYTES = 1 * 1024 * 1024;
3347
- var inputSchema11 = {
3348
- file_id: z11.string().min(1).describe("UUID of the file"),
3349
- revision: z11.number().int().positive().optional().describe("Specific revision number to read (defaults to the latest revision).")
3868
+ var inputSchema17 = {
3869
+ file_id: z17.string().min(1).describe("UUID of the file"),
3870
+ revision: z17.number().int().positive().optional().describe("Specific revision number to read (defaults to the latest revision).")
3350
3871
  };
3351
3872
  var readSpecFileTool = {
3352
3873
  name: "read_spec_file",
3353
3874
  description: "Return a spec file's UTF-8 content. Bytes are cached on disk under the configured cache root (default ~/.myspec) at `project/<projectId>/file/<fileId>/rev/<rev>/<basename>` and reused on subsequent reads of the same revision. Fails for binary files or files larger than 1 MiB \u2014 use download_spec_file to save those to a local file instead.",
3354
- inputSchema: inputSchema11,
3875
+ inputSchema: inputSchema17,
3355
3876
  handler: async (args, ctx) => {
3356
3877
  const meta = await ctx.client.getFileMetadata(args.file_id);
3357
3878
  const targetRevision = args.revision ?? meta.revisionCount;
@@ -3389,7 +3910,7 @@ var readSpecFileTool = {
3389
3910
  if (bytes.byteLength > MAX_READ_BYTES) {
3390
3911
  return errorResult(buildOversizeMessage2(args.file_id, bytes.byteLength));
3391
3912
  }
3392
- if (isBinary(bytes)) {
3913
+ if (isBinary2(bytes)) {
3393
3914
  return errorResult(
3394
3915
  `File ${args.file_id} appears to be binary (file_type=${meta.fileType}, size=${String(bytes.byteLength)} bytes). Use download_spec_file to save it to a local file.`
3395
3916
  );
@@ -3422,7 +3943,7 @@ var readSpecFileTool = {
3422
3943
  function buildOversizeMessage2(fileId, byteLength) {
3423
3944
  return `File ${fileId} is ${String(byteLength)} bytes which exceeds the read cap of ${String(MAX_READ_BYTES)} bytes. Use download_spec_file to save it to a local file and read it in chunks.`;
3424
3945
  }
3425
- function isBinary(bytes) {
3946
+ function isBinary2(bytes) {
3426
3947
  for (const byte of bytes) {
3427
3948
  if (byte === 0) {
3428
3949
  return true;
@@ -3434,15 +3955,59 @@ function sha256Prefixed2(content) {
3434
3955
  return "sha256:" + createHash2("sha256").update(content).digest("hex");
3435
3956
  }
3436
3957
 
3958
+ // src/server/tools/rename_spec_session.ts
3959
+ import { z as z18 } from "zod";
3960
+ var inputSchema18 = {
3961
+ session_id: z18.string().min(1).describe("UUID of the spec session to rename"),
3962
+ session_summary: z18.string().min(1).max(SESSION_SUMMARY_MAX_LENGTH).describe(
3963
+ `New human-readable session title (max ${String(SESSION_SUMMARY_MAX_LENGTH)} characters). Stored as context.sessionSummary \u2014 the field the webapp displays and searches.`
3964
+ )
3965
+ };
3966
+ var renameSpecSessionTool = {
3967
+ name: "rename_spec_session",
3968
+ description: "Rename a spec session by updating its context.sessionSummary \u2014 the session's human-readable title (there is no first-class name field). Permitted on both active and archived sessions. All other context keys are preserved untouched.",
3969
+ inputSchema: inputSchema18,
3970
+ handler: async (args, ctx) => {
3971
+ if (args.session_summary.length > SESSION_SUMMARY_MAX_LENGTH) {
3972
+ return errorResult(
3973
+ `session_summary is ${String(args.session_summary.length)} characters; the limit is ${String(SESSION_SUMMARY_MAX_LENGTH)}.`
3974
+ );
3975
+ }
3976
+ let session;
3977
+ try {
3978
+ session = await ctx.client.getSpecSession(args.session_id);
3979
+ } catch (err) {
3980
+ const notFound = sessionNotFound(err, args.session_id);
3981
+ if (notFound) {
3982
+ return notFound;
3983
+ }
3984
+ throw err;
3985
+ }
3986
+ const merged = mergeSessionSummary(session.context, args.session_summary);
3987
+ if (!merged.ok) {
3988
+ return errorResult(
3989
+ `Session ${args.session_id} was NOT renamed: its context is a JSON ${merged.actualType}, not an object. This shape is unexpected, the tool cannot know what it encodes, and overwriting it would be unrecoverable. Inspect the session in the MySpec webapp.`
3990
+ );
3991
+ }
3992
+ const updated = await ctx.client.updateSpecSessionContext(args.session_id, merged.context);
3993
+ const summary = introspectContext(updated.context);
3994
+ return jsonResult({
3995
+ renamed: true,
3996
+ session_id: updated.id,
3997
+ session_summary: summary.session_summary
3998
+ });
3999
+ }
4000
+ };
4001
+
3437
4002
  // src/server/tools/restore_spec_file_from_trash.ts
3438
- import { z as z12 } from "zod";
3439
- var inputSchema12 = {
3440
- file_id: z12.string().min(1).describe("UUID of the spec file to restore from the Trash Bin.")
4003
+ import { z as z19 } from "zod";
4004
+ var inputSchema19 = {
4005
+ file_id: z19.string().min(1).describe("UUID of the spec file to restore from the Trash Bin.")
3441
4006
  };
3442
4007
  var restoreSpecFileFromTrashTool = {
3443
4008
  name: "restore_spec_file_from_trash",
3444
4009
  description: "Restore a spec file from the Trash Bin back to the active file list, reversing `move_spec_file_to_trash`. The file becomes visible again to the default file list and AI-agent tools.",
3445
- inputSchema: inputSchema12,
4010
+ inputSchema: inputSchema19,
3446
4011
  handler: async (args, ctx) => {
3447
4012
  await ctx.client.restoreFile(args.file_id);
3448
4013
  return jsonResult({ restored: true, file_id: args.file_id });
@@ -3450,34 +4015,58 @@ var restoreSpecFileFromTrashTool = {
3450
4015
  };
3451
4016
 
3452
4017
  // src/server/tools/unarchive_project.ts
3453
- import { z as z13 } from "zod";
3454
- var inputSchema13 = {
3455
- project_id: z13.string().min(1).describe("UUID of the project to unarchive.")
4018
+ import { z as z20 } from "zod";
4019
+ var inputSchema20 = {
4020
+ project_id: z20.string().min(1).describe("UUID of the project to unarchive.")
3456
4021
  };
3457
4022
  var unarchiveProjectTool = {
3458
4023
  name: "unarchive_project",
3459
4024
  description: 'Unarchive a project (sets status to "active"). Idempotent: unarchiving an already-active project succeeds.',
3460
- inputSchema: inputSchema13,
4025
+ inputSchema: inputSchema20,
3461
4026
  handler: async (args, ctx) => {
3462
4027
  const project = await ctx.client.unarchiveProject(args.project_id);
3463
4028
  return jsonResult({ project: projectSummary(project) });
3464
4029
  }
3465
4030
  };
3466
4031
 
4032
+ // src/server/tools/unarchive_spec_session.ts
4033
+ import { z as z21 } from "zod";
4034
+ var inputSchema21 = {
4035
+ session_id: z21.string().min(1).describe("UUID of the spec session to unarchive")
4036
+ };
4037
+ var unarchiveSpecSessionTool = {
4038
+ name: "unarchive_spec_session",
4039
+ description: "Unarchive a spec session, returning it to the active list.",
4040
+ inputSchema: inputSchema21,
4041
+ handler: async (args, ctx) => {
4042
+ let session;
4043
+ try {
4044
+ session = await ctx.client.unarchiveSpecSession(args.session_id);
4045
+ } catch (err) {
4046
+ const notFound = sessionNotFound(err, args.session_id);
4047
+ if (notFound) {
4048
+ return notFound;
4049
+ }
4050
+ throw err;
4051
+ }
4052
+ return jsonResult({ session: sessionMetadata(session) });
4053
+ }
4054
+ };
4055
+
3467
4056
  // src/server/tools/update_project.ts
3468
- import { z as z14 } from "zod";
3469
- var inputSchema14 = {
3470
- project_id: z14.string().min(1).describe("UUID of the project to update."),
3471
- name: z14.string().min(1).optional().describe("New project name."),
3472
- description: z14.string().optional().describe("New project description."),
3473
- project_type: z14.enum(["greenfield", "brownfield", "auto"]).optional().describe(
4057
+ import { z as z22 } from "zod";
4058
+ var inputSchema22 = {
4059
+ project_id: z22.string().min(1).describe("UUID of the project to update."),
4060
+ name: z22.string().min(1).optional().describe("New project name."),
4061
+ description: z22.string().optional().describe("New project description."),
4062
+ project_type: z22.enum(["greenfield", "brownfield", "auto"]).optional().describe(
3474
4063
  "Codebase type. 'greenfield' or 'brownfield' sets it explicitly; 'auto' clears it so the workflow decides. Omit to leave unchanged."
3475
4064
  )
3476
4065
  };
3477
4066
  var updateProjectTool = {
3478
4067
  name: "update_project",
3479
4068
  description: "Update a project's metadata (name, description, project_type). Returns the updated metadata.",
3480
- inputSchema: inputSchema14,
4069
+ inputSchema: inputSchema22,
3481
4070
  handler: async (args, ctx) => {
3482
4071
  if (args.name === void 0 && args.description === void 0 && args.project_type === void 0) {
3483
4072
  return errorResult(
@@ -3500,7 +4089,7 @@ var updateProjectTool = {
3500
4089
  };
3501
4090
 
3502
4091
  // src/server/tools/update_spec_file.ts
3503
- import { z as z15 } from "zod";
4092
+ import { z as z23 } from "zod";
3504
4093
 
3505
4094
  // src/server/tools/spec-content-source.ts
3506
4095
  import { readFile, stat } from "fs/promises";
@@ -3556,24 +4145,24 @@ async function resolveSpecContent(input, maxBytes = MAX_LOCAL_SPEC_FILE_BYTES) {
3556
4145
  }
3557
4146
 
3558
4147
  // src/server/tools/update_spec_file.ts
3559
- var inputSchema15 = {
3560
- file_id: z15.string().min(1).optional().describe("UUID of the spec file to overwrite. Provide this or (project_id + file_path)."),
3561
- project_id: z15.string().min(1).optional().describe("UUID of the project. Required (with file_path) when file_id is not given."),
3562
- file_path: z15.string().min(1).optional().describe("Path of the spec file. Required (with project_id) when file_id is not given."),
3563
- content: z15.string().min(1).optional().describe(
4148
+ var inputSchema23 = {
4149
+ file_id: z23.string().min(1).optional().describe("UUID of the spec file to overwrite. Provide this or (project_id + file_path)."),
4150
+ project_id: z23.string().min(1).optional().describe("UUID of the project. Required (with file_path) when file_id is not given."),
4151
+ file_path: z23.string().min(1).optional().describe("Path of the spec file. Required (with project_id) when file_id is not given."),
4152
+ content: z23.string().min(1).optional().describe(
3564
4153
  "Full UTF-8 replacement content, saved as a new revision. Provide this OR local_file_path (exactly one). Use for in-memory / generated content."
3565
4154
  ),
3566
- local_file_path: z15.string().min(1).optional().describe(
4155
+ local_file_path: z23.string().min(1).optional().describe(
3567
4156
  "Path to a local file whose bytes become the new revision; 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."
3568
4157
  ),
3569
- expected_version: z15.number().int().nonnegative().optional().describe(
4158
+ expected_version: z23.number().int().nonnegative().optional().describe(
3570
4159
  "The content_version the new body was based on, from the get_spec_file or read_spec_file you worked from. The update is rejected with an actionable error if someone else changed the file since. Omit only when overwriting unconditionally is genuinely intended \u2014 the project may be configured to reject unversioned updates."
3571
4160
  )
3572
4161
  };
3573
4162
  var updateSpecFileTool = {
3574
4163
  name: "update_spec_file",
3575
4164
  description: "Overwrite an existing spec file by saving a new revision. Provide the new body inline via `content` or by pointing at a local file with `local_file_path` \u2014 exactly one is required. Prefer `local_file_path` when the body is already an existing local file (the server reads it directly); use `content` for text generated in-memory. Reference the file by file_id or by (project_id + file_path). Pass `expected_version` from the get_spec_file you based the new body on so a concurrent edit by someone else is reported instead of silently overwritten.",
3576
- inputSchema: inputSchema15,
4165
+ inputSchema: inputSchema23,
3577
4166
  handler: async (args, ctx) => {
3578
4167
  const resolved = await resolveSpecContent({
3579
4168
  content: args.content,
@@ -3638,9 +4227,9 @@ function conflictMessage(filePath, conflict) {
3638
4227
  import { createHash as createHash3 } from "crypto";
3639
4228
  import { lstat, readFile as readFile2 } from "fs/promises";
3640
4229
  import { basename, extname, isAbsolute as isAbsolute2 } from "path";
3641
- import { z as z16 } from "zod";
4230
+ import { z as z24 } from "zod";
3642
4231
  var DEFAULT_MIME = "application/octet-stream";
3643
- var MAX_ATTACHMENT_MB = MAX_ATTACHMENT_BYTES / (1024 * 1024);
4232
+ var MAX_ATTACHMENT_MB2 = MAX_ATTACHMENT_BYTES / (1024 * 1024);
3644
4233
  var STRUCTURED_EXTENSION_MIME = {
3645
4234
  ".pdf": "application/pdf",
3646
4235
  ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -3743,23 +4332,23 @@ function inferMimeFromName(name) {
3743
4332
  }
3744
4333
  return DEFAULT_MIME;
3745
4334
  }
3746
- var inputSchema16 = {
3747
- project_id: z16.string().min(1).describe("UUID of the project to attach the file to."),
3748
- file_path: z16.string().min(1).describe(
4335
+ var inputSchema24 = {
4336
+ project_id: z24.string().min(1).describe("UUID of the project to attach the file to."),
4337
+ file_path: z24.string().min(1).describe(
3749
4338
  "Absolute path to the local file to upload. The MCP server reads this path from its own host."
3750
4339
  ),
3751
- file_name: z16.string().min(1).optional().describe("Override the filename recorded on the attachment. Defaults to the path basename."),
3752
- mime_type: z16.string().min(1).optional().describe(
4340
+ file_name: z24.string().min(1).optional().describe("Override the filename recorded on the attachment. Defaults to the path basename."),
4341
+ mime_type: z24.string().min(1).optional().describe(
3753
4342
  "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."
3754
4343
  ),
3755
- override: z16.boolean().optional().describe(
4344
+ override: z24.boolean().optional().describe(
3756
4345
  "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)."
3757
4346
  )
3758
4347
  };
3759
4348
  var uploadAttachmentTool = {
3760
4349
  name: "upload_attachment",
3761
- description: `Upload a local file as a new attachment on a project. Reads \`file_path\` from the host running the MCP server, computes its SHA256, then performs the two-phase platform upload. Max ${String(MAX_ATTACHMENT_MB)} MB. Supported content: PDF, DOCX, XLSX, images (PNG, JPEG, GIF, WEBP), and any UTF-8 text file (XML, JSON, YAML, HTML, SVG, Markdown, CSV, source code, ...). Other binary files are rejected by the platform.`,
3762
- inputSchema: inputSchema16,
4350
+ description: `Upload a local file as a new attachment on a project. Reads \`file_path\` from the host running the MCP server, computes its SHA256, then performs the two-phase platform upload. Max ${String(MAX_ATTACHMENT_MB2)} MB. Supported content: PDF, DOCX, XLSX, images (PNG, JPEG, GIF, WEBP), and any UTF-8 text file (XML, JSON, YAML, HTML, SVG, Markdown, CSV, source code, ...). Other binary files are rejected by the platform.`,
4351
+ inputSchema: inputSchema24,
3763
4352
  handler: async (args, ctx) => {
3764
4353
  if (!isAbsolute2(args.file_path)) {
3765
4354
  return errorResult(`file_path must be an absolute path; got "${args.file_path}".`);
@@ -3792,7 +4381,7 @@ var uploadAttachmentTool = {
3792
4381
  }
3793
4382
  if (stats.size > MAX_ATTACHMENT_BYTES) {
3794
4383
  return errorResult(
3795
- `File size ${String(stats.size)} bytes exceeds the ${String(MAX_ATTACHMENT_MB)} MB attachment limit.`
4384
+ `File size ${String(stats.size)} bytes exceeds the ${String(MAX_ATTACHMENT_MB2)} MB attachment limit.`
3796
4385
  );
3797
4386
  }
3798
4387
  let buf;
@@ -3811,7 +4400,7 @@ var uploadAttachmentTool = {
3811
4400
  }
3812
4401
  if (buf.byteLength > MAX_ATTACHMENT_BYTES) {
3813
4402
  return errorResult(
3814
- `File size ${String(buf.byteLength)} bytes exceeds the ${String(MAX_ATTACHMENT_MB)} MB attachment limit.`
4403
+ `File size ${String(buf.byteLength)} bytes exceeds the ${String(MAX_ATTACHMENT_MB2)} MB attachment limit.`
3815
4404
  );
3816
4405
  }
3817
4406
  const fileName = args.file_name ?? basename(args.file_path);
@@ -3883,7 +4472,7 @@ var uploadAttachmentTool = {
3883
4472
  };
3884
4473
 
3885
4474
  // src/server/tools/upload_spec_file.ts
3886
- import { z as z17 } from "zod";
4475
+ import { z as z25 } from "zod";
3887
4476
 
3888
4477
  // src/server/spec-path.ts
3889
4478
  var MAX_SPEC_DIR_DEPTH = 3;
@@ -3936,23 +4525,23 @@ var SPEC_FILE_TYPES = [
3936
4525
  "proposal",
3937
4526
  "openspec-spec"
3938
4527
  ];
3939
- var inputSchema17 = {
3940
- project_id: z17.string().min(1).describe("UUID of the project to upload the file into."),
3941
- file_path: z17.string().min(1).describe(
4528
+ var inputSchema25 = {
4529
+ project_id: z25.string().min(1).describe("UUID of the project to upload the file into."),
4530
+ file_path: z25.string().min(1).describe(
3942
4531
  '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).'
3943
4532
  ),
3944
- content: z17.string().min(1).optional().describe(
4533
+ content: z25.string().min(1).optional().describe(
3945
4534
  "Full UTF-8 text content of the spec file. Provide this OR local_file_path (exactly one). Use for in-memory / generated content."
3946
4535
  ),
3947
- local_file_path: z17.string().min(1).optional().describe(
4536
+ local_file_path: z25.string().min(1).optional().describe(
3948
4537
  "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."
3949
4538
  ),
3950
- file_type: z17.enum(SPEC_FILE_TYPES).optional().describe("Spec file type. Omit to derive from the filename (e.g. proposal.md \u2192 proposal).")
4539
+ file_type: z25.enum(SPEC_FILE_TYPES).optional().describe("Spec file type. Omit to derive from the filename (e.g. proposal.md \u2192 proposal).")
3951
4540
  };
3952
4541
  var uploadSpecFileTool = {
3953
4542
  name: "upload_spec_file",
3954
4543
  description: "Upload a new, session-less spec file into a project. Provide the file body inline via `content` or by pointing at a local file with `local_file_path` \u2014 exactly one is required. Prefer `local_file_path` when the body is already an existing local file (the server reads it directly, avoiding sending large content through the model); use `content` for text generated in-memory. Rejects if a file already exists at the same path (use update_spec_file to overwrite).",
3955
- inputSchema: inputSchema17,
4544
+ inputSchema: inputSchema25,
3956
4545
  handler: async (args, ctx) => {
3957
4546
  const pathError = validateSpecFilePath(args.file_path);
3958
4547
  if (pathError) {
@@ -4005,6 +4594,14 @@ function buildServer(deps) {
4005
4594
  registerTool(server, updateSpecFileTool, deps.client);
4006
4595
  registerTool(server, moveSpecFileToTrashTool, deps.client);
4007
4596
  registerTool(server, restoreSpecFileFromTrashTool, deps.client);
4597
+ registerTool(server, listAttachmentsTool, deps.client);
4598
+ registerTool(server, readAttachmentTool, deps.client);
4599
+ registerTool(server, listSpecSessionsTool, deps.client);
4600
+ registerTool(server, getSpecSessionTool, deps.client);
4601
+ registerTool(server, renameSpecSessionTool, deps.client);
4602
+ registerTool(server, archiveSpecSessionTool, deps.client);
4603
+ registerTool(server, unarchiveSpecSessionTool, deps.client);
4604
+ registerTool(server, deleteSpecSessionTool, deps.client);
4008
4605
  return server;
4009
4606
  }
4010
4607
  async function startStdioServer(deps) {
@@ -4158,7 +4755,7 @@ async function canonicaliseRoot(root) {
4158
4755
  import { promises as fs4 } from "fs";
4159
4756
  import { join as join2, relative } from "path";
4160
4757
  import { spawn } from "child_process";
4161
- import { z as z18 } from "zod";
4758
+ import { z as z26 } from "zod";
4162
4759
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4163
4760
 
4164
4761
  // src/reverse/ignore.ts
@@ -4255,7 +4852,7 @@ var PackCache = class {
4255
4852
  if (oldestKey === void 0) break;
4256
4853
  this.entries.delete(oldestKey);
4257
4854
  }
4258
- const lines = splitIntoLines(content);
4855
+ const lines = splitIntoLines2(content);
4259
4856
  const pages = planPages(lines);
4260
4857
  const entry = {
4261
4858
  outputId,
@@ -4283,7 +4880,7 @@ var PackCache = class {
4283
4880
  }
4284
4881
  }
4285
4882
  };
4286
- function splitIntoLines(text) {
4883
+ function splitIntoLines2(text) {
4287
4884
  const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4288
4885
  const parts = normalized.split("\n");
4289
4886
  if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
@@ -4463,37 +5060,37 @@ var READ_FILE_MAX_LINES = 2e3;
4463
5060
  var LIST_DIR_HARD_CAP = 5e3;
4464
5061
  var LIST_DIR_DEFAULT_CAP = 1e3;
4465
5062
  var GREP_MAX_MATCHES_DEFAULT = 200;
4466
- var listDirInput = z18.object({
4467
- path: z18.string(),
4468
- recursive: z18.boolean().optional(),
4469
- maxEntries: z18.number().int().positive().optional(),
4470
- includeIgnored: z18.boolean().optional()
5063
+ var listDirInput = z26.object({
5064
+ path: z26.string(),
5065
+ recursive: z26.boolean().optional(),
5066
+ maxEntries: z26.number().int().positive().optional(),
5067
+ includeIgnored: z26.boolean().optional()
4471
5068
  });
4472
- var readFileInput = z18.object({
4473
- path: z18.string(),
4474
- offset: z18.number().int().nonnegative().optional(),
4475
- limit: z18.number().int().positive().optional(),
4476
- includeIgnored: z18.boolean().optional()
5069
+ var readFileInput = z26.object({
5070
+ path: z26.string(),
5071
+ offset: z26.number().int().nonnegative().optional(),
5072
+ limit: z26.number().int().positive().optional(),
5073
+ includeIgnored: z26.boolean().optional()
4477
5074
  });
4478
- var grepInput = z18.object({
4479
- pattern: z18.string().describe(
5075
+ var grepInput = z26.object({
5076
+ pattern: z26.string().describe(
4480
5077
  "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."
4481
5078
  ),
4482
- path: z18.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
4483
- isRegex: z18.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
4484
- caseSensitive: z18.boolean().optional(),
4485
- maxMatches: z18.number().int().positive().optional(),
4486
- contextLines: z18.number().int().nonnegative().optional(),
4487
- includeIgnored: z18.boolean().optional()
5079
+ path: z26.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
5080
+ isRegex: z26.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
5081
+ caseSensitive: z26.boolean().optional(),
5082
+ maxMatches: z26.number().int().positive().optional(),
5083
+ contextLines: z26.number().int().nonnegative().optional(),
5084
+ includeIgnored: z26.boolean().optional()
4488
5085
  });
4489
- var packCodebaseInput = z18.object({
4490
- subpath: z18.string().optional(),
4491
- includePatterns: z18.string().optional(),
4492
- ignorePatterns: z18.string().optional()
5086
+ var packCodebaseInput = z26.object({
5087
+ subpath: z26.string().optional(),
5088
+ includePatterns: z26.string().optional(),
5089
+ ignorePatterns: z26.string().optional()
4493
5090
  });
4494
- var packCodebaseReadPageInput = z18.object({
4495
- outputId: z18.string(),
4496
- page: z18.number().int().positive()
5091
+ var packCodebaseReadPageInput = z26.object({
5092
+ outputId: z26.string(),
5093
+ page: z26.number().int().positive()
4497
5094
  });
4498
5095
  function structuredError(error, message, extra) {
4499
5096
  const payload = { error, message, ...extra ?? {} };
@@ -4609,19 +5206,19 @@ async function handleRead(args, opts) {
4609
5206
  `Binary content detected in ${args.path}; line-based pagination doesn't apply. Skip this file or use local_fs_grep on neighbouring text files.`
4610
5207
  );
4611
5208
  }
4612
- const text = normalizeNewlines(raw.toString("utf-8"));
4613
- const lines = splitIntoLines2(text);
5209
+ const text = normalizeNewlines2(raw.toString("utf-8"));
5210
+ const lines = splitIntoLines3(text);
4614
5211
  const page = paginateLines(lines, args.offset, args.limit);
4615
5212
  if ("error" in page) {
4616
5213
  return structuredError("invalid_offset", page.error);
4617
5214
  }
4618
5215
  return structuredOk(page);
4619
5216
  }
4620
- function normalizeNewlines(s) {
5217
+ function normalizeNewlines2(s) {
4621
5218
  const noBom = s.charCodeAt(0) === 65279 ? s.slice(1) : s;
4622
5219
  return noBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4623
5220
  }
4624
- function splitIntoLines2(text) {
5221
+ function splitIntoLines3(text) {
4625
5222
  const parts = text.split("\n");
4626
5223
  if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
4627
5224
  return parts;
@@ -4934,7 +5531,7 @@ function registerLocalFsTools(server, opts) {
4934
5531
  },
4935
5532
  {
4936
5533
  name: "local_pack_codebase",
4937
- description: "Pack the entire local codebase (under the granted --root) into a single XML bundle using `npx repomix@latest --style xml --compress`. Prefer this over many `local_fs_read_file` calls when you need broad codebase context. Output is paginated at ~2500 lines per page; a `<file>` block is never split across a page boundary, but the `<files>` root wrapper spans pages (its opener is on the first files page and its closer on the last) \u2014 always keep reading while `hasMore` is true rather than stopping at a `</files>`. The response includes `outputId`, `page`, `totalPages`, `totalLines`, `pageLines`, `hasMore`, and a `nextPage` hint. Use `local_pack_codebase_read_page` to fetch subsequent pages by `outputId`. Repeat calls with the same `subpath`/`includePatterns`/`ignorePatterns` return the same `outputId` and are served instantly from the 30-minute cache without re-running repomix.",
5534
+ description: "Pack the local codebase (under the granted --root) into a single compressed XML bundle (embedded repomix, `--style xml --compress`; file bodies use `\u22EE----` delimiters). Prefer this over many `local_fs_read_file` calls when you need broad codebase context. Output is paginated at ~2500 lines per page; a `<file>` block is never split across a page boundary, but the `<files>` root wrapper spans pages (its opener is on the first files page and its closer on the last) \u2014 never treat a missing `</files>` as truncation. The response includes `outputId`, `page`, `totalPages`, `totalLines`, `pageLines`, `hasMore`, and a `nextPage` hint; page 1 opens with the full directory structure. Fetch further pages with `local_pack_codebase_read_page` while `totalPages` is small; for a large pack (roughly 10+ pages) do NOT page through everything \u2014 use page 1 as the repo map, then re-pack narrowed with `subpath`/`includePatterns` or switch to `local_fs_grep`/`local_fs_read_file` for targeted reads. Repeat calls with the same `subpath`/`includePatterns`/`ignorePatterns` return the same `outputId` and are served instantly from the 30-minute cache without re-running repomix.",
4938
5535
  inputSchema: jsonSchemaFromZod(packCodebaseInput),
4939
5536
  handler: async (args) => {
4940
5537
  try {
@@ -4947,7 +5544,7 @@ function registerLocalFsTools(server, opts) {
4947
5544
  },
4948
5545
  {
4949
5546
  name: "local_pack_codebase_read_page",
4950
- description: "Read a specific page of a previously packed codebase output. Provide the `outputId` returned by `local_pack_codebase` and the 1-indexed `page` to fetch. Each page is up to ~2500 lines of XML; no `<file>` block is split across pages, but the `<files>` root wrapper spans pages \u2014 keep fetching while `hasMore` is true, do not stop at a `</files>`. Cache entries expire after 30 minutes; on cache miss, re-run `local_pack_codebase` (same args return the same outputId \u2014 re-pack is cheap, served from cache).",
5547
+ description: "Read a specific page of a previously packed codebase output. Provide the `outputId` returned by `local_pack_codebase` and the 1-indexed `page` to fetch. Each page is up to ~2500 lines of the compressed XML bundle; no `<file>` block is split across pages, but the `<files>` root wrapper spans pages \u2014 never treat a missing `</files>` as truncation. Cache entries expire after 30 minutes; on cache miss, re-run `local_pack_codebase` (same args return the same outputId \u2014 re-pack is cheap, served from cache).",
4951
5548
  inputSchema: jsonSchemaFromZod(packCodebaseReadPageInput),
4952
5549
  handler: (args) => {
4953
5550
  try {
@@ -5021,7 +5618,7 @@ function jsonSchemaFromZod(schema) {
5021
5618
  return zodToJson(schema);
5022
5619
  }
5023
5620
  function zodToJson(schema) {
5024
- if (schema instanceof z18.ZodObject) {
5621
+ if (schema instanceof z26.ZodObject) {
5025
5622
  const shape = schema.shape;
5026
5623
  const properties = {};
5027
5624
  const required = [];
@@ -5036,19 +5633,19 @@ function zodToJson(schema) {
5036
5633
  if (required.length > 0) out.required = required;
5037
5634
  return out;
5038
5635
  }
5039
- if (schema instanceof z18.ZodOptional) {
5636
+ if (schema instanceof z26.ZodOptional) {
5040
5637
  return zodToJson(schema.unwrap());
5041
5638
  }
5042
- if (schema instanceof z18.ZodString) {
5639
+ if (schema instanceof z26.ZodString) {
5043
5640
  return { type: "string" };
5044
5641
  }
5045
- if (schema instanceof z18.ZodNumber) {
5642
+ if (schema instanceof z26.ZodNumber) {
5046
5643
  return { type: "number" };
5047
5644
  }
5048
- if (schema instanceof z18.ZodBoolean) {
5645
+ if (schema instanceof z26.ZodBoolean) {
5049
5646
  return { type: "boolean" };
5050
5647
  }
5051
- if (schema instanceof z18.ZodEnum) {
5648
+ if (schema instanceof z26.ZodEnum) {
5052
5649
  return { type: "string", enum: schema.options };
5053
5650
  }
5054
5651
  return { type: "string" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.4.0-next.95",
3
+ "version": "0.4.0-next.97",
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": {