@myspec/mcp-server 0.4.0-next.106 → 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 +8 -2
  2. package/dist/index.js +771 -63
  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 |
@@ -306,6 +306,12 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
306
306
  | `archive_spec_session` | `session_id` | Archives the session (idempotent — re-archiving keeps the original `archived_at`); the required confirmation step before `delete_spec_session` |
307
307
  | `unarchive_spec_session` | `session_id` | Returns an archived session to the active list |
308
308
  | `delete_spec_session` | `session_id` | Soft-deletes an ARCHIVED session. Refuses non-archived sessions with an archive-first error and never archives on the caller's behalf |
309
+ | `list_artifacts` | `project_id`, optional `limit`, optional `offset` | Paginated list of a project's live artifacts — multi-file bundles (an HTML mockup: `index.html` + `styles.css` + `app.js` + assets) the platform versions as ONE snapshot. Each entry carries `revision_count`, the head number and the `expected_version` to pass when writing |
310
+ | `get_artifact` | `artifact_id`, optional `revision` | Artifact metadata plus the file manifest (path, content type, size, checksum) of the head revision, or of `revision` |
311
+ | `read_artifact_file` | `artifact_id`, `path`, optional `revision`, optional `offset`, optional `limit` | One file's UTF-8 content paginated by line (same `content`/`total_lines`/`truncated`/`next_offset` shape as `read_attachment`); binary files are refused |
312
+ | `create_artifact` | `project_id`, `name`, `files[]` (`path`, `content`, optional `encoding` utf8/base64, optional `content_type`), optional `slug`, `description`, `entry_path`, `message` | Creates the artifact with revision 1. Limits: 50 files, 2 MiB per file, 10 MiB per revision. A taken slug is a conflict |
313
+ | `write_artifact_revision` | `artifact_id`, `files[]`, optional `base_revision` (patch mode), `remove[]`, `expected_version`, `message`, `entry_path` | Appends an immutable revision. Full mode replaces the whole bundle; patch mode (with `base_revision`) sends only changed files plus paths to `remove`. A stale `expected_version` is reported as a conflict naming the new head — re-read with `get_artifact` and retry |
314
+ | `rollback_artifact` | `artifact_id`, `revision_number`, optional `expected_version`, `message` | Restores an earlier revision by APPENDING a revision with its files (`git revert` semantics); history is never rewritten, but only the newest N revisions are retained (50 by default, `ARTIFACT_MAX_REVISIONS` on the platform), so a pruned target answers not found |
309
315
 
310
316
  ### Download destinations
311
317
 
package/dist/index.js CHANGED
@@ -1349,7 +1349,10 @@ var KNOWN_CONFLICT_REASONS = [
1349
1349
  "revision_mismatch",
1350
1350
  "path_taken",
1351
1351
  "precondition_required",
1352
- "precondition_malformed"
1352
+ "precondition_malformed",
1353
+ "slug_taken",
1354
+ "blob_reclaimed",
1355
+ "lock_timeout"
1353
1356
  ];
1354
1357
  var PlatformHttpError = class extends Error {
1355
1358
  status;
@@ -2436,6 +2439,283 @@ var HttpSpecSessionClient = class {
2436
2439
  }
2437
2440
  };
2438
2441
 
