@myspec/mcp-server 0.2.0-next.81 → 0.2.0-next.82

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 +34 -1
  2. package/dist/index.js +106 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -237,13 +237,46 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
237
237
  | `get_project` | `project_id` | Project + active sessions + files + attachments |
238
238
  | `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` |
239
239
  | `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` |
240
- | `download_spec_file` | `file_id`, `destination_path`, optional `revision`, optional `overwrite` | Downloads the file's raw bytes to `destination_path` (absolute or cwd-relative; an existing directory gets the basename appended). 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`) — **not** the content, so a large file can be saved locally and read in chunks |
240
+ | `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 |
241
241
  | `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 |
242
242
  | `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 |
243
243
  | `restore_spec_file_from_trash` | `file_id` | Restores a trashed spec file back to the active file list, reversing `move_spec_file_to_trash` |
244
244
  | `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 |
245
245
  | `upload_attachment` | `project_id`, `file_path` (absolute), optional `file_name`, optional `mime_type`, optional `override` | Uploads the local file as a new attachment on the project. Returns `attachment_id`, `file_name` (the **effective** name the platform stored — may differ from the requested one when auto-dedup adds a `(N)` suffix), `has_file_name_conflict` (`true` when stored name ≠ requested name), `mime_type`, `file_size_bytes`, `checksum` (format: `sha256:<hex>`), `file_uri`, `reused_existing_attachment` (`true` when an existing attachment with the same name AND same checksum was found and returned without re-uploading; `file_uri` is then omitted because the list endpoint does not surface it — call `get_attachment` for a download URL). With `override=true`, any existing attachment whose recorded name matches is soft-deleted first (returned as `overridden_attachment_ids` — empty array means nothing matched) and the new upload keeps the requested name; the same-checksum short-circuit still applies, so a no-op re-upload never deletes anything. Max 10 MB. Supported content: PDF, DOCX, XLSX, and any UTF-8 text file (XML, JSON, YAML, HTML, Markdown, CSV, source code, ...) |
246
246
 
247
+ ### Download destinations
248
+
249
+ `download_spec_file` prefers the **`.specs/` folder in the MCP server's working
250
+ directory** — dot-prefixed because it is conventionally gitignored, so a
251
+ downloaded copy never shows up as untracked noise beside a repo's own `specs/`.
252
+
253
+ - **`destination_path` omitted** → the file's remote path is mirrored under
254
+ `.specs/`, with the remote `specs/` root replaced:
255
+ `specs/add-oauth/proposal.md` → `.specs/add-oauth/proposal.md`. Any other root
256
+ is kept beneath `.specs/` (`openspec/changes/add-x/spec.md` →
257
+ `.specs/openspec/changes/add-x/spec.md`), so every download lands in one folder.
258
+ That includes the legacy `.spec/` prefix on old files, which nests as
259
+ `.specs/.spec/…` — the platform has required `specs/` or `openspec/` since,
260
+ so only pre-existing files land there.
261
+ - **Relative `destination_path` rooted at `specs/`** → redirected to `.specs/`.
262
+ This is the remote path echoed back, and it would otherwise write into the
263
+ repo's own spec folder.
264
+ - **Anything else** → honored exactly as given. An absolute path is the escape
265
+ hatch for saving into a literal `specs/` directory.
266
+
267
+ The response reports which of the three applied as `destination_source`
268
+ (`default` / `redirected` / `explicit`), plus a `note` for the first two, so a
269
+ caller that passed `specs/...` and got `.specs/...` can see why.
270
+
271
+ A `destination_path` that names a directory — it exists as one, or ends with a
272
+ path separator — gets the spec file's basename appended. Across a redirect both
273
+ the path as passed and the rewritten one are consulted, so whichever exists as a
274
+ directory wins: `specs/add-oauth/`, an existing `specs/add-oauth/`, and an
275
+ existing `.specs/add-oauth/` all land at `.specs/add-oauth/<basename>` rather
276
+ than at a file named `.specs/add-oauth`. A bare `specs` is always the folder.
277
+ Derived paths (default and redirected) are confined to `.specs/` and may not be
278
+ `.specs/` itself; an explicit `destination_path` is not sandboxed.
279
+
247
280
  ### `include_download_url` semantics
