@spacelr/mcp 0.0.12 → 0.1.0

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/dist/index.mjs CHANGED
@@ -597,6 +597,53 @@ function registerAuthTools(server, api) {
597
597
  }
598
598
  }
599
599
  );
600
+ server.registerTool(
601
+ "auth_verify_two_factor",
602
+ {
603
+ description: "Complete a two-factor authentication login. Call this after auth_login returns twoFactorRequired: true. Note: device trust is not persisted in MCP sessions \u2014 2FA will be required on every login. WARNING: token and code are visible in the conversation.",
604
+ inputSchema: {
605
+ token: z.string().min(1),
606
+ code: z.string().trim().regex(/^\d{6}$/, "Must be a 6-digit numeric code")
607
+ }
608
+ },
609
+ async ({ token, code }) => {
610
+ try {
611
+ const result = await api.post("/auth/verify-two-factor", { body: { token, code } });
612
+ return {
613
+ content: [{ type: "text", text: JSON.stringify(redactTokens(result), null, 2) }]
614
+ };
615
+ } catch (error) {
616
+ const message = error instanceof Error ? error.message : String(error);
617
+ return {
618
+ content: [{ type: "text", text: `Two-factor verification failed: ${message}` }],
619
+ isError: true
620
+ };
621
+ }
622
+ }
623
+ );
624
+ server.registerTool(
625
+ "auth_resend_two_factor_code",
626
+ {
627
+ description: "Resend the two-factor authentication code. Rate-limited to 3 requests per minute. Requires the twoFactorToken from auth_login. WARNING: the token is sensitive and will be visible in the conversation.",
628
+ inputSchema: {
629
+ token: z.string().min(1)
630
+ }
631
+ },
632
+ async ({ token }) => {
633
+ try {
634
+ const result = await api.post("/auth/resend-two-factor-code", { body: { token } });
635
+ return {
636
+ content: [{ type: "text", text: JSON.stringify(redactTokens(result), null, 2) }]
637
+ };
638
+ } catch (error) {
639
+ const message = error instanceof Error ? error.message : String(error);
640
+ return {
641
+ content: [{ type: "text", text: `Failed to resend two-factor code: ${message}` }],
642
+ isError: true
643
+ };
644
+ }
645
+ }
646
+ );
600
647
  }
601
648
 
602
649
  // libs/mcp-server/src/tools/projects.ts
@@ -1178,6 +1225,88 @@ function registerDatabaseTools(server, api) {
1178
1225
  }
1179
1226
  }
1180
1227
  );