2442
+ // ../../packages/platform-client/artifact/types/artifact.types.ts
2443
+ function transformArtifactResponse(raw) {
2444
+ return {
2445
+ id: raw.id,
2446
+ projectId: raw.project_id,
2447
+ org: raw.org,
2448
+ sessionId: raw.session_id ?? null,
2449
+ kind: raw.kind,
2450
+ name: raw.name,
2451
+ slug: raw.slug,
2452
+ description: raw.description ?? "",
2453
+ entryPath: raw.entry_path,
2454
+ revisionCount: raw.revision_count,
2455
+ createdBy: raw.created_by,
2456
+ createdAt: new Date(raw.created_at),
2457
+ updatedAt: new Date(raw.updated_at),
2458
+ trashedAt: raw.trashed_at ? new Date(raw.trashed_at) : void 0,
2459
+ deletedAt: raw.deleted_at ? new Date(raw.deleted_at) : void 0
2460
+ };
2461
+ }
2462
+ function transformArtifactRevisionResponse(raw) {
2463
+ return {
2464
+ id: raw.id,
2465
+ artifactId: raw.artifact_id,
2466
+ revisionNumber: raw.revision_number,
2467
+ source: raw.source,
2468
+ restoredFromRevision: raw.restored_from_revision,
2469
+ message: raw.message ?? "",
2470
+ changedBy: raw.changed_by,
2471
+ changedByKind: raw.changed_by_kind === "ai" ? "ai" : "human",
2472
+ sessionId: raw.session_id ?? null,
2473
+ fileCount: raw.file_count,
2474
+ totalBytes: raw.total_bytes,
2475
+ manifestChecksum: raw.manifest_checksum,
2476
+ createdAt: new Date(raw.created_at)
2477
+ };
2478
+ }
2479
+ function transformArtifactFileEntry(raw) {
2480
+ return {
2481
+ path: raw.path,
2482
+ contentType: raw.content_type,
2483
+ sizeBytes: raw.size_bytes,
2484
+ checksum: raw.checksum
2485
+ };
2486
+ }
2487
+ function transformArtifactRevisionDetail(raw) {
2488
+ return {
2489
+ ...transformArtifactRevisionResponse(raw),
2490
+ files: (raw.files ?? []).map(transformArtifactFileEntry)
2491
+ };
2492
+ }
2493
+ function transformArtifactDetail(raw) {
2494
+ return {
2495
+ artifact: transformArtifactResponse(raw.artifact),
2496
+ head: transformArtifactRevisionDetail(raw.head)
2497
+ };
2498
+ }
2499
+ function transformArtifactWriteResult(raw) {
2500
+ return {
2501
+ artifact: transformArtifactResponse(raw.artifact),
2502
+ revision: transformArtifactRevisionDetail(raw.revision),
2503
+ noChange: raw.no_change === true
2504
+ };
2505
+ }
2506
+ function transformArtifactListResult(raw) {
2507
+ return {
2508
+ items: (raw.items ?? []).map(transformArtifactResponse),
2509
+ total: raw.total,
2510
+ limit: raw.limit,
2511
+ offset: raw.offset
2512
+ };
2513
+ }
2514
+ function transformArtifactRevisionListResult(raw) {
2515
+ return {
2516
+ items: (raw.items ?? []).map(transformArtifactRevisionResponse),
2517
+ total: raw.total,
2518
+ limit: raw.limit,
2519
+ offset: raw.offset
2520
+ };
2521
+ }
2522
+ function buildFilesBody(files) {
2523
+ return files.map((f) => {
2524
+ const body = { path: f.path, content: f.content };
2525
+ if (f.encoding) {
2526
+ body.encoding = f.encoding;
2527
+ }
2528
+ if (f.contentType) {
2529
+ body.content_type = f.contentType;
2530
+ }
2531
+ return body;
2532
+ });
2533
+ }
2534
+ function buildCreateArtifactBody(request) {
2535
+ const body = {
2536
+ name: request.name,
2537
+ files: buildFilesBody(request.files)
2538
+ };
2539
+ if (request.slug) body.slug = request.slug;
2540
+ if (request.kind) body.kind = request.kind;
2541
+ if (request.description !== void 0) body.description = request.description;
2542
+ if (request.entryPath) body.entry_path = request.entryPath;
2543
+ if (request.message) body.message = request.message;
2544
+ if (request.sessionId) body.session_id = request.sessionId;
2545
+ return body;
2546
+ }
2547
+ function buildCreateArtifactRevisionBody(request) {
2548
+ const body = { files: buildFilesBody(request.files) };
2549
+ if (request.message) body.message = request.message;
2550
+ if (request.baseRevision !== void 0) body.base_revision = request.baseRevision;
2551
+ if (request.remove && request.remove.length > 0) body.remove = request.remove;
2552
+ if (request.entryPath) body.entry_path = request.entryPath;
2553
+ if (request.sessionId) body.session_id = request.sessionId;
2554
+ return body;
2555
+ }
2556
+ function buildRollbackArtifactBody(request) {
2557
+ const body = { revision_number: request.revisionNumber };
2558
+ if (request.message) body.message = request.message;
2559
+ if (request.sessionId) body.session_id = request.sessionId;
2560
+ return body;
2561
+ }
2562
+ function buildUpdateArtifactBody(request) {
2563
+ const body = {};
2564
+ if (request.name !== void 0) body.name = request.name;
2565
+ if (request.description !== void 0) body.description = request.description;
2566
+ if (request.entryPath !== void 0) body.entry_path = request.entryPath;
2567
+ return body;
2568
+ }
2569
+
2570
+ // ../../packages/platform-client/artifact/http/artifact.http-client.ts
2571
+ var BASE_PATH = "/artifact/v1";
2572
+ var HEADER_EXPECTED_VERSION2 = "X-Expected-Version";
2573
+ var HEADER_ACTOR_KIND2 = "X-Actor-Kind";
2574
+ function writeHeaders(opts) {
2575
+ const headers = {};
2576
+ if (opts?.expectedVersion !== void 0) {
2577
+ headers[HEADER_EXPECTED_VERSION2] = String(opts.expectedVersion);
2578
+ }
2579
+ if (opts?.actorKind) {
2580
+ headers[HEADER_ACTOR_KIND2] = opts.actorKind;
2581
+ }
2582
+ return headers;
2583
+ }
2584
+ function requestOptions(headers) {
2585
+ return Object.keys(headers).length > 0 ? { headers } : {};
2586
+ }
2587
+ function encodeArtifactPath(path4) {
2588
+ return path4.split("/").map(encodeURIComponent).join("/");
2589
+ }
2590
+ function paginationQuery(opts) {
2591
+ const params = new URLSearchParams();
2592
+ if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
2593
+ if (opts?.offset !== void 0) params.set("offset", String(opts.offset));
2594
+ if (opts?.kind) params.set("kind", opts.kind);
2595
+ const query = params.toString();
2596
+ return query ? `?${query}` : "";
2597
+ }
2598
+ var HttpArtifactClient = class {
2599
+ httpClient;
2600
+ constructor(httpClient) {
2601
+ this.httpClient = httpClient;
2602
+ }
2603
+ async createArtifact(projectId, request, jwtToken, opts) {
2604
+ const raw = await this.httpClient.post(
2605
+ `${BASE_PATH}/projects/${projectId}/artifacts`,
2606
+ buildCreateArtifactBody(request),
2607
+ jwtToken,
2608
+ requestOptions(writeHeaders(opts))
2609
+ );
2610
+ return transformArtifactWriteResult(raw);
2611
+ }
2612
+ async listArtifacts(projectId, jwtToken, opts) {
2613
+ const raw = await this.httpClient.get(
2614
+ `${BASE_PATH}/projects/${projectId}/artifacts${paginationQuery(opts)}`,
2615
+ jwtToken,
2616
+ {}
2617
+ );
2618
+ return transformArtifactListResult(raw);
2619
+ }
2620
+ async listTrashedArtifacts(projectId, jwtToken) {
2621
+ const raw = await this.httpClient.get(
2622
+ `${BASE_PATH}/projects/${projectId}/artifacts/trashed`,
2623
+ jwtToken,
2624
+ {}
2625
+ );
2626
+ return (raw.items ?? []).map(transformArtifactResponse);
2627
+ }
2628
+ async getArtifact(artifactId, jwtToken) {
2629
+ const raw = await this.httpClient.get(
2630
+ `${BASE_PATH}/artifacts/${artifactId}`,
2631
+ jwtToken,
2632
+ {}
2633
+ );
2634
+ return transformArtifactDetail(raw);
2635
+ }
2636
+ async updateArtifact(artifactId, request, jwtToken) {
2637
+ const raw = await this.httpClient.patch(
2638
+ `${BASE_PATH}/artifacts/${artifactId}`,
2639
+ buildUpdateArtifactBody(request),
2640
+ jwtToken,
2641
+ {}
2642
+ );
2643
+ return transformArtifactResponse(raw);
2644
+ }
2645
+ async trashArtifact(artifactId, jwtToken) {
2646
+ await this.httpClient.delete(`${BASE_PATH}/artifacts/${artifactId}`, jwtToken, {});
2647
+ }
2648
+ async restoreArtifact(artifactId, jwtToken) {
2649
+ await this.httpClient.post(
2650
+ `${BASE_PATH}/artifacts/${artifactId}/restore`,
2651
+ {},
2652
+ jwtToken,
2653
+ {}
2654
+ );
2655
+ }
2656
+ async deleteArtifactPermanently(artifactId, jwtToken) {
2657
+ await this.httpClient.delete(
2658
+ `${BASE_PATH}/artifacts/${artifactId}/permanent`,
2659
+ jwtToken,
2660
+ {}
2661
+ );
2662
+ }
2663
+ async createRevision(artifactId, request, jwtToken, opts) {
2664
+ const raw = await this.httpClient.post(
2665
+ `${BASE_PATH}/artifacts/${artifactId}/revisions`,
2666
+ buildCreateArtifactRevisionBody(request),
2667
+ jwtToken,
2668
+ requestOptions(writeHeaders(opts))
2669
+ );
2670
+ return transformArtifactWriteResult(raw);
2671
+ }
2672
+ async rollback(artifactId, request, jwtToken, opts) {
2673
+ const raw = await this.httpClient.post(
2674
+ `${BASE_PATH}/artifacts/${artifactId}/rollback`,
2675
+ buildRollbackArtifactBody(request),
2676
+ jwtToken,
2677
+ requestOptions(writeHeaders(opts))
2678
+ );
2679
+ return transformArtifactWriteResult(raw);
2680
+ }
2681
+ async listRevisions(artifactId, jwtToken, opts) {
2682
+ const raw = await this.httpClient.get(
2683
+ `${BASE_PATH}/artifacts/${artifactId}/revisions${paginationQuery(opts)}`,
2684
+ jwtToken,
2685
+ {}
2686
+ );
2687
+ return transformArtifactRevisionListResult(raw);
2688
+ }
2689
+ async getRevision(artifactId, revisionNumber, jwtToken) {
2690
+ const raw = await this.httpClient.get(
2691
+ `${BASE_PATH}/artifacts/${artifactId}/revisions/${revisionNumber}`,
2692
+ jwtToken,
2693
+ {}
2694
+ );
2695
+ return transformArtifactRevisionDetail(raw);
2696
+ }
2697
+ fileUrl(artifactId, path4, revision) {
2698
+ const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
2699
+ const encoded = encodeArtifactPath(path4);
2700
+ return revision === void 0 ? `${baseUrl}${BASE_PATH}/artifacts/${artifactId}/files/${encoded}` : `${baseUrl}${BASE_PATH}/artifacts/${artifactId}/revisions/${revision}/files/${encoded}`;
2701
+ }
2702
+ async downloadFile(artifactId, path4, jwtToken, opts) {
2703
+ const response = await fetch(this.fileUrl(artifactId, path4, opts?.revision), {
2704
+ method: "GET",
2705
+ headers: { Authorization: `Bearer ${jwtToken}` }
2706
+ });
2707
+ if (!response.ok) {
2708
+ await throwFromResponse(response, "Artifact file download failed");
2709
+ }
2710
+ const buffer = await response.arrayBuffer();
2711
+ return {
2712
+ content: new Uint8Array(buffer),
2713
+ contentType: response.headers.get("Content-Type") ?? "application/octet-stream",
2714
+ checksum: (response.headers.get("ETag") ?? "").replace(/"/g, "").trim()
2715
+ };
2716
+ }
2717
+ };
2718
+
2439
2719
  // src/platform/client.ts
2440
2720
  var silentLogger = {
2441
2721
  debug: () => void 0,
@@ -2722,9 +3002,43 @@ var PlatformClient = class {
2722
3002
  project: new HttpProjectClient(httpClient),
2723
3003
  session: new HttpSpecSessionClient(httpClient),
2724
3004
  file: new HttpFileClient(httpClient),
2725
- attachment: new HttpAttachmentClient(httpClient)
3005
+ attachment: new HttpAttachmentClient(httpClient),
3006
+ artifact: new HttpArtifactClient(httpClient)
2726
3007
  };
2727
3008
  }
3009
+ // ----------------------------------------------------------- artifacts
3010
+ //
3011
+ // Every write is attributed `actorKind: 'ai'`: an MCP client is an
3012
+ // assistant acting on the user's behalf, exactly as for spec files.
3013
+ async listArtifacts(projectId, opts = {}) {
3014
+ return this.withTokenRetry((jwt, c) => c.artifact.listArtifacts(projectId, jwt, opts));
3015
+ }
3016
+ async getArtifact(artifactId) {
3017
+ return this.withTokenRetry((jwt, c) => c.artifact.getArtifact(artifactId, jwt));
3018
+ }
3019
+ async getArtifactRevision(artifactId, revisionNumber) {
3020
+ return this.withTokenRetry((jwt, c) => c.artifact.getRevision(artifactId, revisionNumber, jwt));
3021
+ }
3022
+ async downloadArtifactFile(artifactId, path4, revision) {
3023
+ return this.withTokenRetry(
3024
+ (jwt, c) => c.artifact.downloadFile(artifactId, path4, jwt, revision === void 0 ? void 0 : { revision })
3025
+ );
3026
+ }
3027
+ async createArtifact(projectId, request) {
3028
+ return this.withTokenRetry(
3029
+ (jwt, c) => c.artifact.createArtifact(projectId, request, jwt, { actorKind: "ai" })
3030
+ );
3031
+ }
3032
+ async createArtifactRevision(artifactId, request, expectedVersion) {
3033
+ return this.withTokenRetry(
3034
+ (jwt, c) => c.artifact.createRevision(artifactId, request, jwt, { expectedVersion, actorKind: "ai" })
3035
+ );
3036
+ }
3037
+ async rollbackArtifact(artifactId, request, expectedVersion) {
3038
+ return this.withTokenRetry(
3039
+ (jwt, c) => c.artifact.rollback(artifactId, request, jwt, { expectedVersion, actorKind: "ai" })
3040
+ );
3041
+ }
2728
3042
  async withTokenRetry(call) {
2729
3043
  const clients = await this.clients();
2730
3044
  const jwt = await this.tokenManager.getValidAccessToken();
@@ -3479,6 +3793,9 @@ function sliceWindow(items, limit, offset) {
3479
3793
  };
3480
3794
  }
3481
3795
  function nextOffsetOf(offset, returned, total) {
3796
+ if (returned === 0) {
3797
+ return void 0;
3798
+ }
3482
3799
  const end = offset + returned;
3483
3800
  return end < total ? end : void 0;
3484
3801
  }
@@ -3556,6 +3873,8 @@ var listProjectsTool = {
3556
3873
  // src/server/tools/list_spec_file.ts
3557
3874
  import { z as z13 } from "zod";
3558
3875
  var PAGE_SIZE = 100;
3876
+ var LIST_DEFAULT2 = 50;
3877
+ var LIST_MAX2 = 200;
3559
3878
  var inputSchema13 = {
3560
3879
  project_id: z13.string().min(1).describe("UUID of the project whose spec files to list."),
3561
3880
  path: z13.string().optional().describe(
@@ -3563,26 +3882,57 @@ var inputSchema13 = {
3563
3882
  ),
3564
3883
  trashed: z13.boolean().optional().describe(
3565
3884
  "When true, list files in the project Trash Bin instead. When false or omitted, list only live files (trashed files excluded)."
3566
- )
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)")
3567
3890
  };
3568
3891
  var listSpecFileTool = {
3569
3892
  name: "list_spec_file",
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.',
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`.',
3571
3894
  inputSchema: inputSchema13,
3572
3895
  handler: async (args, ctx) => {
3573
3896
  const trashed = args.trashed ?? false;
3574
- 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;
3575
3899
  const prefix = normalizePrefix(args.path);
3576
- const matches = files.filter((f) => isUnderPath(f.filePath, prefix)).map(toEntry).sort((a, b) => a.file_path.localeCompare(b.file_path));
3577
- return jsonResult({
3578
- project_id: args.project_id,
3579
- path: prefix,
3580
- trashed,
3581
- total: matches.length,
3582
- files: matches
3583
- });
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);
3584
3920
  }
3585
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
+ }
3586
3936
  async function listAllLiveFiles(client, projectId) {
3587
3937
  const all = [];
3588
3938
  let offset = 0;
@@ -3626,24 +3976,24 @@ function isUnderPath(filePath, prefix) {
3626
3976
 
3627
3977
  // src/server/tools/list_spec_sessions.ts
3628
3978
  import { z as z14 } from "zod";
3629
- var LIST_DEFAULT2 = 10;
3630
- var LIST_MAX2 = 20;
3979
+ var LIST_DEFAULT3 = 10;
3980
+ var LIST_MAX3 = 20;
3631
3981
  var inputSchema14 = {
3632
3982
  project_id: z14.string().min(1).describe("UUID of the project whose spec sessions to list."),
3633
3983
  archive_filter: z14.enum(["active", "archived", "all"]).optional().describe("Which sessions to list: 'active' (default), 'archived', or 'all'."),
3634
3984
  status: z14.enum(["generating", "pending_input", "completed"]).optional().describe("Filter by session status."),
3635
3985
  query: z14.string().optional().describe("Search term matched against the session summary."),
3636
3986
  limit: z14.number().int().positive().optional().describe(
3637
- `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)}).`
3638
3988
  ),
3639
3989
  offset: z14.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3640
3990
  };
3641
3991
  var listSpecSessionsTool = {
3642
3992
  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\`.`,
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\`.`,
3644
3994
  inputSchema: inputSchema14,