248
281
 
249
282
  `get_spec_file`:
package/dist/index.js CHANGED
@@ -2632,11 +2632,37 @@ function resolveDownloadRoot(env = process.env) {
2632
2632
  }
2633
2633
  return path2.join(os2.homedir(), ".myspec");
2634
2634
  }
2635
+ var LOCAL_SPEC_DIR = ".specs";
2636
+ var REMOTE_SPEC_ROOT = "specs";
2637
+ function defaultSpecDestination(remoteFilePath) {
2638
+ const segments = remoteFilePath.split("/").filter((s) => s !== "" && s !== ".");
2639
+ if (segments[0] === REMOTE_SPEC_ROOT) {
2640
+ segments.shift();
2641
+ }
2642
+ if (segments.length === 0) {
2643
+ segments.push("content");
2644
+ }
2645
+ return path2.join(LOCAL_SPEC_DIR, ...segments);
2646
+ }
2647
+ function redirectSpecRootToLocal(destPath) {
2648
+ if (path2.isAbsolute(destPath)) {
2649
+ return null;
2650
+ }
2651
+ const segments = path2.normalize(destPath).split(path2.sep);
2652
+ if (segments[0] !== REMOTE_SPEC_ROOT) {
2653
+ return null;
2654
+ }
2655
+ if (segments.length === 1) {
2656
+ return LOCAL_SPEC_DIR + path2.sep;
2657
+ }
2658
+ segments[0] = LOCAL_SPEC_DIR;
2659
+ return segments.join(path2.sep);
2660
+ }
2635
2661
  function assertWithinRoot(target, root) {
2636
2662
  const resolvedTarget = path2.resolve(target);
2637
2663
  const resolvedRoot = path2.resolve(root);
2638
2664
  const rel = path2.relative(resolvedRoot, resolvedTarget);
2639
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
2665
+ if (rel === ".." || rel.startsWith(".." + path2.sep) || path2.isAbsolute(rel)) {
2640
2666
  return `path resolves outside the configured root (${resolvedRoot}): ${resolvedTarget}`;
2641
2667
  }
2642
2668
  return null;
@@ -2667,20 +2693,25 @@ function maskHomedir(p, home = os2.homedir()) {
2667
2693
  return p;
2668
2694
  }
2669
2695
  async function resolveDestinationFile(destPath, basename4) {
2670
- const absPath = path2.isAbsolute(destPath) ? path2.resolve(destPath) : path2.resolve(process.cwd(), destPath);
2671
- const endsWithSep = destPath.endsWith(path2.sep) || destPath.endsWith("/");
2672
- let isDir = endsWithSep;
2673
- if (!isDir) {
2674
- try {
2675
- const info = await fs2.stat(absPath);
2676
- isDir = info.isDirectory();
2677
- } catch (err) {
2678
- if (!isFsNotFound2(err)) {
2679
- throw err;
2680
- }
2696
+ const absPath = toAbsoluteDestination(destPath);
2697
+ return await isDirectoryDestination(destPath) ? path2.join(absPath, basename4) : absPath;
2698
+ }
2699
+ async function isDirectoryDestination(destPath) {
2700
+ if (destPath.endsWith(path2.sep) || destPath.endsWith("/")) {
2701
+ return true;
2702
+ }
2703
+ try {
2704
+ const info = await fs2.stat(toAbsoluteDestination(destPath));
2705
+ return info.isDirectory();
2706
+ } catch (err) {
2707
+ if (!isFsNotFound2(err)) {
2708
+ throw err;
2681
2709
  }
2710
+ return false;
2682
2711
  }
2683
- return isDir ? path2.join(absPath, basename4) : absPath;
2712
+ }
2713
+ function toAbsoluteDestination(destPath) {
2714
+ return path2.isAbsolute(destPath) ? path2.resolve(destPath) : path2.resolve(process.cwd(), destPath);
2684
2715
  }
2685
2716
  async function pathExists(p) {
2686
2717
  try {
@@ -2721,14 +2752,14 @@ var MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
2721
2752
  var inputSchema4 = {
2722
2753
  file_id: z4.string().min(1).describe("UUID of the file"),
2723
2754
  revision: z4.number().int().positive().optional().describe("Specific revision number to download (defaults to the latest revision)."),
2724
- destination_path: z4.string().min(1).describe(
2725
- "Local path to save the file to. Absolute, or relative to the MCP server's working directory. If it points to an existing directory (or ends with a path separator), the spec file's basename is appended."
2755
+ destination_path: z4.string().min(1).optional().describe(
2756
+ "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."
2726
2757
  ),
2727
2758
  overwrite: z4.boolean().optional().describe("Overwrite the destination file if it already exists (default: false).")
2728
2759
  };
2729
2760
  var downloadSpecFileTool = {
2730
2761
  name: "download_spec_file",
2731
- description: "Download a spec file's raw bytes to a local file at `destination_path`. 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.",
2762
+ 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.",
2732
2763
  inputSchema: inputSchema4,
2733
2764
  handler: async (args, ctx) => {
2734
2765
  const meta = await ctx.client.getFileMetadata(args.file_id);
@@ -2737,12 +2768,35 @@ var downloadSpecFileTool = {
2737
2768
  return errorResult(buildOversizeMessage(args.file_id, meta.fileSizeBytes));
2738
2769
  }
2739
2770
  const basename4 = path3.basename(meta.filePath) || "content";
2771
+ const requested = args.destination_path;
2772
+ let redirected = null;
2740
2773
  let destFile;
2774
+ let destination = requested ?? "";
2741
2775
  try {
2742
- destFile = await resolveDestinationFile(args.destination_path, basename4);
2776
+ if (requested === void 0) {
2777
+ destination = defaultSpecDestination(meta.filePath);
2778
+ } else {
2779
+ redirected = await carryDirectoryIntent(requested, redirectSpecRootToLocal(requested));
2780
+ destination = redirected ?? requested;
2781
+ }
2782
+ destFile = await resolveDestinationFile(destination, basename4);
2743
2783
  } catch (err) {
2744
2784
  const message = err instanceof Error ? err.message : String(err);
2745
- return errorResult(`Failed to resolve destination_path ${args.destination_path}: ${message}`);
2785
+ return errorResult(`Failed to resolve destination_path ${maskHomedir(destination)}: ${message}`);
2786
+ }
2787
+ if (requested === void 0 || redirected !== null) {
2788
+ const specRoot = path3.resolve(process.cwd(), LOCAL_SPEC_DIR);
2789
+ const hint = requested === void 0 ? "Pass a destination_path to save elsewhere." : "Pass an absolute path to save into a literal specs/ directory.";
2790
+ if (assertWithinRoot(destFile, specRoot) !== null) {
2791
+ return errorResult(
2792
+ `download_spec_file refused to write outside ${LOCAL_SPEC_DIR}/ (${maskHomedir(specRoot)}): ${maskHomedir(destFile)}. ${hint}`
2793
+ );
2794
+ }
2795
+ if (path3.resolve(destFile) === specRoot) {
2796
+ return errorResult(
2797
+ `download_spec_file refused to write ${maskHomedir(destFile)}: that is the ${LOCAL_SPEC_DIR}/ folder itself, not a file inside it. ${hint}`
2798
+ );
2799
+ }
2746
2800
  }
2747
2801
  if (!args.overwrite && await pathExists(destFile)) {
2748
2802
  return errorResult(
@@ -2760,16 +2814,45 @@ var downloadSpecFileTool = {
2760
2814
  const message = err instanceof Error ? err.message : String(err);
2761
2815
  return errorResult(`Failed to write ${maskHomedir(destFile)}: ${message}`);
2762
2816
  }
2817
+ const source = requested === void 0 ? "default" : redirected !== null ? "redirected" : "explicit";
2763
2818
  return jsonResult({
2764
2819
  file_id: meta.id,
2765
2820
  revision_number: downloaded.revisionNumber,
2766
2821
  file_path: meta.filePath,
2767
2822
  file_type: meta.fileType,
2768
2823
  saved_path: maskHomedir(destFile),
2769
- bytes_written: bytes.byteLength
2824
+ bytes_written: bytes.byteLength,
2825
+ // Say where the path came from, so a caller that passed `specs/...` and got
2826
+ // `.specs/...` back can see why rather than hunting for a missing file.
2827
+ destination_source: source,
2828
+ ...source === "explicit" ? {} : { note: buildDestinationNote(source, requested) }
2770
2829
  });
2771
2830
  }
2772
2831
  };
2832
+ async function carryDirectoryIntent(requested, rewritten) {
2833
+ if (rewritten === null || rewritten.endsWith(path3.sep)) {
2834
+ return rewritten;
2835
+ }
2836
+ let isDir;
2837
+ try {
2838
+ isDir = await isDirectoryDestination(requested);
2839
+ } catch (err) {
2840
+ if (!isFilesystemError(err)) {
2841
+ throw err;
2842
+ }
2843
+ isDir = false;
2844
+ }
2845
+ return isDir ? rewritten + path3.sep : rewritten;
2846
+ }
2847
+ function isFilesystemError(err) {
2848
+ return typeof err === "object" && err !== null && typeof err.code === "string";
2849
+ }
2850
+ function buildDestinationNote(source, requested) {
2851
+ if (source === "default") {
2852
+ return `destination_path was omitted, so the file was saved under the preferred ${LOCAL_SPEC_DIR}/ folder in the working directory.`;
2853
+ }
2854
+ return `destination_path ${String(requested)} was rooted at specs/ and was redirected to the preferred ${LOCAL_SPEC_DIR}/ folder. Pass an absolute path to save into a literal specs/ directory.`;
2855
+ }
2773
2856
  function buildOversizeMessage(fileId, byteLength) {
2774
2857
  return `File ${fileId} is ${String(byteLength)} bytes which exceeds the download cap of ${String(MAX_DOWNLOAD_BYTES)} bytes.`;
2775
2858
  }
@@ -3049,7 +3132,7 @@ var inputSchema11 = {
3049
3132
  };
3050
3133
  var readSpecFileTool = {
3051
3134
  name: "read_spec_file",
3052
- 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 with a `destination_path` to save those to a local file instead.",
3135
+ 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.",
3053
3136
  inputSchema: inputSchema11,
3054
3137
  handler: async (args, ctx) => {
3055
3138
  const meta = await ctx.client.getFileMetadata(args.file_id);
@@ -3090,7 +3173,7 @@ var readSpecFileTool = {
3090
3173
  }
3091
3174
  if (isBinary(bytes)) {
3092
3175
  return errorResult(
3093
- `File ${args.file_id} appears to be binary (file_type=${meta.fileType}, size=${String(bytes.byteLength)} bytes). Use download_spec_file with a \`destination_path\` to save it to a local file.`
3176
+ `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.`
3094
3177
  );
3095
3178
  }
3096
3179
  let text;
@@ -3098,7 +3181,7 @@ var readSpecFileTool = {
3098
3181
  text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
3099
3182
  } catch {
3100
3183
  return errorResult(
3101
- `File ${args.file_id} is not valid UTF-8 text. Use download_spec_file with a \`destination_path\` to save it to a local file.`
3184
+ `File ${args.file_id} is not valid UTF-8 text. Use download_spec_file to save it to a local file.`
3102
3185
  );
3103
3186
  }
3104
3187
  return jsonResult({
@@ -3119,7 +3202,7 @@ var readSpecFileTool = {
3119
3202
  }
3120
3203
  };
3121
3204
  function buildOversizeMessage2(fileId, byteLength) {
3122
- return `File ${fileId} is ${String(byteLength)} bytes which exceeds the read cap of ${String(MAX_READ_BYTES)} bytes. Use download_spec_file with a \`destination_path\` to save it to a local file and read it in chunks.`;
3205
+ 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.`;
3123
3206
  }
3124
3207
  function isBinary(bytes) {
3125
3208
  for (const byte of bytes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.2.0-next.81",
3
+ "version": "0.2.0-next.82",
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": {