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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +422 -68
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -291,9 +291,9 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
291
291
  | `list_projects` | `query?`, `limit?`, `offset?` | Paginated list of projects |
292
292
  | `get_project` | `project_id` | Project + active sessions + files + attachments |
293
293
  | `get_spec_file` | `file_id`, optional `include_download_url` | File metadata, including `content_version` (see [Concurrent edits](#concurrent-edits-content_version)); with `include_download_url=N` also returns a download URL targeting revision `N` |
294
- | `read_spec_file` | `file_id`, optional `revision` | UTF-8 content of the file, plus `content_version` when reading the latest revision (errors for binary or files > 1 MiB — use `download_spec_file` for those). Cached on disk under `MYSPEC_DOWNLOAD_ROOT` |
294
+ | `read_spec_file` | `file_id`, optional `revision`, optional `offset`, optional `limit` | UTF-8 content paginated by line (`offset` is 1-based, default/cap 2000 lines, 1 MiB per response) with `content`, `start_line`, `end_line`, `read_lines`, `total_lines`, `truncated`, `next_offset`; plus `content_version` when reading the latest revision (errors for binary or files > 8 MiB — use `download_spec_file` for those). Cached on disk under `MYSPEC_DOWNLOAD_ROOT`, so paging through a file downloads it once |
295
295
  | `download_spec_file` | `file_id`, optional `destination_path`, optional `revision`, optional `overwrite` | Downloads the file's raw bytes to a local file — by default under `.specs/` in the working directory (see [Download destinations](#download-destinations)). Handles binary files and files up to 50 MiB. Refuses to replace an existing file unless `overwrite=true`. Returns only metadata (`saved_path`, `bytes_written`, `revision_number`, `destination_source`) — **not** the content, so a large file can be saved locally and read in chunks |
296
- | `list_spec_file` | `project_id`, optional `path`, optional `trashed` | Lists a project's spec files (`file_id`, `file_path`, `file_type`, `file_size_bytes`, `revision_count`, `session_id`, `updated_at`). Filter to a directory with `path` (exact file or everything under it; `""`/`"/"`/omit = all). `trashed=true` lists the Trash Bin instead of live files |
296
+ | `list_spec_file` | `project_id`, optional `path`, optional `trashed`, optional `limit`, optional `offset` | Lists a project's spec files (`file_id`, `file_path`, `file_type`, `file_size_bytes`, `revision_count`, `session_id`, `updated_at`, plus `trashed_at` on Trash Bin entries). Filter to a directory with `path` (exact file or everything under it; `""`/`"/"`/omit = all). `trashed=true` lists the Trash Bin instead of live files. Returns `total`, `limit`, `offset` and, when more records remain, `next_offset` (default 50, capped at 200). Ordered by `file_path` when `path` or `trashed` is given, most-recently-created first otherwise |
297
297
  | `move_spec_file_to_trash` | `file_id` | Moves a spec file to the Trash Bin (recoverable — hidden from listings and AI-agent tools, restorable indefinitely). Not a permanent delete |
298
298
  | `restore_spec_file_from_trash` | `file_id` | Restores a trashed spec file back to the active file list, reversing `move_spec_file_to_trash` |
299
299
  | `get_attachment` | `attachment_id`, optional `include_download_url` | Attachment metadata (looked up org-scoped by id); with `include_download_url>0` also returns a signed download URL |
package/dist/index.js CHANGED
@@ -32,10 +32,11 @@ var REFRESH_TOKEN_PATTERN = /"refreshToken"\s*:\s*"[^"]+"/g;
32
32
  var API_TOKEN_PATTERN = /"apiToken"\s*:\s*"[^"]+"/g;
33
33
  var API_TOKEN_VALUE_PATTERN = /\bmsp_pat_[A-Za-z0-9]+/g;
34
34
  var ACCESS_TOKEN_PATTERN = /"accessToken"\s*:\s*"[^"]+"/g;
35
- var SNAKE_TOKEN_PATTERN = /"(access_token|refresh_token|id_token)"\s*:\s*"[^"]+"/g;
35
+ var SNAKE_TOKEN_PATTERN = /"(access_token|refresh_token|id_token|token)"\s*:\s*"[^"]+"/g;
36
36
  var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
37
+ var STREAM_URL_PATTERN = /\/realtime\/v1\/stream\/[A-Za-z0-9_-]{20,}/g;
37
38
  function redactSensitive(value) {
38
- return value.replace(BEARER_PATTERN, "Bearer [REDACTED]").replace(REFRESH_TOKEN_PATTERN, '"refreshToken":"[REDACTED]"').replace(API_TOKEN_PATTERN, '"apiToken":"[REDACTED]"').replace(API_TOKEN_VALUE_PATTERN, "[REDACTED_API_TOKEN]").replace(ACCESS_TOKEN_PATTERN, '"accessToken":"[REDACTED]"').replace(SNAKE_TOKEN_PATTERN, (_match, key) => `"${key}":"[REDACTED]"`).replace(JWT_PATTERN, "[REDACTED_JWT]");
39
+ return value.replace(BEARER_PATTERN, "Bearer [REDACTED]").replace(REFRESH_TOKEN_PATTERN, '"refreshToken":"[REDACTED]"').replace(API_TOKEN_PATTERN, '"apiToken":"[REDACTED]"').replace(API_TOKEN_VALUE_PATTERN, "[REDACTED_API_TOKEN]").replace(ACCESS_TOKEN_PATTERN, '"accessToken":"[REDACTED]"').replace(SNAKE_TOKEN_PATTERN, (_match, key) => `"${key}":"[REDACTED]"`).replace(JWT_PATTERN, "[REDACTED_JWT]").replace(STREAM_URL_PATTERN, "/realtime/v1/stream/[REDACTED_STREAM_TOKEN]");
39
40
  }