3645
3995
  handler: async (args, ctx) => {
3646
- const limit = clampLimit(args.limit, LIST_DEFAULT2, LIST_MAX2);
3996
+ const limit = clampLimit(args.limit, LIST_DEFAULT3, LIST_MAX3);
3647
3997
  const offset = args.offset ?? 0;
3648
3998
  const result = await ctx.client.listProjectSessions(args.project_id, {
3649
3999
  archiveFilter: args.archive_filter ?? "active",
@@ -3694,6 +4044,9 @@ var MAX_LINES_PER_CALL = 2e3;
3694
4044
  var MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
3695
4045
  function normalizeNewlines(text) {
3696
4046
  const withoutBom = text.startsWith("\uFEFF") ? text.slice(1) : text;
4047
+ if (!withoutBom.includes("\r")) {
4048
+ return withoutBom;
4049
+ }
3697
4050
  return withoutBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3698
4051
  }
3699
4052
  function splitIntoLines(text) {
@@ -3864,20 +4217,27 @@ var readAttachmentTool = {
3864
4217
  import { promises as fs3 } from "fs";
3865
4218
  import { createHash as createHash2 } from "crypto";
3866
4219
  import { z as z17 } from "zod";
3867
- var MAX_READ_BYTES = 1 * 1024 * 1024;
4220
+ var MAX_READABLE_SPEC_FILE_BYTES = 8 * 1024 * 1024;
3868
4221
  var inputSchema17 = {
3869
4222
  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).")
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).")
3871
4226
  };
3872
4227
  var readSpecFileTool = {
3873
4228
  name: "read_spec_file",
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.",
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.",
3875
4230
  inputSchema: inputSchema17,
3876
4231
  handler: async (args, ctx) => {
3877
4232
  const meta = await ctx.client.getFileMetadata(args.file_id);
3878
4233
  const targetRevision = args.revision ?? meta.revisionCount;
3879
4234
  const isLatestRevision = targetRevision === meta.revisionCount;
3880
- 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) {
3881
4241
  return errorResult(buildOversizeMessage2(args.file_id, meta.fileSizeBytes));
3882
4242
  }
3883
4243
  const cacheRoot = resolveDownloadRoot();
@@ -3894,20 +4254,42 @@ var readSpecFileTool = {
3894
4254
  `read_spec_file refused to write cache: ${escape}. This indicates an unexpected platform response shape (projectId/fileId/filePath).`
3895
4255
  );
3896
4256
  }
3897
- 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);
3898
4262
  const cacheHit = cached !== null && (!isLatestRevision || sha256Prefixed2(cached) === meta.checksum);
3899
4263
  let bytes;
4264
+ let effectiveRevision = targetRevision;
4265
+ let effectiveCachePath = cachePath;
3900
4266
  if (cacheHit) {
3901
4267
  bytes = cached;
3902
4268
  } else {
3903
4269
  const downloaded = await ctx.client.downloadFile(args.file_id, args.revision);
3904
4270
  bytes = downloaded.bytes;
3905
- if (bytes.byteLength > MAX_READ_BYTES) {
4271
+ if (bytes.byteLength > MAX_READABLE_SPEC_FILE_BYTES) {
3906
4272
  return errorResult(buildOversizeMessage2(args.file_id, bytes.byteLength));
3907
4273
  }
3908
- 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);
3909
4291
  }
3910
- if (bytes.byteLength > MAX_READ_BYTES) {
4292
+ if (bytes.byteLength > MAX_READABLE_SPEC_FILE_BYTES) {
3911
4293
  return errorResult(buildOversizeMessage2(args.file_id, bytes.byteLength));
3912
4294
  }
3913
4295
  if (isBinary2(bytes)) {
@@ -3923,25 +4305,44 @@ var readSpecFileTool = {
3923
4305
  `File ${args.file_id} is not valid UTF-8 text. Use download_spec_file to save it to a local file.`
3924
4306
  );
3925
4307
  }
4308
+ const paginated = paginateText(text, args.offset, args.limit);
4309
+ if (!paginated.ok) {
4310
+ return errorResult(paginated.error);
4311
+ }
3926
4312
  return jsonResult({
3927
4313
  file_id: meta.id,
3928
- revision_number: targetRevision,
4314
+ revision_number: effectiveRevision,
3929
4315
  // Pass this back as update_spec_file's expected_version so an edit made by
3930
4316
  // someone else since this read is reported instead of overwritten. It
3931
4317
  // describes the file's CURRENT content, so it is emitted only when that is
3932
4318
  // what was read — beside a historical revision it would invite an update
3933
4319
  // that overwrites everything written since.
3934
- ...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 } : {},
3935
4326
  file_path: meta.filePath,
3936
4327
  file_type: meta.fileType,
3937
- cache_path: maskHomedir(cachePath),
4328
+ cache_path: maskHomedir(effectiveCachePath),
3938
4329
  cache_hit: cacheHit,
3939
- 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
3940
4334
  });
3941
4335
  }
3942
4336
  };
