@myspec/mcp-server 0.2.0-next.80 → 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 +38 -1
  2. package/dist/index.js +121 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -64,6 +64,10 @@ value, including the dot** and paste it back into the terminal. Two traps:
64
64
  without a person present (a server, a container, a CI job, a shared machine),
65
65
  use an API token instead.
66
66
 
67
+ > Needs **`@myspec/mcp-server` 0.2.0 or newer** (`npm view @myspec/mcp-server
68
+ > version`). Earlier builds ignore the `apiToken` field and report that they are
69
+ > not signed in, without mentioning the version.
70
+
67
71
  **1. Create the token.** In the MySpec webapp, open the avatar menu → **API
68
72
  tokens** → **Create token**. Choose the organization it should act in, whether
69
73
  it may write or only read, and an expiry. The token is shown **once**; it
@@ -233,13 +237,46 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
233
237
  | `get_project` | `project_id` | Project + active sessions + files + attachments |
234
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` |
235
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` |
236
- | `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 |
237
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 |
238
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 |
239
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` |
240
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 |
241
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, ...) |
242
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
+
243
280
  ### `include_download_url` semantics
244
281
 
245
282
  `get_spec_file`:
package/dist/index.js CHANGED
@@ -565,12 +565,20 @@ var TokenManager = class {
565
565
  this.apiTokenRejection = rejection;
566
566
  throw rejection;
567
567
  }
568
+ if (response.status === 404) {
569
+ const body = await response.text().catch(() => "");
570
+ throw new HttpStatusError(
571
+ response.status,
572
+ body,
573
+ `Token exchange failed: ${url} returned 404, so no exchange endpoint is there. This URL comes from userAuthUrl in ~/.myspec/settings.json \u2014 check it names the environment the token was created in, and rewrite it with \`npx @myspec/mcp-server login --user-auth-url <url>\`. Note that --user-auth-url and MYSPEC_USER_AUTH_URL do not redirect the exchange on their own, and userAuthUrl in oauth_creds.json is ignored entirely.`
574
+ );
575
+ }
568
576
  if (!response.ok) {
569
577
  const body = await response.text().catch(() => "");
570
578
  throw new HttpStatusError(
571
579
  response.status,
572
580
  body,
573
- `Token exchange failed: HTTP ${String(response.status)}`
581
+ `Token exchange failed: HTTP ${String(response.status)} from ${url}`
574
582
  );
575
583
  }
576
584
  const payload = await response.json().catch(() => null);
@@ -2624,11 +2632,37 @@ function resolveDownloadRoot(env = process.env) {
2624
2632
  }
2625
2633
  return path2.join(os2.homedir(), ".myspec");
2626
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
+ }
2627
2661
  function assertWithinRoot(target, root) {
2628
2662
  const resolvedTarget = path2.resolve(target);
2629
2663
  const resolvedRoot = path2.resolve(root);
2630
2664
  const rel = path2.relative(resolvedRoot, resolvedTarget);
2631
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
2665
+ if (rel === ".." || rel.startsWith(".." + path2.sep) || path2.isAbsolute(rel)) {
2632
2666
  return `path resolves outside the configured root (${resolvedRoot}): ${resolvedTarget}`;
2633
2667
  }
2634
2668
  return null;
@@ -2659,20 +2693,25 @@ function maskHomedir(p, home = os2.homedir()) {
2659
2693
  return p;
2660
2694
  }
2661
2695
  async function resolveDestinationFile(destPath, basename4) {
2662
- const absPath = path2.isAbsolute(destPath) ? path2.resolve(destPath) : path2.resolve(process.cwd(), destPath);
2663
- const endsWithSep = destPath.endsWith(path2.sep) || destPath.endsWith("/");
2664
- let isDir = endsWithSep;
2665
- if (!isDir) {
2666
- try {
2667
- const info = await fs2.stat(absPath);
2668
- isDir = info.isDirectory();
2669
- } catch (err) {
2670
- if (!isFsNotFound2(err)) {
2671
- throw err;
2672
- }
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;
2673
2709
  }
2710
+ return false;
2674
2711
  }
2675
- 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);
2676
2715
  }
