@myspec/mcp-server 0.1.5-next.62 → 0.1.5-next.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/dist/index.js +335 -104
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,8 +89,8 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
|
|
|
89
89
|
|---|---|---|
|
|
90
90
|
| `list_projects` | `query?`, `limit?`, `offset?` | Paginated list of projects |
|
|
91
91
|
| `get_project` | `project_id` | Project + active sessions + files + attachments |
|
|
92
|
-
| `get_spec_file` | `file_id`, optional `include_download_url` | File metadata; with `include_download_url=N` also returns a download URL targeting revision `N` |
|
|
93
|
-
| `read_spec_file` | `file_id`, optional `revision` | UTF-8 content of the file (errors for binary or files > 1 MiB — use `download_spec_file` for those). Cached on disk under `MYSPEC_DOWNLOAD_ROOT` |
|
|
92
|
+
| `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` |
|
|
93
|
+
| `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` |
|
|
94
94
|
| `download_spec_file` | `file_id`, `destination_path`, optional `revision`, optional `overwrite` | Downloads the file's raw bytes to `destination_path` (absolute or cwd-relative; an existing directory gets the basename appended). Handles binary files and files up to 50 MiB. Refuses to replace an existing file unless `overwrite=true`. Returns only metadata (`saved_path`, `bytes_written`, `revision_number`) — **not** the content, so a large file can be saved locally and read in chunks |
|
|
95
95
|
| `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 |
|
|
96
96
|
| `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 |
|
|
@@ -112,6 +112,31 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
|
|
|
112
112
|
- Omit or `0` → metadata only.
|
|
113
113
|
- Any positive integer → adds a signed `download_url` and `expires_at`. Attachments do not have revisions, so the integer value is just an on/off flag.
|
|
114
114
|
|
|
115
|
+
### Concurrent edits: `content_version`
|
|
116
|
+
|
|
117
|
+
Several people (and AI sessions) can work the same project at once, so a spec file
|
|
118
|
+
can change between the moment you read it and the moment you write it back.
|
|
119
|
+
|
|
120
|
+
`get_spec_file` and `read_spec_file` return `content_version`, an opaque token for
|
|
121
|
+
the content they returned. Pass it to `update_spec_file` as `expected_version`:
|
|
122
|
+
|
|
123
|
+
- If nothing changed, the update succeeds and the file's version advances.
|
|
124
|
+
- If someone else saved in the meantime, the update is **rejected** — their version
|
|
125
|
+
is kept, nothing of theirs is overwritten — and the error names who changed it and
|
|
126
|
+
what to do: re-read, decide how the two changes combine, then update again.
|
|
127
|
+
|
|
128
|
+
Omitting `expected_version` falls back to the file's version as of the update call,
|
|
129
|
+
which only detects a change racing that one call. Always pass the token from the read
|
|
130
|
+
your new content was based on.
|
|
131
|
+
|
|
132
|
+
Notes:
|
|
133
|
+
|
|
134
|
+
- `content_version` is **not** `revision_count`: a revision revert decrements the
|
|
135
|
+
count, so the same count can describe different content.
|
|
136
|
+
- It is omitted rather than zeroed when the platform does not report one, and
|
|
137
|
+
`read_spec_file` omits it for a historical `revision` because it describes the
|
|
138
|
+
file's current content, not the revision you asked for.
|
|
139
|
+
|
|
115
140
|
## Claude Desktop config snippet
|
|
116
141
|
|
|
117
142
|
```json
|
package/dist/index.js
CHANGED
|
@@ -873,6 +873,153 @@ async function withRetry(fn, config = DEFAULT_RETRY_CONFIG, shouldRetry = defaul
|
|
|
873
873
|
};
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
+
// ../../packages/platform-client/shared/errors.ts
|
|
877
|
+
var PLATFORM_ERROR_BRAND = "__myspecPlatformError";
|
|
878
|
+
var KNOWN_CONFLICT_REASONS = [
|
|
879
|
+
"revision_mismatch",
|
|
880
|
+
"path_taken",
|
|
881
|
+
"precondition_required",
|
|
882
|
+
"precondition_malformed"
|
|
883
|
+
];
|
|
884
|
+
var PlatformHttpError = class extends Error {
|
|
885
|
+
status;
|
|
886
|
+
details;
|
|
887
|
+
/** Populated once the request layer knows them; useful in logs. */
|
|
888
|
+
method;
|
|
889
|
+
path;
|
|
890
|
+
/** How many attempts were made before giving up. */
|
|
891
|
+
attempts;
|
|
892
|
+
constructor(message, status, details) {
|
|
893
|
+
super(message);
|
|
894
|
+
this.name = "PlatformHttpError";
|
|
895
|
+
this.status = status;
|
|
896
|
+
this.details = details;
|
|
897
|
+
Object.defineProperty(this, PLATFORM_ERROR_BRAND, {
|
|
898
|
+
value: true,
|
|
899
|
+
enumerable: false
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
var PlatformConflictError = class extends PlatformHttpError {
|
|
904
|
+
conflict;
|
|
905
|
+
constructor(message, status, details, conflict) {
|
|
906
|
+
super(message, status, details);
|
|
907
|
+
this.name = "PlatformConflictError";
|
|
908
|
+
this.conflict = conflict;
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
var PlatformPreconditionRequiredError = class extends PlatformHttpError {
|
|
912
|
+
constructor(message, details) {
|
|
913
|
+
super(message, 428, details);
|
|
914
|
+
this.name = "PlatformPreconditionRequiredError";
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
function isPlatformHttpError(err) {
|
|
918
|
+
return typeof err === "object" && err !== null && err[PLATFORM_ERROR_BRAND] === true;
|
|
919
|
+
}
|
|
920
|
+
function conflictOf(err) {
|
|
921
|
+
if (err instanceof PlatformConflictError) {
|
|
922
|
+
return err.conflict;
|
|
923
|
+
}
|
|
924
|
+
if (statusOf(err) !== 409) {
|
|
925
|
+
return void 0;
|
|
926
|
+
}
|
|
927
|
+
const ownDetails = err.details;
|
|
928
|
+
const own = typeof ownDetails === "object" && ownDetails !== null && !Array.isArray(ownDetails) ? ownDetails : void 0;
|
|
929
|
+
const fromMessage = err instanceof Error ? parseHttpErrorDetails(err.message) : void 0;
|
|
930
|
+
return toConflictDetails(
|
|
931
|
+
own || fromMessage ? { ...fromMessage ?? {}, ...own ?? {} } : void 0
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
function isPlatformPreconditionRequiredError(err) {
|
|
935
|
+
return isPlatformHttpError(err) && err.status === 428;
|
|
936
|
+
}
|
|
937
|
+
function statusOf(err) {
|
|
938
|
+
if (typeof err !== "object" || err === null) {
|
|
939
|
+
return void 0;
|
|
940
|
+
}
|
|
941
|
+
const status = err.status;
|
|
942
|
+
if (typeof status === "number") {
|
|
943
|
+
return status;
|
|
944
|
+
}
|
|
945
|
+
const message = err.message;
|
|
946
|
+
if (typeof message === "string") {
|
|
947
|
+
const match = /HTTP (\d{3}):/.exec(message);
|
|
948
|
+
if (match) {
|
|
949
|
+
return Number(match[1]);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return void 0;
|
|
953
|
+
}
|
|
954
|
+
function parseHttpErrorDetails(message) {
|
|
955
|
+
const start = message.lastIndexOf("[details: ");
|
|
956
|
+
if (start === -1) {
|
|
957
|
+
return void 0;
|
|
958
|
+
}
|
|
959
|
+
const jsonStart = start + "[details: ".length;
|
|
960
|
+
for (let end = message.lastIndexOf("]"); end > jsonStart; end = message.lastIndexOf("]", end - 1)) {
|
|
961
|
+
try {
|
|
962
|
+
const parsed = JSON.parse(message.slice(jsonStart, end));
|
|
963
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
964
|
+
return parsed;
|
|
965
|
+
}
|
|
966
|
+
} catch {
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return void 0;
|
|
970
|
+
}
|
|
971
|
+
function numberField(details, key) {
|
|
972
|
+
const value = details?.[key];
|
|
973
|
+
return typeof value === "number" ? value : void 0;
|
|
974
|
+
}
|
|
975
|
+
function stringField(details, key) {
|
|
976
|
+
const value = details?.[key];
|
|
977
|
+
return typeof value === "string" ? value : void 0;
|
|
978
|
+
}
|
|
979
|
+
function toConflictDetails(details) {
|
|
980
|
+
const rawReason = stringField(details, "reason");
|
|
981
|
+
const reason = KNOWN_CONFLICT_REASONS.includes(rawReason) ? rawReason : "unknown";
|
|
982
|
+
return {
|
|
983
|
+
reason,
|
|
984
|
+
currentVersion: numberField(details, "current_version"),
|
|
985
|
+
expectedVersion: numberField(details, "expected_version"),
|
|
986
|
+
currentRevision: numberField(details, "current_revision"),
|
|
987
|
+
currentChecksum: stringField(details, "current_checksum"),
|
|
988
|
+
changedBy: stringField(details, "last_changed_by"),
|
|
989
|
+
changedByKind: stringField(details, "last_changed_by_kind"),
|
|
990
|
+
changedAt: stringField(details, "last_modified_at")
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function createPlatformError(message, status, details) {
|
|
994
|
+
if (status === 409) {
|
|
995
|
+
return new PlatformConflictError(message, status, details, toConflictDetails(details));
|
|
996
|
+
}
|
|
997
|
+
if (status === 428) {
|
|
998
|
+
return new PlatformPreconditionRequiredError(message, details);
|
|
999
|
+
}
|
|
1000
|
+
return new PlatformHttpError(message, status, details);
|
|
1001
|
+
}
|
|
1002
|
+
function truncate(text) {
|
|
1003
|
+
const MAX_ERROR_LENGTH = 500;
|
|
1004
|
+
return text.length > MAX_ERROR_LENGTH ? text.slice(0, MAX_ERROR_LENGTH) + "... [truncated]" : text;
|
|
1005
|
+
}
|
|
1006
|
+
async function throwFromResponse(response, fallbackMessage) {
|
|
1007
|
+
const rawBody = await response.text().catch(() => "");
|
|
1008
|
+
let message = fallbackMessage;
|
|
1009
|
+
let details;
|
|
1010
|
+
if (rawBody) {
|
|
1011
|
+
try {
|
|
1012
|
+
const parsed = JSON.parse(rawBody);
|
|
1013
|
+
details = parsed.details;
|
|
1014
|
+
const suffix = details ? ` [details: ${JSON.stringify(details)}]` : "";
|
|
1015
|
+
message = truncate(parsed.message ?? rawBody) + suffix;
|
|
1016
|
+
} catch {
|
|
1017
|
+
message = truncate(rawBody);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
throw createPlatformError(`HTTP ${response.status}: ${message}`, response.status, details);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
876
1023
|
// ../../packages/shared/logger.ts
|
|
877
1024
|
var LOG_LEVEL_PRIORITY = {
|
|
878
1025
|
debug: 0,
|
|
@@ -1002,6 +1149,12 @@ var BaseHttpClient = class {
|
|
|
1002
1149
|
path: path4,
|
|
1003
1150
|
attempts: result.attempts
|
|
1004
1151
|
});
|
|
1152
|
+
if (isPlatformHttpError(error)) {
|
|
1153
|
+
error.method = method;
|
|
1154
|
+
error.path = path4;
|
|
1155
|
+
error.attempts = result.attempts;
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1005
1158
|
const wrapped = new Error(
|
|
1006
1159
|
`${method} ${path4} failed after ${result.attempts} attempts: ${error.message}`
|
|
1007
1160
|
);
|
|
@@ -1030,23 +1183,7 @@ var BaseHttpClient = class {
|
|
|
1030
1183
|
signal: controller.signal
|
|
1031
1184
|
});
|
|
1032
1185
|
if (!response.ok) {
|
|
1033
|
-
|
|
1034
|
-
const rawBody = await response.text().catch(() => "");
|
|
1035
|
-
if (rawBody) {
|
|
1036
|
-
try {
|
|
1037
|
-
const errorBody = JSON.parse(rawBody);
|
|
1038
|
-
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1039
|
-
errorMessage2 = (errorBody.message ?? JSON.stringify(errorBody)) + details;
|
|
1040
|
-
} catch {
|
|
1041
|
-
const MAX_ERROR_LENGTH = 500;
|
|
1042
|
-
errorMessage2 = rawBody.length > MAX_ERROR_LENGTH ? rawBody.slice(0, MAX_ERROR_LENGTH) + "... [truncated]" : rawBody;
|
|
1043
|
-
}
|
|
1044
|
-
} else {
|
|
1045
|
-
errorMessage2 = "Unknown error";
|
|
1046
|
-
}
|
|
1047
|
-
const error = new Error(`HTTP ${response.status}: ${errorMessage2}`);
|
|
1048
|
-
error.status = response.status;
|
|
1049
|
-
throw error;
|
|
1186
|
+
await throwFromResponse(response, "Unknown error");
|
|
1050
1187
|
}
|
|
1051
1188
|
const contentLength = response.headers.get("Content-Length");
|
|
1052
1189
|
if (contentLength === "0" || response.status === 204) {
|
|
@@ -1137,6 +1274,7 @@ function transformSpecFileResponse(raw) {
|
|
|
1137
1274
|
fileSizeBytes: raw.file_size_bytes,
|
|
1138
1275
|
checksum: raw.checksum,
|
|
1139
1276
|
revisionCount: raw.revision_count,
|
|
1277
|
+
contentVersion: raw.content_version,
|
|
1140
1278
|
createdAt: new Date(raw.created_at),
|
|
1141
1279
|
updatedAt: new Date(raw.updated_at),
|
|
1142
1280
|
trashedAt: raw.trashed_at ? new Date(raw.trashed_at) : void 0,
|
|
@@ -1207,6 +1345,9 @@ function transformSpecSessionResponse(raw) {
|
|
|
1207
1345
|
};
|
|
1208
1346
|
}
|
|
1209
1347
|
|
|
1348
|
+
// ../../packages/platform-client/project/types/lock.types.ts
|
|
1349
|
+
var SPEC_FILE_LOCK_TTL_MS = 2 * 6e4;
|
|
1350
|
+
|
|
1210
1351
|
// ../../packages/platform-client/project/http/attachment.http-client.ts
|
|
1211
1352
|
var UPLOAD_TIMEOUT_MS = 3e4;
|
|
1212
1353
|
var HttpAttachmentClient = class {
|
|
@@ -1333,11 +1474,41 @@ var HttpAttachmentClient = class {
|
|
|
1333
1474
|
};
|
|
1334
1475
|
|
|
1335
1476
|
// ../../packages/platform-client/project/http/file.http-client.ts
|
|
1477
|
+
var HEADER_EXPECTED_VERSION = "X-Expected-Version";
|
|
1478
|
+
var HEADER_ACTOR_KIND = "X-Actor-Kind";
|
|
1479
|
+
function parseVersionETag(etag) {
|
|
1480
|
+
if (!etag) {
|
|
1481
|
+
return void 0;
|
|
1482
|
+
}
|
|
1483
|
+
const parsed = Number(etag.replace(/"/g, "").trim());
|
|
1484
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
|
|
1485
|
+
}
|
|
1336
1486
|
var HttpFileClient = class {
|
|
1337
1487
|
httpClient;
|
|
1338
1488
|
constructor(httpClient) {
|
|
1339
1489
|
this.httpClient = httpClient;
|
|
1340
1490
|
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Headers for the raw binary routes.
|
|
1493
|
+
*
|
|
1494
|
+
* BaseHttpClient is JSON-only, so uploadContent/saveManualRevision hand-roll
|
|
1495
|
+
* their fetch. Centralised here so the write precondition is attached
|
|
1496
|
+
* identically on both — a path that quietly omits it would look guarded while
|
|
1497
|
+
* writing blind, which is the worst possible failure for this mechanism.
|
|
1498
|
+
*/
|
|
1499
|
+
binaryWriteHeaders(jwtToken, opts) {
|
|
1500
|
+
const headers = {
|
|
1501
|
+
"Content-Type": "application/octet-stream",
|
|
1502
|
+
Authorization: `Bearer ${jwtToken}`
|
|
1503
|
+
};
|
|
1504
|
+
if (opts?.expectedVersion !== void 0) {
|
|
1505
|
+
headers[HEADER_EXPECTED_VERSION] = String(opts.expectedVersion);
|
|
1506
|
+
}
|
|
1507
|
+
if (opts?.actorKind) {
|
|
1508
|
+
headers[HEADER_ACTOR_KIND] = opts.actorKind;
|
|
1509
|
+
}
|
|
1510
|
+
return headers;
|
|
1511
|
+
}
|
|
1341
1512
|
async initiateUpload(request, jwtToken) {
|
|
1342
1513
|
const body = {
|
|
1343
1514
|
project_id: request.projectId,
|
|
@@ -1353,11 +1524,15 @@ var HttpFileClient = class {
|
|
|
1353
1524
|
"/project/v1/files/initiate",
|
|
1354
1525
|
body,
|
|
1355
1526
|
jwtToken,
|
|
1356
|
-
{}
|
|
1527
|
+
request.expectedVersion !== void 0 ? { headers: { [HEADER_EXPECTED_VERSION]: String(request.expectedVersion) } } : {}
|
|
1357
1528
|
);
|
|
1358
|
-
return {
|
|
1529
|
+
return {
|
|
1530
|
+
fileId: response.file_id,
|
|
1531
|
+
revision: response.revision,
|
|
1532
|
+
contentVersion: response.content_version
|
|
1533
|
+
};
|
|
1359
1534
|
}
|
|
1360
|
-
async uploadContent(fileId, content, jwtToken) {
|
|
1535
|
+
async uploadContent(fileId, content, jwtToken, opts) {
|
|
1361
1536
|
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1362
1537
|
const url = `${baseUrl}/project/v1/files/${fileId}/content`;
|
|
1363
1538
|
const blob = new Blob([content], {
|
|
@@ -1365,28 +1540,22 @@ var HttpFileClient = class {
|
|
|
1365
1540
|
});
|
|
1366
1541
|
const response = await fetch(url, {
|
|
1367
1542
|
method: "PUT",
|
|
1368
|
-
headers:
|
|
1369
|
-
"Content-Type": "application/octet-stream",
|
|
1370
|
-
Authorization: `Bearer ${jwtToken}`
|
|
1371
|
-
},
|
|
1543
|
+
headers: this.binaryWriteHeaders(jwtToken, opts),
|
|
1372
1544
|
body: blob
|
|
1373
1545
|
});
|
|
1374
1546
|
if (!response.ok) {
|
|
1375
|
-
|
|
1376
|
-
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1377
|
-
throw new Error(
|
|
1378
|
-
`HTTP ${response.status}: ${errorBody.message ?? "Upload failed"}${details}`
|
|
1379
|
-
);
|
|
1547
|
+
await throwFromResponse(response, "Upload failed");
|
|
1380
1548
|
}
|
|
1381
1549
|
const result = await response.json();
|
|
1382
1550
|
return {
|
|
1383
1551
|
fileId: result.file_id,
|
|
1384
1552
|
fileRevisionId: result.file_revision_id,
|
|
1385
1553
|
fileUri: result.file_uri,
|
|
1386
|
-
revisionNumber: result.revision_number
|
|
1554
|
+
revisionNumber: result.revision_number,
|
|
1555
|
+
contentVersion: result.content_version
|
|
1387
1556
|
};
|
|
1388
1557
|
}
|
|
1389
|
-
async saveManualRevision(fileId, content, jwtToken) {
|
|
1558
|
+
async saveManualRevision(fileId, content, jwtToken, opts) {
|
|
1390
1559
|
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1391
1560
|
const url = `${baseUrl}/project/v1/files/${fileId}/revisions`;
|
|
1392
1561
|
const blob = new Blob([content], {
|
|
@@ -1394,18 +1563,11 @@ var HttpFileClient = class {
|
|
|
1394
1563
|
});
|
|
1395
1564
|
const response = await fetch(url, {
|
|
1396
1565
|
method: "POST",
|
|
1397
|
-
headers:
|
|
1398
|
-
"Content-Type": "application/octet-stream",
|
|
1399
|
-
Authorization: `Bearer ${jwtToken}`
|
|
1400
|
-
},
|
|
1566
|
+
headers: this.binaryWriteHeaders(jwtToken, opts),
|
|
1401
1567
|
body: blob
|
|
1402
1568
|
});
|
|
1403
1569
|
if (!response.ok) {
|
|
1404
|
-
|
|
1405
|
-
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1406
|
-
throw new Error(
|
|
1407
|
-
`HTTP ${response.status}: ${errorBody.message ?? "Save failed"}${details}`
|
|
1408
|
-
);
|
|
1570
|
+
await throwFromResponse(response, "Save failed");
|
|
1409
1571
|
}
|
|
1410
1572
|
const result = await response.json();
|
|
1411
1573
|
return {
|
|
@@ -1414,7 +1576,8 @@ var HttpFileClient = class {
|
|
|
1414
1576
|
// The manual-revision endpoint does not return a current file URI (the FE only
|
|
1415
1577
|
// needs the revision id + number); keep the response shape stable with ''.
|
|
1416
1578
|
fileUri: "",
|
|
1417
|
-
revisionNumber: result.revision_number
|
|
1579
|
+
revisionNumber: result.revision_number,
|
|
1580
|
+
contentVersion: result.content_version
|
|
1418
1581
|
};
|
|
1419
1582
|
}
|
|
1420
1583
|
async getFile(fileId, jwtToken) {
|
|
@@ -1448,6 +1611,23 @@ var HttpFileClient = class {
|
|
|
1448
1611
|
};
|
|
1449
1612
|
}
|
|
1450
1613
|
async downloadContent(fileId, jwtToken) {
|
|
1614
|
+
return (await this.downloadContentWithVersion(fileId, jwtToken)).content;
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Downloads content together with the concurrency token it was read at.
|
|
1618
|
+
*
|
|
1619
|
+
* Use this, not {@link downloadContent}, when the bytes are going to be edited
|
|
1620
|
+
* and written back. The platform sets `ETag: "<contentVersion>"` on this
|
|
1621
|
+
* response precisely so the two arrive together; fetching the content and then
|
|
1622
|
+
* calling `getFile` for the version is two round trips with a window in
|
|
1623
|
+
* between where they disagree — and basing a write on a version that does not
|
|
1624
|
+
* match the content you edited is the bug the precondition exists to prevent.
|
|
1625
|
+
*
|
|
1626
|
+
* `contentVersion` is undefined when the platform sent no usable ETag (an
|
|
1627
|
+
* older deployment). A caller that needs a guarded write should treat that as
|
|
1628
|
+
* "unknown" rather than substituting a guess.
|
|
1629
|
+
*/
|
|
1630
|
+
async downloadContentWithVersion(fileId, jwtToken) {
|
|
1451
1631
|
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1452
1632
|
const url = `${baseUrl}/project/v1/files/${fileId}`;
|
|
1453
1633
|
const response = await fetch(url, {
|
|
@@ -1458,14 +1638,13 @@ var HttpFileClient = class {
|
|
|
1458
1638
|
}
|
|
1459
1639
|
});
|
|
1460
1640
|
if (!response.ok) {
|
|
1461
|
-
|
|
1462
|
-
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1463
|
-
throw new Error(
|
|
1464
|
-
`HTTP ${response.status}: ${errorBody.message ?? "Download failed"}${details}`
|
|
1465
|
-
);
|
|
1641
|
+
await throwFromResponse(response, "Download failed");
|
|
1466
1642
|
}
|
|
1467
1643
|
const buffer = await response.arrayBuffer();
|
|
1468
|
-
return
|
|
1644
|
+
return {
|
|
1645
|
+
content: new Uint8Array(buffer),
|
|
1646
|
+
contentVersion: parseVersionETag(response.headers.get("ETag"))
|
|
1647
|
+
};
|
|
1469
1648
|
}
|
|
1470
1649
|
async listFiles(projectId, jwtToken, opts) {
|
|
1471
1650
|
const queryParams = new URLSearchParams();
|
|
@@ -1572,11 +1751,7 @@ var HttpFileClient = class {
|
|
|
1572
1751
|
}
|
|
1573
1752
|
});
|
|
1574
1753
|
if (!response.ok) {
|
|
1575
|
-
|
|
1576
|
-
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1577
|
-
throw new Error(
|
|
1578
|
-
`HTTP ${response.status}: ${errorBody.message ?? "Download failed"}${details}`
|
|
1579
|
-
);
|
|
1754
|
+
await throwFromResponse(response, "Download failed");
|
|
1580
1755
|
}
|
|
1581
1756
|
const buffer = await response.arrayBuffer();
|
|
1582
1757
|
return new Uint8Array(buffer);
|
|
@@ -1840,7 +2015,7 @@ var PlatformClient = class {
|
|
|
1840
2015
|
try {
|
|
1841
2016
|
return await this.withTokenRetry((jwt, c) => c.attachment.getById(attachmentId, jwt));
|
|
1842
2017
|
} catch (err) {
|
|
1843
|
-
if (
|
|
2018
|
+
if (statusOf2(err) === 404) {
|
|
1844
2019
|
return null;
|
|
1845
2020
|
}
|
|
1846
2021
|
throw err;
|
|
@@ -1947,19 +2122,37 @@ var PlatformClient = class {
|
|
|
1947
2122
|
jwt
|
|
1948
2123
|
)
|
|
1949
2124
|
);
|
|
1950
|
-
await this.withTokenRetry(
|
|
1951
|
-
(jwt, c) => c.file.uploadContent(initiated.fileId, args.content, jwt
|
|
2125
|
+
const written = await this.withTokenRetry(
|
|
2126
|
+
(jwt, c) => c.file.uploadContent(initiated.fileId, args.content, jwt, {
|
|
2127
|
+
// Phase 1 just set this file's version; Phase 2 must be based on it, or a
|
|
2128
|
+
// second uploader racing us between the two phases silently wins the PUT.
|
|
2129
|
+
...initiated.contentVersion === void 0 ? {} : { expectedVersion: initiated.contentVersion },
|
|
2130
|
+
actorKind: "ai"
|
|
2131
|
+
})
|
|
1952
2132
|
);
|
|
1953
|
-
return this.getFileMetadata(initiated.fileId);
|
|
2133
|
+
return withWrittenVersion(await this.getFileMetadata(initiated.fileId), written.contentVersion);
|
|
1954
2134
|
}
|
|
1955
2135
|
/**
|
|
1956
2136
|
* Saves a new revision (overwrite) of an existing spec file by id. The
|
|
1957
2137
|
* platform computes the checksum server-side. Returns the file's updated
|
|
1958
2138
|
* metadata (including the bumped revision count).
|
|
2139
|
+
*
|
|
2140
|
+
* `expectedVersion` is the file's `contentVersion` as last read. The platform
|
|
2141
|
+
* rejects the write with 409 if the file has moved past it, which is what stops
|
|
2142
|
+
* an MCP overwrite from silently discarding an edit someone made in the webapp
|
|
2143
|
+
* since. Omitting it is a blind write.
|
|
2144
|
+
*
|
|
2145
|
+
* The write is attributed to an AI actor: an MCP client is an assistant acting
|
|
2146
|
+
* on the user's behalf, so a human editing the same file wins any race.
|
|
1959
2147
|
*/
|
|
1960
|
-
async saveSpecFileRevision(fileId, content) {
|
|
1961
|
-
await this.withTokenRetry(
|
|
1962
|
-
|
|
2148
|
+
async saveSpecFileRevision(fileId, content, opts = {}) {
|
|
2149
|
+
const written = await this.withTokenRetry(
|
|
2150
|
+
(jwt, c) => c.file.saveManualRevision(fileId, content, jwt, {
|
|
2151
|
+
...opts.expectedVersion === void 0 ? {} : { expectedVersion: opts.expectedVersion },
|
|
2152
|
+
actorKind: "ai"
|
|
2153
|
+
})
|
|
2154
|
+
);
|
|
2155
|
+
return withWrittenVersion(await this.getFileMetadata(fileId), written.contentVersion);
|
|
1963
2156
|
}
|
|
1964
2157
|
/**
|
|
1965
2158
|
* Finds a project's spec file by exact file_path, paging through the list.
|
|
@@ -2009,7 +2202,7 @@ var PlatformClient = class {
|
|
|
2009
2202
|
try {
|
|
2010
2203
|
return await call(jwt, clients);
|
|
2011
2204
|
} catch (err) {
|
|
2012
|
-
if (
|
|
2205
|
+
if (statusOf2(err) === 401) {
|
|
2013
2206
|
const refreshed = await this.tokenManager.forceRefresh();
|
|
2014
2207
|
return await call(refreshed, clients);
|
|
2015
2208
|
}
|
|
@@ -2020,7 +2213,10 @@ var PlatformClient = class {
|
|
|
2020
2213
|
function sha256Prefixed(content) {
|
|
2021
2214
|
return "sha256:" + createHash("sha256").update(content).digest("hex");
|
|
2022
2215
|
}
|
|
2023
|
-
function
|
|
2216
|
+
function withWrittenVersion(meta, contentVersion) {
|
|
2217
|
+
return contentVersion === void 0 ? meta : { ...meta, contentVersion };
|
|
2218
|
+
}
|
|
2219
|
+
function statusOf2(err) {
|
|
2024
2220
|
if (typeof err !== "object" || err === null) {
|
|
2025
2221
|
return void 0;
|
|
2026
2222
|
}
|
|
@@ -2385,6 +2581,25 @@ var getProjectTool = {
|
|
|
2385
2581
|
|
|
2386
2582
|
// src/server/tools/get_spec_file.ts
|
|
2387
2583
|
import { z as z7 } from "zod";
|
|
2584
|
+
|
|
2585
|
+
// src/server/tools/spec-file-output.ts
|
|
2586
|
+
function specFileSummary(file) {
|
|
2587
|
+
return {
|
|
2588
|
+
id: file.id,
|
|
2589
|
+
project_id: file.projectId,
|
|
2590
|
+
session_id: file.sessionId,
|
|
2591
|
+
file_path: file.filePath,
|
|
2592
|
+
file_type: file.fileType,
|
|
2593
|
+
file_size_bytes: file.fileSizeBytes,
|
|
2594
|
+
checksum: file.checksum,
|
|
2595
|
+
revision_count: file.revisionCount,
|
|
2596
|
+
...file.contentVersion === void 0 ? {} : { content_version: file.contentVersion },
|
|
2597
|
+
created_at: file.createdAt,
|
|
2598
|
+
updated_at: file.updatedAt
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// src/server/tools/get_spec_file.ts
|
|
2388
2603
|
var inputSchema7 = {
|
|
2389
2604
|
file_id: z7.string().min(1).describe("UUID of the file"),
|
|
2390
2605
|
include_download_url: z7.number().int().nonnegative().optional().describe(
|
|
@@ -2397,18 +2612,7 @@ var getSpecFileTool = {
|
|
|
2397
2612
|
inputSchema: inputSchema7,
|
|
2398
2613
|
handler: async (args, ctx) => {
|
|
2399
2614
|
const meta = await ctx.client.getFileMetadata(args.file_id);
|
|
2400
|
-
const base =
|
|
2401
|
-
id: meta.id,
|
|
2402
|
-
project_id: meta.projectId,
|
|
2403
|
-
session_id: meta.sessionId,
|
|
2404
|
-
file_type: meta.fileType,
|
|
2405
|
-
file_path: meta.filePath,
|
|
2406
|
-
file_size_bytes: meta.fileSizeBytes,
|
|
2407
|
-
checksum: meta.checksum,
|
|
2408
|
-
revision_count: meta.revisionCount,
|
|
2409
|
-
created_at: meta.createdAt,
|
|
2410
|
-
updated_at: meta.updatedAt
|
|
2411
|
-
};
|
|
2615
|
+
const base = specFileSummary(meta);
|
|
2412
2616
|
const requested = args.include_download_url ?? 0;
|
|
2413
2617
|
if (requested <= 0) {
|
|
2414
2618
|
return jsonResult(base);
|
|
@@ -2551,6 +2755,7 @@ var moveSpecFileToTrashTool = {
|
|
|
2551
2755
|
|
|
2552
2756
|
// src/server/tools/read_spec_file.ts
|
|
2553
2757
|
import { promises as fs3 } from "fs";
|
|
2758
|
+
import { createHash as createHash2 } from "crypto";
|
|
2554
2759
|
import { z as z11 } from "zod";
|
|
2555
2760
|
var MAX_READ_BYTES = 1 * 1024 * 1024;
|
|
2556
2761
|
var inputSchema11 = {
|
|
@@ -2564,7 +2769,8 @@ var readSpecFileTool = {
|
|
|
2564
2769
|
handler: async (args, ctx) => {
|
|
2565
2770
|
const meta = await ctx.client.getFileMetadata(args.file_id);
|
|
2566
2771
|
const targetRevision = args.revision ?? meta.revisionCount;
|
|
2567
|
-
|
|
2772
|
+
const isLatestRevision = targetRevision === meta.revisionCount;
|
|
2773
|
+
if (isLatestRevision && meta.fileSizeBytes > MAX_READ_BYTES) {
|
|
2568
2774
|
return errorResult(buildOversizeMessage2(args.file_id, meta.fileSizeBytes));
|
|
2569
2775
|
}
|
|
2570
2776
|
const cacheRoot = resolveDownloadRoot();
|
|
@@ -2581,11 +2787,11 @@ var readSpecFileTool = {
|
|
|
2581
2787
|
`read_spec_file refused to write cache: ${escape}. This indicates an unexpected platform response shape (projectId/fileId/filePath).`
|
|
2582
2788
|
);
|
|
2583
2789
|
}
|
|
2790
|
+
const cached = await pathExists(cachePath) ? await fs3.readFile(cachePath) : null;
|
|
2791
|
+
const cacheHit = cached !== null && (!isLatestRevision || sha256Prefixed2(cached) === meta.checksum);
|
|
2584
2792
|
let bytes;
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
bytes = await fs3.readFile(cachePath);
|
|
2588
|
-
cacheHit = true;
|
|
2793
|
+
if (cacheHit) {
|
|
2794
|
+
bytes = cached;
|
|
2589
2795
|
} else {
|
|
2590
2796
|
const downloaded = await ctx.client.downloadFile(args.file_id, args.revision);
|
|
2591
2797
|
bytes = downloaded.bytes;
|
|
@@ -2613,6 +2819,12 @@ var readSpecFileTool = {
|
|
|
2613
2819
|
return jsonResult({
|
|
2614
2820
|
file_id: meta.id,
|
|
2615
2821
|
revision_number: targetRevision,
|
|
2822
|
+
// Pass this back as update_spec_file's expected_version so an edit made by
|
|
2823
|
+
// someone else since this read is reported instead of overwritten. It
|
|
2824
|
+
// describes the file's CURRENT content, so it is emitted only when that is
|
|
2825
|
+
// what was read — beside a historical revision it would invite an update
|
|
2826
|
+
// that overwrites everything written since.
|
|
2827
|
+
...isLatestRevision && meta.contentVersion !== void 0 ? { content_version: meta.contentVersion } : {},
|
|
2616
2828
|
file_path: meta.filePath,
|
|
2617
2829
|
file_type: meta.fileType,
|
|
2618
2830
|
cache_path: maskHomedir(cachePath),
|
|
@@ -2632,6 +2844,9 @@ function isBinary(bytes) {
|
|
|
2632
2844
|
}
|
|
2633
2845
|
return false;
|
|
2634
2846
|
}
|
|
2847
|
+
function sha256Prefixed2(content) {
|
|
2848
|
+
return "sha256:" + createHash2("sha256").update(content).digest("hex");
|
|
2849
|
+
}
|
|
2635
2850
|
|
|
2636
2851
|
// src/server/tools/restore_spec_file_from_trash.ts
|
|
2637
2852
|
import { z as z12 } from "zod";
|
|
@@ -2754,22 +2969,6 @@ async function resolveSpecContent(input, maxBytes = MAX_LOCAL_SPEC_FILE_BYTES) {
|
|
|
2754
2969
|
return { bytes: new Uint8Array(raw) };
|
|
2755
2970
|
}
|
|
2756
2971
|
|
|
2757
|
-
// src/server/tools/spec-file-output.ts
|
|
2758
|
-
function specFileSummary(file) {
|
|
2759
|
-
return {
|
|
2760
|
-
id: file.id,
|
|
2761
|
-
project_id: file.projectId,
|
|
2762
|
-
session_id: file.sessionId,
|
|
2763
|
-
file_path: file.filePath,
|
|
2764
|
-
file_type: file.fileType,
|
|
2765
|
-
file_size_bytes: file.fileSizeBytes,
|
|
2766
|
-
checksum: file.checksum,
|
|
2767
|
-
revision_count: file.revisionCount,
|
|
2768
|
-
created_at: file.createdAt,
|
|
2769
|
-
updated_at: file.updatedAt
|
|
2770
|
-
};
|
|
2771
|
-
}
|
|
2772
|
-
|
|
2773
2972
|
// src/server/tools/update_spec_file.ts
|
|
2774
2973
|
var inputSchema15 = {
|
|
2775
2974
|
file_id: z15.string().min(1).optional().describe("UUID of the spec file to overwrite. Provide this or (project_id + file_path)."),
|
|
@@ -2780,11 +2979,14 @@ var inputSchema15 = {
|
|
|
2780
2979
|
),
|
|
2781
2980
|
local_file_path: z15.string().min(1).optional().describe(
|
|
2782
2981
|
"Path to a local file whose bytes become the new revision; the server reads it directly. Provide this OR content (exactly one). Absolute paths are most reliable; a relative path resolves against the MCP server working directory. Point this only at an intended spec file \u2014 its bytes are uploaded to the project as-is; never use it for secrets or unrelated files."
|
|
2982
|
+
),
|
|
2983
|
+
expected_version: z15.number().int().nonnegative().optional().describe(
|
|
2984
|
+
"The content_version the new body was based on, from the get_spec_file or read_spec_file you worked from. The update is rejected with an actionable error if someone else changed the file since. Omit only when overwriting unconditionally is genuinely intended \u2014 the project may be configured to reject unversioned updates."
|
|
2783
2985
|
)
|
|
2784
2986
|
};
|
|
2785
2987
|
var updateSpecFileTool = {
|
|
2786
2988
|
name: "update_spec_file",
|
|
2787
|
-
description: "Overwrite an existing spec file by saving a new revision. Provide the new body inline via `content` or by pointing at a local file with `local_file_path` \u2014 exactly one is required. Prefer `local_file_path` when the body is already an existing local file (the server reads it directly); use `content` for text generated in-memory. Reference the file by file_id or by (project_id + file_path).",
|
|
2989
|
+
description: "Overwrite an existing spec file by saving a new revision. Provide the new body inline via `content` or by pointing at a local file with `local_file_path` \u2014 exactly one is required. Prefer `local_file_path` when the body is already an existing local file (the server reads it directly); use `content` for text generated in-memory. Reference the file by file_id or by (project_id + file_path). Pass `expected_version` from the get_spec_file you based the new body on so a concurrent edit by someone else is reported instead of silently overwritten.",
|
|
2788
2990
|
inputSchema: inputSchema15,
|
|
2789
2991
|
handler: async (args, ctx) => {
|
|
2790
2992
|
const resolved = await resolveSpecContent({
|
|
@@ -2794,13 +2996,13 @@ var updateSpecFileTool = {
|
|
|
2794
2996
|
if (resolved.error || !resolved.bytes) {
|
|
2795
2997
|
return errorResult(resolved.error ?? "Failed to resolve file content.");
|
|
2796
2998
|
}
|
|
2797
|
-
let
|
|
2999
|
+
let target;
|
|
2798
3000
|
if (args.file_id) {
|
|
2799
3001
|
const meta = await ctx.client.getFileMetadata(args.file_id).catch(() => null);
|
|
2800
3002
|
if (!meta) {
|
|
2801
3003
|
return errorResult(`No spec file found with id ${args.file_id}.`);
|
|
2802
3004
|
}
|
|
2803
|
-
|
|
3005
|
+
target = meta;
|
|
2804
3006
|
} else if (args.project_id && args.file_path) {
|
|
2805
3007
|
const match = await ctx.client.findSpecFileByPath(args.project_id, args.file_path);
|
|
2806
3008
|
if (!match) {
|
|
@@ -2808,17 +3010,46 @@ var updateSpecFileTool = {
|
|
|
2808
3010
|
`No spec file found at ${args.file_path} in project ${args.project_id}.`
|
|
2809
3011
|
);
|
|
2810
3012
|
}
|
|
2811
|
-
|
|
3013
|
+
target = match;
|
|
2812
3014
|
} else {
|
|
2813
3015
|
return errorResult("Provide either file_id, or both project_id and file_path.");
|
|
2814
3016
|
}
|
|
2815
|
-
const
|
|
2816
|
-
|
|
3017
|
+
const expectedVersion = args.expected_version;
|
|
3018
|
+
try {
|
|
3019
|
+
const file = await ctx.client.saveSpecFileRevision(target.id, resolved.bytes, {
|
|
3020
|
+
...expectedVersion === void 0 ? {} : { expectedVersion }
|
|
3021
|
+
});
|
|
3022
|
+
return jsonResult({ file: specFileSummary(file) });
|
|
3023
|
+
} catch (err) {
|
|
3024
|
+
if (isPlatformPreconditionRequiredError(err)) {
|
|
3025
|
+
return errorResult(
|
|
3026
|
+
`${target.filePath} was NOT updated: this project requires every update to declare the version it was based on. Read the file with get_spec_file (or read_spec_file), then call update_spec_file again passing its content_version as expected_version.`
|
|
3027
|
+
);
|
|
3028
|
+
}
|
|
3029
|
+
const conflict = conflictOf(err);
|
|
3030
|
+
if (!conflict || !isVersionRace(conflict)) {
|
|
3031
|
+
throw err;
|
|
3032
|
+
}
|
|
3033
|
+
return errorResult(conflictMessage(target.filePath, conflict));
|
|
3034
|
+
}
|
|
2817
3035
|
}
|
|
2818
3036
|
};
|
|
3037
|
+
function isVersionRace(conflict) {
|
|
3038
|
+
return conflict.reason === "revision_mismatch" || conflict.reason === "unknown";
|
|
3039
|
+
}
|
|
3040
|
+
function conflictMessage(filePath, conflict) {
|
|
3041
|
+
const who = conflict.changedByKind === "ai" ? "an AI session" : (
|
|
3042
|
+
// changedBy is a user id, not a display name — attribute, do not name.
|
|
3043
|
+
conflict.changedBy ? `another user (id ${conflict.changedBy})` : "someone else"
|
|
3044
|
+
);
|
|
3045
|
+
const when = conflict.changedAt ? ` at ${conflict.changedAt}` : "";
|
|
3046
|
+
const rev = conflict.currentRevision === void 0 ? "" : ` It is now at revision ${String(conflict.currentRevision)}.`;
|
|
3047
|
+
const version = conflict.currentVersion === void 0 ? "" : ` (It was at version ${String(conflict.currentVersion)} when your write was rejected.)`;
|
|
3048
|
+
return `${filePath} was NOT updated: ${who} changed it${when} since you read it.${rev} Their version was kept \u2014 nothing of theirs was overwritten. Re-read the file with get_spec_file (or read_spec_file), decide how your change should combine with theirs, then update again passing the content_version that re-read returns as expected_version.${version} Do not simply resend the same content \u2014 you would undo their work.`;
|
|
3049
|
+
}
|
|
2819
3050
|
|
|
2820
3051
|
// src/server/tools/upload_attachment.ts
|
|
2821
|
-
import { createHash as
|
|
3052
|
+
import { createHash as createHash3 } from "crypto";
|
|
2822
3053
|
import { lstat, readFile as readFile2 } from "fs/promises";
|
|
2823
3054
|
import { basename, extname, isAbsolute as isAbsolute2 } from "path";
|
|
2824
3055
|
import { z as z16 } from "zod";
|
|
@@ -2994,7 +3225,7 @@ var uploadAttachmentTool = {
|
|
|
2994
3225
|
}
|
|
2995
3226
|
const fileName = args.file_name ?? basename(args.file_path);
|
|
2996
3227
|
const mimeType = args.mime_type ?? inferMimeFromName(fileName);
|
|
2997
|
-
const checksum = `sha256:${
|
|
3228
|
+
const checksum = `sha256:${createHash3("sha256").update(buf).digest("hex")}`;
|
|
2998
3229
|
const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
2999
3230
|
const existing = await ctx.client.listAttachments(args.project_id);
|
|
3000
3231
|
const nameMatches = existing.filter((a) => a.name === fileName);
|
|
@@ -3415,7 +3646,7 @@ function ripgrepIgnoreGlobs() {
|
|
|
3415
3646
|
}
|
|
3416
3647
|
|
|
3417
3648
|
// src/reverse/pack-codebase.ts
|
|
3418
|
-
import { createHash as
|
|
3649
|
+
import { createHash as createHash4 } from "crypto";
|
|
3419
3650
|
import { mkdtemp, readFile as readFile3, rm } from "fs/promises";
|
|
3420
3651
|
import { tmpdir } from "os";
|
|
3421
3652
|
import { join } from "path";
|
|
@@ -3542,7 +3773,7 @@ function computeOutputId(inputs) {
|
|
|
3542
3773
|
i: inputs.includePatterns ?? "",
|
|
3543
3774
|
x: inputs.ignorePatterns ?? ""
|
|
3544
3775
|
});
|
|
3545
|
-
const digest =
|
|
3776
|
+
const digest = createHash4("sha256").update(payload).digest("hex").slice(0, 12);
|
|
3546
3777
|
return `pack-${digest}`;
|
|
3547
3778
|
}
|
|
3548
3779
|
function readPage(cache, outputId, page) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myspec/mcp-server",
|
|
3
|
-
"version": "0.1.5-next.
|
|
3
|
+
"version": "0.1.5-next.64",
|
|
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": {
|