3943
4337
  function buildOversizeMessage2(fileId, byteLength) {
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.`;
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
+ }
3945
4346
  }
3946
4347
  function isBinary2(bytes) {
3947
4348
  for (const byte of bytes) {
@@ -4571,6 +4972,307 @@ var uploadSpecFileTool = {
4571
4972
  }
4572
4973
  };
4573
4974
 
4975
+ // src/server/tools/list_artifacts.ts
4976
+ import { z as z27 } from "zod";
4977
+
4978
+ // src/server/tools/artifact-output.ts
4979
+ import { z as z26 } from "zod";
4980
+ var artifactFilesSchema = z26.array(
4981
+ z26.object({
4982
+ path: z26.string().min(1).describe('Bundle-relative path, "/"-separated, no leading slash (e.g. assets/logo.svg).'),
4983
+ content: z26.string().describe("The file content, in `encoding` (default utf8)."),
4984
+ encoding: z26.enum(["utf8", "base64"]).optional().describe("utf8 (default) for text; base64 for binary files such as images."),
4985
+ content_type: z26.string().optional().describe("Hint used only for extensions the platform does not recognise.")
4986
+ })
4987
+ ).describe("Files of the bundle.");
4988
+ function artifactSummary(a) {
4989
+ return {
4990
+ id: a.id,
4991
+ project_id: a.projectId,
4992
+ name: a.name,
4993
+ slug: a.slug,
4994
+ kind: a.kind,
4995
+ description: a.description,
4996
+ entry_path: a.entryPath,
4997
+ revision_count: a.revisionCount,
4998
+ session_id: a.sessionId,
4999
+ created_by: a.createdBy,
5000
+ created_at: a.createdAt,
5001
+ updated_at: a.updatedAt,
5002
+ ...a.trashedAt ? { trashed_at: a.trashedAt } : {}
5003
+ };
5004
+ }
5005
+ function fileEntry(f) {
5006
+ return { path: f.path, content_type: f.contentType, size_bytes: f.sizeBytes, checksum: f.checksum };
5007
+ }
5008
+ function revisionSummary(r) {
5009
+ return {
5010
+ id: r.id,
5011
+ revision_number: r.revisionNumber,
5012
+ source: r.source,
5013
+ ...r.restoredFromRevision === void 0 ? {} : { restored_from_revision: r.restoredFromRevision },
5014
+ message: r.message,
5015
+ changed_by: r.changedBy,
5016
+ changed_by_kind: r.changedByKind,
5017
+ file_count: r.fileCount,
5018
+ total_bytes: r.totalBytes,
5019
+ manifest_checksum: r.manifestChecksum,
5020
+ created_at: r.createdAt,
5021
+ ..."files" in r ? { files: r.files.map(fileEntry) } : {}
5022
+ };
5023
+ }
5024
+ function writeResultOutput(result) {
5025
+ return {
5026
+ artifact: artifactSummary(result.artifact),
5027
+ revision: revisionSummary(result.revision),
5028
+ no_change: result.noChange
5029
+ };
5030
+ }
5031
+ function isArtifactWriteConflict(conflict) {
5032
+ return conflict.reason === "revision_mismatch" || conflict.reason === "blob_reclaimed" || conflict.reason === "lock_timeout" || conflict.reason === "unknown";
5033
+ }
5034
+ function artifactConflictMessage(artifactId, conflict) {
5035
+ if (conflict.reason === "lock_timeout") {
5036
+ return `Artifact ${artifactId} was NOT written: another write or a storage collection held its lock and this call timed out waiting. Nothing changed \u2014 retry the same call as-is.`;
5037
+ }
5038
+ if (conflict.reason === "blob_reclaimed") {
5039
+ return `Artifact ${artifactId} was NOT written: a file this write relied on from an older revision is no longer stored (that revision was pruned). Call get_artifact to read the current head, then resend the full content of every file (a full snapshot, or a patch based on the head).`;
5040
+ }
5041
+ const head = conflict.currentVersion === void 0 ? "" : ` The head is now revision ${String(conflict.currentVersion)}.`;
5042
+ return `Artifact ${artifactId} was NOT written: it changed since the revision this write was based on.${head} Call get_artifact to read the current head, reapply your change to it, and retry with expected_version (or base_revision) set to that head.`;
5043
+ }
5044
+ function artifactCreateConflictMessage(conflict) {
5045
+ if (conflict.reason === "slug_taken") {
5046
+ return "Artifact was NOT created: the slug is already taken in this project (trashed artifacts keep their slug). Pick another slug, or call list_artifacts to find the existing artifact and write a new revision to it instead.";
5047
+ }
5048
+ if (conflict.reason === "lock_timeout") {
5049
+ return "Artifact was NOT created: the platform timed out waiting for a lock. Nothing changed \u2014 retry the same call as-is.";
5050
+ }
5051
+ return `Artifact was NOT created: the platform reported a conflict (${conflict.reason}). Re-read the project's artifacts with list_artifacts before retrying.`;
5052
+ }
5053
+
5054
+ // src/server/tools/list_artifacts.ts
5055
+ var LIST_DEFAULT4 = 50;
5056
+ var LIST_MAX4 = 100;
5057
+ var inputSchema26 = {
5058
+ project_id: z27.string().min(1).describe("UUID of the project whose artifacts to list."),
5059
+ limit: z27.number().int().positive().optional().describe(`Max artifacts to return (default ${String(LIST_DEFAULT4)}, capped at ${String(LIST_MAX4)}).`),
5060
+ offset: z27.number().int().nonnegative().optional().describe("Pagination offset (default 0).")
5061
+ };
5062
+ var listArtifactsTool = {
5063
+ name: "list_artifacts",
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.",
5065
+ inputSchema: inputSchema26,
5066
+ handler: async (args, ctx) => {
5067
+ const limit = clampLimit(args.limit, LIST_DEFAULT4, LIST_MAX4);
5068
+ const offset = args.offset ?? 0;
5069
+ const page = await ctx.client.listArtifacts(args.project_id, { limit, offset });
5070
+ const next = nextOffsetOf(offset, page.items.length, page.total);
5071
+ return jsonResult({
5072
+ project_id: args.project_id,
5073
+ artifacts: page.items.map(artifactSummary),
5074
+ total: page.total,
5075
+ limit,
5076
+ offset,
5077
+ ...next === void 0 ? {} : { next_offset: next }
5078
+ });
5079
+ }
5080
+ };
5081
+
5082
+ // src/server/tools/get_artifact.ts
5083
+ import { z as z28 } from "zod";
5084
+ var inputSchema27 = {
5085
+ artifact_id: z28.string().min(1).describe("UUID of the artifact."),
5086
+ revision: z28.number().int().positive().optional().describe("Revision whose manifest to return (defaults to the head revision).")
5087
+ };
5088
+ var getArtifactTool = {
5089
+ name: "get_artifact",
5090
+ description: "Get an artifact's metadata plus the file manifest (path, content type, size, checksum of every file) of its head revision, or of `revision` when given. `revision_count` is the head number AND the value to pass as expected_version when writing; it only ever grows because rollback appends a revision rather than discarding history.",
5091
+ inputSchema: inputSchema27,
5092
+ handler: async (args, ctx) => {
5093
+ const detail = await ctx.client.getArtifact(args.artifact_id);
5094
+ if (args.revision === void 0) {
5095
+ return jsonResult({ artifact: artifactSummary(detail.artifact), revision: revisionSummary(detail.head) });
5096
+ }
5097
+ if (args.revision > detail.artifact.revisionCount) {
5098
+ return errorResult(
5099
+ `revision=${String(args.revision)} is greater than revision_count=${String(detail.artifact.revisionCount)} for artifact ${args.artifact_id}.`
5100
+ );
5101
+ }
5102
+ const revision = await ctx.client.getArtifactRevision(args.artifact_id, args.revision);
5103
+ return jsonResult({ artifact: artifactSummary(detail.artifact), revision: revisionSummary(revision) });
5104
+ }
5105
+ };
5106
+
5107
+ // src/server/tools/read_artifact_file.ts
5108
+ import { z as z29 } from "zod";
5109
+ var MAX_READABLE_ARTIFACT_FILE_BYTES = 8 * 1024 * 1024;
5110
+ var inputSchema28 = {
5111
+ artifact_id: z29.string().min(1).describe("UUID of the artifact."),
5112
+ path: z29.string().min(1).describe("Bundle-relative path of the file (e.g. index.html, assets/app.js)."),
5113
+ revision: z29.number().int().positive().optional().describe("Revision to read from (defaults to the head revision)."),
5114
+ offset: z29.number().int().nonnegative().optional().describe("1-based line number to start reading from (default 1; 0 is an alias for 1)."),
5115
+ limit: z29.number().int().positive().optional().describe("Max lines to return (default 2000, capped at 2000; larger values are clamped).")
5116
+ };
5117
+ var readArtifactFileTool = {
5118
+ name: "read_artifact_file",
5119
+ description: "Return one artifact file's UTF-8 content, paginated by line (offset/limit, default and cap 2000 lines, 1 MiB per response) with `total_lines`, `truncated` and `next_offset`. Reads the head revision unless `revision` is given. Binary files (images, fonts) cannot be read through this tool; get_artifact lists their size and checksum.",
5120
+ inputSchema: inputSchema28,
5121
+ handler: async (args, ctx) => {
5122
+ const file = await ctx.client.downloadArtifactFile(args.artifact_id, args.path, args.revision);
5123
+ if (file.content.byteLength > MAX_READABLE_ARTIFACT_FILE_BYTES) {
5124
+ return errorResult(
5125
+ `File ${args.path} of artifact ${args.artifact_id} is ${String(file.content.byteLength)} bytes, above the ${String(MAX_READABLE_ARTIFACT_FILE_BYTES)}-byte read ceiling.`
5126
+ );
5127
+ }
5128
+ if (isBinary(file.content)) {
5129
+ return errorResult(
5130
+ `File ${args.path} of artifact ${args.artifact_id} appears to be binary (content_type=${file.contentType}, size=${String(file.content.byteLength)} bytes) and cannot be read through this tool.`
5131
+ );
5132
+ }
5133
+ const text = decodeUtf8Strict(file.content);
5134
+ if (text === null) {
5135
+ return errorResult(
5136
+ `File ${args.path} of artifact ${args.artifact_id} is not valid UTF-8 text (content_type=${file.contentType}).`
5137
+ );
5138
+ }
5139
+ const paginated = paginateText(text, args.offset, args.limit);
5140
+ if (!paginated.ok) {
5141
+ return errorResult(paginated.error);
5142
+ }
5143
+ return jsonResult({
5144
+ artifact_id: args.artifact_id,
5145
+ path: args.path,
5146
+ ...args.revision === void 0 ? {} : { revision_number: args.revision },
5147
+ content_type: file.contentType,
5148
+ checksum: file.checksum,
5149
+ ...paginated.window
5150
+ });
5151
+ }
5152
+ };
5153
+
5154
+ // src/server/tools/create_artifact.ts
5155
+ import { z as z30 } from "zod";
5156
+ var inputSchema29 = {
5157
+ project_id: z30.string().min(1).describe("UUID of the project to create the artifact in."),
5158
+ name: z30.string().min(1).describe("Display name (max 200 characters)."),
5159
+ slug: z30.string().optional().describe("URL-safe identifier, unique within the project; derived from the name when omitted."),
5160
+ description: z30.string().optional().describe("Optional description (max 2000 characters)."),
5161
+ entry_path: z30.string().optional().describe("The file a renderer opens first. Defaults to index.html when present, else the only file."),
5162
+ message: z30.string().optional().describe("Short summary of this first revision (max 500 characters)."),
5163
+ files: artifactFilesSchema
5164
+ };
5165
+ var createArtifactTool = {
5166
+ name: "create_artifact",
5167
+ description: "Create an artifact \u2014 a bundle of files (e.g. an HTML mockup: index.html + styles.css + app.js) the platform versions AS ONE snapshot \u2014 with the given files as revision 1. At most 50 files, 2 MiB per file, 10 MiB per revision. Fails with a conflict when the slug is already taken in the project (trashed artifacts still own their slug).",
5168
+ inputSchema: inputSchema29,
5169
+ handler: async (args, ctx) => {
5170
+ try {
5171
+ const result = await ctx.client.createArtifact(args.project_id, {
5172
+ name: args.name,
5173
+ slug: args.slug,
5174
+ description: args.description,
5175
+ entryPath: args.entry_path,
5176
+ message: args.message,
5177
+ files: args.files.map((f) => ({
5178
+ path: f.path,
5179
+ content: f.content,
5180
+ encoding: f.encoding,
5181
+ contentType: f.content_type
5182
+ }))
5183
+ });
5184
+ return jsonResult(writeResultOutput(result));
5185
+ } catch (err) {
5186
+ const conflict = conflictOf(err);
5187
+ if (conflict) {
5188
+ return errorResult(artifactCreateConflictMessage(conflict));
5189
+ }
5190
+ throw err;
5191
+ }
5192
+ }
5193
+ };
5194
+
5195
+ // src/server/tools/write_artifact_revision.ts
5196
+ import { z as z31 } from "zod";
5197
+ var inputSchema30 = {
5198
+ artifact_id: z31.string().min(1).describe("UUID of the artifact."),
5199
+ files: artifactFilesSchema,
5200
+ base_revision: z31.number().int().positive().optional().describe(
5201
+ "PATCH mode: start from this revision's files, upsert `files` by path and drop `remove` paths. Omit for FULL mode, where `files` is the complete new file set. Patch mode defaults expected_version to base_revision."
5202
+ ),
5203
+ remove: z31.array(z31.string().min(1)).optional().describe("Patch mode only: paths to drop from the base revision."),
5204
+ expected_version: z31.number().int().nonnegative().optional().describe(
5205
+ "The revision_count this write is based on (from get_artifact). The platform refuses the write with a conflict if the head moved. Defaults to base_revision in patch mode, else the current head."
5206
+ ),
5207
+ message: z31.string().optional().describe("Short summary of the change (max 500 characters)."),
5208
+ entry_path: z31.string().optional().describe("Change the file a renderer opens first, from this revision on.")
5209
+ };
5210
+ var writeArtifactRevisionTool = {
5211
+ name: "write_artifact_revision",
5212
+ description: "Write a new revision of an artifact. FULL mode (no base_revision): `files` becomes the whole bundle. PATCH mode (base_revision set): only the changed files are sent, plus `remove` for paths to drop \u2014 the platform stores the complete result either way. Every revision is immutable; the newest N are retained (50 by default, set by the platform; older ones are pruned); use rollback_artifact to restore an earlier one. Read the current head with get_artifact first and pass its revision_count as expected_version.",
5213
+ inputSchema: inputSchema30,
5214
+ handler: async (args, ctx) => {
5215
+ if (args.remove && args.remove.length > 0 && args.base_revision === void 0) {
5216
+ return errorResult("`remove` is only valid in patch mode: set base_revision to the revision you are editing.");
5217
+ }
5218
+ try {
5219
+ const result = await ctx.client.createArtifactRevision(
5220
+ args.artifact_id,
5221
+ {
5222
+ files: args.files.map((f) => ({
5223
+ path: f.path,
5224
+ content: f.content,
5225
+ encoding: f.encoding,
5226
+ contentType: f.content_type
5227
+ })),
5228
+ remove: args.remove,
5229
+ baseRevision: args.base_revision,
5230
+ message: args.message,
5231
+ entryPath: args.entry_path
5232
+ },
5233
+ args.expected_version
5234
+ );
5235
+ return jsonResult(writeResultOutput(result));
5236
+ } catch (err) {
5237
+ const conflict = conflictOf(err);
5238
+ if (!conflict || !isArtifactWriteConflict(conflict)) {
5239
+ throw err;
5240
+ }
5241
+ return errorResult(artifactConflictMessage(args.artifact_id, conflict));
5242
+ }
5243
+ }
5244
+ };
5245
+
5246
+ // src/server/tools/rollback_artifact.ts
5247
+ import { z as z32 } from "zod";
5248
+ var inputSchema31 = {
5249
+ artifact_id: z32.string().min(1).describe("UUID of the artifact."),
5250
+ revision_number: z32.number().int().positive().describe("The revision whose files to restore."),
5251
+ expected_version: z32.number().int().nonnegative().optional().describe("The revision_count this rollback is based on (from get_artifact); defaults to the current head."),
5252
+ message: z32.string().optional().describe('Optional summary; defaults to "Rolled back to revision N".')
5253
+ };
5254
+ var rollbackArtifactTool = {
5255
+ name: "rollback_artifact",
5256
+ description: "Restore an earlier revision of an artifact. Like `git revert`, this APPENDS a new revision whose files equal the chosen one \u2014 history is never rewritten, and the new head is revision_count + 1. Only the newest N revisions are retained (50 by default, set by the platform): older ones are pruned, and rolling back to a pruned revision fails with not found \u2014 list the revisions (get_artifact) first when in doubt. Rolling back to the head itself is allowed and recorded with no_change=true.",
5257
+ inputSchema: inputSchema31,
5258
+ handler: async (args, ctx) => {
5259
+ try {
5260
+ const result = await ctx.client.rollbackArtifact(
5261
+ args.artifact_id,
5262
+ { revisionNumber: args.revision_number, message: args.message },
5263
+ args.expected_version
5264
+ );
5265
+ return jsonResult(writeResultOutput(result));
5266
+ } catch (err) {
5267
+ const conflict = conflictOf(err);
5268
+ if (!conflict || !isArtifactWriteConflict(conflict)) {
5269
+ throw err;
5270
+ }
5271
+ return errorResult(artifactConflictMessage(args.artifact_id, conflict));
5272
+ }
5273
+ }
5274
+ };
5275
+
4574
5276
  // src/server/server.ts