1228
+ server.registerTool(
1229
+ "database_collections_update_metadata",
1230
+ {
1231
+ description: "Update a collection's realtime delivery mode, stream retention, write rate-limit override, or search filter requirements. `realtimeMode: 'stream'` switches the collection to Redis Streams for durable event replay (needed for chat, notifications, activity feeds). `realtimeMode: 'pubsub'` (default) is fire-and-forget, used for dashboards and list views. Exactly one of `streamRetention.maxLen` (approx. entry count) or `streamRetention.maxAgeMs` (retention age in ms) may be set. `writeThrottle` overrides the default per-collection write rate limit (10 writes / 10 s) \u2014 set `enabled: false` to disable entirely (e.g. audit logs), or override `limit` / `windowMs`. `searchConfig.requireFilter` declares top-level filter keys that callers MUST include in every `collection.search()` call to prevent unindexable full-scan regex queries \u2014 affects ALL callers immediately (no deprecation window). Response may include `warnings[]` if the project exceeds the 50-collection soft cap.",
1232
+ inputSchema: {
1233
+ projectId: z4.string(),
1234
+ name: z4.string(),
1235
+ realtimeMode: z4.enum(["pubsub", "stream"]).optional(),
1236
+ streamRetention: z4.object({
1237
+ maxLen: z4.number().int().min(1).optional(),
1238
+ maxAgeMs: z4.number().int().min(1e3).optional()
1239
+ }).refine(
1240
+ (r) => !(r.maxLen !== void 0 && r.maxAgeMs !== void 0),
1241
+ { message: "Set at most one of maxLen or maxAgeMs" }
1242
+ ).optional(),
1243
+ writeThrottle: z4.object({
1244
+ enabled: z4.boolean().optional().describe("When false, skip write throttling entirely for this collection"),
1245
+ limit: z4.number().int().min(1).max(1e4).optional().describe("Max writes per window (1..10000)"),
1246
+ windowMs: z4.number().int().min(1e3).max(36e5).optional().describe("Window duration in ms (1000..3_600_000 = 1s to 1h)")
1247
+ }).refine(
1248
+ (t) => t.enabled !== void 0 || t.limit !== void 0 || t.windowMs !== void 0,
1249
+ { message: "writeThrottle must set at least one of enabled, limit, or windowMs" }
1250
+ ).optional(),
1251
+ searchConfig: z4.object({
1252
+ requireFilter: z4.array(
1253
+ z4.string().regex(/^[a-zA-Z0-9_]+$/, "Top-level field names only (no dots, alphanumeric + underscore)").max(64).refine(
1254
+ (k) => !["__proto__", "constructor", "prototype"].includes(k),
1255
+ { message: "Reserved JS object keys (__proto__, constructor, prototype) are not allowed" }
1256
+ )
1257
+ ).min(1).max(10).optional().describe(
1258
+ "Top-level filter keys required on every search() call. Empty array rejected \u2014 to CLEAR an existing requirement, pass `searchConfig: {}` (an empty object). Omitting `searchConfig` entirely is a no-op (no change). Duplicate keys are deduplicated server-side, but you should send a deduplicated list."
1259
+ )
1260
+ // `searchConfig.fields` is intentionally NOT exposed here. The
1261
+ // backend DTO accepts it but treats it as "Reserved: future
1262
+ // allow-list of searchable fields. Stored but not enforced.".
1263
+ // Surfacing it via MCP would let callers persist values that
1264
+ // have no observable effect today and may take on different
1265
+ // semantics when enforcement ships. Add back once the feature
1266
+ // is live.
1267
+ }).strict().optional()
1268
+ }
1269
+ },
1270
+ async ({ projectId, name, realtimeMode, streamRetention, writeThrottle, searchConfig }) => {
1271
+ try {
1272
+ const body = {};
1273
+ if (realtimeMode !== void 0) body.realtimeMode = realtimeMode;
1274
+ if (streamRetention !== void 0) body.streamRetention = streamRetention;
1275
+ if (writeThrottle !== void 0) body.writeThrottle = writeThrottle;
1276
+ if (searchConfig !== void 0) {
1277
+ const normalized = {};
1278
+ if (searchConfig.requireFilter !== void 0) {
1279
+ normalized.requireFilter = Array.from(new Set(searchConfig.requireFilter));
1280
+ }
1281
+ body.searchConfig = normalized;
1282
+ }
1283
+ if (Object.keys(body).length === 0) {
1284
+ return {
1285
+ content: [
1286
+ {
1287
+ type: "text",
1288
+ text: "At least one of realtimeMode, streamRetention, writeThrottle, or searchConfig must be provided"
1289
+ }
1290
+ ],
1291
+ isError: true
1292
+ };
1293
+ }
1294
+ const result = await api.patch(
1295
+ `/databases/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(name)}`,
1296
+ { body }
1297
+ );
1298
+ return {
1299
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1300
+ };
1301
+ } catch (error) {
1302
+ const message = error instanceof Error ? error.message : String(error);
1303
+ return {
1304
+ content: [{ type: "text", text: `Failed to update collection metadata: ${message}` }],
1305
+ isError: true
1306
+ };
1307
+ }
1308
+ }
1309
+ );
1181
1310
  server.registerTool(
1182
1311
  "database_collections_delete",
1183
1312
  {
@@ -1592,6 +1721,9 @@ function registerDatabaseTools(server, api) {
1592
1721
  }
1593
1722
 
1594
1723
  // libs/mcp-server/src/tools/hosting.ts
1724
+ import { readdir, readFile } from "fs/promises";
1725
+ import { lstatSync } from "fs";
1726
+ import { join as join2, relative, resolve, sep } from "path";
1595
1727
  import { z as z5 } from "zod";
1596
1728
 
1597
1729
  // libs/mcp-server/src/zip.ts
@@ -1607,17 +1739,29 @@ var ZipBuilder = class {
1607
1739
  * @param content - UTF-8 text content of the file
1608
1740
  */
1609
1741
  addFile(name, content) {
1742
+ this.addFileBuffer(name, Buffer.from(content, "utf-8"));
1743
+ }
1744
+ /**
1745
+ * Add a binary file to the archive.
1746
+ *
1747
+ * @param name - File path inside the ZIP (e.g. "images/logo.png")
1748
+ * @param content - Raw file content as a Buffer
1749
+ */
1750
+ addFileBuffer(name, content) {
1610
1751
  const nameBuffer = Buffer.from(name, "utf-8");
1611
- const contentBuffer = Buffer.from(content, "utf-8");
1612
- const compressed = deflateRawSync(contentBuffer, { level: 9 });
1613
- const crc32 = computeCrc32(contentBuffer);
1752
+ const compressed = deflateRawSync(content, { level: 9 });
1753
+ const crc32 = computeCrc32(content);
1614
1754
  this.files.push({
1615
1755
  name: nameBuffer,
1616
- content: contentBuffer,
1756
+ content,
1617
1757
  compressed,
1618
1758
  crc32
1619
1759
  });
1620
1760
  }
1761
+ /** Number of files added to the archive. */
1762
+ get fileCount() {
1763
+ return this.files.length;
1764
+ }
1621
1765
  /** Generate the complete ZIP archive as a Buffer. */
1622
1766
  toBuffer() {
1623
1767
  const localHeaders = [];
@@ -1691,6 +1835,17 @@ function computeCrc32(data) {
1691
1835
  }
1692
1836
 
1693
1837
  // libs/mcp-server/src/tools/hosting.ts
1838
+ async function walkDir(dir, base, zip) {
1839
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
1840
+ const fullPath = join2(dir, entry.name);
1841
+ if (entry.isSymbolicLink()) continue;
1842
+ if (entry.isDirectory()) {
1843
+ await walkDir(fullPath, base, zip);
1844
+ } else {
1845
+ zip.addFileBuffer(relative(base, fullPath).replace(/\\/g, "/"), await readFile(fullPath));
1846
+ }
1847
+ }
1848
+ }
1694
1849
  function registerHostingTools(server, api) {
1695
1850
  server.registerTool(
1696
1851
  "hosting_deployments_list",
@@ -2106,6 +2261,73 @@ function registerHostingTools(server, api) {
2106
2261
  }
2107
2262
  }
2108
2263
  );
2264
+ server.registerTool(
2265
+ "hosting_deployments_upload_directory",
2266
+ {
2267
+ description: "Upload a local directory as a ZIP archive to an existing deployment. Supports binary files (images, fonts, compiled assets). Use this instead of hosting_deployments_upload when deploying a built frontend from the local filesystem.\n\nTypical workflow:\n 1. hosting_deployments_create \u2192 get deploymentId\n 2. hosting_deployments_upload_directory \u2192 upload ./dist or any local folder\n 3. hosting_deployments_activate \u2192 make the deployment live",
2268
+ inputSchema: {
2269
+ projectId: z5.string().describe("Project ID"),
2270
+ deploymentId: z5.string().describe("Deployment ID (from hosting_deployments_create)"),
2271
+ directoryPath: z5.string().describe('Absolute or relative path to the local directory to upload (e.g. "./dist")')
2272
+ }
2273
+ },
2274
+ async ({ projectId, deploymentId, directoryPath }) => {
2275
+ try {
2276
+ const absDir = resolve(directoryPath);
2277
+ const cwd = process.cwd();
2278
+ if (absDir !== cwd && !absDir.startsWith(cwd + sep)) {
2279
+ return {
2280
+ content: [{ type: "text", text: `directoryPath must be within the current working directory (${cwd})` }],
2281
+ isError: true
2282
+ };
2283
+ }
2284
+ const stat = lstatSync(absDir, { throwIfNoEntry: false });
2285
+ if (!stat) {
2286
+ return {
2287
+ content: [{ type: "text", text: `Directory not found: ${absDir}` }],
2288
+ isError: true
2289
+ };
2290
+ }
2291
+ if (!stat.isDirectory()) {
2292
+ return {
2293
+ content: [{ type: "text", text: `Path is not a directory: ${absDir}` }],
2294
+ isError: true
2295
+ };
2296
+ }
2297
+ const zip = new ZipBuilder();
2298
+ await walkDir(absDir, absDir, zip);
2299
+ if (zip.fileCount === 0) {
2300
+ return {
2301
+ content: [{ type: "text", text: `Directory is empty: ${absDir}` }],
2302
+ isError: true
2303
+ };
2304
+ }
2305
+ const zipBuffer = zip.toBuffer();
2306
+ const result = await api.uploadFile(
2307
+ `/hosting/projects/${encodeURIComponent(projectId)}/deployments/${encodeURIComponent(deploymentId)}/upload`,
2308
+ zipBuffer,
2309
+ "site.zip"
2310
+ );
2311
+ const response = {
2312
+ success: true,
2313
+ directoryPath: absDir,
2314
+ bundleSizeBytes: zipBuffer.length
2315
+ };
2316
+ if (result && typeof result === "object") {
2317
+ Object.assign(response, result);
2318
+ }
2319
+ return {
2320
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2321
+ };
2322
+ } catch (error) {
2323
+ const message = error instanceof Error ? error.message : String(error);
2324
+ return {
2325
+ content: [{ type: "text", text: `Directory upload failed: ${message}` }],
2326
+ isError: true
2327
+ };
2328
+ }
2329
+ }
2330
+ );
2109
2331
  }
2110
2332
 
2111
2333
  // libs/mcp-server/src/tools/storage.ts