40
41
  var ConfigError = class extends Error {
41
42
  constructor(message) {
@@ -2716,6 +2717,127 @@ var HttpArtifactClient = class {
2716
2717
  }
2717
2718
  };
2718
2719
 
2720
+ // ../../packages/platform-client/stream/types/stream-token.types.ts
2721
+ function optionalDate(value) {
2722
+ return value ? new Date(value) : null;
2723
+ }
2724
+ function transformStreamToken(raw) {
2725
+ return {
2726
+ id: raw.id,
2727
+ tokenPrefix: raw.token_prefix,
2728
+ label: raw.label ?? "",
2729
+ resourceType: raw.resource_type,
2730
+ resourceId: raw.resource_id,
2731
+ eventTypes: raw.event_types ?? [],
2732
+ createdBy: raw.created_by,
2733
+ createdAt: new Date(raw.created_at),
2734
+ expiresAt: new Date(raw.expires_at),
2735
+ revokedAt: optionalDate(raw.revoked_at),
2736
+ resourceDeletedAt: optionalDate(raw.resource_deleted_at),
2737
+ lastConnectedAt: optionalDate(raw.last_connected_at),
2738
+ connectCount: raw.connect_count,
2739
+ live: raw.live
2740
+ };
2741
+ }
2742
+ function transformStreamTokenMintResult(raw) {
2743
+ if (!raw.token || !raw.url) {
2744
+ throw new Error("stream token mint response carried no token or url");
2745
+ }
2746
+ return { ...transformStreamToken(raw), token: raw.token, url: raw.url };
2747
+ }
2748
+ function transformStreamTokenListResult(raw) {
2749
+ return {
2750
+ tokens: (raw.tokens ?? []).map(transformStreamToken),
2751
+ total: raw.total ?? 0
2752
+ };
2753
+ }
2754
+ function transformStreamCatalogue(raw) {
2755
+ return {
2756
+ resources: (raw.resources ?? []).map((resource) => ({
2757
+ resourceType: resource.resource_type,
2758
+ eventTypes: resource.event_types ?? []
2759
+ })),
2760
+ maxTtlSeconds: raw.max_ttl_seconds,
2761
+ defaultTtlSeconds: raw.default_ttl_seconds
2762
+ };
2763
+ }
2764
+ function buildCreateStreamTokenBody(request) {
2765
+ const body = {
2766
+ resource_type: request.resourceType,
2767
+ resource_id: request.resourceId
2768
+ };
2769
+ body.event_types = request.eventTypes;
2770
+ if (request.ttlSeconds !== void 0) {
2771
+ body.ttl_seconds = request.ttlSeconds;
2772
+ }
2773
+ if (request.label) {
2774
+ body.label = request.label;
2775
+ }
2776
+ return body;
2777
+ }
2778
+
2779
+ // ../../packages/platform-client/stream/http/stream-token.http-client.ts
2780
+ var BASE_PATH2 = "/realtime/v1";
2781
+ function requestOptions2(options) {
2782
+ return options ?? {};
2783
+ }
2784
+ var HttpStreamTokenClient = class {
2785
+ constructor(httpClient) {
2786
+ this.httpClient = httpClient;
2787
+ }
2788
+ httpClient;
2789
+ async createStreamToken(request, jwtToken, options) {
2790
+ const raw = await this.httpClient.post(
2791
+ `${BASE_PATH2}/stream-tokens`,
2792
+ buildCreateStreamTokenBody(request),
2793
+ jwtToken,
2794
+ requestOptions2(options)
2795
+ );
2796
+ return transformStreamTokenMintResult(raw);
2797
+ }
2798
+ async listStreamTokens(options, jwtToken, httpOptions) {
2799
+ const query = new URLSearchParams();
2800
+ if (options?.resourceId) {
2801
+ query.set("resource_id", options.resourceId);
2802
+ }
2803
+ if (options?.liveOnly) {
2804
+ query.set("live", "true");
2805
+ }
2806
+ if (options?.mineOnly) {
2807
+ query.set("mine", "true");
2808
+ }
2809
+ if (options?.limit !== void 0) {
2810
+ query.set("limit", String(options.limit));
2811
+ }
2812
+ if (options?.offset !== void 0) {
2813
+ query.set("offset", String(options.offset));
2814
+ }
2815
+ const suffix = query.toString() ? `?${query.toString()}` : "";
2816
+ const raw = await this.httpClient.get(
2817
+ `${BASE_PATH2}/stream-tokens${suffix}`,
2818
+ jwtToken,
2819
+ requestOptions2(httpOptions)
2820
+ );
2821
+ return transformStreamTokenListResult(raw);
2822
+ }
2823
+ async revokeStreamToken(id, jwtToken, options) {
2824
+ const raw = await this.httpClient.delete(
2825
+ `${BASE_PATH2}/stream-tokens/${encodeURIComponent(id)}`,
2826
+ jwtToken,
2827
+ requestOptions2(options)
2828
+ );
2829
+ return transformStreamToken(raw);
2830
+ }
2831
+ async getStreamCatalogue(jwtToken, options) {
2832
+ const raw = await this.httpClient.get(
2833
+ `${BASE_PATH2}/stream-catalogue`,
2834
+ jwtToken,
2835
+ requestOptions2(options)
2836
+ );
2837
+ return transformStreamCatalogue(raw);
2838
+ }
2839
+ };
2840
+
2719
2841
  // src/platform/client.ts
2720
2842
  var silentLogger = {
2721
2843
  debug: () => void 0,
@@ -3003,9 +3125,24 @@ var PlatformClient = class {
3003
3125
  session: new HttpSpecSessionClient(httpClient),
3004
3126
  file: new HttpFileClient(httpClient),
3005
3127
  attachment: new HttpAttachmentClient(httpClient),
3006
- artifact: new HttpArtifactClient(httpClient)
3128
+ artifact: new HttpArtifactClient(httpClient),
3129
+ streamToken: new HttpStreamTokenClient(httpClient)
3007
3130
  };
3008
3131
  }
3132
+ // ------------------------------------------------------- stream tokens
3133
+ //
3134
+ // A stream token is a presigned WebSocket URL for a project's event feed —
3135
+ // the URL is the whole credential, which is what lets a tool that takes one
3136
+ // string and nothing else consume the feed.
3137
+ async createStreamToken(request) {
3138
+ return this.withTokenRetry((jwt, c) => c.streamToken.createStreamToken(request, jwt));
3139
+ }
3140
+ async listStreamTokens(opts = {}) {
3141
+ return this.withTokenRetry((jwt, c) => c.streamToken.listStreamTokens(opts, jwt));
3142
+ }
3143
+ async revokeStreamToken(id) {
3144
+ return this.withTokenRetry((jwt, c) => c.streamToken.revokeStreamToken(id, jwt));
3145
+ }
3009
3146
  // ----------------------------------------------------------- artifacts
3010
3147
  //
3011
3148
  // Every write is attributed `actorKind: 'ai'`: an MCP client is an
@@ -3793,6 +3930,9 @@ function sliceWindow(items, limit, offset) {
3793
3930
  };
3794
3931
  }
