@myspec/mcp-server 0.4.0-next.107 → 0.4.0-next.108

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 +2 -2
  2. package/dist/index.js +120 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -291,9 +291,9 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
291
291
  | `list_projects` | `query?`, `limit?`, `offset?` | Paginated list of projects |
292
292
  | `get_project` | `project_id` | Project + active sessions + files + attachments |
293
293
  | `get_spec_file` | `file_id`, optional `include_download_url` | File metadata, including `content_version` (see [Concurrent edits](#concurrent-edits-content_version)); with `include_download_url=N` also returns a download URL targeting revision `N` |
294
- | `read_spec_file` | `file_id`, optional `revision` | UTF-8 content of the file, plus `content_version` when reading the latest revision (errors for binary or files > 1 MiB — use `download_spec_file` for those). Cached on disk under `MYSPEC_DOWNLOAD_ROOT` |
294
+ | `read_spec_file` | `file_id`, optional `revision`, optional `offset`, optional `limit` | UTF-8 content 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`; plus `content_version` when reading the latest revision (errors for binary or files > 8 MiB — use `download_spec_file` for those). Cached on disk under `MYSPEC_DOWNLOAD_ROOT`, so paging through a file downloads it once |
295
295
  | `download_spec_file` | `file_id`, optional `destination_path`, optional `revision`, optional `overwrite` | Downloads the file's raw bytes to a local file — by default under `.specs/` in the working directory (see [Download destinations](#download-destinations)). Handles binary files and files up to 50 MiB. Refuses to replace an existing file unless `overwrite=true`. Returns only metadata (`saved_path`, `bytes_written`, `revision_number`, `destination_source`) — **not** the content, so a large file can be saved locally and read in chunks |
296
- | `list_spec_file` | `project_id`, optional `path`, optional `trashed` | Lists a project's spec files (`file_id`, `file_path`, `file_type`, `file_size_bytes`, `revision_count`, `session_id`, `updated_at`). Filter to a directory with `path` (exact file or everything under it; `""`/`"/"`/omit = all). `trashed=true` lists the Trash Bin instead of live files |
296
+ | `list_spec_file` | `project_id`, optional `path`, optional `trashed`, optional `limit`, optional `offset` | Lists a project's spec files (`file_id`, `file_path`, `file_type`, `file_size_bytes`, `revision_count`, `session_id`, `updated_at`, plus `trashed_at` on Trash Bin entries). Filter to a directory with `path` (exact file or everything under it; `""`/`"/"`/omit = all). `trashed=true` lists the Trash Bin instead of live files. Returns `total`, `limit`, `offset` and, when more records remain, `next_offset` (default 50, capped at 200). Ordered by `file_path` when `path` or `trashed` is given, most-recently-created first otherwise |
297
297
  | `move_spec_file_to_trash` | `file_id` | Moves a spec file to the Trash Bin (recoverable — hidden from listings and AI-agent tools, restorable indefinitely). Not a permanent delete |
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 |
package/dist/index.js CHANGED
@@ -3793,6 +3793,9 @@ function sliceWindow(items, limit, offset) {
3793
3793
  };
3794
3794
  }
3795
3795
  function nextOffsetOf(offset, returned, total) {
3796
+ if (returned === 0) {
3797
+ return void 0;
3798
+ }
3796
3799
  const end = offset + returned;
3797
3800
  return end < total ? end : void 0;
3798
3801
  }
@@ -3870,6 +3873,8 @@ var listProjectsTool = {
3870
3873
  // src/server/tools/list_spec_file.ts
3871
3874
  import { z as z13 } from "zod";
3872
3875
  var PAGE_SIZE = 100;
3876
+ var LIST_DEFAULT2 = 50;
3877
+ var LIST_MAX2 = 200;
3873
3878
  var inputSchema13 = {
3874
3879
  project_id: z13.string().min(1).describe("UUID of the project whose spec files to list."),
3875
3880
  path: z13.string().optional().describe(
@@ -3877,26 +3882,57 @@ var inputSchema13 = {
3877
3882
  ),
3878
3883
  trashed: z13.boolean().optional().describe(
3879
3884
  "When true, list files in the project Trash Bin instead. When false or omitted, list only live files (trashed files excluded)."
3880
- )
3885
+ ),
3886
+ limit: z13.number().int().positive().optional().describe(
3887
+ `Max files to return (default ${String(LIST_DEFAULT2)}, capped at ${String(LIST_MAX2)}).`
3888
+ ),
3889
+ offset: z13.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3881
3890
  };
3882
3891
  var listSpecFileTool = {
3883
3892
  name: "list_spec_file",
3884
- 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.',
3893
+ 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. Ordered by `file_path` ascending whenever `path` or `trashed` is given, most-recently-created first otherwise. Returns `total` and, when more records remain, `next_offset`.',
3885
3894
  inputSchema: inputSchema13,
3886
3895
  handler: async (args, ctx) => {
3887
3896
  const trashed = args.trashed ?? false;
3888
- const files = trashed ? await ctx.client.listTrashedFiles(args.project_id) : await listAllLiveFiles(ctx.client, args.project_id);
3897
+ const limit = clampLimit(args.limit, LIST_DEFAULT2, LIST_MAX2);
3898
+ const offset = args.offset ?? 0;
3889
3899
  const prefix = normalizePrefix(args.path);
3890
- const matches = files.filter((f) => isUnderPath(f.filePath, prefix)).map(toEntry).sort((a, b) => a.file_path.localeCompare(b.file_path));
3891
- return jsonResult({
3892
- project_id: args.project_id,
3893
- path: prefix,
3894
- trashed,
3895
- total: matches.length,
3896
- files: matches
3897
- });
3900
+ if (trashed) {
3901
+ const all2 = await ctx.client.listTrashedFiles(args.project_id);
3902
+ return windowedListResult(args.project_id, prefix, true, all2, limit, offset);
3903
+ }
3904
+ if (prefix === "") {
3905
+ const page = await ctx.client.listFiles(args.project_id, { limit, offset });
3906
+ const next = nextOffsetOf(offset, page.files.length, page.total);
3907
+ return jsonResult({
3908
+ project_id: args.project_id,
3909
+ path: prefix,
3910
+ trashed: false,
3911
+ files: page.files.map(toEntry),
3912
+ total: page.total,
3913
+ limit,
3914
+ offset,
3915
+ ...next === void 0 ? {} : { next_offset: next }
3916
+ });
3917
+ }
3918
+ const all = await listAllLiveFiles(ctx.client, args.project_id);
3919
+ return windowedListResult(args.project_id, prefix, false, all, limit, offset);
3898
3920
  }
3899
3921
  };
3922
+ function windowedListResult(projectId, prefix, trashed, files, limit, offset) {
3923
+ const matches = files.filter((f) => isUnderPath(f.filePath, prefix)).map(toEntry).sort((a, b) => a.file_path.localeCompare(b.file_path));
3924
+ const window = sliceWindow(matches, limit, offset);
3925
+ return jsonResult({
3926
+ project_id: projectId,
3927
+ path: prefix,
3928
+ trashed,
3929
+ files: window.items,
3930
+ total: window.total,
3931
+ limit: window.limit,
3932
+ offset,
3933
+ ...window.next_offset === void 0 ? {} : { next_offset: window.next_offset }
3934
+ });
3935
+ }
3900
3936
  async function listAllLiveFiles(client, projectId) {
3901
3937
  const all = [];
3902
3938
  let offset = 0;
@@ -3940,24 +3976,24 @@ function isUnderPath(filePath, prefix) {
3940
3976
 
3941
3977
  // src/server/tools/list_spec_sessions.ts
3942
3978
  import { z as z14 } from "zod";
3943
- var LIST_DEFAULT2 = 10;
3944
- var LIST_MAX2 = 20;
3979
+ var LIST_DEFAULT3 = 10;
3980
+ var LIST_MAX3 = 20;
3945
3981
  var inputSchema14 = {
3946
3982
  project_id: z14.string().min(1).describe("UUID of the project whose spec sessions to list."),
3947
3983
  archive_filter: z14.enum(["active", "archived", "all"]).optional().describe("Which sessions to list: 'active' (default), 'archived', or 'all'."),
3948
3984
  status: z14.enum(["generating", "pending_input", "completed"]).optional().describe("Filter by session status."),
3949
3985
  query: z14.string().optional().describe("Search term matched against the session summary."),
3950
3986
  limit: z14.number().int().positive().optional().describe(
3951
- `Max sessions to return (default ${String(LIST_DEFAULT2)}, capped at ${String(LIST_MAX2)}).`
3987
+ `Max sessions to return (default ${String(LIST_DEFAULT3)}, capped at ${String(LIST_MAX3)}).`
3952
3988
  ),
3953
3989
  offset: z14.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3954
3990
  };
3955
3991
  var listSpecSessionsTool = {
3956
3992
  name: "list_spec_sessions",
3957
- 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\`.`,
3993
+ 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_MAX3)} per page; returns \`total\` and \`next_offset\`.`,
3958
3994
  inputSchema: inputSchema14,