4575
5277
  function buildServer(deps) {
4576
5278
  const server = new McpServer({
@@ -4602,6 +5304,12 @@ function buildServer(deps) {
4602
5304
  registerTool(server, archiveSpecSessionTool, deps.client);
4603
5305
  registerTool(server, unarchiveSpecSessionTool, deps.client);
4604
5306
  registerTool(server, deleteSpecSessionTool, deps.client);
5307
+ registerTool(server, listArtifactsTool, deps.client);
5308
+ registerTool(server, getArtifactTool, deps.client);
5309
+ registerTool(server, readArtifactFileTool, deps.client);
5310
+ registerTool(server, createArtifactTool, deps.client);
5311
+ registerTool(server, writeArtifactRevisionTool, deps.client);
5312
+ registerTool(server, rollbackArtifactTool, deps.client);
4605
5313
  return server;
4606
5314
  }
4607
5315
  async function startStdioServer(deps) {
@@ -4755,7 +5463,7 @@ async function canonicaliseRoot(root) {
4755
5463
  import { promises as fs4 } from "fs";
4756
5464
  import { join as join2, relative } from "path";
4757
5465
  import { spawn } from "child_process";
4758
- import { z as z26 } from "zod";
5466
+ import { z as z33 } from "zod";
4759
5467
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4760
5468
 
4761
5469
  // src/reverse/ignore.ts
@@ -5060,37 +5768,37 @@ var READ_FILE_MAX_LINES = 2e3;
5060
5768
  var LIST_DIR_HARD_CAP = 5e3;
5061
5769
  var LIST_DIR_DEFAULT_CAP = 1e3;
5062
5770
  var GREP_MAX_MATCHES_DEFAULT = 200;
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()
5771
+ var listDirInput = z33.object({
5772
+ path: z33.string(),
5773
+ recursive: z33.boolean().optional(),
5774
+ maxEntries: z33.number().int().positive().optional(),
5775
+ includeIgnored: z33.boolean().optional()
5068
5776
  });
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()
5777
+ var readFileInput = z33.object({
5778
+ path: z33.string(),
5779
+ offset: z33.number().int().nonnegative().optional(),
5780
+ limit: z33.number().int().positive().optional(),
5781
+ includeIgnored: z33.boolean().optional()
5074
5782
  });
5075
- var grepInput = z26.object({
5076
- pattern: z26.string().describe(
5783
+ var grepInput = z33.object({
5784
+ pattern: z33.string().describe(
5077
5785
  "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."
5078
5786
  ),
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()
5787
+ path: z33.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
5788
+ isRegex: z33.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
5789
+ caseSensitive: z33.boolean().optional(),
5790
+ maxMatches: z33.number().int().positive().optional(),
5791
+ contextLines: z33.number().int().nonnegative().optional(),
5792
+ includeIgnored: z33.boolean().optional()
5085
5793
  });
5086
- var packCodebaseInput = z26.object({
5087
- subpath: z26.string().optional(),
5088
- includePatterns: z26.string().optional(),
5089
- ignorePatterns: z26.string().optional()
5794
+ var packCodebaseInput = z33.object({
5795
+ subpath: z33.string().optional(),
5796
+ includePatterns: z33.string().optional(),
5797
+ ignorePatterns: z33.string().optional()
5090
5798
  });
5091
- var packCodebaseReadPageInput = z26.object({
5092
- outputId: z26.string(),
5093
- page: z26.number().int().positive()
5799
+ var packCodebaseReadPageInput = z33.object({
5800
+ outputId: z33.string(),
5801
+ page: z33.number().int().positive()
5094
5802
  });
5095
5803
  function structuredError(error, message, extra) {
5096
5804
  const payload = { error, message, ...extra ?? {} };
@@ -5618,7 +6326,7 @@ function jsonSchemaFromZod(schema) {
5618
6326
  return zodToJson(schema);
5619
6327
  }
5620
6328
  function zodToJson(schema) {
5621
- if (schema instanceof z26.ZodObject) {
6329
+ if (schema instanceof z33.ZodObject) {
5622
6330
  const shape = schema.shape;
5623
6331
  const properties = {};
5624
6332
  const required = [];
@@ -5633,19 +6341,19 @@ function zodToJson(schema) {
5633
6341
  if (required.length > 0) out.required = required;
5634
6342
  return out;
5635
6343
  }
5636
- if (schema instanceof z26.ZodOptional) {
6344
+ if (schema instanceof z33.ZodOptional) {
5637
6345
  return zodToJson(schema.unwrap());
5638
6346
  }
5639
- if (schema instanceof z26.ZodString) {
6347
+ if (schema instanceof z33.ZodString) {
5640
6348
  return { type: "string" };
5641
6349
  }
5642
- if (schema instanceof z26.ZodNumber) {
6350
+ if (schema instanceof z33.ZodNumber) {
5643
6351
  return { type: "number" };
5644
6352
  }
5645
- if (schema instanceof z26.ZodBoolean) {
6353
+ if (schema instanceof z33.ZodBoolean) {
5646
6354
  return { type: "boolean" };
5647
6355
  }
5648
- if (schema instanceof z26.ZodEnum) {
6356
+ if (schema instanceof z33.ZodEnum) {
5649
6357
  return { type: "string", enum: schema.options };
5650
6358
  }
5651
6359
  return { type: "string" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.4.0-next.106",
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": {