@homespunapps/mcp 1.0.0 → 1.0.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @homespunapps/mcp
2
2
 
3
- A thin **stdio [Model Context Protocol](https://modelcontextprotocol.io) server** for [Homespun](https://github.com/aerolalit/homespun). It lets any MCP client Claude Desktop, Cursor, Windsurf, Cline, your own host hand a human a rich interactive UI by URL and get structured data back: forms, approvals, pickers, surveys, dashboards, diff/doc review, multi-step wizards.
3
+ A thin **stdio [Model Context Protocol](https://modelcontextprotocol.io) server** for [Homespun](https://homespun.dev). It lets any MCP client (Claude Desktop, Cursor, Windsurf, Cline, your own host) hand a human a rich interactive UI by URL and get structured data back: forms, approvals, pickers, surveys, dashboards, diff/doc review, multi-step wizards.
4
4
 
5
5
  It is a wrapper, not a reimplementation: all relay I/O goes through [`@homespunapps/core`](https://www.npmjs.com/package/@homespunapps/core), and config is shared with the [`homespun` CLI](https://www.npmjs.com/package/@homespunapps/cli) (`~/.config/homespun/config.json`) — so the CLI and this server use the **same agent identity**.
6
6
 
@@ -84,7 +84,7 @@ All environment variables are optional — the defaults target the hosted relay
84
84
 
85
85
  | Variable | Default | Purpose |
86
86
  | --- | --- | --- |
87
- | `HOMESPUN_URL` | `https://homespun.dev` | Relay base URL. Set to point at a self-hosted relay. |
87
+ | `HOMESPUN_URL` | `https://homespun.dev` | Relay base URL. Set to point at a different relay. |
88
88
  | `HOMESPUN_API_KEY` | _(auto-registered)_ | Agent API key. If unset, the server registers an agent on first use and saves the key to `~/.config/homespun/config.json` (shared with the CLI). |
89
89
  | `HOMESPUN_TOKEN` | — | Alias for `HOMESPUN_API_KEY` (for hosts that name secrets `*_TOKEN`). `HOMESPUN_API_KEY` wins if both are set. |
90
90
  | `HOMESPUN_AGENT_NAME` | `homespun-mcp` | Display name for the auto-registered agent. |
package/dist/index.js CHANGED
@@ -69,7 +69,7 @@ Tools exposed: deploy_app, list_rows, get_row, upsert_row, update_row,
69
69
  delete_row, get_feed_events, apps, members, attachments, taste, key,
70
70
  feedback, agent, get_skill.
71
71
 
72
- See https://github.com/aerolalit/homespun for docs.
72
+ See https://docs.homespun.dev for docs.
73
73
  `;
74
74
  main().catch((e) => {
75
75
  process.stderr.write(`homespun-mcp: fatal: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}\n`);
package/dist/tools.js CHANGED
@@ -157,7 +157,7 @@ const deployAppShape = {
157
157
  .string()
158
158
  .min(1)
159
159
  .describe("The app's UI as a complete HTML document (single file, up to the relay's size cap)."),
160
- manifest: jsonObjectSchema.describe("The x-homespun-manifest capability document (a JSON object): app metadata, declared collections (+ per-collection write/delete role lists), external fetch hosts, CDN flag. Call get_skill for the full grammar before authoring one from scratch."),
160
+ manifest: jsonObjectSchema.describe("The x-homespun-manifest capability document (a JSON object). Eight extension keys: app metadata; collections (+ per-collection write/read/delete role lists); externalHosts (fetch allowlist); cdn (allow CDN scripts/styles); capabilities (Permissions-Policy opt-ins); embeds (iframe frame-src allowlist); notify (email-on-row rules); webhooks (signed HTTP POST on-row rules). Call get_skill for the full grammar before authoring one from scratch."),
161
161
  visibility: z
162
162
  .enum(["private", "link", "public"])
163
163
  .optional()
@@ -170,6 +170,21 @@ const deployAppShape = {
170
170
  .boolean()
171
171
  .optional()
172
172
  .describe("REDEPLOY only. Bypass the compat gate on a narrowing manifest change (a removed/narrowed collection is detached, never deleted)."),
173
+ assets: z
174
+ .array(z.object({
175
+ path: z
176
+ .string()
177
+ .describe("App-relative, same-origin reference the HTML uses, e.g. 'frames/000.jpg' or 'media/intro.mp4'. Relative ONLY: no leading '/', no '..' segment, no backslash, charset [A-Za-z0-9._/-], not under a reserved prefix (_hs, b)."),
178
+ content_base64: z
179
+ .string()
180
+ .describe("Standard base64 of the asset's raw bytes."),
181
+ mime: z
182
+ .string()
183
+ .optional()
184
+ .describe("Advisory content-type. The relay sniffs the REAL type from the bytes and enforces the attachment allowlist; omit it (or set application/octet-stream) for data files like CSV that don't magic-byte-sniff, so they are stored + served as an inert download."),
185
+ }))
186
+ .optional()
187
+ .describe("Optional bundle of files shipped WITH the app in ONE deploy: images, fonts, audio/video, data. Each asset is validated + stored app-scoped exactly like a normal attachment (byte-sniff, allowlist, size cap, quota, scan) and served at its `path` on the app's OWN origin, so the page references it by a stable same-origin path (`<img src=\"frames/000.jpg\">`, `<video src=\"media/intro.mp4\">`; media/font paths support HTTP Range). The whole deploy is rejected atomically if any asset fails validation. A redeploy's assets REPLACE the previous version's set. Bounded by the relay's per-deploy asset-count cap; total bytes by the per-app blob quota."),
173
188
  };