3959
3995
  handler: async (args, ctx) => {
3960
- const limit = clampLimit(args.limit, LIST_DEFAULT2, LIST_MAX2);
3996
+ const limit = clampLimit(args.limit, LIST_DEFAULT3, LIST_MAX3);
3961
3997
  const offset = args.offset ?? 0;
3962
3998
  const result = await ctx.client.listProjectSessions(args.project_id, {
3963
3999
  archiveFilter: args.archive_filter ?? "active",
@@ -4008,6 +4044,9 @@ var MAX_LINES_PER_CALL = 2e3;
4008
4044
  var MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
4009
4045
  function normalizeNewlines(text) {
4010
4046
  const withoutBom = text.startsWith("\uFEFF") ? text.slice(1) : text;
4047
+ if (!withoutBom.includes("\r")) {
4048
+ return withoutBom;
4049
+ }
4011
4050
  return withoutBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4012
4051
  }
4013
4052
  function splitIntoLines(text) {
@@ -4178,20 +4217,27 @@ var readAttachmentTool = {
4178
4217
  import { promises as fs3 } from "fs";
4179
4218
  import { createHash as createHash2 } from "crypto";
4180
4219
  import { z as z17 } from "zod";
4181
- var MAX_READ_BYTES = 1 * 1024 * 1024;
4220
+ var MAX_READABLE_SPEC_FILE_BYTES = 8 * 1024 * 1024;
4182
4221
  var inputSchema17 = {
4183
4222
  file_id: z17.string().min(1).describe("UUID of the file"),
4184
- revision: z17.number().int().positive().optional().describe("Specific revision number to read (defaults to the latest revision).")
4223
+ revision: z17.number().int().positive().optional().describe("Specific revision number to read (defaults to the latest revision)."),
4224
+ offset: z17.number().int().nonnegative().optional().describe("1-based line number to start reading from (default 1; 0 is an alias for 1)."),
4225
+ limit: z17.number().int().positive().optional().describe("Max lines to return (default 2000, capped at 2000; larger values are clamped).")
4185
4226
  };
4186
4227
  var readSpecFileTool = {
4187
4228
  name: "read_spec_file",
4188
- 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.",
4229
+ description: "Return a window of a spec file's UTF-8 content, paginated by line (offset/limit, default and cap 2000 lines, 1 MiB per response). Large files are read across multiple calls using `next_offset`. 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, so paging through a file downloads it once. Files above 8 MiB and binary content are refused \u2014 use download_spec_file to save those to a local file instead.",
4189
4230
  inputSchema: inputSchema17,
4190
4231
  handler: async (args, ctx) => {
4191
4232
  const meta = await ctx.client.getFileMetadata(args.file_id);
4192
4233
  const targetRevision = args.revision ?? meta.revisionCount;
4193
4234
  const isLatestRevision = targetRevision === meta.revisionCount;
4194
- if (isLatestRevision && meta.fileSizeBytes > MAX_READ_BYTES) {
4235
+ if (args.revision !== void 0 && args.revision > meta.revisionCount) {
4236
+ return errorResult(
4237
+ `revision=${String(args.revision)} is greater than revision_count=${String(meta.revisionCount)} for file ${args.file_id}.`
4238
+ );
4239
+ }
4240
+ if (isLatestRevision && meta.fileSizeBytes > MAX_READABLE_SPEC_FILE_BYTES) {
4195
4241
  return errorResult(buildOversizeMessage2(args.file_id, meta.fileSizeBytes));
4196
4242
  }
4197
4243
  const cacheRoot = resolveDownloadRoot();
@@ -4208,20 +4254,42 @@ var readSpecFileTool = {
4208
4254
  `read_spec_file refused to write cache: ${escape}. This indicates an unexpected platform response shape (projectId/fileId/filePath).`
4209
4255
  );
4210
4256
  }
4211
- const cached = await pathExists(cachePath) ? await fs3.readFile(cachePath) : null;
4257
+ const cachedSize = await fileSizeOrNull(cachePath);
4258
+ if (cachedSize !== null && cachedSize > MAX_READABLE_SPEC_FILE_BYTES) {
4259
+ return errorResult(buildOversizeMessage2(args.file_id, cachedSize));
4260
+ }
4261
+ const cached = cachedSize === null ? null : await fs3.readFile(cachePath);
4212
4262
  const cacheHit = cached !== null && (!isLatestRevision || sha256Prefixed2(cached) === meta.checksum);
4213
4263
  let bytes;
4264
+ let effectiveRevision = targetRevision;
4265
+ let effectiveCachePath = cachePath;
4214
4266
  if (cacheHit) {
4215
4267
  bytes = cached;
4216
4268
  } else {
4217
4269
  const downloaded = await ctx.client.downloadFile(args.file_id, args.revision);
4218
4270
  bytes = downloaded.bytes;
4219
- if (bytes.byteLength > MAX_READ_BYTES) {
4271
+ if (bytes.byteLength > MAX_READABLE_SPEC_FILE_BYTES) {
4220
4272
  return errorResult(buildOversizeMessage2(args.file_id, bytes.byteLength));
4221
4273
  }
4222
- await writeBytesAtomically(cachePath, bytes);
4274
+ if (downloaded.revisionNumber !== targetRevision) {
4275
+ effectiveRevision = downloaded.revisionNumber;
4276
+ effectiveCachePath = computeFileCachePath({
4277
+ cacheRoot,
4278
+ projectId: meta.projectId,
4279
+ fileId: meta.id,
4280
+ revision: effectiveRevision,
4281
+ filePath: meta.filePath
4282
+ });
4283
+ const raceEscape = assertWithinRoot(effectiveCachePath, cacheRoot);
4284
+ if (raceEscape) {
4285
+ return errorResult(
4286
+ `read_spec_file refused to write cache: ${raceEscape}. This indicates an unexpected platform response shape (projectId/fileId/filePath).`
4287
+ );
4288
+ }
4289
+ }
4290
+ await writeBytesAtomically(effectiveCachePath, bytes);
4223
4291
  }
4224
- if (bytes.byteLength > MAX_READ_BYTES) {
4292
+ if (bytes.byteLength > MAX_READABLE_SPEC_FILE_BYTES) {
4225
4293
  return errorResult(buildOversizeMessage2(args.file_id, bytes.byteLength));
4226
4294
  }
4227
4295
  if (isBinary2(bytes)) {
@@ -4237,25 +4305,44 @@ var readSpecFileTool = {
4237
4305
  `File ${args.file_id} is not valid UTF-8 text. Use download_spec_file to save it to a local file.`
4238
4306
  );
4239
4307
  }
4308
+ const paginated = paginateText(text, args.offset, args.limit);
4309
+ if (!paginated.ok) {
4310
+ return errorResult(paginated.error);
4311
+ }
4240
4312
  return jsonResult({
4241
4313
  file_id: meta.id,
4242
- revision_number: targetRevision,
4314
+ revision_number: effectiveRevision,
4243
4315
  // Pass this back as update_spec_file's expected_version so an edit made by
4244
4316
  // someone else since this read is reported instead of overwritten. It
4245
4317
  // describes the file's CURRENT content, so it is emitted only when that is
4246
4318
  // what was read — beside a historical revision it would invite an update
4247
4319
  // that overwrites everything written since.
4248
- ...isLatestRevision && meta.contentVersion !== void 0 ? { content_version: meta.contentVersion } : {},
4320
+ // ...and only when it describes THESE bytes. If a concurrent save moved
4321
+ // the revision under us, meta.contentVersion belongs to the older one:
4322
+ // a stale token is fail-safe (the platform answers 409) but it would
4323
+ // cost the model a round trip it cannot diagnose, so omit it and let the
4324
+ // re-read supply a current one.
4325
+ ...isLatestRevision && effectiveRevision === targetRevision && meta.contentVersion !== void 0 ? { content_version: meta.contentVersion } : {},
4249
4326
  file_path: meta.filePath,
4250
4327
  file_type: meta.fileType,
4251
- cache_path: maskHomedir(cachePath),
4328
+ cache_path: maskHomedir(effectiveCachePath),
4252
4329
  cache_hit: cacheHit,
4253
- content: text
4330
+ // `content` plus the window fields (start_line/end_line/read_lines/
4331
+ // total_lines/truncated/next_offset) — the same shape read_attachment
4332
+ // and mcp-server-http's read_spec_file return.
4333
+ ...paginated.window
4254
4334
  });
4255
4335
  }
4256
4336
  };
4257
4337
  function buildOversizeMessage2(fileId, byteLength) {
4258
- 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.`;
4338
+ return `File ${fileId} is ${String(byteLength)} bytes which exceeds the read cap of ${String(MAX_READABLE_SPEC_FILE_BYTES)} bytes. Use download_spec_file to save it to a local file and read it in chunks.`;
4339
+ }
4340
+ async function fileSizeOrNull(filePath) {
4341
+ try {
4342
+ return (await fs3.stat(filePath)).size;
4343
+ } catch {
4344
+ return null;
4345
+ }
4259
4346
  }
4260
4347
  function isBinary2(bytes) {
4261
4348
  for (const byte of bytes) {
@@ -4965,11 +5052,11 @@ function artifactCreateConflictMessage(conflict) {
4965
5052
  }
4966
5053
 
4967
5054
  // src/server/tools/list_artifacts.ts
4968
- var LIST_DEFAULT3 = 50;
4969
- var LIST_MAX3 = 100;
5055
+ var LIST_DEFAULT4 = 50;
5056
+ var LIST_MAX4 = 100;
4970
5057
  var inputSchema26 = {
4971
5058
  project_id: z27.string().min(1).describe("UUID of the project whose artifacts to list."),
4972
- limit: z27.number().int().positive().optional().describe(`Max artifacts to return (default ${String(LIST_DEFAULT3)}, capped at ${String(LIST_MAX3)}).`),
5059
+ limit: z27.number().int().positive().optional().describe(`Max artifacts to return (default ${String(LIST_DEFAULT4)}, capped at ${String(LIST_MAX4)}).`),
4973
5060
  offset: z27.number().int().nonnegative().optional().describe("Pagination offset (default 0).")
4974
5061
  };
4975
5062
  var listArtifactsTool = {
@@ -4977,7 +5064,7 @@ var listArtifactsTool = {
4977
5064
  description: "List a project's live artifacts (multi-file bundles such as HTML mockups, each versioned as one snapshot) with id, slug, entry path and revision_count. Returns `total` and, when more remain, `next_offset`. Use get_artifact for the file manifest and read_artifact_file for content.",
4978
5065
  inputSchema: inputSchema26,
4979
5066
  handler: async (args, ctx) => {
4980
- const limit = clampLimit(args.limit, LIST_DEFAULT3, LIST_MAX3);
5067
+ const limit = clampLimit(args.limit, LIST_DEFAULT4, LIST_MAX4);
4981
5068
  const offset = args.offset ?? 0;
4982
5069
  const page = await ctx.client.listArtifacts(args.project_id, { limit, offset });
4983
5070
  const next = nextOffsetOf(offset, page.items.length, page.total);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.4.0-next.107",
3
+ "version": "0.4.0-next.108",
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": {