2677
2716
  async function pathExists(p) {
2678
2717
  try {
@@ -2713,14 +2752,14 @@ var MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
2713
2752
  var inputSchema4 = {
2714
2753
  file_id: z4.string().min(1).describe("UUID of the file"),
2715
2754
  revision: z4.number().int().positive().optional().describe("Specific revision number to download (defaults to the latest revision)."),
2716
- destination_path: z4.string().min(1).describe(
2717
- "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."
2718
2757
  ),
2719
2758
  overwrite: z4.boolean().optional().describe("Overwrite the destination file if it already exists (default: false).")
2720
2759
  };
2721
2760
  var downloadSpecFileTool = {
2722
2761
  name: "download_spec_file",
2723
- 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.",
2724
2763
  inputSchema: inputSchema4,
2725
2764
  handler: async (args, ctx) => {
2726
2765
  const meta = await ctx.client.getFileMetadata(args.file_id);
@@ -2729,12 +2768,35 @@ var downloadSpecFileTool = {
2729
2768
  return errorResult(buildOversizeMessage(args.file_id, meta.fileSizeBytes));
2730
2769
  }
2731
2770
  const basename4 = path3.basename(meta.filePath) || "content";
2771
+ const requested = args.destination_path;
2772
+ let redirected = null;
2732
2773
  let destFile;
2774
+ let destination = requested ?? "";
2733
2775
  try {
2734
- 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);
2735
2783
  } catch (err) {
2736
2784
  const message = err instanceof Error ? err.message : String(err);
2737
- 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
+ }
2738
2800
  }
2739
2801
  if (!args.overwrite && await pathExists(destFile)) {
2740
2802
  return errorResult(
@@ -2752,16 +2814,45 @@ var downloadSpecFileTool = {
2752
2814
  const message = err instanceof Error ? err.message : String(err);
2753
2815
  return errorResult(`Failed to write ${maskHomedir(destFile)}: ${message}`);
2754
2816
  }
2817
+ const source = requested === void 0 ? "default" : redirected !== null ? "redirected" : "explicit";
2755
2818
  return jsonResult({
2756
2819
  file_id: meta.id,
2757
2820
  revision_number: downloaded.revisionNumber,
2758
2821
  file_path: meta.filePath,
2759
2822
  file_type: meta.fileType,
2760
2823
  saved_path: maskHomedir(destFile),
2761
- 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) }
2762
2829
  });
2763
2830
  }
2764
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
+ }
2765
2856
  function buildOversizeMessage(fileId, byteLength) {
2766
2857
  return `File ${fileId} is ${String(byteLength)} bytes which exceeds the download cap of ${String(MAX_DOWNLOAD_BYTES)} bytes.`;
2767
2858
  }
@@ -3041,7 +3132,7 @@ var inputSchema11 = {
3041
3132
  };
3042
3133
  var readSpecFileTool = {
3043
3134
  name: "read_spec_file",
3044
- 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.",
3045
3136
  inputSchema: inputSchema11,
3046
3137
  handler: async (args, ctx) => {
3047
3138
  const meta = await ctx.client.getFileMetadata(args.file_id);
@@ -3082,7 +3173,7 @@ var readSpecFileTool = {
3082
3173
  }
3083
3174
  if (isBinary(bytes)) {
3084
3175
  return errorResult(
3085
- `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.`
3086
3177
  );
3087
3178
  }
3088
3179
  let text;
@@ -3090,7 +3181,7 @@ var readSpecFileTool = {
3090
3181
  text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
3091
3182
  } catch {
3092
3183
  return errorResult(
3093
- `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.`
3094
3185
  );
3095
3186
  }
3096
3187
  return jsonResult({
@@ -3111,7 +3202,7 @@ var readSpecFileTool = {
3111
3202
  }
3112
3203
  };
3113
3204
  function buildOversizeMessage2(fileId, byteLength) {
3114
- 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.`;
3115
3206
  }
3116
3207
  function isBinary(bytes) {
3117
3208
  for (const byte of bytes) {
@@ -5057,13 +5148,16 @@ function printHelp() {
5057
5148
  " login --org <slug> Sign in and pin that organization. Tokens only work",
5058
5149
  " when an organization is active; the CLI asks when you",
5059
5150
  " belong to several and this flag is not given.",
5060
- " logout Revoke refresh token and clear local credentials",
5151
+ " logout Sign out and clear the session credentials. A",
5152
+ " configured apiToken is KEPT, so an unattended",
5153
+ " install keeps working.",
5061
5154
  " reverse --root <dir> Connect to ai-agent and expose local_fs tools.",
5062
5155
  " The agent WebSocket URL is discovered from the webapp",
5063
5156
  " (GET /api/v1/config) using your saved credentials;",
5064
5157
  " override with --agent-url <url> or MYSPEC_AI_AGENT_WS_URL.",
5065
- " Uses the saved refresh_token from `npx @myspec/mcp-server login`",
5066
- " to obtain & auto-refresh access tokens. Override with",
5158
+ " Uses the apiToken from ~/.myspec/oauth_creds.json if one",
5159
+ " is set, otherwise the refresh_token saved by `login`, to",
5160
+ " obtain & auto-refresh access tokens. Override with",
5067
5161
  " --access-token <jwt> or MYSPEC_ACCESS_TOKEN for local testing.",
5068
5162
  " --version Print version",
5069
5163
  " --help Print this help",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.2.0-next.80",
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": {