@myspec/mcp-server 0.3.0 → 0.4.0-next.101

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 +20 -4
  2. package/dist/index.js +867 -148
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -255,6 +255,7 @@ async function exchangeCode(userAuthUrl, code, fetchImpl) {
255
255
  }
256
256
 
257
257
  // src/auth/credentials-store.ts
258
+ import { randomUUID } from "crypto";
258
259
  import { promises as fs } from "fs";
259
260
  import os from "os";
260
261
  import path from "path";
@@ -359,15 +360,26 @@ async function loadSettings(target) {
359
360
  throw err;
360
361
  }
361
362
  }
363
+ async function readSettingsUserAuthUrl(settingsPath = defaultSettingsPath()) {
364
+ const settings = await loadSettings(settingsPath);
365
+ return settings?.userAuthUrl;
366
+ }
362
367
  async function writeJsonFile(target, data, opts) {
363
368
  const dir = path.dirname(target);
364
369
  await fs.mkdir(dir, { recursive: true, mode: 448 });
365
370
  if (process.platform !== "win32") {
366
371
  await fs.chmod(dir, 448);
367
372
  }
368
- await fs.writeFile(target, JSON.stringify(data, null, 2), { mode: 384 });
369
- if (opts.secret && process.platform !== "win32") {
370
- await fs.chmod(target, 384);
373
+ const tmp = `${target}.${randomUUID()}.tmp`;
374
+ try {
375
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode: 384 });
376
+ if (opts.secret && process.platform !== "win32") {
377
+ await fs.chmod(tmp, 384);
378
+ }
379
+ await fs.rename(tmp, target);
380
+ } catch (err) {
381
+ await unlinkIfExists(tmp);
382
+ throw err;
371
383
  }
372
384
  }
373
385
  async function unlinkIfExists(target) {
@@ -584,6 +596,37 @@ var TokenManager = class {
584
596
  now;
585
597
  cached = null;
586
598
  refreshInFlight = null;
599
+ /**
600
+ * The in-flight disk read, shared by every concurrent caller.
601
+ *
602
+ * `refresh` below has always deduped; this did not, so N concurrent platform
603
+ * calls each issued their own `store.load()` before any of them populated
604
+ * `cached`. That is N redundant reads on every cold call, and it is what made
605
+ * a torn read reachable at all: one caller could be refreshing (and so
606
+ * rewriting the credential files) while the others were still reading them.
607
+ *
608
+ * The atomic write in credentials-store.ts is the real guarantee. This is the
609
+ * other half — it removes the redundant reads rather than merely surviving
610
+ * them.
611
+ */
612
+ loadInFlight = null;
613
+ /**
614
+ * Bumped by clear(), so a read that started before it cannot publish its
615
+ * result to `cached` afterwards.
616
+ *
617
+ * Scope, precisely: the caller already awaiting that read still receives the
618
+ * pre-clear credentials as its own return value and proceeds with them —
619
+ * nothing can retract a value from a call already in flight. What the guard
620
+ * buys is that the value stops there instead of being cached, so no
621
+ * SUBSEQUENT caller is handed credentials the user asked us to forget.
622
+ *
623
+ * Dropping `loadInFlight` alone would not achieve even that: the awaiting
624
+ * caller resumes after the clear and would assign `cached` itself.
625
+ * Pre-existing — the old code assigned `cached` straight after an un-deduped
626
+ * `await` — but the fix belongs with the dedupe rather than left as a claim
627
+ * the comment cannot back up.
628
+ */
629
+ loadGeneration = 0;
587
630
  /**
588
631
  * Set once the exchange endpoint rejects the API token outright.
589
632
  *
@@ -616,17 +659,25 @@ var TokenManager = class {
616
659
  }
617
660
  async clear() {
618
661
  this.cached = null;
662
+ this.loadInFlight = null;
663
+ this.loadGeneration += 1;
619
664
  await this.store.clear();
620
665
  }
621
666
  async ensureLoaded() {
622
667
  if (this.cached) {
623
668
  return this.cached;
624
669
  }
625
- const loaded = await this.store.load();
670
+ const generation = this.loadGeneration;
671
+ this.loadInFlight ??= this.store.load().finally(() => {
672
+ this.loadInFlight = null;
673
+ });
674
+ const loaded = await this.loadInFlight;
626
675
  if (!loaded) {
627
676
  throw new NeedsLoginError();
628
677
  }
629
- this.cached = loaded;
678
+ if (generation === this.loadGeneration) {
679
+ this.cached = loaded;
680
+ }
630
681
  return loaded;
631
682
  }
632
683
  async refresh(creds) {
@@ -679,7 +730,7 @@ var TokenManager = class {
679
730
  if (response.status === 401 || response.status === 403) {
680
731
  const reason = response.status === 403 ? "the token owner no longer has access to its organization" : "the token is invalid, disabled, or expired";
681
732
  const rejection = new ApiTokenRejectedError(
682
- `API token exchange failed: ${reason}. Create a new API token in MySpec and set it as MYSPEC_API_TOKEN (or write it to the apiToken field of your credentials file).`
733
+ `API token exchange failed at ${url}: ${reason}. Check that URL names the environment the token was created in. With MYSPEC_API_TOKEN it comes from MYSPEC_USER_AUTH_URL (or --user-auth-url), then userAuthUrl in ~/.myspec/settings.json, defaulting to https://auth.myspec.dev; with an apiToken in oauth_creds.json it comes from that settings.json value alone, which only \`npx @myspec/mcp-server login --user-auth-url <url>\` rewrites. If the host is right, create a new API token in MySpec and set it as MYSPEC_API_TOKEN (or write it to the apiToken field of your credentials file).`
683
734
  );
684
735
  this.apiTokenRejection = rejection;
685
736
  throw rejection;
@@ -689,7 +740,7 @@ var TokenManager = class {
689
740
  throw new HttpStatusError(
690
741
  response.status,
691
742
  body,
692
- `Token exchange failed: ${url} returned 404, so no exchange endpoint is there. Check that URL names the environment the token was created in. With MYSPEC_API_TOKEN it comes from MYSPEC_USER_AUTH_URL (or --user-auth-url), defaulting to https://auth.myspec.dev. Otherwise it comes from userAuthUrl in ~/.myspec/settings.json, which only \`npx @myspec/mcp-server login --user-auth-url <url>\` rewrites \u2014 the flag and the variable do not redirect a file-configured exchange on their own, and userAuthUrl in oauth_creds.json is ignored entirely.`
743
+ `Token exchange failed: ${url} returned 404, so no exchange endpoint is there. Check that URL names the environment the token was created in. With MYSPEC_API_TOKEN it comes from MYSPEC_USER_AUTH_URL (or --user-auth-url), then userAuthUrl in ~/.myspec/settings.json, defaulting to https://auth.myspec.dev. Otherwise it comes from that same settings.json value, which only \`npx @myspec/mcp-server login --user-auth-url <url>\` rewrites \u2014 the flag and the variable do not redirect a file-configured exchange on their own, and userAuthUrl in oauth_creds.json is ignored entirely.`
693
744
  );
694
745
  }
695
746
  if (!response.ok) {
@@ -1333,6 +1384,26 @@ var PlatformPreconditionRequiredError = class extends PlatformHttpError {
1333
1384
  this.name = "PlatformPreconditionRequiredError";
1334
1385
  }
1335
1386
  };
1387
+ var QUOTA_EXCEEDED_CODE = "quota_exceeded";
1388
+ var PlatformQuotaError = class extends PlatformHttpError {
1389
+ /** The platform's machine-readable code, e.g. `quota_exceeded`. */
1390
+ code;
1391
+ constructor(message, status, details, code) {
1392
+ super(message, status, details);
1393
+ this.name = "PlatformQuotaError";
1394
+ this.code = code;
1395
+ }
1396
+ };
1397
+ function isPlatformQuotaError(err) {
1398
+ if (statusOf(err) !== 429) {
1399
+ return false;
1400
+ }
1401
+ if (typeof err !== "object" || err === null) {
1402
+ return false;
1403
+ }
1404
+ const code = err.code;
1405
+ return typeof code === "string" && code.toLowerCase() === QUOTA_EXCEEDED_CODE;
1406
+ }
1336
1407
  function isPlatformHttpError(err) {
1337
1408
  return typeof err === "object" && err !== null && err[PLATFORM_ERROR_BRAND] === true;
1338
1409
  }
@@ -1409,13 +1480,16 @@ function toConflictDetails(details) {
1409
1480
  changedAt: stringField(details, "last_modified_at")
1410
1481
  };
1411
1482
  }