174
189
  const listRowsShape = {
175
190
  app_id: z.string().min(1).describe("The app id."),
@@ -248,12 +263,21 @@ const getFeedEventsShape = {
248
263
  };
249
264
  const appsShape = {
250
265
  action: z
251
- .enum(["list", "show", "update", "delete", "wake"])
252
- .describe("list: YOUR owning human's apps. show/update/delete/wake: act on one app (app_id)."),
266
+ .enum([
267
+ "list",
268
+ "show",
269
+ "update",
270
+ "delete",
271
+ "wake",
272
+ "domain_set",
273
+ "domain_status",
274
+ "domain_remove",
275
+ ])
276
+ .describe("list: YOUR owning human's apps. show/update/delete/wake: act on one app (app_id). domain_set/domain_status/domain_remove: manage the app's ONE custom domain (app_id; domain_set also needs domain)."),
253
277
  app_id: z
254
278
  .string()
255
279
  .optional()
256
- .describe("Required for show/update/delete/wake."),
280
+ .describe("Required for show/update/delete/wake/domain_set/domain_status/domain_remove."),
257
281
  status: z
258
282
  .enum(["active", "dormant", "archived", "all"])
259
283
  .optional()
@@ -274,6 +298,10 @@ const appsShape = {
274
298
  .enum(["private", "link", "public"])
275
299
  .optional()
276
300
  .describe("update only. The new visibility (slug is immutable)."),
301
+ domain: z
302
+ .string()
303
+ .optional()
304
+ .describe("domain_set only. The bare custom domain to bind (e.g. app.example.com). The response's dns_records lists the DNS entries the domain owner must publish."),
277
305
  };
278
306
  const membersShape = {
279
307
  action: z
@@ -300,6 +328,8 @@ const attachmentsShape = {
300
328
  action: z
301
329
  .enum([
302
330
  "upload",
331
+ "presign",
332
+ "finalize",
303
333
  "download",
304
334
  "show",
305
335
  "list",
@@ -308,7 +338,17 @@ const attachmentsShape = {
308
338
  "revoke_token",
309
339
  "list_tokens",
310
340
  ])
311
- .describe("Binary attachment operations. upload: read a local file (file_path) and upload it; scope agent|app. download: fetch bytes by attachment_id to out_path (absolute) or return base64. show: metadata only. list: the agent's attachments. delete: soft-delete. mint_token: mint a /b/<token> capability URL (returned ONCE). revoke_token / list_tokens: manage those tokens."),
341
+ .describe("Binary attachment operations. upload: send bytes as an attachment inline; pass `content_base64` (base64 bytes, no filesystem) when you have bytes but no local file (e.g. an image you generated) or you are a hosted/remote agent, or `file_path` (absolute, read on the RELAY host) when the file is local to the relay; scope agent|app. presign + finalize: the LARGE-FILE path (e.g. a video). presign returns a { put_url, attachment_id }, then YOU PUT the raw bytes to put_url over HTTP out-of-band (bytes never pass through this tool / the model context as base64), then finalize confirms it. download: fetch bytes by attachment_id to out_path (absolute) or return base64. show: metadata only. list: the agent's attachments. delete: soft-delete. mint_token: mint a /b/<token> capability URL (returned ONCE). revoke_token / list_tokens: manage those tokens."),
342
+ size: z
343
+ .number()
344
+ .int()
345
+ .positive()
346
+ .optional()
347
+ .describe("presign: the exact byte length you will PUT. Committed at presign and re-verified against the uploaded bytes at finalize."),
348
+ sha256: z
349
+ .string()
350
+ .optional()
351
+ .describe("presign: the hex SHA-256 (64 chars) of the exact bytes you will PUT. Committed at presign and re-verified against the uploaded bytes at finalize."),
312
352
  attachment_id: z
313
353
  .string()
314
354
  .optional()
@@ -316,7 +356,11 @@ const attachmentsShape = {
316
356
  file_path: z
317
357
  .string()
318
358
  .optional()
319
- .describe("upload: ABSOLUTE path to the local file to upload."),
359
+ .describe("upload: ABSOLUTE path to a file read on the SERVER host running this MCP connector (the relay), NOT your machine. Only works when the file is local to the relay (e.g. a locally-run CLI). For a hosted or remote agent, use `content_base64` instead."),
360
+ content_base64: z
361
+ .string()
362
+ .optional()
363
+ .describe("upload: the file bytes as base64, uploaded WITHOUT any filesystem access. This is the upload path for hosted / remote agents (e.g. an image you just generated): pass it when you have bytes but no file on the relay host. If both `content_base64` and `file_path` are given, `content_base64` wins. The relay sniffs the real type and enforces the same size/allowlist/quota checks as a file upload."),
320
364
  scope: z
321
365
  .enum(["agent", "app"])
322
366
  .optional()
@@ -329,7 +373,7 @@ const attachmentsShape = {
329
373
  mime: z
330
374
  .string()
331
375
  .optional()
332
- .describe("upload: advisory Content-Type (the relay sniffs the bytes regardless)."),
376
+ .describe("upload/presign: advisory Content-Type. The relay BYTE-SNIFFS the actual bytes and stores/serves that sniffed type regardless (a lying mime is caught, never served inline). Required for presign (scopes the upload URL + fails fast against the allowlist)."),
333
377
  out_path: z
334
378
  .string()
335
379
  .optional()
@@ -409,6 +453,50 @@ const agentShape = {
409
453
  .optional()
410
454
  .describe("The one-shot claim code (required for claim)."),
411
455
  };
456
+ const communityShape = {
457
+ action: z
458
+ .enum(["publish", "list_pending", "get_submission", "approve", "reject"])
459
+ .describe("publish: publish one of YOUR apps as a community template (app_id; optional title/description/category/tags). The capture (html + manifest + the seed rows of seedOnInstall collections) lands PENDING review, installable by its returned direct link but not listed until approved. list_pending / get_submission / approve / reject are RELAY-OPERATOR-only review actions: list_pending (the review queue), get_submission (a submission's full html+manifest+seedRows, by snapshot_id), approve (snapshot_id, lists it in the gallery + supersedes the app's prior approved version), reject (snapshot_id + a required note that lands in the publisher's app feed)."),
460
+ app_id: z
461
+ .string()
462
+ .optional()
463
+ .describe("publish only. The id of an app YOU own to publish."),
464
+ title: z
465
+ .string()
466
+ .optional()
467
+ .describe("publish only. Listing title (1 to 80 chars). Defaults to the app's manifest name."),
468
+ description: z
469
+ .string()
470
+ .optional()
471
+ .describe("publish only. Listing blurb (up to 200 chars). Defaults to the manifest description."),
472
+ category: z
473
+ .string()
474
+ .optional()
475
+ .describe("publish only. Optional single-word category (e.g. 'household')."),
476
+ tags: z
477
+ .array(z.string())
478
+ .optional()
479
+ .describe("publish only. Up to 6 curation tags."),
480
+ snapshot_id: z
481
+ .string()
482
+ .optional()
483
+ .describe("Required for get_submission/approve/reject. The submission's snapshot id (from publish's response or list_pending)."),
484
+ note: z
485
+ .string()
486
+ .optional()
487
+ .describe("reject only. The required rejection note shown to the publisher (delivered to their app feed)."),
488
+ limit: z
489
+ .number()
490
+ .int()
491
+ .positive()
492
+ .max(200)
493
+ .optional()
494
+ .describe("list_pending only. Page size (1..200)."),
495
+ cursor: z
496
+ .string()
497
+ .optional()
498
+ .describe("list_pending only. Opaque cursor from a prior next_cursor."),
499
+ };
412
500
  const getSkillShape = {
413
501
  version_only: z
414
502
  .boolean()
@@ -422,7 +510,7 @@ export const TOOLS = [
422
510
  // ----- v2 app lifecycle + data (discrete, hot-path) -----------------------
423
511
  {
424
512
  name: "deploy_app",
425
- description: "Deploy a v2 app: an HTML document + a capability manifest (declared collections, external hosts, CDN flag), hosted at its own URL. Pass EITHER no `app_id` (create mints a slug + URL) OR `app_id` (redeploy an existing app with new content). A redeploy that NARROWS the manifest (drops a collection, tightens a schema, revokes a role) is refused with manifest_incompatible_redeploy unless force:true; a narrowed collection is then detached, never deleted. BEFORE authoring: call get_skill for the manifest grammar. Returns { app_id, slug, url, version, visibility, created } (create) or { app_id, version, compat, breaks? } (redeploy).",
513
+ description: 'Deploy a v2 app: an HTML document + a capability manifest, hosted at its own URL. The manifest has eight extension keys: app metadata; collections (+ per-collection write/read/delete role lists); externalHosts (fetch allowlist); cdn (allow CDN scripts/styles); capabilities (Permissions-Policy opt-ins); embeds (iframe frame-src allowlist); notify (email-on-row rules); webhooks (signed HTTP POST on-row rules). Pass EITHER no `app_id` (create, mints a slug + URL) OR `app_id` (redeploy an existing app with new content). A redeploy that NARROWS the manifest (drops a collection, tightens a schema, revokes a role) is refused with manifest_incompatible_redeploy unless force:true; a narrowed collection is then detached, never deleted. Ship images/fonts/audio/video/data FILES with the app in the SAME call via `assets[]`: each is validated + stored app-scoped and served at its `path` on the app\'s own origin, so the HTML references it by a stable same-origin path (`<img src="frames/000.jpg">`, `<video src="media/clip.mp4">`), media and font paths support HTTP Range for seeking. A redeploy\'s assets replace the previous version\'s set. BEFORE authoring: call get_skill for the manifest grammar. Returns { app_id, slug, url, version, visibility, created } (create) or { app_id, version, compat, breaks? } (redeploy).',
426
514
  inputSchema: deployAppShape,
427
515
  annotations: {
428
516
  title: "Deploy App",
@@ -451,6 +539,7 @@ export const TOOLS = [
451
539
  manifest: manifest.value,
452
540
  visibility,
453
541
  slug,
542
+ assets: args["assets"],
454
543
  }));
455
544
  }
456
545
  if (str(args, "html") === undefined) {
@@ -463,6 +552,7 @@ export const TOOLS = [
463
552
  html: String(args["html"]),
464
553
  manifest: manifest.value,
465
554
  force: args["force"],
555
+ assets: args["assets"],
466
556
  });
467
557
  return jsonResult(redeployed);
468
558
  }
@@ -611,7 +701,7 @@ export const TOOLS = [
611
701
  },
612
702
  {
613
703
  name: "apps",
614
- description: "Manage v2 app lifecycle (deploy_app creates/redeploys; this tool covers the rest). ONE tool with an `action` enum: list (YOUR owning human's apps) | show (full detail incl. manifest) | update (visibility only slug is immutable) | delete (soft-delete, idempotent) | wake (a dormant app; a no-op reporting the actual status otherwise).",
704
+ description: "Manage v2 app lifecycle (deploy_app creates/redeploys; this tool covers the rest). ONE tool with an `action` enum: list (YOUR owning human's apps) | show (full detail incl. manifest) | update (visibility only - slug is immutable) | delete (soft-delete, idempotent) | wake (a dormant app; a no-op reporting the actual status otherwise) | domain_set (bind ONE custom domain; returns the DNS records the domain owner must publish) | domain_status (the domain record, live-refreshed against Cloudflare when enabled; inspect last_error when it is not activating) | domain_remove (unbind the domain, idempotent).",
615
705
  inputSchema: appsShape,
616
706
  // Consolidated tool: read actions (list/show) + mutating ones (update/
617
707
  // delete/wake). Hint reflects delete, the most-privileged action.
@@ -662,6 +752,25 @@ export const TOOLS = [
662
752
  return invalidArgs("wake requires `app_id`");
663
753
  }
664
754
  return jsonResult(await client.wakeApp(String(args["app_id"])));
755
+ case "domain_set":
756
+ if (str(args, "app_id") === undefined) {
757
+ return invalidArgs("domain_set requires `app_id`");
758
+ }
759
+ if (str(args, "domain") === undefined) {
760
+ return invalidArgs("domain_set requires `domain`");
761
+ }
762
+ return jsonResult(await client.setAppDomain(String(args["app_id"]), String(args["domain"])));
763
+ case "domain_status":
764
+ if (str(args, "app_id") === undefined) {
765
+ return invalidArgs("domain_status requires `app_id`");
766
+ }
767
+ return jsonResult(await client.getAppDomain(String(args["app_id"])));
768
+ case "domain_remove":
769
+ if (str(args, "app_id") === undefined) {
770
+ return invalidArgs("domain_remove requires `app_id`");
771
+ }
772
+ await client.deleteAppDomain(String(args["app_id"]));
773
+ return jsonResult({ app_id: args["app_id"], domain_removed: true });
665
774
  default:
666
775
  return invalidArgs(`unknown apps action '${action}'`);
667
776
  }
@@ -728,7 +837,7 @@ export const TOOLS = [
728
837
  // ----- consolidated management tools --------------------------------------
729
838
  {
730
839
  name: "attachments",
731
- description: "Binary attachments (images, PDFs, audio, video) referenced from event payloads / input_data via `format: homespun-attachment-id`. ONE tool with an `action` enum: upload | download | show | list | delete | mint_token | revoke_token | list_tokens. upload reads an ABSOLUTE file_path; download writes to an ABSOLUTE out_path (or returns base64). Scope an upload to agent (default, reusable) or app. mint_token returns a /b/<token> capability URL (ONCE) a browser can GET without your API key.",
840
+ description: "Binary attachments (images, PDFs, audio, video) referenced from event payloads / input_data via `format: homespun-attachment-id`. ONE tool with an `action` enum: upload | presign | finalize | download | show | list | delete | mint_token | revoke_token | list_tokens. upload (inline) takes EITHER `content_base64` (base64 bytes, no filesystem; use when you generated the bytes in-session or run as a hosted/remote agent) OR `file_path` (ABSOLUTE path read on the RELAY host, only usable when the file is local to the relay). FOR A LARGE FILE (e.g. a video) use presign + finalize instead of base64: (1) presign with { mime, size, sha256, scope } returns { put_url, attachment_id }; (2) YOU PUT the raw bytes to put_url over plain HTTP out-of-band, so the bytes never route through this tool or the model context, which is why base64 inline is impractical at that size; (3) finalize with the attachment_id. At finalize the relay re-reads the stored bytes, BYTE-SNIFFS the real type, and enforces the same allowlist + size + sha256 + quota + scan checks as any upload, so a presign that lies about its mime is caught and never served inline. The presigned path requires the Azure storage backend; a filesystem self-host returns a clear not-supported error (use inline upload there). download writes to an ABSOLUTE out_path (or returns base64). Scope an upload to agent (default, reusable) or app. mint_token returns a /b/<token> capability URL (ONCE) a browser can GET without your API key.",
732
841
  inputSchema: attachmentsShape,
733
842
  // Consolidated tool: read actions (download/show/list/list_tokens) +
734
843
  // mutating ones (upload/delete/mint_token/revoke_token). openWorld:true
@@ -746,18 +855,38 @@ export const TOOLS = [
746
855
  try {
747
856
  switch (action) {
748
857
  case "upload": {
858
+ // `content_base64` is the documented field; `content` is a silent
859
+ // alias for callers that used the earlier name.
860
+ const contentBase64 = str(args, "content_base64") ?? str(args, "content");
749
861
  const filePath = str(args, "file_path");
750
- if (filePath === undefined)
751
- return invalidArgs("upload requires `file_path` (absolute)");
862
+ if (contentBase64 === undefined && filePath === undefined)
863
+ return invalidArgs("upload requires `content_base64` (base64 bytes) or `file_path` (a path local to the relay host)");
752
864
  const scope = (str(args, "scope") ?? "agent");
753
865
  if (scope === "app" && str(args, "app_id") === undefined)
754
866
  return invalidArgs("scope=app requires `app_id`");
867
+ // Inline bytes win when both are given: an explicit `content_base64`
868
+ // is a deliberate no-filesystem upload, so never fall back to reading
869
+ // a file the caller also happened to name. No readFileSync on this
870
+ // path; the base64 is sent straight to the relay's inline route.
871
+ if (contentBase64 !== undefined) {
872
+ const ref = await client.uploadBlobInline(contentBase64, {
873
+ scope,
874
+ appId: str(args, "app_id"),
875
+ filename: str(args, "filename"),
876
+ mime: str(args, "mime"),
877
+ });
878
+ return jsonResult(ref);
879
+ }
755
880
  let bytes;
756
881
  try {
757
882
  bytes = readFileSync(filePath);
758
883
  }
759
884
  catch (e) {
760
- return invalidArgs(`failed to read file_path '${filePath}': ${e instanceof Error ? e.message : String(e)}`);
885
+ // `file_path` is read on the MCP server / relay host, NOT the
886
+ // calling agent's machine. For a hosted connector that host is
887
+ // Homespun's infra, so a remote agent's path always ENOENTs even
888
+ // when the file exists on its side. Say so, and point at the fix.
889
+ return invalidArgs(`failed to read file_path '${filePath}' (${e instanceof Error ? e.message : String(e)}). Note: file_path is read on the MCP server / relay host, not on your machine, so it only works when the file is local to the relay (e.g. a locally-run CLI). For a hosted or remote agent, pass content_base64 with the file bytes instead.`);
761
890
  }
762
891
  const ref = await client.uploadBlob(bytes, {
763
892
  scope,
@@ -767,6 +896,43 @@ export const TOOLS = [
767
896
  });
768
897
  return jsonResult(ref);
769
898
  }
899
+ case "presign": {
900
+ // Large-file direct-to-storage: reserve a pending attachment + get a
901
+ // PUT URL. The caller PUTs the bytes to put_url over HTTP, then calls
902
+ // finalize. `mime` is advisory (re-sniffed at finalize); size +
903
+ // sha256 are the commitment the finalize re-verifies against the
904
+ // uploaded bytes.
905
+ const mime = str(args, "mime");
906
+ const size = args["size"];
907
+ const sha256 = str(args, "sha256");
908
+ if (mime === undefined ||
909
+ typeof size !== "number" ||
910
+ sha256 === undefined)
911
+ return invalidArgs("presign requires `mime`, `size` (positive integer byte length), and `sha256` (hex sha-256 of the exact bytes you will PUT)");
912
+ const scope = (str(args, "scope") ?? "agent");
913
+ if (scope === "app" && str(args, "app_id") === undefined)
914
+ return invalidArgs("scope=app requires `app_id`");
915
+ const res = await client.presignBlob({
916
+ mime,
917
+ size,
918
+ sha256,
919
+ scope,
920
+ appId: str(args, "app_id"),
921
+ filename: str(args, "filename"),
922
+ });
923
+ // Surface it as { put_url, attachment_id, expires_at }; `put_url`
924
+ // is the name the flow docs use for the out-of-band PUT target.
925
+ return jsonResult({
926
+ put_url: res.upload_url,
927
+ attachment_id: res.attachment_id,
928
+ expires_at: res.expires_at,
929
+ });
930
+ }
931
+ case "finalize": {
932
+ if (str(args, "attachment_id") === undefined)
933
+ return invalidArgs("finalize requires `attachment_id`");
934
+ return jsonResult(await client.finalizeBlob(String(args["attachment_id"])));
935
+ }
770
936
  case "download": {
771
937
  if (str(args, "attachment_id") === undefined)
772
938
  return invalidArgs("download requires `attachment_id`");
@@ -991,6 +1157,74 @@ export const TOOLS = [
991
1157
  }
992
1158
  },
993
1159
  },
1160
+ {
1161
+ name: "community",
1162
+ description: "Publish an app you own as a COMMUNITY TEMPLATE, and (relay operators only) review submissions. ONE tool with an `action` enum: publish | list_pending | get_submission | approve | reject. publish captures your live app (html + manifest + the seed rows of its seedOnInstall collections + listing metadata) into a PENDING template - installable by the returned direct link but NOT listed in the public gallery until an operator approves it; you must have a verified email and at most a few pending submissions at once. The review actions are limited to the relay's configured community reviewers: list_pending (the queue), get_submission (a submission's full content by snapshot_id), approve (list it in the gallery; a re-publish supersedes your app's prior approved version), reject (with a required note that lands in the publisher's app feed).",
1163
+ inputSchema: communityShape,
1164
+ // Consolidated tool: read actions (list_pending/get_submission) + mutating
1165
+ // ones (publish/approve/reject). Hint reflects the most-privileged action.
1166
+ annotations: {
1167
+ title: "Community Templates",
1168
+ readOnlyHint: false,
1169
+ destructiveHint: true,
1170
+ idempotentHint: false,
1171
+ openWorldHint: true,
1172
+ },
1173
+ handler: async (client, args) => {
1174
+ const action = String(args["action"]);
1175
+ try {
1176
+ switch (action) {
1177
+ case "publish": {
1178
+ if (str(args, "app_id") === undefined) {
1179
+ return invalidArgs("publish requires `app_id`");
1180
+ }
1181
+ return jsonResult(await client.publishCommunityTemplate({
1182
+ appId: String(args["app_id"]),
1183
+ title: str(args, "title"),
1184
+ description: str(args, "description"),
1185
+ category: str(args, "category"),
1186
+ tags: Array.isArray(args["tags"])
1187
+ ? args["tags"]
1188
+ : undefined,
1189
+ }));
1190
+ }
1191
+ case "list_pending": {
1192
+ const opts = {};
1193
+ if (args["limit"] !== undefined)
1194
+ opts.limit = args["limit"];
1195
+ if (str(args, "cursor") !== undefined)
1196
+ opts.cursor = String(args["cursor"]);
1197
+ return jsonResult(await client.listCommunitySubmissions(opts));
1198
+ }
1199
+ case "get_submission":
1200
+ if (str(args, "snapshot_id") === undefined) {
1201
+ return invalidArgs("get_submission requires `snapshot_id`");
1202
+ }
1203
+ return jsonResult(await client.getCommunitySubmission(String(args["snapshot_id"])));
1204
+ case "approve":
1205
+ if (str(args, "snapshot_id") === undefined) {
1206
+ return invalidArgs("approve requires `snapshot_id`");
1207
+ }
1208
+ return jsonResult(await client.reviewCommunitySubmission(String(args["snapshot_id"]), { decision: "approve" }));
1209
+ case "reject": {
1210
+ if (str(args, "snapshot_id") === undefined) {
1211
+ return invalidArgs("reject requires `snapshot_id`");
1212
+ }
1213
+ const note = str(args, "note");
1214
+ if (note === undefined) {
1215
+ return invalidArgs("reject requires a non-empty `note`");
1216
+ }
1217
+ return jsonResult(await client.reviewCommunitySubmission(String(args["snapshot_id"]), { decision: "reject", note }));
1218
+ }
1219
+ default:
1220
+ return invalidArgs(`unknown community action '${action}'`);
1221
+ }
1222
+ }
1223
+ catch (e) {
1224
+ return errorResult(e);
1225
+ }
1226
+ },
1227
+ },
994
1228
  {
995
1229
  name: "get_skill",
996
1230
  description: "Fetch the relay's auto-updating SKILL.md (the full Homespun usage guide) — UNAUTHENTICATED, needs no API key. Call this to self-teach the Homespun workflow (events vs records, schema grammars, the poll loop) before driving the other tools. Pass version_only:true to get just the relay's skill version string (to check if a cached copy is stale).",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homespunapps/mcp",
3
3
  "mcpName": "io.github.aerolalit/homespun",
4
- "version": "1.0.0",
4
+ "version": "1.0.1",
5
5
  "description": "Model Context Protocol (stdio) server for Homespun: lets any MCP client (Claude Desktop, Cursor, …) hand a human a rich interactive UI by URL and get structured data back.",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -13,14 +13,9 @@
13
13
  "relay",
14
14
  "human-in-the-loop"
15
15
  ],
16
- "homepage": "https://github.com/aerolalit/homespun#readme",
17
- "repository": {
18
- "type": "git",
19
- "url": "git+https://github.com/aerolalit/homespun.git",
20
- "directory": "packages/mcp"
21
- },
16
+ "homepage": "https://homespun.dev",
22
17
  "bugs": {
23
- "url": "https://github.com/aerolalit/homespun/issues"
18
+ "url": "mailto:support@homespun.dev"
24
19
  },
25
20
  "engines": {
26
21
  "node": ">=20"
package/server.json CHANGED
@@ -4,10 +4,7 @@
4
4
  "title": "Homespun",
5
5
  "description": "Hand a human a rich interactive UI by URL and get structured data back, from any MCP client.",
6
6
  "version": "0.0.29",
7
- "repository": {
8
- "url": "https://github.com/aerolalit/homespun",
9
- "source": "github"
10
- },
7
+ "websiteUrl": "https://homespun.dev",
11
8
  "packages": [
12
9
  {
13
10
  "registryType": "npm",
@@ -26,7 +23,7 @@
26
23
  },
27
24
  {
28
25
  "name": "HOMESPUN_URL",
29
- "description": "Homespun relay base URL. Defaults to the hosted relay https://homespun.dev; set this to point at a self-hosted relay.",
26
+ "description": "Homespun relay base URL. Defaults to the hosted relay https://homespun.dev; set this to point at a different relay.",
30
27
  "isRequired": false,
31
28
  "isSecret": false
32
29
  },