3795
3932
  function nextOffsetOf(offset, returned, total) {
3933
+ if (returned === 0) {
3934
+ return void 0;
3935
+ }
3796
3936
  const end = offset + returned;
3797
3937
  return end < total ? end : void 0;
3798
3938
  }
@@ -3870,6 +4010,8 @@ var listProjectsTool = {
3870
4010
  // src/server/tools/list_spec_file.ts
3871
4011
  import { z as z13 } from "zod";
3872
4012
  var PAGE_SIZE = 100;
4013
+ var LIST_DEFAULT2 = 50;
4014
+ var LIST_MAX2 = 200;
3873
4015
  var inputSchema13 = {
3874
4016
  project_id: z13.string().min(1).describe("UUID of the project whose spec files to list."),
3875
4017
  path: z13.string().optional().describe(
@@ -3877,26 +4019,57 @@ var inputSchema13 = {
3877
4019
  ),
3878
4020
  trashed: z13.boolean().optional().describe(
3879
4021
  "When true, list files in the project Trash Bin instead. When false or omitted, list only live files (trashed files excluded)."
3880
- )
4022
+ ),
4023
+ limit: z13.number().int().positive().optional().describe(
4024
+ `Max files to return (default ${String(LIST_DEFAULT2)}, capped at ${String(LIST_MAX2)}).`
4025
+ ),
4026
+ offset: z13.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3881
4027
  };
3882
4028
  var listSpecFileTool = {
3883
4029
  name: "list_spec_file",
3884
- description: 'List a project\'s spec files with id, path, and metadata (revision count, file size of the latest revision, file type, owning session, last update). Filter to a directory with `path` (exact file or everything under it; pass "", "/", or omit for all). Set `trashed=true` to list files in the Trash Bin instead of live files.',
4030
+ description: 'List a project\'s spec files with id, path, and metadata (revision count, file size of the latest revision, file type, owning session, last update). Filter to a directory with `path` (exact file or everything under it; pass "", "/", or omit for all). Set `trashed=true` to list files in the Trash Bin instead of live files. Ordered by `file_path` ascending whenever `path` or `trashed` is given, most-recently-created first otherwise. Returns `total` and, when more records remain, `next_offset`.',
3885
4031
  inputSchema: inputSchema13,
3886
4032
  handler: async (args, ctx) => {
3887
4033
  const trashed = args.trashed ?? false;
3888
- const files = trashed ? await ctx.client.listTrashedFiles(args.project_id) : await listAllLiveFiles(ctx.client, args.project_id);
4034
+ const limit = clampLimit(args.limit, LIST_DEFAULT2, LIST_MAX2);
4035
+ const offset = args.offset ?? 0;
3889
4036
  const prefix = normalizePrefix(args.path);
3890
- const matches = files.filter((f) => isUnderPath(f.filePath, prefix)).map(toEntry).sort((a, b) => a.file_path.localeCompare(b.file_path));
3891
- return jsonResult({
3892
- project_id: args.project_id,
3893
- path: prefix,
3894
- trashed,
3895
- total: matches.length,
3896
- files: matches
3897
- });
4037
+ if (trashed) {
4038
+ const all2 = await ctx.client.listTrashedFiles(args.project_id);
4039
+ return windowedListResult(args.project_id, prefix, true, all2, limit, offset);
4040
+ }
4041
+ if (prefix === "") {
4042
+ const page = await ctx.client.listFiles(args.project_id, { limit, offset });
4043
+ const next = nextOffsetOf(offset, page.files.length, page.total);
4044
+ return jsonResult({
4045
+ project_id: args.project_id,
4046
+ path: prefix,
4047
+ trashed: false,
4048
+ files: page.files.map(toEntry),
4049
+ total: page.total,
4050
+ limit,
4051
+ offset,
4052
+ ...next === void 0 ? {} : { next_offset: next }
4053
+ });
4054
+ }
4055
+ const all = await listAllLiveFiles(ctx.client, args.project_id);
4056
+ return windowedListResult(args.project_id, prefix, false, all, limit, offset);
3898
4057
  }
3899
4058
  };
4059
+ function windowedListResult(projectId, prefix, trashed, files, limit, offset) {
4060
+ const matches = files.filter((f) => isUnderPath(f.filePath, prefix)).map(toEntry).sort((a, b) => a.file_path.localeCompare(b.file_path));
4061
+ const window = sliceWindow(matches, limit, offset);
4062
+ return jsonResult({
4063
+ project_id: projectId,
4064
+ path: prefix,
4065
+ trashed,
4066
+ files: window.items,
4067
+ total: window.total,
4068
+ limit: window.limit,
4069
+ offset,
4070
+ ...window.next_offset === void 0 ? {} : { next_offset: window.next_offset }
4071
+ });
4072
+ }
3900
4073
  async function listAllLiveFiles(client, projectId) {
3901
4074
  const all = [];
3902
4075
  let offset = 0;
@@ -3940,24 +4113,24 @@ function isUnderPath(filePath, prefix) {
3940
4113
 
3941
4114
  // src/server/tools/list_spec_sessions.ts
3942
4115
  import { z as z14 } from "zod";
3943
- var LIST_DEFAULT2 = 10;
3944
- var LIST_MAX2 = 20;
4116
+ var LIST_DEFAULT3 = 10;
4117
+ var LIST_MAX3 = 20;
3945
4118
  var inputSchema14 = {
3946
4119
  project_id: z14.string().min(1).describe("UUID of the project whose spec sessions to list."),
3947
4120
  archive_filter: z14.enum(["active", "archived", "all"]).optional().describe("Which sessions to list: 'active' (default), 'archived', or 'all'."),
3948
4121
  status: z14.enum(["generating", "pending_input", "completed"]).optional().describe("Filter by session status."),
3949
4122
  query: z14.string().optional().describe("Search term matched against the session summary."),
3950
4123
  limit: z14.number().int().positive().optional().describe(
3951
- `Max sessions to return (default ${String(LIST_DEFAULT2)}, capped at ${String(LIST_MAX2)}).`
4124
+ `Max sessions to return (default ${String(LIST_DEFAULT3)}, capped at ${String(LIST_MAX3)}).`
3952
4125
  ),
3953
4126
  offset: z14.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
3954
4127
  };
3955
4128
  var listSpecSessionsTool = {
3956
4129
  name: "list_spec_sessions",
3957
- description: `List a project's spec sessions (metadata plus session_summary and context_size_bytes \u2014 never the context itself or the chat transcript). Filter with archive_filter ('active'/'archived'/'all'), status, and query (searches the session summary). Capped at ${String(LIST_MAX2)} per page; returns \`total\` and \`next_offset\`.`,
4130
+ description: `List a project's spec sessions (metadata plus session_summary and context_size_bytes \u2014 never the context itself or the chat transcript). Filter with archive_filter ('active'/'archived'/'all'), status, and query (searches the session summary). Capped at ${String(LIST_MAX3)} per page; returns \`total\` and \`next_offset\`.`,
3958
4131
  inputSchema: inputSchema14,
3959
4132
  handler: async (args, ctx) => {
3960
- const limit = clampLimit(args.limit, LIST_DEFAULT2, LIST_MAX2);
4133
+ const limit = clampLimit(args.limit, LIST_DEFAULT3, LIST_MAX3);
3961
4134
  const offset = args.offset ?? 0;
3962
4135
  const result = await ctx.client.listProjectSessions(args.project_id, {
3963
4136
  archiveFilter: args.archive_filter ?? "active",
@@ -4008,6 +4181,9 @@ var MAX_LINES_PER_CALL = 2e3;
4008
4181
  var MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
4009
4182
  function normalizeNewlines(text) {
4010
4183
  const withoutBom = text.startsWith("\uFEFF") ? text.slice(1) : text;
4184
+ if (!withoutBom.includes("\r")) {
4185
+ return withoutBom;
4186
+ }
4011
4187
  return withoutBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4012
4188
  }
4013
4189
  function splitIntoLines(text) {
@@ -4178,20 +4354,27 @@ var readAttachmentTool = {
4178
4354
  import { promises as fs3 } from "fs";
4179
4355
  import { createHash as createHash2 } from "crypto";
4180
4356
  import { z as z17 } from "zod";
4181
- var MAX_READ_BYTES = 1 * 1024 * 1024;
4357
+ var MAX_READABLE_SPEC_FILE_BYTES = 8 * 1024 * 1024;
4182
4358
  var inputSchema17 = {
4183
4359
  file_id: z17.string().min(1).describe("UUID of the file"),
4184
- revision: z17.number().int().positive().optional().describe("Specific revision number to read (defaults to the latest revision).")
4360
+ revision: z17.number().int().positive().optional().describe("Specific revision number to read (defaults to the latest revision)."),
4361
+ offset: z17.number().int().nonnegative().optional().describe("1-based line number to start reading from (default 1; 0 is an alias for 1)."),
4362
+ limit: z17.number().int().positive().optional().describe("Max lines to return (default 2000, capped at 2000; larger values are clamped).")
4185
4363
  };
4186
4364
  var readSpecFileTool = {
4187
4365
  name: "read_spec_file",
4188
- description: "Return a spec file's UTF-8 content. Bytes are cached on disk under the configured cache root (default ~/.myspec) at `project/<projectId>/file/<fileId>/rev/<rev>/<basename>` and reused on subsequent reads of the same revision. Fails for binary files or files larger than 1 MiB \u2014 use download_spec_file to save those to a local file instead.",
4366
+ description: "Return a window of a spec file's UTF-8 content, paginated by line (offset/limit, default and cap 2000 lines, 1 MiB per response). Large files are read across multiple calls using `next_offset`. Bytes are cached on disk under the configured cache root (default ~/.myspec) at `project/<projectId>/file/<fileId>/rev/<rev>/<basename>` and reused on subsequent reads of the same revision, so paging through a file downloads it once. Files above 8 MiB and binary content are refused \u2014 use download_spec_file to save those to a local file instead.",
4189
4367
  inputSchema: inputSchema17,
4190
4368
  handler: async (args, ctx) => {
4191
4369
  const meta = await ctx.client.getFileMetadata(args.file_id);
4192
4370
  const targetRevision = args.revision ?? meta.revisionCount;
4193
4371
  const isLatestRevision = targetRevision === meta.revisionCount;
4194
- if (isLatestRevision && meta.fileSizeBytes > MAX_READ_BYTES) {
4372
+ if (args.revision !== void 0 && args.revision > meta.revisionCount) {
4373
+ return errorResult(
4374
+ `revision=${String(args.revision)} is greater than revision_count=${String(meta.revisionCount)} for file ${args.file_id}.`
4375
+ );
4376
+ }
4377
+ if (isLatestRevision && meta.fileSizeBytes > MAX_READABLE_SPEC_FILE_BYTES) {
4195
4378
  return errorResult(buildOversizeMessage2(args.file_id, meta.fileSizeBytes));
4196
4379
  }
4197
4380
  const cacheRoot = resolveDownloadRoot();
@@ -4208,20 +4391,42 @@ var readSpecFileTool = {
4208
4391
  `read_spec_file refused to write cache: ${escape}. This indicates an unexpected platform response shape (projectId/fileId/filePath).`
4209
4392
  );
4210
4393
  }
4211
- const cached = await pathExists(cachePath) ? await fs3.readFile(cachePath) : null;
4394
+ const cachedSize = await fileSizeOrNull(cachePath);
4395
+ if (cachedSize !== null && cachedSize > MAX_READABLE_SPEC_FILE_BYTES) {
4396
+ return errorResult(buildOversizeMessage2(args.file_id, cachedSize));
4397
+ }
4398
+ const cached = cachedSize === null ? null : await fs3.readFile(cachePath);
4212
4399
  const cacheHit = cached !== null && (!isLatestRevision || sha256Prefixed2(cached) === meta.checksum);
4213
4400
  let bytes;
4401
+ let effectiveRevision = targetRevision;
4402
+ let effectiveCachePath = cachePath;
4214
4403
  if (cacheHit) {
4215
4404
  bytes = cached;
4216
4405
  } else {
4217
4406
  const downloaded = await ctx.client.downloadFile(args.file_id, args.revision);
4218
4407
  bytes = downloaded.bytes;
4219
- if (bytes.byteLength > MAX_READ_BYTES) {
4408
+ if (bytes.byteLength > MAX_READABLE_SPEC_FILE_BYTES) {
4220
4409
  return errorResult(buildOversizeMessage2(args.file_id, bytes.byteLength));
4221
4410
  }
4222
- await writeBytesAtomically(cachePath, bytes);
4411
+ if (downloaded.revisionNumber !== targetRevision) {
4412
+ effectiveRevision = downloaded.revisionNumber;
4413
+ effectiveCachePath = computeFileCachePath({
4414
+ cacheRoot,
4415
+ projectId: meta.projectId,
4416
+ fileId: meta.id,
4417
+ revision: effectiveRevision,
4418
+ filePath: meta.filePath
4419
+ });
4420
+ const raceEscape = assertWithinRoot(effectiveCachePath, cacheRoot);
4421
+ if (raceEscape) {
4422
+ return errorResult(
4423
+ `read_spec_file refused to write cache: ${raceEscape}. This indicates an unexpected platform response shape (projectId/fileId/filePath).`
4424
+ );
4425
+ }
4426
+ }
4427
+ await writeBytesAtomically(effectiveCachePath, bytes);
4223
4428
  }
4224
- if (bytes.byteLength > MAX_READ_BYTES) {
4429
+ if (bytes.byteLength > MAX_READABLE_SPEC_FILE_BYTES) {
4225
4430
  return errorResult(buildOversizeMessage2(args.file_id, bytes.byteLength));
4226
4431
  }
4227
4432
  if (isBinary2(bytes)) {
@@ -4237,25 +4442,44 @@ var readSpecFileTool = {
4237
4442
  `File ${args.file_id} is not valid UTF-8 text. Use download_spec_file to save it to a local file.`
4238
4443
  );
4239
4444
  }
4445
+ const paginated = paginateText(text, args.offset, args.limit);
4446
+ if (!paginated.ok) {
4447
+ return errorResult(paginated.error);
4448
+ }
4240
4449
  return jsonResult({
4241
4450
  file_id: meta.id,
4242
- revision_number: targetRevision,
4451
+ revision_number: effectiveRevision,
4243
4452
  // Pass this back as update_spec_file's expected_version so an edit made by
4244
4453
  // someone else since this read is reported instead of overwritten. It
4245
4454
  // describes the file's CURRENT content, so it is emitted only when that is
4246
4455
  // what was read — beside a historical revision it would invite an update
4247
4456
  // that overwrites everything written since.
4248
- ...isLatestRevision && meta.contentVersion !== void 0 ? { content_version: meta.contentVersion } : {},
4457
+ // ...and only when it describes THESE bytes. If a concurrent save moved
4458
+ // the revision under us, meta.contentVersion belongs to the older one:
4459
+ // a stale token is fail-safe (the platform answers 409) but it would
4460
+ // cost the model a round trip it cannot diagnose, so omit it and let the
4461
+ // re-read supply a current one.
4462
+ ...isLatestRevision && effectiveRevision === targetRevision && meta.contentVersion !== void 0 ? { content_version: meta.contentVersion } : {},
4249
4463
  file_path: meta.filePath,
4250
4464
  file_type: meta.fileType,
4251
- cache_path: maskHomedir(cachePath),
4465
+ cache_path: maskHomedir(effectiveCachePath),
4252
4466
  cache_hit: cacheHit,
4253
- content: text
4467
+ // `content` plus the window fields (start_line/end_line/read_lines/
4468
+ // total_lines/truncated/next_offset) — the same shape read_attachment
4469
+ // and mcp-server-http's read_spec_file return.
4470
+ ...paginated.window
4254
4471
  });
4255
4472
  }
4256
4473
  };
4257
4474
  function buildOversizeMessage2(fileId, byteLength) {
4258
- return `File ${fileId} is ${String(byteLength)} bytes which exceeds the read cap of ${String(MAX_READ_BYTES)} bytes. Use download_spec_file to save it to a local file and read it in chunks.`;
4475
+ 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.`;
4476
+ }
4477
+ async function fileSizeOrNull(filePath) {
4478
+ try {
4479
+ return (await fs3.stat(filePath)).size;
4480
+ } catch {
4481
+ return null;
4482
+ }
4259
4483
  }
4260
4484
  function isBinary2(bytes) {
4261
4485
  for (const byte of bytes) {
@@ -4965,11 +5189,11 @@ function artifactCreateConflictMessage(conflict) {
4965
5189
  }
4966
5190
 
4967
5191
  // src/server/tools/list_artifacts.ts
4968
- var LIST_DEFAULT3 = 50;
4969
- var LIST_MAX3 = 100;
5192
+ var LIST_DEFAULT4 = 50;
5193
+ var LIST_MAX4 = 100;
4970
5194
  var inputSchema26 = {
4971
5195
  project_id: z27.string().min(1).describe("UUID of the project whose artifacts to list."),
4972
- limit: z27.number().int().positive().optional().describe(`Max artifacts to return (default ${String(LIST_DEFAULT3)}, capped at ${String(LIST_MAX3)}).`),
5196
+ limit: z27.number().int().positive().optional().describe(`Max artifacts to return (default ${String(LIST_DEFAULT4)}, capped at ${String(LIST_MAX4)}).`),
4973
5197
  offset: z27.number().int().nonnegative().optional().describe("Pagination offset (default 0).")
4974
5198
  };
4975
5199
  var listArtifactsTool = {
@@ -4977,7 +5201,7 @@ var listArtifactsTool = {
4977
5201
  description: "List a project's live artifacts (multi-file bundles such as HTML mockups, each versioned as one snapshot) with id, slug, entry path and revision_count. Returns `total` and, when more remain, `next_offset`. Use get_artifact for the file manifest and read_artifact_file for content.",
4978
5202
  inputSchema: inputSchema26,
4979
5203
  handler: async (args, ctx) => {
4980
- const limit = clampLimit(args.limit, LIST_DEFAULT3, LIST_MAX3);
5204
+ const limit = clampLimit(args.limit, LIST_DEFAULT4, LIST_MAX4);
4981
5205
  const offset = args.offset ?? 0;
4982
5206
  const page = await ctx.client.listArtifacts(args.project_id, { limit, offset });
4983
5207
  const next = nextOffsetOf(offset, page.items.length, page.total);
@@ -5186,6 +5410,133 @@ var rollbackArtifactTool = {
5186
5410
  }
5187
5411
  };
5188
5412
 
5413
+ // src/server/tools/create_stream_token.ts
5414
+ import { z as z34 } from "zod";
5415
+
5416
+ // src/server/tools/stream-token-output.ts
5417
+ import { z as z33 } from "zod";
5418
+ var projectStreamEventTypes = [
5419
+ "project.updated",
5420
+ "project.deleted",
5421
+ "spec_file.created",
5422
+ "spec_file.updated",
5423
+ "spec_file.deleted",
5424
+ "spec_file.lock",
5425
+ "attachment.created",
5426
+ "attachment.deleted",
5427
+ "spec_session.created",
5428
+ "spec_session.updated",
5429
+ "spec_session.deleted",
5430
+ "*"
5431
+ ];
5432
+ var streamEventTypesSchema = z33.array(z33.enum(projectStreamEventTypes)).min(1).describe(
5433
+ 'Event types to receive. Required \u2014 pass ["*"] to follow all of them. spec_file.updated covers new revisions, rollbacks, and moves to or from the trash; spec_file.lock covers acquire, renew, release and takeover; spec_session.updated covers completion, archive, unarchive and rename.'
5434
+ );
5435
+ function streamTokenSummary(token) {
5436
+ return {
5437
+ id: token.id,
5438
+ token_prefix: token.tokenPrefix,
5439
+ label: token.label,
5440
+ resource_type: token.resourceType,
5441
+ resource_id: token.resourceId,
5442
+ event_types: token.eventTypes,
5443
+ created_by: token.createdBy,
5444
+ created_at: token.createdAt.toISOString(),
5445
+ expires_at: token.expiresAt.toISOString(),
5446
+ live: token.live,
5447
+ connect_count: token.connectCount,
5448
+ ...token.revokedAt ? { revoked_at: token.revokedAt.toISOString() } : {},
5449
+ ...token.resourceDeletedAt ? { resource_deleted_at: token.resourceDeletedAt.toISOString() } : {},
5450
+ ...token.lastConnectedAt ? { last_connected_at: token.lastConnectedAt.toISOString() } : {}
5451
+ };
5452
+ }
5453
+ function streamTokenMintOutput(result) {
5454
+ return {
5455
+ ...streamTokenSummary(result),
5456
+ url: result.url,
5457
+ note: "Treat this URL as a password: anyone who has it can read this project's events until it expires, with no further authentication. Pass it straight to a WebSocket client and do NOT write it to a file, a commit, an issue or a chat reply, and do not log or repeat it. It is shown once and cannot be retrieved again. If it may have been exposed, call revoke_stream_token with the id above. Open it with subprotocol 'v1'; each text frame is one JSON event, and reconnecting with ?after=<seq> resumes from the last frame you saw."
5458
+ };
5459
+ }
5460
+
5461
+ // src/server/tools/create_stream_token.ts
5462
+ var inputSchema32 = {
5463
+ resource_type: z34.enum(["project"]).default("project").describe("What to follow. Only 'project' is supported today."),
5464
+ resource_id: z34.string().min(1).describe("UUID of the project to stream events for."),
5465
+ event_types: streamEventTypesSchema,
5466
+ ttl_seconds: z34.number().int().positive().optional().describe(
5467
+ "How long the URL stays valid. Defaults to 24 hours, capped at 7 days. Out-of-range values are clamped rather than rejected \u2014 check expires_at in the result."
5468
+ ),
5469
+ label: z34.string().optional().describe("Short human-readable label, so a person can recognise this token in a listing.")
5470
+ };
5471
+ var createStreamTokenTool = {
5472
+ name: "create_stream_token",
5473
+ description: "Create a short-lived WebSocket URL that streams a project's events in real time \u2014 like an S3 presigned URL, the returned URL is the whole credential and needs no auth header. Choose the project and the event types to follow; the result carries the full wss:// URL. Feed it to any WebSocket client with subprotocol 'v1' (for example Claude Code's Monitor tool). Each text frame is one JSON event carrying a `seq` cursor; reconnect with ?after=<seq> to resume without missing events. Defaults to 24 hours, 7 days maximum. TREAT THE RETURNED URL LIKE A PASSWORD: anyone holding it can read every event on this project until it expires, without authenticating. Pass it directly to the WebSocket client; never write it to a file, a commit, an issue or a chat reply, and never log or repeat it. It is shown once and cannot be retrieved again. If it may have been exposed, revoke it at once with revoke_stream_token \u2014 that is also how you end one early. Prefer the shortest ttl_seconds that covers the work.",
5474
+ inputSchema: inputSchema32,
5475
+ handler: async (args, ctx) => {
5476
+ const result = await ctx.client.createStreamToken({
5477
+ resourceType: args.resource_type,
5478
+ resourceId: args.resource_id,
5479
+ eventTypes: args.event_types,
5480
+ ttlSeconds: args.ttl_seconds,
5481
+ label: args.label
5482
+ });
5483
+ return jsonResult(streamTokenMintOutput(result));
5484
+ }
5485
+ };
5486
+
5487
+ // src/server/tools/list_stream_tokens.ts
5488
+ import { z as z35 } from "zod";
5489
+ var LIST_DEFAULT5 = 50;
5490
+ var LIST_MAX5 = 200;
5491
+ var inputSchema33 = {
5492
+ resource_id: z35.string().optional().describe("Only tokens following this project."),
5493
+ live_only: z35.boolean().optional().describe("Hide expired and revoked tokens."),
5494
+ mine_only: z35.boolean().optional().describe(
5495
+ "Only tokens you created. Off by default, so a leaked URL can be found \u2014 note that revoking one you did not mint needs the organisation owner or admin role."
5496
+ ),
5497
+ limit: z35.number().int().positive().optional().describe(`Max tokens to return (default ${String(LIST_DEFAULT5)}, capped at ${String(LIST_MAX5)}).`),
5498
+ offset: z35.number().int().nonnegative().optional().describe("Pagination offset (default 0).")
5499
+ };
5500
+ var listStreamTokensTool = {
5501
+ name: "list_stream_tokens",
5502
+ description: "List the stream tokens in your organisation, with their scope, expiry and whether each is still live. Secrets are never returned \u2014 only a short prefix to tell tokens apart. Returns `total` and, when more remain, `next_offset`.",
5503
+ inputSchema: inputSchema33,
5504
+ handler: async (args, ctx) => {
5505
+ const limit = clampLimit(args.limit, LIST_DEFAULT5, LIST_MAX5);
5506
+ const offset = args.offset ?? 0;
5507
+ const page = await ctx.client.listStreamTokens({
5508
+ resourceId: args.resource_id,
5509
+ liveOnly: args.live_only,
5510
+ mineOnly: args.mine_only,
5511
+ limit,
5512
+ offset
5513
+ });
5514
+ const next = nextOffsetOf(offset, page.tokens.length, page.total);
5515
+ return jsonResult({
5516
+ tokens: page.tokens.map(streamTokenSummary),
5517
+ total: page.total,
5518
+ limit,
5519
+ offset,
5520
+ ...next === void 0 ? {} : { next_offset: next }
5521
+ });
5522
+ }
5523
+ };
5524
+
5525
+ // src/server/tools/revoke_stream_token.ts
5526
+ import { z as z36 } from "zod";
5527
+ var inputSchema34 = {
5528
+ id: z36.string().min(1).describe("Token id (from create_stream_token or list_stream_tokens) \u2014 NOT the secret itself.")
5529
+ };
5530
+ var revokeStreamTokenTool = {
5531
+ name: "revoke_stream_token",
5532
+ description: "Revoke a stream token immediately, closing its URL and deleting the events buffered for it. Use this as soon as a stream is no longer needed, or if its URL may have leaked \u2014 the URL is the credential, so revoking is the only way to stop it before it expires. Revoking a token you minted yourself always works. Revoking one somebody ELSE minted additionally requires the organisation owner or admin role, and is refused with 403 otherwise \u2014 so when hunting a leaked URL you did not create, expect that refusal and ask an organisation owner or admin rather than retrying.",
5533
+ inputSchema: inputSchema34,
5534
+ handler: async (args, ctx) => {
5535
+ const token = await ctx.client.revokeStreamToken(args.id);
5536
+ return jsonResult({ ...streamTokenSummary(token), revoked: true });
5537
+ }
5538
+ };
5539
+
5189
5540
  // src/server/server.ts
5190
5541
  function buildServer(deps) {
5191
5542
  const server = new McpServer({
@@ -5223,6 +5574,9 @@ function buildServer(deps) {
5223
5574
  registerTool(server, createArtifactTool, deps.client);
5224
5575
  registerTool(server, writeArtifactRevisionTool, deps.client);
5225
5576
  registerTool(server, rollbackArtifactTool, deps.client);
5577
+ registerTool(server, createStreamTokenTool, deps.client);
5578
+ registerTool(server, listStreamTokensTool, deps.client);
5579
+ registerTool(server, revokeStreamTokenTool, deps.client);
5226
5580
  return server;
5227
5581
  }
5228
5582
  async function startStdioServer(deps) {
@@ -5376,7 +5730,7 @@ async function canonicaliseRoot(root) {
5376
5730
  import { promises as fs4 } from "fs";
5377
5731
  import { join as join2, relative } from "path";
5378
5732
  import { spawn } from "child_process";
5379
- import { z as z33 } from "zod";
5733
+ import { z as z37 } from "zod";
5380
5734
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5381
5735
 
5382
5736
  // src/reverse/ignore.ts
@@ -5681,37 +6035,37 @@ var READ_FILE_MAX_LINES = 2e3;
5681
6035
  var LIST_DIR_HARD_CAP = 5e3;
5682
6036
  var LIST_DIR_DEFAULT_CAP = 1e3;
5683
6037
  var GREP_MAX_MATCHES_DEFAULT = 200;
5684
- var listDirInput = z33.object({
5685
- path: z33.string(),
5686
- recursive: z33.boolean().optional(),
5687
- maxEntries: z33.number().int().positive().optional(),
5688
- includeIgnored: z33.boolean().optional()
6038
+ var listDirInput = z37.object({
6039
+ path: z37.string(),
6040
+ recursive: z37.boolean().optional(),
6041
+ maxEntries: z37.number().int().positive().optional(),
6042
+ includeIgnored: z37.boolean().optional()
5689
6043
  });
5690
- var readFileInput = z33.object({
5691
- path: z33.string(),
5692
- offset: z33.number().int().nonnegative().optional(),
5693
- limit: z33.number().int().positive().optional(),
5694
- includeIgnored: z33.boolean().optional()
6044
+ var readFileInput = z37.object({
6045
+ path: z37.string(),
6046
+ offset: z37.number().int().nonnegative().optional(),
6047
+ limit: z37.number().int().positive().optional(),
6048
+ includeIgnored: z37.boolean().optional()
5695
6049
  });
5696
- var grepInput = z33.object({
5697
- pattern: z33.string().describe(
6050
+ var grepInput = z37.object({
6051
+ pattern: z37.string().describe(
5698
6052
  "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."
5699
6053
  ),
5700
- path: z33.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
5701
- isRegex: z33.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
5702
- caseSensitive: z33.boolean().optional(),
5703
- maxMatches: z33.number().int().positive().optional(),
5704
- contextLines: z33.number().int().nonnegative().optional(),
5705
- includeIgnored: z33.boolean().optional()
6054
+ path: z37.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
6055
+ isRegex: z37.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
6056
+ caseSensitive: z37.boolean().optional(),
6057
+ maxMatches: z37.number().int().positive().optional(),
6058
+ contextLines: z37.number().int().nonnegative().optional(),
6059
+ includeIgnored: z37.boolean().optional()
5706
6060
  });
5707
- var packCodebaseInput = z33.object({
5708
- subpath: z33.string().optional(),
5709
- includePatterns: z33.string().optional(),
5710
- ignorePatterns: z33.string().optional()
6061
+ var packCodebaseInput = z37.object({
6062
+ subpath: z37.string().optional(),
6063
+ includePatterns: z37.string().optional(),
6064
+ ignorePatterns: z37.string().optional()
5711
6065
  });
5712
- var packCodebaseReadPageInput = z33.object({
5713
- outputId: z33.string(),
5714
- page: z33.number().int().positive()
6066
+ var packCodebaseReadPageInput = z37.object({
6067
+ outputId: z37.string(),
6068
+ page: z37.number().int().positive()
5715
6069
  });
5716
6070
  function structuredError(error, message, extra) {
5717
6071
  const payload = { error, message, ...extra ?? {} };
@@ -6239,7 +6593,7 @@ function jsonSchemaFromZod(schema) {
6239
6593
  return zodToJson(schema);
6240
6594
  }
6241
6595
  function zodToJson(schema) {
6242
- if (schema instanceof z33.ZodObject) {
6596
+ if (schema instanceof z37.ZodObject) {
6243
6597
  const shape = schema.shape;
6244
6598
  const properties = {};
6245
6599
  const required = [];
@@ -6254,19 +6608,19 @@ function zodToJson(schema) {
6254
6608
  if (required.length > 0) out.required = required;
6255
6609
  return out;
6256
6610
  }
6257
- if (schema instanceof z33.ZodOptional) {
6611
+ if (schema instanceof z37.ZodOptional) {
6258
6612
  return zodToJson(schema.unwrap());
6259
6613
  }
6260
- if (schema instanceof z33.ZodString) {
6614
+ if (schema instanceof z37.ZodString) {
6261
6615
  return { type: "string" };
6262
6616
  }
6263
- if (schema instanceof z33.ZodNumber) {
6617
+ if (schema instanceof z37.ZodNumber) {
6264
6618
  return { type: "number" };
6265
6619
  }
6266
- if (schema instanceof z33.ZodBoolean) {
6620
+ if (schema instanceof z37.ZodBoolean) {
6267
6621
  return { type: "boolean" };
6268
6622
  }
6269
- if (schema instanceof z33.ZodEnum) {
6623
+ if (schema instanceof z37.ZodEnum) {
6270
6624
  return { type: "string", enum: schema.options };
6271
6625
  }
6272
6626
  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.107",
3
+ "version": "0.4.0-next.109",
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": {