@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.
- package/README.md +34 -1
- package/dist/index.js +106 -23
- 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
|
|
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 =
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": {
|