1412
- function createPlatformError(message, status, details) {
1483
+ function createPlatformError(message, status, details, code) {
1413
1484
  if (status === 409) {
1414
1485
  return new PlatformConflictError(message, status, details, toConflictDetails(details));
1415
1486
  }
1416
1487
  if (status === 428) {
1417
1488
  return new PlatformPreconditionRequiredError(message, details);
1418
1489
  }
1490
+ if (status === 429 && typeof code === "string" && code.toLowerCase() === QUOTA_EXCEEDED_CODE) {
1491
+ return new PlatformQuotaError(message, status, details, code.toLowerCase());
1492
+ }
1419
1493
  return new PlatformHttpError(message, status, details);
1420
1494
  }
1421
1495
  function truncate(text) {
@@ -1426,17 +1500,19 @@ async function throwFromResponse(response, fallbackMessage) {
1426
1500
  const rawBody = await response.text().catch(() => "");
1427
1501
  let message = fallbackMessage;
1428
1502
  let details;
1503
+ let code;
1429
1504
  if (rawBody) {
1430
1505
  try {
1431
1506
  const parsed = JSON.parse(rawBody);
1432
1507
  details = parsed.details;
1508
+ code = typeof parsed.error === "string" ? parsed.error : void 0;
1433
1509
  const suffix = details ? ` [details: ${JSON.stringify(details)}]` : "";
1434
1510
  message = truncate(parsed.message ?? rawBody) + suffix;
1435
1511
  } catch {
1436
1512
  message = truncate(rawBody);
1437
1513
  }
1438
1514
  }
1439
- throw createPlatformError(`HTTP ${response.status}: ${message}`, response.status, details);
1515
+ throw createPlatformError(`HTTP ${response.status}: ${message}`, response.status, details, code);
1440
1516
  }
1441
1517
 
1442
1518
  // ../../packages/shared/logger.ts
@@ -1644,6 +1720,10 @@ var BaseHttpClient = class {
1644
1720
  this.logger.debug("Retrying after abort/timeout", { error: error.message });
1645
1721
  return true;
1646
1722
  }
1723
+ if (isPlatformQuotaError(error)) {
1724
+ this.logger.debug("Not retrying quota rejection", { status: 429 });
1725
+ return false;
1726
+ }
1647
1727
  const httpError = error;
1648
1728
  if (httpError.status !== void 0) {
1649
1729
  const shouldRetry = RETRYABLE_STATUS_CODES.includes(httpError.status);
@@ -1700,6 +1780,9 @@ function transformSpecFileResponse(raw) {
1700
1780
  deletedAt: raw.deleted_at ? new Date(raw.deleted_at) : void 0
1701
1781
  };
1702
1782
  }
1783
+ function toRevisionAuthorKind(raw) {
1784
+ return raw === "ai" ? "ai" : "human";
1785
+ }
1703
1786
  function transformFileRevisionResponse(raw) {
1704
1787
  return {
1705
1788
  id: raw.id,
@@ -1710,6 +1793,7 @@ function transformFileRevisionResponse(raw) {
1710
1793
  fileSizeBytes: raw.file_size_bytes,
1711
1794
  checksum: raw.checksum,
1712
1795
  changedBy: raw.changed_by,
1796
+ changedByKind: toRevisionAuthorKind(raw.changed_by_kind),
1713
1797
  createdAt: new Date(raw.created_at)
1714
1798
  };
1715
1799
  }
@@ -2387,6 +2471,32 @@ var PlatformClient = class {
2387
2471
  };
2388
2472
  return this.withTokenRetry((jwt, c) => c.session.listSpecSessions(projectId, jwt, merged));
2389
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
+ }
2390
2500
  async listFiles(projectId, opts = {}) {
2391
2501
  return this.withTokenRetry((jwt, c) => c.file.listFiles(projectId, jwt, opts));
2392
2502
  }
@@ -2660,6 +2770,14 @@ function jsonResult(value) {
2660
2770
  function errorResult(message) {
2661
2771
  return { content: [{ type: "text", text: message }], isError: true };
2662
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
+ }
2663
2781
 
2664
2782
  // src/server/tools/archive_project.ts
2665
2783
  import { z } from "zod";
@@ -2691,16 +2809,63 @@ var archiveProjectTool = {
2691
2809
  }
2692
2810
  };
2693
2811
 
2694
- // src/server/tools/create_project.ts
2812
+ // src/server/tools/archive_spec_session.ts
2695
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
2696
2837
  var inputSchema2 = {
2697
- name: z2.string().describe("Name of the project to create."),
2698
- 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.")
2699
2864
  };
2700
2865
  var createProjectTool = {
2701
2866
  name: "create_project",
2702
2867
  description: "Create a new project. Returns the created project id and metadata.",
2703
- inputSchema: inputSchema2,
2868
+ inputSchema: inputSchema3,
2704
2869
  handler: async (args, ctx) => {
2705
2870
  const name = args.name.trim();
2706
2871
  if (name === "") {
@@ -2715,14 +2880,14 @@ var createProjectTool = {
2715
2880
  };
2716
2881
 
2717
2882
  // src/server/tools/delete_project.ts
2718
- import { z as z3 } from "zod";
2719
- var inputSchema3 = {
2720
- 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.")
2721
2886
  };
2722
2887
  var deleteProjectTool = {
2723
2888
  name: "delete_project",
2724
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.",
2725
- inputSchema: inputSchema3,
2890
+ inputSchema: inputSchema4,
2726
2891
  handler: async (args, ctx) => {
2727
2892
  let status;
2728
2893
  try {
@@ -2747,9 +2912,38 @@ var deleteProjectTool = {
2747
2912
  }
2748
2913
  };
2749
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
+
2750
2944
  // src/server/tools/download_spec_file.ts
2751
2945
  import path3 from "path";
2752
- import { z as z4 } from "zod";
2946
+ import { z as z6 } from "zod";
2753
2947
 
2754
2948
  // src/server/download-path.ts
2755
2949
  import { randomBytes as randomBytes2 } from "crypto";
@@ -2883,18 +3077,18 @@ async function writeBytesAtomically(target, bytes) {
2883
3077
 
2884
3078
  // src/server/tools/download_spec_file.ts
2885
3079
  var MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
2886
- var inputSchema4 = {
2887
- file_id: z4.string().min(1).describe("UUID of the file"),
2888
- revision: z4.number().int().positive().optional().describe("Specific revision number to download (defaults to the latest revision)."),
2889
- 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(
2890
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."
2891
3085
  ),
2892
- 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).")
2893
3087
  };
2894
3088
  var downloadSpecFileTool = {
2895
3089
  name: "download_spec_file",
2896
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.",
2897
- inputSchema: inputSchema4,
3091
+ inputSchema: inputSchema6,
2898
3092
  handler: async (args, ctx) => {
2899
3093
  const meta = await ctx.client.getFileMetadata(args.file_id);
2900
3094
  const targetRevision = args.revision ?? meta.revisionCount;
@@ -2992,15 +3186,15 @@ function buildOversizeMessage(fileId, byteLength) {
2992
3186
  }
2993
3187
 
2994
3188
  // src/server/tools/get_attachment.ts
2995
- import { z as z5 } from "zod";
2996
- var inputSchema5 = {
2997
- attachment_id: z5.string().min(1).describe("UUID of the attachment"),
2998
- 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.")
2999
3193
  };
3000
3194
  var getAttachmentTool = {
3001
3195
  name: "get_attachment",
3002
3196
  description: "Get attachment metadata. With `include_download_url>0`, also returns a signed `download_url`.",
3003
- inputSchema: inputSchema5,
3197
+ inputSchema: inputSchema7,
3004
3198
  handler: async (args, ctx) => {
3005
3199
  const attachment = await ctx.client.getAttachmentMetadata(args.attachment_id);
3006
3200
  if (!attachment) {
@@ -3029,14 +3223,14 @@ var getAttachmentTool = {
3029
3223
  };
3030
3224
 
3031
3225
  // src/server/tools/get_project.ts
3032
- import { z as z6 } from "zod";
3033
- var inputSchema6 = {
3034
- 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")
3035
3229
  };
3036
3230
  var getProjectTool = {
3037
3231
  name: "get_project",
3038
3232
  description: "Get details for a project including all active spec sessions, files (id + metadata), and attachments (id + metadata).",
3039
- inputSchema: inputSchema6,
3233
+ inputSchema: inputSchema8,
3040
3234
  handler: async (args, ctx) => {
3041
3235
  const [project, sessions, files, attachments] = await Promise.all([
3042
3236
  ctx.client.getProject(args.project_id),
@@ -3082,7 +3276,7 @@ var getProjectTool = {
3082
3276
  };
3083
3277
 
3084
3278
  // src/server/tools/get_spec_file.ts
3085
- import { z as z7 } from "zod";
3279
+ import { z as z9 } from "zod";
3086
3280
 
3087
3281
  // src/server/tools/spec-file-output.ts
3088
3282
  function specFileSummary(file) {
@@ -3102,16 +3296,16 @@ function specFileSummary(file) {
3102
3296
  }
3103
3297
 
3104
3298
  // src/server/tools/get_spec_file.ts
3105
- var inputSchema7 = {
3106
- file_id: z7.string().min(1).describe("UUID of the file"),
3107
- 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(
3108
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."
3109
3303
  )
3110
3304
  };
3111
3305
  var getSpecFileTool = {
3112
3306
  name: "get_spec_file",
3113
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.",
3114
- inputSchema: inputSchema7,
3308
+ inputSchema: inputSchema9,
3115
3309
  handler: async (args, ctx) => {
3116
3310
  const meta = await ctx.client.getFileMetadata(args.file_id);
3117
3311
  const base = specFileSummary(meta);
@@ -3135,19 +3329,209 @@ var getSpecFileTool = {
3135
3329
  }
3136
3330
  };
3137
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
+
3138
3522
  // src/server/tools/list_projects.ts
3139
- import { z as z8 } from "zod";
3140
- var inputSchema8 = {
3141
- query: z8.string().optional().describe(
3523
+ import { z as z12 } from "zod";
3524
+ var inputSchema12 = {
3525
+ query: z12.string().optional().describe(
3142
3526
  "Search term to filter projects by name. Omit to list all projects."
3143
3527
  ),
3144
- limit: z8.number().int().positive().max(100).optional().describe("Max projects to return (default 20)"),
3145
- 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)")
3146
3530
  };
3147
3531
  var listProjectsTool = {
3148
3532
  name: "list_projects",
3149
3533
  description: "List or search MySpec projects accessible to the authenticated user.",
3150
- inputSchema: inputSchema8,
3534
+ inputSchema: inputSchema12,
3151
3535
  handler: async (args, ctx) => {
3152
3536
  const result = await ctx.client.listProjects({
3153
3537
  query: args.query,
@@ -3170,21 +3554,21 @@ var listProjectsTool = {
3170
3554
  };
3171
3555
 
3172
3556
  // src/server/tools/list_spec_file.ts
3173
- import { z as z9 } from "zod";
3557
+ import { z as z13 } from "zod";
3174
3558
  var PAGE_SIZE = 100;
3175
- var inputSchema9 = {
3176
- project_id: z9.string().min(1).describe("UUID of the project whose spec files to list."),
3177
- 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(
3178
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.'
3179
3563
  ),
3180
- trashed: z9.boolean().optional().describe(
3564
+ trashed: z13.boolean().optional().describe(
3181
3565
  "When true, list files in the project Trash Bin instead. When false or omitted, list only live files (trashed files excluded)."
3182
3566
  )
3183
3567
  };
3184
3568
  var listSpecFileTool = {
3185
3569
  name: "list_spec_file",
3186
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.',
3187
- inputSchema: inputSchema9,
3571
+ inputSchema: inputSchema13,
3188
3572
  handler: async (args, ctx) => {
3189
3573
  const trashed = args.trashed ?? false;
3190
3574
  const files = trashed ? await ctx.client.listTrashedFiles(args.project_id) : await listAllLiveFiles(ctx.client, args.project_id);
@@ -3240,34 +3624,255 @@ function isUnderPath(filePath, prefix) {
3240
3624
  return filePath === prefix || filePath.startsWith(prefix + "/");
3241
3625
  }
3242
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
+
3243
3674
  // src/server/tools/move_spec_file_to_trash.ts
3244
- import { z as z10 } from "zod";
3245
- var inputSchema10 = {
3246
- 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.")
3247
3678
  };
3248
3679
  var moveSpecFileToTrashTool = {
3249
3680
  name: "move_spec_file_to_trash",
3250
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.",
3251
- inputSchema: inputSchema10,
3682
+ inputSchema: inputSchema15,
3252
3683
  handler: async (args, ctx) => {
3253
3684
  await ctx.client.trashFile(args.file_id);
3254
3685
  return jsonResult({ trashed: true, file_id: args.file_id });
3255
3686
  }
3256
3687
  };
3257
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
+
3258
3863
  // src/server/tools/read_spec_file.ts
3259
3864
  import { promises as fs3 } from "fs";
3260
3865
  import { createHash as createHash2 } from "crypto";
3261
- import { z as z11 } from "zod";
3866
+ import { z as z17 } from "zod";
3262
3867
  var MAX_READ_BYTES = 1 * 1024 * 1024;
3263
- var inputSchema11 = {
3264
- file_id: z11.string().min(1).describe("UUID of the file"),
3265
- 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).")
3266
3871
  };
3267
3872
  var readSpecFileTool = {
3268
3873
  name: "read_spec_file",
3269
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.",
3270
- inputSchema: inputSchema11,
3875
+ inputSchema: inputSchema17,
3271
3876
  handler: async (args, ctx) => {
3272
3877
  const meta = await ctx.client.getFileMetadata(args.file_id);
3273
3878
  const targetRevision = args.revision ?? meta.revisionCount;
@@ -3305,7 +3910,7 @@ var readSpecFileTool = {
3305
3910
  if (bytes.byteLength > MAX_READ_BYTES) {
3306
3911
  return errorResult(buildOversizeMessage2(args.file_id, bytes.byteLength));
3307
3912
  }
3308
- if (isBinary(bytes)) {
3913
+ if (isBinary2(bytes)) {
3309
3914
  return errorResult(
3310
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.`
3311
3916
  );
@@ -3338,7 +3943,7 @@ var readSpecFileTool = {
3338
3943
  function buildOversizeMessage2(fileId, byteLength) {
3339
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.`;
3340
3945
  }
3341
- function isBinary(bytes) {
3946
+ function isBinary2(bytes) {
3342
3947
  for (const byte of bytes) {
3343
3948
  if (byte === 0) {
3344
3949
  return true;
@@ -3350,15 +3955,59 @@ function sha256Prefixed2(content) {
3350
3955
  return "sha256:" + createHash2("sha256").update(content).digest("hex");
3351
3956
  }
3352
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
+
3353
4002
  // src/server/tools/restore_spec_file_from_trash.ts
3354
- import { z as z12 } from "zod";
3355
- var inputSchema12 = {
3356
- 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.")
3357
4006
  };
3358
4007
  var restoreSpecFileFromTrashTool = {
3359
4008
  name: "restore_spec_file_from_trash",
3360
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.",
3361
- inputSchema: inputSchema12,
4010
+ inputSchema: inputSchema19,
3362
4011
  handler: async (args, ctx) => {
3363
4012
  await ctx.client.restoreFile(args.file_id);
3364
4013
  return jsonResult({ restored: true, file_id: args.file_id });
@@ -3366,34 +4015,58 @@ var restoreSpecFileFromTrashTool = {
3366
4015
  };
3367
4016
 
3368
4017
  // src/server/tools/unarchive_project.ts
3369
- import { z as z13 } from "zod";
3370
- var inputSchema13 = {
3371
- 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.")
3372
4021
  };
3373
4022
  var unarchiveProjectTool = {
3374
4023
  name: "unarchive_project",
3375
4024
  description: 'Unarchive a project (sets status to "active"). Idempotent: unarchiving an already-active project succeeds.',
3376
- inputSchema: inputSchema13,
4025
+ inputSchema: inputSchema20,
3377
4026
  handler: async (args, ctx) => {
3378
4027
  const project = await ctx.client.unarchiveProject(args.project_id);
3379
4028
  return jsonResult({ project: projectSummary(project) });
3380
4029
  }
3381
4030
  };
3382
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
+
3383
4056
  // src/server/tools/update_project.ts
3384
- import { z as z14 } from "zod";
3385
- var inputSchema14 = {
3386
- project_id: z14.string().min(1).describe("UUID of the project to update."),
3387
- name: z14.string().min(1).optional().describe("New project name."),
3388
- description: z14.string().optional().describe("New project description."),
3389
- 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(
3390
4063
  "Codebase type. 'greenfield' or 'brownfield' sets it explicitly; 'auto' clears it so the workflow decides. Omit to leave unchanged."
3391
4064
  )
3392
4065
  };
3393
4066
  var updateProjectTool = {
3394
4067
  name: "update_project",
3395
4068
  description: "Update a project's metadata (name, description, project_type). Returns the updated metadata.",
3396
- inputSchema: inputSchema14,
4069
+ inputSchema: inputSchema22,
3397
4070
  handler: async (args, ctx) => {
3398
4071
  if (args.name === void 0 && args.description === void 0 && args.project_type === void 0) {
3399
4072
  return errorResult(
@@ -3416,7 +4089,7 @@ var updateProjectTool = {
3416
4089
  };
3417
4090
 
3418
4091
  // src/server/tools/update_spec_file.ts
3419
- import { z as z15 } from "zod";
4092
+ import { z as z23 } from "zod";
3420
4093
 
3421
4094
  // src/server/tools/spec-content-source.ts
3422
4095
  import { readFile, stat } from "fs/promises";
@@ -3472,24 +4145,24 @@ async function resolveSpecContent(input, maxBytes = MAX_LOCAL_SPEC_FILE_BYTES) {
3472
4145
  }
3473
4146
 
3474
4147
  // src/server/tools/update_spec_file.ts
3475
- var inputSchema15 = {
3476
- file_id: z15.string().min(1).optional().describe("UUID of the spec file to overwrite. Provide this or (project_id + file_path)."),
3477
- project_id: z15.string().min(1).optional().describe("UUID of the project. Required (with file_path) when file_id is not given."),
3478
- file_path: z15.string().min(1).optional().describe("Path of the spec file. Required (with project_id) when file_id is not given."),
3479
- 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(
3480
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."
3481
4154
  ),
3482
- local_file_path: z15.string().min(1).optional().describe(
4155
+ local_file_path: z23.string().min(1).optional().describe(
3483
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."
3484
4157
  ),
3485
- expected_version: z15.number().int().nonnegative().optional().describe(
4158
+ expected_version: z23.number().int().nonnegative().optional().describe(
3486
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."
3487
4160
  )
3488
4161
  };
3489
4162
  var updateSpecFileTool = {
3490
4163
  name: "update_spec_file",
3491
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.",
3492
- inputSchema: inputSchema15,
4165
+ inputSchema: inputSchema23,
3493
4166
  handler: async (args, ctx) => {
3494
4167
  const resolved = await resolveSpecContent({
3495
4168
  content: args.content,
@@ -3554,9 +4227,9 @@ function conflictMessage(filePath, conflict) {
3554
4227
  import { createHash as createHash3 } from "crypto";
3555
4228
  import { lstat, readFile as readFile2 } from "fs/promises";
3556
4229
  import { basename, extname, isAbsolute as isAbsolute2 } from "path";
3557
- import { z as z16 } from "zod";
4230
+ import { z as z24 } from "zod";
3558
4231
  var DEFAULT_MIME = "application/octet-stream";
3559
- var MAX_ATTACHMENT_MB = MAX_ATTACHMENT_BYTES / (1024 * 1024);
4232
+ var MAX_ATTACHMENT_MB2 = MAX_ATTACHMENT_BYTES / (1024 * 1024);
3560
4233
  var STRUCTURED_EXTENSION_MIME = {
3561
4234
  ".pdf": "application/pdf",
3562
4235
  ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -3659,23 +4332,23 @@ function inferMimeFromName(name) {
3659
4332
  }
3660
4333
  return DEFAULT_MIME;
3661
4334
  }
3662
- var inputSchema16 = {
3663
- project_id: z16.string().min(1).describe("UUID of the project to attach the file to."),
3664
- 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(
3665
4338
  "Absolute path to the local file to upload. The MCP server reads this path from its own host."
3666
4339
  ),
3667
- file_name: z16.string().min(1).optional().describe("Override the filename recorded on the attachment. Defaults to the path basename."),
3668
- 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(
3669
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."
3670
4343
  ),
3671
- override: z16.boolean().optional().describe(
4344
+ override: z24.boolean().optional().describe(
3672
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)."
3673
4346
  )
3674
4347
  };
3675
4348
  var uploadAttachmentTool = {
3676
4349
  name: "upload_attachment",
3677
- 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.`,
3678
- 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,
3679
4352
  handler: async (args, ctx) => {
3680
4353
  if (!isAbsolute2(args.file_path)) {
3681
4354
  return errorResult(`file_path must be an absolute path; got "${args.file_path}".`);
@@ -3708,7 +4381,7 @@ var uploadAttachmentTool = {
3708
4381
  }
3709
4382
  if (stats.size > MAX_ATTACHMENT_BYTES) {
3710
4383
  return errorResult(
3711
- `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.`
3712
4385
  );
3713
4386
  }
3714
4387
  let buf;
@@ -3727,7 +4400,7 @@ var uploadAttachmentTool = {
3727
4400
  }
3728
4401
  if (buf.byteLength > MAX_ATTACHMENT_BYTES) {
3729
4402
  return errorResult(
3730
- `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.`
3731
4404
  );
3732
4405
  }
3733
4406
  const fileName = args.file_name ?? basename(args.file_path);
@@ -3799,7 +4472,7 @@ var uploadAttachmentTool = {
3799
4472
  };
3800
4473
 
3801
4474
  // src/server/tools/upload_spec_file.ts
3802
- import { z as z17 } from "zod";
4475
+ import { z as z25 } from "zod";
3803
4476
 
3804
4477
  // src/server/spec-path.ts
3805
4478
  var MAX_SPEC_DIR_DEPTH = 3;
@@ -3852,23 +4525,23 @@ var SPEC_FILE_TYPES = [
3852
4525
  "proposal",
3853
4526
  "openspec-spec"
3854
4527
  ];
3855
- var inputSchema17 = {
3856
- project_id: z17.string().min(1).describe("UUID of the project to upload the file into."),
3857
- 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(
3858
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).'
3859
4532
  ),
3860
- content: z17.string().min(1).optional().describe(
4533
+ content: z25.string().min(1).optional().describe(
3861
4534
  "Full UTF-8 text content of the spec file. Provide this OR local_file_path (exactly one). Use for in-memory / generated content."
3862
4535
  ),
3863
- local_file_path: z17.string().min(1).optional().describe(
4536
+ local_file_path: z25.string().min(1).optional().describe(
3864
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."
3865
4538
  ),
3866
- 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).")
3867
4540
  };
3868
4541
  var uploadSpecFileTool = {
3869
4542
  name: "upload_spec_file",
3870
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).",
3871
- inputSchema: inputSchema17,
4544
+ inputSchema: inputSchema25,
3872
4545
  handler: async (args, ctx) => {
3873
4546
  const pathError = validateSpecFilePath(args.file_path);
3874
4547
  if (pathError) {
@@ -3921,6 +4594,14 @@ function buildServer(deps) {
3921
4594
  registerTool(server, updateSpecFileTool, deps.client);
3922
4595
  registerTool(server, moveSpecFileToTrashTool, deps.client);
3923
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);
3924
4605
  return server;
3925
4606
  }
3926
4607
  async function startStdioServer(deps) {
@@ -4074,7 +4755,7 @@ async function canonicaliseRoot(root) {
4074
4755
  import { promises as fs4 } from "fs";
4075
4756
  import { join as join2, relative } from "path";
4076
4757
  import { spawn } from "child_process";
4077
- import { z as z18 } from "zod";
4758
+ import { z as z26 } from "zod";
4078
4759
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4079
4760
 
4080
4761
  // src/reverse/ignore.ts
@@ -4171,7 +4852,7 @@ var PackCache = class {
4171
4852
  if (oldestKey === void 0) break;
4172
4853
  this.entries.delete(oldestKey);
4173
4854
  }
4174
- const lines = splitIntoLines(content);
4855
+ const lines = splitIntoLines2(content);
4175
4856
  const pages = planPages(lines);
4176
4857
  const entry = {
4177
4858
  outputId,
@@ -4199,7 +4880,7 @@ var PackCache = class {
4199
4880
  }
4200
4881
  }
4201
4882
  };
4202
- function splitIntoLines(text) {
4883
+ function splitIntoLines2(text) {
4203
4884
  const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4204
4885
  const parts = normalized.split("\n");
4205
4886
  if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
@@ -4379,37 +5060,37 @@ var READ_FILE_MAX_LINES = 2e3;
4379
5060
  var LIST_DIR_HARD_CAP = 5e3;
4380
5061
  var LIST_DIR_DEFAULT_CAP = 1e3;
4381
5062
  var GREP_MAX_MATCHES_DEFAULT = 200;
4382
- var listDirInput = z18.object({
4383
- path: z18.string(),
4384
- recursive: z18.boolean().optional(),
4385
- maxEntries: z18.number().int().positive().optional(),
4386
- 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()
4387
5068
  });
4388
- var readFileInput = z18.object({
4389
- path: z18.string(),
4390
- offset: z18.number().int().nonnegative().optional(),
4391
- limit: z18.number().int().positive().optional(),
4392
- 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()
4393
5074
  });
4394
- var grepInput = z18.object({
4395
- pattern: z18.string().describe(
5075
+ var grepInput = z26.object({
5076
+ pattern: z26.string().describe(
4396
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."
4397
5078
  ),
4398
- path: z18.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
4399
- isRegex: z18.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
4400
- caseSensitive: z18.boolean().optional(),
4401
- maxMatches: z18.number().int().positive().optional(),
4402
- contextLines: z18.number().int().nonnegative().optional(),
4403
- 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()
4404
5085
  });
4405
- var packCodebaseInput = z18.object({
4406
- subpath: z18.string().optional(),
4407
- includePatterns: z18.string().optional(),
4408
- ignorePatterns: z18.string().optional()
5086
+ var packCodebaseInput = z26.object({
5087
+ subpath: z26.string().optional(),
5088
+ includePatterns: z26.string().optional(),
5089
+ ignorePatterns: z26.string().optional()
4409
5090
  });
4410
- var packCodebaseReadPageInput = z18.object({
4411
- outputId: z18.string(),
4412
- page: z18.number().int().positive()
5091
+ var packCodebaseReadPageInput = z26.object({
5092
+ outputId: z26.string(),
5093
+ page: z26.number().int().positive()
4413
5094
  });
4414
5095
  function structuredError(error, message, extra) {
4415
5096
  const payload = { error, message, ...extra ?? {} };
@@ -4525,19 +5206,19 @@ async function handleRead(args, opts) {
4525
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.`
4526
5207
  );
4527
5208
  }
4528
- const text = normalizeNewlines(raw.toString("utf-8"));
4529
- const lines = splitIntoLines2(text);
5209
+ const text = normalizeNewlines2(raw.toString("utf-8"));
5210
+ const lines = splitIntoLines3(text);
4530
5211
  const page = paginateLines(lines, args.offset, args.limit);
4531
5212
  if ("error" in page) {
4532
5213
  return structuredError("invalid_offset", page.error);
4533
5214
  }
4534
5215
  return structuredOk(page);
4535
5216
  }
4536
- function normalizeNewlines(s) {
5217
+ function normalizeNewlines2(s) {
4537
5218
  const noBom = s.charCodeAt(0) === 65279 ? s.slice(1) : s;
4538
5219
  return noBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4539
5220
  }
4540
- function splitIntoLines2(text) {
5221
+ function splitIntoLines3(text) {
4541
5222
  const parts = text.split("\n");
4542
5223
  if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
4543
5224
  return parts;
@@ -4850,7 +5531,7 @@ function registerLocalFsTools(server, opts) {
4850
5531
  },
4851
5532
  {
4852
5533
  name: "local_pack_codebase",
4853
- 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.",
4854
5535
  inputSchema: jsonSchemaFromZod(packCodebaseInput),
4855
5536
  handler: async (args) => {
4856
5537
  try {
@@ -4863,7 +5544,7 @@ function registerLocalFsTools(server, opts) {
4863
5544
  },
4864
5545
  {
4865
5546
  name: "local_pack_codebase_read_page",
4866
- 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).",
4867
5548
  inputSchema: jsonSchemaFromZod(packCodebaseReadPageInput),
4868
5549
  handler: (args) => {
4869
5550
  try {
@@ -4937,7 +5618,7 @@ function jsonSchemaFromZod(schema) {
4937
5618
  return zodToJson(schema);
4938
5619
  }
4939
5620
  function zodToJson(schema) {
4940
- if (schema instanceof z18.ZodObject) {
5621
+ if (schema instanceof z26.ZodObject) {
4941
5622
  const shape = schema.shape;
4942
5623
  const properties = {};
4943
5624
  const required = [];
@@ -4952,19 +5633,19 @@ function zodToJson(schema) {
4952
5633
  if (required.length > 0) out.required = required;
4953
5634
  return out;
4954
5635
  }
4955
- if (schema instanceof z18.ZodOptional) {
5636
+ if (schema instanceof z26.ZodOptional) {
4956
5637
  return zodToJson(schema.unwrap());
4957
5638
  }
4958
- if (schema instanceof z18.ZodString) {
5639
+ if (schema instanceof z26.ZodString) {
4959
5640
  return { type: "string" };
4960
5641
  }
4961
- if (schema instanceof z18.ZodNumber) {
5642
+ if (schema instanceof z26.ZodNumber) {
4962
5643
  return { type: "number" };
4963
5644
  }
4964
- if (schema instanceof z18.ZodBoolean) {
5645
+ if (schema instanceof z26.ZodBoolean) {
4965
5646
  return { type: "boolean" };
4966
5647
  }
4967
- if (schema instanceof z18.ZodEnum) {
5648
+ if (schema instanceof z26.ZodEnum) {
4968
5649
  return { type: "string", enum: schema.options };
4969
5650
  }
4970
5651
  return { type: "string" };
@@ -5407,21 +6088,59 @@ async function resolveReverseAgentUrl(sources) {
5407
6088
  throw err;
5408
6089
  }
5409
6090
  }
6091
+ function ignoringSettings(settingsPath, reason) {
6092
+ return `myspec-mcp: ignoring the userAuthUrl in ${settingsPath} for the MYSPEC_API_TOKEN exchange URL \u2014 ${reason}.`;
6093
+ }
6094
+ function usableAuthUrl(candidate, settingsPath, warn) {
6095
+ if (candidate === void 0) {
6096
+ return void 0;
6097
+ }
6098
+ try {
6099
+ resolveConfig({ env: {}, storedUserAuthUrl: candidate });
6100
+ return candidate;
6101
+ } catch (err) {
6102
+ if (!(err instanceof ConfigError)) {
6103
+ throw err;
6104
+ }
6105
+ warn(ignoringSettings(settingsPath, err.message));
6106
+ return void 0;
6107
+ }
6108
+ }
5410
6109
  async function buildCredentialsStore(opts = {}) {
5411
6110
  const env = opts.env ?? process.env;
5412
- const fileStore = opts.fileStore ?? createFileCredentialsStore();
6111
+ const settingsPath = opts.settingsPath ?? defaultSettingsPath();
6112
+ const fileStore = opts.fileStore ?? createFileCredentialsStore({ settingsPath });
6113
+ const warn = opts.warn ?? ((message) => {
6114
+ process.stderr.write(message + "\n");
6115
+ });
5413
6116
  const usingEnvApiToken = hasEnvApiToken(env);
5414
6117
  const tolerate = opts.toleratesUnreadableFile ?? usingEnvApiToken;
5415
6118
  const stored = tolerate ? await fileStore.load().catch(() => null) : await fileStore.load();
6119
+ const overridden = (opts.cliUserAuthUrl ?? env.MYSPEC_USER_AUTH_URL) !== void 0;
6120
+ const fromSettings = usingEnvApiToken && !overridden && stored?.userAuthUrl === void 0 ? await readSettingsUserAuthUrl(settingsPath).catch((err) => {
6121
+ const message = err instanceof Error ? err.message : String(err);
6122
+ warn(ignoringSettings(settingsPath, `it could not be read (${message})`));
6123
+ return void 0;
6124
+ }) : void 0;
5416
6125
  const config = resolveConfig({
5417
6126
  env,
5418
6127
  cliUserAuthUrl: opts.cliUserAuthUrl,
5419
- storedUserAuthUrl: stored?.userAuthUrl
6128
+ // Validated on its own before it joins the chain, for the same reason the
6129
+ // unreadable file above is forgiven: parseSettings only requires a
6130
+ // non-empty string, so a hand-written `htps://…` or a bare host in a
6131
+ // settings.json that is not even the credential would otherwise turn a
6132
+ // pure-env install that used to start on the prod default into a
6133
+ // ConfigError exit — and one that never names the file it came from.
6134
+ storedUserAuthUrl: stored?.userAuthUrl ?? usableAuthUrl(fromSettings, settingsPath, warn)
5420
6135
  });
5421
6136
  return {
5422
6137
  store: createEnvApiTokenStore({
5423
6138
  fileStore,
5424
- envConfig: readEnvApiToken(config.userAuthUrl, env)
6139
+ // `warn` rather than the default sink, so the "cannot be sent as a
6140
+ // header" warning lands wherever the caller's other warnings do.
6141
+ envConfig: readEnvApiToken(config.userAuthUrl, env, warn),
6142
+ settingsPath,
6143
+ warn
5425
6144
  }),
5426
6145
  config,
5427
6146
  usingEnvApiToken