@homespunapps/mcp 1.0.1 → 1.4.2

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/tools.d.ts CHANGED
@@ -39,6 +39,18 @@ export interface ToolEnv {
39
39
  markdown?: string;
40
40
  version?: string;
41
41
  }>;
42
+ /**
43
+ * Whether tool handlers may touch the HOST filesystem on behalf of the
44
+ * caller (readFileSync for html_path / file_path, writeFileSync for
45
+ * out_path). When absent or true, host filesystem access is allowed: the
46
+ * stdio / local CLI is a trusted local host, so this preserves the existing
47
+ * convenience of passing a local path. When explicitly false, host
48
+ * filesystem access is DENIED. The hosted multi-tenant relay sets this to
49
+ * false so an authenticated remote agent can never read or write the relay
50
+ * container's own files (a local file inclusion / exfiltration vector),
51
+ * e.g. deploy_app html_path=/app/.env.
52
+ */
53
+ hostFsReads?: boolean;
42
54
  }
43
55
  /** One registered tool: name, human/LLM description, Zod input shape, handler. */
44
56
  export interface ToolDef {
package/dist/tools.js CHANGED
@@ -95,6 +95,11 @@ function str(args, key) {
95
95
  const v = args[key];
96
96
  return typeof v === "string" && v !== "" ? v : undefined;
97
97
  }
98
+ /** Read a boolean arg; undefined when absent or not a boolean. */
99
+ function bool(args, key) {
100
+ const v = args[key];
101
+ return typeof v === "boolean" ? v : undefined;
102
+ }
98
103
  /** True for a non-null, non-array plain object (`{"type":"object"}` land). */
99
104
  function isPlainObject(v) {
100
105
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -156,12 +161,22 @@ const deployAppShape = {
156
161
  html: z
157
162
  .string()
158
163
  .min(1)
159
- .describe("The app's UI as a complete HTML document (single file, up to the relay's size cap)."),
164
+ .optional()
165
+ .describe("The app's UI as a complete HTML document (single file, up to the relay's size cap), sent INLINE. Provide EITHER this or `html_path`. Inline is required for a hosted/remote connector that has no filesystem. If both are given, inline `html` wins."),
166
+ html_path: z
167
+ .string()
168
+ .optional()
169
+ .describe("ABSOLUTE path to the app's HTML document, read on the MCP-SERVER host (the machine running this connector: the relay for a hosted connector, or your CLI host for a locally-run one), NOT on the remote agent's machine. Alternative to inline `html` that avoids retransmitting a large HTML file on every deploy. Only works when the file is local to the MCP server, so it helps a locally-run connector, not a hosted/remote one (where the path will not exist and you get a clean error, so pass inline `html` there). If both `html` and `html_path` are given, inline `html` wins."),
170
+ dry_run: z
171
+ .boolean()
172
+ .optional()
173
+ .describe("Validate only: run the full manifest + asset-shape validation, the compat gate (for a redeploy), and the schedule-timezone advisory, then return { ok, warnings, compat?, breaks? } WITHOUT creating a version or mutating anything. An invalid manifest returns the SAME error a real deploy would; a narrowing redeploy reports the compat break instead of applying it. `check` is an accepted alias."),
174
+ check: z.boolean().optional().describe("Alias for `dry_run`."),
160
175
  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
176
  visibility: z
162
177
  .enum(["private", "link", "public"])
163
178
  .optional()
164
- .describe("CREATE only. Default 'private' (owner plus invited members, sign-in gated). 'link' shares with anyone holding the URL and always gets a server-generated unguessable slug; 'private' and 'public' accept an owner-chosen `slug`."),
179
+ .describe("CREATE only. Default 'private' (owner plus invited members, sign-in gated). 'link' shares with anyone holding the returned share_url, whose #k= fragment carries a secret key that can be reset (rotate it via the apps tool, action share_link_rotate) to cut off everyone with the old link; a 'link' app always gets a server-generated unguessable slug. 'private' and 'public' accept an owner-chosen `slug`."),
165
180
  slug: z
166
181
  .string()
167
182
  .optional()
@@ -267,17 +282,18 @@ const appsShape = {
267
282
  "list",
268
283
  "show",
269
284
  "update",
285
+ "share_link_rotate",
270
286
  "delete",
271
287
  "wake",
272
288
  "domain_set",
273
289
  "domain_status",
274
290
  "domain_remove",
275
291
  ])
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)."),
292
+ .describe("list: YOUR owning human's apps. show/update/delete/wake: act on one app (app_id). share_link_rotate: rotate a 'link' app's share token, returning a new share_url (the old link stops working); also generates one if the app has none yet. domain_set/domain_status/domain_remove: manage the app's ONE custom domain (app_id; domain_set also needs domain)."),
277
293
  app_id: z
278
294
  .string()
279
295
  .optional()
280
- .describe("Required for show/update/delete/wake/domain_set/domain_status/domain_remove."),
296
+ .describe("Required for show/update/share_link_rotate/delete/wake/domain_set/domain_status/domain_remove."),
281
297
  status: z
282
298
  .enum(["active", "dormant", "archived", "all"])
283
299
  .optional()
@@ -298,6 +314,10 @@ const appsShape = {
298
314
  .enum(["private", "link", "public"])
299
315
  .optional()
300
316
  .describe("update only. The new visibility (slug is immutable)."),
317
+ timezone: z
318
+ .string()
319
+ .optional()
320
+ .describe("update only. The app's IANA timezone for `schedules` reminders (e.g. Europe/Berlin). An app that declares schedules with no timezone fires reminders at 08:00 UTC."),
301
321
  domain: z
302
322
  .string()
303
323
  .optional()
@@ -305,8 +325,8 @@ const appsShape = {
305
325
  };
306
326
  const membersShape = {
307
327
  action: z
308
- .enum(["add", "list", "remove"])
309
- .describe("add: invite-or-attach a member by email (app_id+email). list: the app's owner + members (app_id). remove: drop a member (app_id+human_id)."),
328
+ .enum(["add", "list", "set_role", "remove", "roles"])
329
+ .describe("add: invite-or-attach a member by email (app_id+email; optional custom_role). list: the app's owner + members (app_id). set_role: change an existing member's custom role in place without signing them out (app_id+human_id+custom_role, null to clear). remove: drop a member (app_id+human_id). roles: the app's declared roles with, per collection, the EFFECTIVE access a holder has (separately for members and grant-link holders, whose role floors differ) plus how many members and live grant links hold each role (app_id)."),
310
330
  app_id: z.string().min(1).describe("The app id."),
311
331
  email: z
312
332
  .string()
@@ -316,14 +336,71 @@ const membersShape = {
316
336
  .enum(["member"])
317
337
  .optional()
318
338
  .describe("add only. Defaults to 'member' server-side — no other role is assignable via this API (ownership transfer is not available here)."),
339
+ custom_role: z
340
+ .string()
341
+ .nullable()
342
+ .optional()
343
+ .describe("add (optional) and set_role (required). A DECLARED custom role (an x-homespun-manifest.roles key) attached to the member ALONGSIDE their base member powers. A built-in/reserved role or an undeclared role is rejected. Omit on add for an ordinary member; pass null on set_role to clear the role back to a plain member."),
319
344
  human_id: z
320
345
  .string()
321
346
  .optional()
322
- .describe("remove only. The Human id to remove — see list's `humanId` field. The app owner cannot be removed."),
347
+ .describe("remove and set_role. The Human id to target — see list's `humanId` field. The app owner can be neither removed nor re-roled."),
348
+ };
349
+ const ingestShape = {
350
+ action: z
351
+ .enum(["list", "rotate"])
352
+ .describe("list: the app's inbound catch-hooks, each with its full secret URL, current rule (collection/mode/wake/handshake), and per-status delivery counts (app_id). rotate: mint a fresh secret for one hook and return its new URL once, invalidating the old URL immediately (app_id+name)."),
353
+ app_id: z.string().min(1).describe("The app id."),
354
+ name: z
355
+ .string()
356
+ .optional()
357
+ .describe("rotate only. The manifest ingest hook name to rotate (an x-homespun-manifest.ingest[].name). See list's `name` field."),
323
358
  };
324
359
  // ===========================================================================
325
360
  // Consolidated management tools
326
361
  // ===========================================================================
362
+ const grantsShape = {
363
+ action: z
364
+ .enum(["mint", "list", "revoke"])
365
+ .describe("mint: create a grant link carrying a declared custom role (app_id+role). list: the app's grant links (app_id). revoke: revoke one link (app_id+grant_id)."),
366
+ app_id: z.string().min(1).describe("The app id."),
367
+ role: z
368
+ .string()
369
+ .optional()
370
+ .describe("mint only. A DECLARED custom role for the app (an x-homespun-manifest.roles key). A built-in role (owner/member/agent/anyone) is rejected: a grant can never escalate."),
371
+ mode: z
372
+ .enum(["once", "multi"])
373
+ .optional()
374
+ .describe("mint only. once: one-time link, claimed by the first browser that opens it (a real per-person link; later opens by others are inert). multi (default): a shared link, capped by max_uses within expiry."),
375
+ max_uses: z
376
+ .number()
377
+ .int()
378
+ .positive()
379
+ .optional()
380
+ .describe("mint only (multi mode). Cap total claims; omit for unlimited within expiry. Ignored for once (forced to 1)."),
381
+ label: z
382
+ .string()
383
+ .optional()
384
+ .describe("mint only. Optional owner label shown in the grant list."),
385
+ ttl_seconds: z
386
+ .number()
387
+ .int()
388
+ .positive()
389
+ .optional()
390
+ .describe("mint only. Grant lifetime in seconds; defaults to the server default (30 days) and is clamped to the server max."),
391
+ pin_row_key: z
392
+ .string()
393
+ .optional()
394
+ .describe("mint only. Optional narrowing pin to a single row key. NARROWS within the role (never widens). Mutually exclusive with pin_where."),
395
+ pin_where: z
396
+ .array(z.unknown())
397
+ .optional()
398
+ .describe("mint only. Optional narrowing pin as Wave C2 where conditions ({field, op, value}[]). NARROWS within the role (never widens). Mutually exclusive with pin_row_key."),
399
+ grant_id: z
400
+ .string()
401
+ .optional()
402
+ .describe("revoke only. The grant link id (see list's `id` field)."),
403
+ };
327
404
  const attachmentsShape = {
328
405
  action: z
329
406
  .enum([
@@ -338,7 +415,7 @@ const attachmentsShape = {
338
415
  "revoke_token",
339
416
  "list_tokens",
340
417
  ])
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."),
418
+ .describe("Binary attachment operations. PREFER presign + finalize for any real image or media (anything beyond a tiny icon): presign returns a { put_url, attachment_id }, then YOU PUT the raw bytes to put_url over HTTP out-of-band, so the bytes NEVER enter the model context and cost NO tokens. upload with `content_base64` sends the bytes INLINE in the tool-call arguments, which loads them into the model context and costs tokens PROPORTIONAL TO FILE SIZE (even a few-hundred-KB image is very costly); use it only as a fallback for small assets or clients that cannot PUT out-of-band. upload: `content_base64` (base64 bytes, no filesystem) when you have bytes but no local file, or `file_path` (absolute, read on the RELAY host) when the file is local to the relay; scope agent|app. presign + finalize: (1) presign with { mime, size, sha256, scope }, (2) PUT the bytes to put_url out-of-band, (3) finalize confirms it (re-sniffs + re-checks the bytes). 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
419
  size: z
343
420
  .number()
344
421
  .int()
@@ -360,7 +437,7 @@ const attachmentsShape = {
360
437
  content_base64: z
361
438
  .string()
362
439
  .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."),
440
+ .describe("upload: the file bytes as base64, sent INLINE with no filesystem access. WARNING: the base64 rides in the tool-call arguments and enters the MODEL CONTEXT, costing tokens PROPORTIONAL TO FILE SIZE (a few-hundred-KB image is already very costly, and it compounds on every retry). PREFER presign + finalize for any real image or media whenever the client can do an out-of-band HTTP PUT; reserve `content_base64` for small assets (a tiny icon) or clients that cannot PUT out-of-band. 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."),
364
441
  scope: z
365
442
  .enum(["agent", "app"])
366
443
  .optional()
@@ -412,8 +489,8 @@ const tasteShape = {
412
489
  };
413
490
  const keyShape = {
414
491
  action: z
415
- .enum(["list", "revoke"])
416
- .describe("The calling agent's API key. list: key info (agent_id, key_prefix, timestamps). revoke: self-destruct the agent's OWN key it stops working immediately and is irreversible (requires confirm:true)."),
492
+ .enum(["list", "revoke", "mint"])
493
+ .describe("The calling agent's API key. list: key info (agent_id, key_prefix, timestamps). mint: mint a NEW sibling API key for YOUR OWN agent identity (same scope/ownership) and return its raw value ONCE, so use it to hand a CLI or child process a working credential; the sibling is a distinct key that shows up in a subsequent `list` made WITH it, the owner can revoke it, and the raw value is never retrievable again. mint always acts on the calling agent, never another agent's id. revoke: self-destruct the agent's OWN key, which stops working immediately and is irreversible (requires confirm:true)."),
417
494
  confirm: z.boolean().optional().describe("Required (true) for revoke."),
418
495
  };
419
496
  const feedbackShape = {
@@ -455,8 +532,15 @@ const agentShape = {
455
532
  };
456
533
  const communityShape = {
457
534
  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)."),
535
+ .enum([
536
+ "publish",
537
+ "list_pending",
538
+ "get_submission",
539
+ "approve",
540
+ "reject",
541
+ "set_trust_level",
542
+ ])
543
+ .describe("publish: publish one of YOUR apps as a community template (app_id; optional title/description/category/tags). PRIVACY: publishing makes the template content AND the captured seed rows (the LIVE rows of every seedOnInstall collection, captured at publish time) PUBLIC to every platform user once approved. Do NOT publish an app whose seedOnInstall collections hold real personal data (names, emails, addresses, messages, anything private): seed data must be example-only. Pass attest_example_only:true to attest you have checked this. The capture (html + manifest + seed rows) lands PENDING review, installable by its returned direct link but not listed until approved; an ESTABLISHED publisher is fast-tracked (the response's expedited/auto_approved tell you which). list_pending / get_submission / approve / reject / set_trust_level are RELAY-OPERATOR-only review actions: list_pending (the review queue, expedited submissions first), 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), set_trust_level (promote/demote a publisher by handle: handle + trust_level 'new'|'established')."),
460
544
  app_id: z
461
545
  .string()
462
546
  .optional()
@@ -469,6 +553,10 @@ const communityShape = {
469
553
  .string()
470
554
  .optional()
471
555
  .describe("publish only. Listing blurb (up to 200 chars). Defaults to the manifest description."),
556
+ long_description: z
557
+ .string()
558
+ .optional()
559
+ .describe("publish only. Optional long-form description (up to 4000 chars) shown on the template detail page below the short blurb, for readers and search ranking. Plain text: blank lines become paragraphs, and it is escaped (never rendered as raw HTML), so write prose, not markup."),
472
560
  category: z
473
561
  .string()
474
562
  .optional()
@@ -477,6 +565,63 @@ const communityShape = {
477
565
  .array(z.string())
478
566
  .optional()
479
567
  .describe("publish only. Up to 6 curation tags."),
568
+ slug: z
569
+ .string()
570
+ .optional()
571
+ .describe("publish only. Optional per-publisher slug (lowercase, 3 to 48 chars, hyphens). Gives the template a namespaced id <your-handle>/<slug>; a republish reuses the slug and must bump the version."),
572
+ version: z
573
+ .string()
574
+ .optional()
575
+ .describe("publish only. Semver MAJOR.MINOR.PATCH (default '1.0.0'). A republish under the same slug must be strictly greater than the current version."),
576
+ changelog_note: z
577
+ .string()
578
+ .optional()
579
+ .describe("publish only. A short note recorded in this version's changelog."),
580
+ setup_steps: z
581
+ .array(z.object({
582
+ kind: z
583
+ .enum(["config", "seed-data", "connect", "note", "upload"])
584
+ .describe("config = set a value; upload = an install-time file/image the app stores as an attachment id; connect = wire up an external data source; seed-data = review/replace captured starter data; note = a plain instruction."),
585
+ label: z.string().describe("Short step label (<= 80 chars)."),
586
+ key: z
587
+ .string()
588
+ .optional()
589
+ .describe("The settings-collection field this answer is written into (letters, digits, '_', up to 64 chars). Required for an 'upload' step, optional for a 'config' step, not allowed on the others. Must name a declared top-level field of the manifest's x-homespun-manifest.settingsCollection; an 'upload' target must be a string-typed field."),
590
+ description: z
591
+ .string()
592
+ .optional()
593
+ .describe("Optional longer instruction (<= 300 chars)."),
594
+ required: z
595
+ .boolean()
596
+ .optional()
597
+ .describe("Whether this step is required (default false)."),
598
+ secret: z
599
+ .boolean()
600
+ .optional()
601
+ .describe("Mark a step whose value is sensitive (an API key/token). Its default is MASKED on the public detail page; publish only your own example default, never a real secret."),
602
+ default: z
603
+ .string()
604
+ .optional()
605
+ .describe("Optional example/default value (<= 200 chars)."),
606
+ choices: z
607
+ .array(z.string())
608
+ .optional()
609
+ .describe("Optional list of allowed values (up to 12)."),
610
+ valueHint: z
611
+ .string()
612
+ .optional()
613
+ .describe("Optional format hint (<= 120 chars)."),
614
+ }))
615
+ .optional()
616
+ .describe("publish only. Ordered typed setup steps an installing agent follows after install (up to 20). A 'config'/'upload' step may carry a `key` naming a field of the manifest's settingsCollection that its install-time answer is written into. Read back via get_submission and rendered on the template detail page."),
617
+ derived_from_snapshot_id: z
618
+ .string()
619
+ .optional()
620
+ .describe("publish only. Optional remix/fork lineage: the snapshot id this template was derived from."),
621
+ attest_example_only: z
622
+ .boolean()
623
+ .optional()
624
+ .describe("publish only. Set true to attest that the template content AND the captured seed rows contain NO real personal data. Publishing makes both PUBLIC to every platform user, so seed data (the live rows of your seedOnInstall collections) must be example-only, never real names/emails/addresses/private messages. Recorded and shown to the reviewer; omitting it still publishes but is flagged to the operator as not attested."),
480
625
  snapshot_id: z
481
626
  .string()
482
627
  .optional()
@@ -496,6 +641,79 @@ const communityShape = {
496
641
  .string()
497
642
  .optional()
498
643
  .describe("list_pending only. Opaque cursor from a prior next_cursor."),
644
+ handle: z
645
+ .string()
646
+ .optional()
647
+ .describe("set_trust_level only. The @-handle of the publisher to promote or demote."),
648
+ trust_level: z
649
+ .enum(["new", "established"])
650
+ .optional()
651
+ .describe("set_trust_level only. 'established' fast-tracks the publisher's future submissions through review; 'new' reverts to full review."),
652
+ };
653
+ const publisherShape = {
654
+ action: z
655
+ .enum(["claim", "get", "update"])
656
+ .describe("get: return YOUR publisher profile (handle, tenure, counters). claim: set your @-handle ONCE (handle arg; lowercase, 3 to 32 chars, permanent after claiming; needs a verified email). update: change your public display_name/bio/url (any of them; needs a verified email)."),
657
+ handle: z
658
+ .string()
659
+ .optional()
660
+ .describe("claim only. The lowercase @-handle to claim (^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$). Permanent once claimed."),
661
+ display_name: z
662
+ .string()
663
+ .nullable()
664
+ .optional()
665
+ .describe("update only. Public display name (up to 80 chars); null clears it."),
666
+ bio: z
667
+ .string()
668
+ .nullable()
669
+ .optional()
670
+ .describe("update only. Short public bio (up to 500 chars); null clears it."),
671
+ url: z
672
+ .string()
673
+ .nullable()
674
+ .optional()
675
+ .describe("update only. Public http(s) URL (up to 200 chars); null clears it."),
676
+ };
677
+ const reviewShape = {
678
+ action: z
679
+ .enum(["create", "respond", "report", "remove", "unhold"])
680
+ .describe('create: leave a star rating (1..5) and optional body on a community template YOU have installed (identify it by `template` "<handle>/<slug>" or by `handle`+`slug`); requires a verified email, and one review per install. A body containing a link or contact email is auto-held for a moderator before it shows. respond: reply to a review of one of YOUR OWN templates (review_id + response; null clears it). report: flag a review for the relay\'s moderators (review_id + reason; one report per account). remove / unhold are RELAY-OPERATOR-only moderation actions on a review_id: remove takes a review down (adjusting the aggregate), unhold publishes a previously auto-held review.'),
681
+ template: z
682
+ .string()
683
+ .optional()
684
+ .describe("create only. The namespaced template id <handle>/<slug> to review."),
685
+ handle: z
686
+ .string()
687
+ .optional()
688
+ .describe("create only. Publisher handle (with `slug`), an alternative to `template`."),
689
+ slug: z
690
+ .string()
691
+ .optional()
692
+ .describe("create only. Per-publisher slug (with `handle`)."),
693
+ stars: z
694
+ .number()
695
+ .int()
696
+ .min(1)
697
+ .max(5)
698
+ .optional()
699
+ .describe("create only. Star rating, an integer 1 to 5."),
700
+ body: z
701
+ .string()
702
+ .optional()
703
+ .describe("create only. Optional written review (up to 2000 chars)."),
704
+ review_id: z
705
+ .string()
706
+ .optional()
707
+ .describe("Required for respond/report/remove/unhold. The review's id."),
708
+ response: z
709
+ .string()
710
+ .nullable()
711
+ .optional()
712
+ .describe("respond only. The publisher's public response (up to 2000 chars); null clears it."),
713
+ reason: z
714
+ .string()
715
+ .optional()
716
+ .describe("report only. Why you are reporting this review (up to 500 chars)."),
499
717
  };
500
718
  const getSkillShape = {
501
719
  version_only: z
@@ -510,7 +728,7 @@ export const TOOLS = [
510
728
  // ----- v2 app lifecycle + data (discrete, hot-path) -----------------------
511
729
  {
512
730
  name: "deploy_app",
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).',
731
+ 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). Supply the HTML as INLINE `html` OR as `html_path` (an absolute path read on the MCP-SERVER host, which is the relay for a hosted connector or your CLI host for a locally-run one, NOT the remote agent's machine; use it to avoid retransmitting a large HTML file every deploy, but only a locally-run connector can read it, and if both are given inline `html` wins). Pass `dry_run:true` (alias `check`) to VALIDATE ONLY: it runs the full manifest + asset-shape validation, the redeploy compat gate, and the schedule-timezone advisory, then returns { ok, warnings, compat?, breaks? } WITHOUT creating a version or mutating anything. 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).",
514
732
  inputSchema: deployAppShape,
515
733
  annotations: {
516
734
  title: "Deploy App",
@@ -519,15 +737,44 @@ export const TOOLS = [
519
737
  idempotentHint: false,
520
738
  openWorldHint: true,
521
739
  },
522
- handler: async (client, args) => {
740
+ handler: async (client, args, env) => {
523
741
  try {
524
742
  const manifest = parseMaybeStringifiedObject(args["manifest"], "manifest");
525
743
  if ("error" in manifest)
526
744
  return manifest.error;
745
+ // Resolve the HTML from INLINE `html` or from `html_path` (read on the
746
+ // MCP-server host). Inline wins when both are given: an explicit `html`
747
+ // is a deliberate inline deploy, so never read a file the caller also
748
+ // happened to name. `html_path` is read here (on the MCP server / relay
749
+ // host), NOT on the calling agent's machine; a hosted connector's host
750
+ // is Homespun's infra, so a remote agent's path ENOENTs; say so.
751
+ const inlineHtml = str(args, "html");
752
+ const htmlPath = str(args, "html_path");
753
+ let html = inlineHtml;
754
+ if (html === undefined && htmlPath !== undefined) {
755
+ if (env?.hostFsReads === false) {
756
+ return invalidArgs("html_path is not available on this connection: the hosted relay does not read files from its own host on your behalf. Pass the HTML inline as `html` instead.");
757
+ }
758
+ try {
759
+ html = readFileSync(htmlPath, "utf8");
760
+ }
761
+ catch (e) {
762
+ return invalidArgs(`failed to read html_path '${htmlPath}' (${e instanceof Error ? e.message : String(e)}). Note: html_path is read on the MCP server / relay host, not on your machine, so it only works when the file is local to the connector (e.g. a locally-run CLI). For a hosted or remote agent, pass the HTML inline as \`html\` instead.`);
763
+ }
764
+ }
765
+ const dryRun = args["dry_run"] === true || args["check"] === true;
766
+ const assets = args["assets"];
527
767
  const appId = str(args, "app_id");
528
768
  if (appId === undefined) {
529
- if (str(args, "html") === undefined) {
530
- return invalidArgs("create requires `html`");
769
+ if (html === undefined) {
770
+ return invalidArgs("create requires `html` or `html_path`");
771
+ }
772
+ if (dryRun) {
773
+ return jsonResult(await client.checkDeploy({
774
+ html,
775
+ manifest: manifest.value,
776
+ assets,
777
+ }));
531
778
  }
532
779
  const slug = str(args, "slug");
533
780
  const visibility = args["visibility"];
@@ -535,24 +782,33 @@ export const TOOLS = [
535
782
  return invalidArgs("a `slug` is not allowed with visibility 'link' (link slugs are server-generated); drop visibility 'link', or omit slug");
536
783
  }
537
784
  return jsonResult(await client.deployApp({
538
- html: String(args["html"]),
785
+ html,
539
786
  manifest: manifest.value,
540
787
  visibility,
541
788
  slug,
542
- assets: args["assets"],
789
+ assets,
543
790
  }));
544
791
  }
545
- if (str(args, "html") === undefined) {
546
- return invalidArgs("redeploy requires `html`");
792
+ if (html === undefined) {
793
+ return invalidArgs("redeploy requires `html` or `html_path`");
794
+ }
795
+ if (dryRun) {
796
+ return jsonResult(await client.checkDeploy({
797
+ app_id: appId,
798
+ html,
799
+ manifest: manifest.value,
800
+ force: args["force"],
801
+ assets,
802
+ }));
547
803
  }
548
804
  if (args["slug"] !== undefined || args["visibility"] !== undefined) {
549
805
  return invalidArgs("slug/visibility cannot change on redeploy — slug is immutable, visibility changes via the `apps` tool (action: update)");
550
806
  }
551
807
  const redeployed = await client.redeployApp(appId, {
552
- html: String(args["html"]),
808
+ html,
553
809
  manifest: manifest.value,
554
810
  force: args["force"],
555
- assets: args["assets"],
811
+ assets,
556
812
  });
557
813
  return jsonResult(redeployed);
558
814
  }
@@ -701,7 +957,7 @@ export const TOOLS = [
701
957
  },
702
958
  {
703
959
  name: "apps",
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).",
960
+ 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, timezone, has_share_token) | update (visibility and/or timezone - slug is immutable; switching TO 'link' returns a share_url once) | share_link_rotate (rotate a 'link' app's share token, returning a new share_url and revoking the old link; also generates one if the app has none) | 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).",
705
961
  inputSchema: appsShape,
706
962
  // Consolidated tool: read actions (list/show) + mutating ones (update/
707
963
  // delete/wake). Hint reflects delete, the most-privileged action.
@@ -733,20 +989,36 @@ export const TOOLS = [
733
989
  return invalidArgs("show requires `app_id`");
734
990
  }
735
991
  return jsonResult(await client.getApp(String(args["app_id"])));
736
- case "update":
992
+ case "update": {
737
993
  if (str(args, "app_id") === undefined) {
738
994
  return invalidArgs("update requires `app_id`");
739
995
  }
740
- if (str(args, "visibility") === undefined) {
741
- return invalidArgs("update requires `visibility`");
996
+ if (str(args, "visibility") === undefined &&
997
+ str(args, "timezone") === undefined) {
998
+ return invalidArgs("update requires `visibility` and/or `timezone`");
742
999
  }
743
- return jsonResult(await client.updateApp(String(args["app_id"]), args["visibility"]));
1000
+ return jsonResult(await client.updateApp(String(args["app_id"]), {
1001
+ ...(args["visibility"] !== undefined
1002
+ ? {
1003
+ visibility: args["visibility"],
1004
+ }
1005
+ : {}),
1006
+ ...(args["timezone"] !== undefined
1007
+ ? { timezone: String(args["timezone"]) }
1008
+ : {}),
1009
+ }));
1010
+ }
744
1011
  case "delete":
745
1012
  if (str(args, "app_id") === undefined) {
746
1013
  return invalidArgs("delete requires `app_id`");
747
1014
  }
748
1015
  await client.deleteApp(String(args["app_id"]));
749
1016
  return jsonResult({ app_id: args["app_id"], deleted: true });
1017
+ case "share_link_rotate":
1018
+ if (str(args, "app_id") === undefined) {
1019
+ return invalidArgs("share_link_rotate requires `app_id`");
1020
+ }
1021
+ return jsonResult(await client.rotateShareLink(String(args["app_id"])));
750
1022
  case "wake":
751
1023
  if (str(args, "app_id") === undefined) {
752
1024
  return invalidArgs("wake requires `app_id`");
@@ -782,7 +1054,7 @@ export const TOOLS = [
782
1054
  },
783
1055
  {
784
1056
  name: "members",
785
- description: "Manage a v2 app's membership (auth spec §6) — who besides the app's owner can sign in to a private app / write to member-scoped collections. ONE tool with an `action` enum: add (invite-or-attach a member by email — attaches immediately if the email already has a Human, otherwise the relay emails a magic-link invite) | list (the app's owner + members) | remove (idempotent; also revokes the human's live sessions on this app — the app owner cannot be removed).",
1057
+ description: "Manage a v2 app's membership (auth spec §6) — who besides the app's owner can sign in to a private app / write to member-scoped collections. ONE tool with an `action` enum: add (invite-or-attach a member by email — attaches immediately if the email already has a Human, otherwise the relay emails a magic-link invite) | list (the app's owner + members) | set_role (change an existing member's declared custom role in place, or null to clear it — does NOT revoke their sessions, so use this rather than remove-then-add to re-role someone) | remove (idempotent; also revokes the human's live sessions on this app — the app owner cannot be removed) | roles (the derived roles summary: per declared role and collection, the EFFECTIVE access a holder actually has, reported separately for signed-in members and for grant-link holders since their role floors differ, plus member and active-grant-link counts).",
786
1058
  inputSchema: membersShape,
787
1059
  // Consolidated tool: read action (list) + mutating ones (add/remove).
788
1060
  // Hint reflects remove, the most-privileged action.
@@ -810,10 +1082,29 @@ export const TOOLS = [
810
1082
  ...(args["role"] !== undefined
811
1083
  ? { role: args["role"] }
812
1084
  : {}),
1085
+ ...(args["custom_role"] !== undefined
1086
+ ? { customRole: String(args["custom_role"]) }
1087
+ : {}),
813
1088
  }));
814
1089
  }
815
1090
  case "list":
816
1091
  return jsonResult(await client.listAppMembers(appId));
1092
+ case "roles":
1093
+ return jsonResult(await client.listAppRoles(appId));
1094
+ case "set_role": {
1095
+ if (str(args, "human_id") === undefined) {
1096
+ return invalidArgs("set_role requires `human_id`");
1097
+ }
1098
+ // The key must be PRESENT: null means "clear the role", which is a
1099
+ // real instruction, so an omitted key cannot be read as one.
1100
+ const role = args["custom_role"];
1101
+ if (role === undefined) {
1102
+ return invalidArgs("set_role requires `custom_role` (a declared role name, or null to clear it)");
1103
+ }
1104
+ return jsonResult(await client.setAppMemberRole(appId, String(args["human_id"]), {
1105
+ customRole: role === null ? null : String(role),
1106
+ }));
1107
+ }
817
1108
  case "remove": {
818
1109
  if (str(args, "human_id") === undefined) {
819
1110
  return invalidArgs("remove requires `human_id`");
@@ -835,9 +1126,127 @@ export const TOOLS = [
835
1126
  },
836
1127
  },
837
1128
  // ----- consolidated management tools --------------------------------------
1129
+ {
1130
+ name: "grants",
1131
+ description: "Manage a v2 app's grant links (M5). A grant link is a capability URL that confers a DECLARED custom role (x-homespun-manifest.roles) on a stable, per-holder anonymous identity, so the holder's own rows are isolated by author/:own scoping. A grant NEVER escalates to owner/member/agent. ONE tool with an `action` enum: mint (create a link; returns a `grant_url` carrying the token in its #g= fragment, shown ONCE, never recoverable) | list (the app's links, never any token) | revoke (idempotent). mode once = one-time (first-browser-claims), multi = shared (capped by max_uses within expiry). An optional pin (pin_row_key OR pin_where) NARROWS the holder to specific rows, never widens. Note: a write-only grant pinned to a single row key can still read that row's existing data back via create dedup, so a 'write-only to slot X' grant exposes slot X's current contents to the holder.",
1132
+ inputSchema: grantsShape,
1133
+ // Consolidated tool: read action (list) + mutating ones (mint/revoke).
1134
+ // Hint reflects revoke, the most-privileged action.
1135
+ annotations: {
1136
+ title: "Manage App Grant Links",
1137
+ readOnlyHint: false,
1138
+ destructiveHint: true,
1139
+ idempotentHint: true,
1140
+ openWorldHint: false,
1141
+ },
1142
+ handler: async (client, args) => {
1143
+ const action = String(args["action"]);
1144
+ if (str(args, "app_id") === undefined) {
1145
+ return invalidArgs(`${action} requires \`app_id\``);
1146
+ }
1147
+ const appId = String(args["app_id"]);
1148
+ try {
1149
+ switch (action) {
1150
+ case "mint": {
1151
+ if (str(args, "role") === undefined) {
1152
+ return invalidArgs("mint requires `role`");
1153
+ }
1154
+ const pinRowKey = str(args, "pin_row_key");
1155
+ const pinWhere = Array.isArray(args["pin_where"])
1156
+ ? args["pin_where"]
1157
+ : undefined;
1158
+ if (pinRowKey !== undefined && pinWhere !== undefined) {
1159
+ return invalidArgs("mint accepts either `pin_row_key` or `pin_where`, not both");
1160
+ }
1161
+ const pin = pinRowKey !== undefined
1162
+ ? { rowKey: pinRowKey }
1163
+ : pinWhere !== undefined
1164
+ ? { where: pinWhere }
1165
+ : undefined;
1166
+ return jsonResult(await client.mintAppGrant(appId, {
1167
+ role: String(args["role"]),
1168
+ ...(args["mode"] !== undefined
1169
+ ? { mode: args["mode"] }
1170
+ : {}),
1171
+ ...(typeof args["max_uses"] === "number"
1172
+ ? { maxUses: args["max_uses"] }
1173
+ : {}),
1174
+ ...(str(args, "label") !== undefined
1175
+ ? { label: String(args["label"]) }
1176
+ : {}),
1177
+ ...(typeof args["ttl_seconds"] === "number"
1178
+ ? { ttlSeconds: args["ttl_seconds"] }
1179
+ : {}),
1180
+ ...(pin !== undefined ? { pin } : {}),
1181
+ }));
1182
+ }
1183
+ case "list":
1184
+ return jsonResult(await client.listAppGrants(appId));
1185
+ case "revoke": {
1186
+ if (str(args, "grant_id") === undefined) {
1187
+ return invalidArgs("revoke requires `grant_id`");
1188
+ }
1189
+ await client.revokeAppGrant(appId, String(args["grant_id"]));
1190
+ return jsonResult({
1191
+ app_id: appId,
1192
+ grant_id: args["grant_id"],
1193
+ revoked: true,
1194
+ });
1195
+ }
1196
+ default:
1197
+ return invalidArgs(`unknown grants action '${action}'`);
1198
+ }
1199
+ }
1200
+ catch (e) {
1201
+ return errorResult(e);
1202
+ }
1203
+ },
1204
+ },
1205
+ // ----- consolidated management tools --------------------------------------
1206
+ {
1207
+ name: "ingest",
1208
+ description: "Manage a v2 app's inbound catch-hooks (inbound-webhooks). A catch-hook lets an EXTERNAL system (Stripe, Zapier, Make, Home Assistant, an email router) POST JSON to a secret URL that writes into a declared collection, so the app receives data even with no agent online. Hooks are DECLARED IN THE MANIFEST (x-homespun-manifest.ingest) and materialized at deploy, so this tool has no create/delete: use it to READ BACK the URL and rotate a leaked one. ONE tool with an `action` enum: list (the app's hooks, each with its full secret URL, current rule collection/mode/wake/handshake, and per-status delivery counts) | rotate (mint a fresh secret for one hook by name and return its NEW url once; the old url stops working immediately, no redeploy needed). After deploying a manifest that declares a hook, run list and tell the owner the exact url to paste into the external system.",
1209
+ inputSchema: ingestShape,
1210
+ // Consolidated tool: read action (list) + a mutating one (rotate). Marked
1211
+ // destructive (not read-only) because rotate invalidates the old URL, which
1212
+ // breaks any external system still using it, following the same "any
1213
+ // consolidated tool that can mutate is destructive" convention as members/
1214
+ // grants/apps.
1215
+ annotations: {
1216
+ title: "Manage App Inbound Hooks",
1217
+ readOnlyHint: false,
1218
+ destructiveHint: true,
1219
+ openWorldHint: false,
1220
+ },
1221
+ handler: async (client, args) => {
1222
+ const action = String(args["action"]);
1223
+ if (str(args, "app_id") === undefined) {
1224
+ return invalidArgs(`${action} requires \`app_id\``);
1225
+ }
1226
+ const appId = String(args["app_id"]);
1227
+ try {
1228
+ switch (action) {
1229
+ case "list":
1230
+ return jsonResult(await client.listIngestHooks(appId));
1231
+ case "rotate": {
1232
+ if (str(args, "name") === undefined) {
1233
+ return invalidArgs("rotate requires `name`");
1234
+ }
1235
+ return jsonResult(await client.rotateIngestHook(appId, String(args["name"])));
1236
+ }
1237
+ default:
1238
+ return invalidArgs(`unknown ingest action '${action}'`);
1239
+ }
1240
+ }
1241
+ catch (e) {
1242
+ return errorResult(e);
1243
+ }
1244
+ },
1245
+ },
1246
+ // ----- consolidated management tools --------------------------------------
838
1247
  {
839
1248
  name: "attachments",
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.",
1249
+ 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. TOKEN COST, READ FIRST: an inline `upload` with `content_base64` carries the bytes in the tool-call arguments, so they enter the MODEL CONTEXT and cost tokens PROPORTIONAL TO FILE SIZE (a few-hundred-KB image is already very costly, worse on every retry). PREFER presign + finalize for ANY real image or media (anything beyond a tiny icon) whenever the client can do an out-of-band HTTP PUT, because the bytes then never touch the model context. upload (inline) takes EITHER `content_base64` (base64 bytes, no filesystem; use for SMALL assets or clients that cannot PUT out-of-band) OR `file_path` (ABSOLUTE path read on the RELAY host, only usable when the file is local to the relay). presign + finalize (the token-free path, for images/video/big audio): (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; (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.",
841
1250
  inputSchema: attachmentsShape,
842
1251
  // Consolidated tool: read actions (download/show/list/list_tokens) +
843
1252
  // mutating ones (upload/delete/mint_token/revoke_token). openWorld:true
@@ -850,7 +1259,7 @@ export const TOOLS = [
850
1259
  idempotentHint: false,
851
1260
  openWorldHint: true,
852
1261
  },
853
- handler: async (client, args) => {
1262
+ handler: async (client, args, env) => {
854
1263
  const action = String(args["action"]);
855
1264
  try {
856
1265
  switch (action) {
@@ -878,6 +1287,9 @@ export const TOOLS = [
878
1287
  return jsonResult(ref);
879
1288
  }
880
1289
  let bytes;
1290
+ if (env?.hostFsReads === false) {
1291
+ return invalidArgs("file_path is not available on this connection: the hosted relay does not read files from its own host on your behalf. Pass `content_base64` with the file bytes instead.");
1292
+ }
881
1293
  try {
882
1294
  bytes = readFileSync(filePath);
883
1295
  }
@@ -939,6 +1351,9 @@ export const TOOLS = [
939
1351
  const buf = await client.downloadBlob(String(args["attachment_id"]));
940
1352
  const outPath = str(args, "out_path");
941
1353
  if (outPath !== undefined) {
1354
+ if (env?.hostFsReads === false) {
1355
+ return invalidArgs("out_path is not available on this connection: the hosted relay does not write files to its own host on your behalf. Omit out_path to receive the bytes as base64 instead.");
1356
+ }
942
1357
  try {
943
1358
  writeFileSync(outPath, Buffer.from(buf));
944
1359
  }
@@ -1038,7 +1453,7 @@ export const TOOLS = [
1038
1453
  },
1039
1454
  {
1040
1455
  name: "key",
1041
- description: "Inspect or revoke the calling agent's API key. ONE tool with an `action` enum: list (key info agent_id, key_prefix, timestamps) | revoke (self-destruct the agent's OWN key; it stops working immediately and is irreversible pass confirm:true). The relay scopes keys to the caller, so both act only on your own key.",
1456
+ description: "Inspect, mint, or revoke the calling agent's API key. ONE tool with an `action` enum: list (key info: agent_id, key_prefix, timestamps) | mint (mint a NEW sibling API key for YOUR OWN agent identity, same scope/ownership, and return its raw value ONCE, the way an MCP-driven agent bootstraps a CLI/child-process credential; the raw key is never retrievable again, the sibling appears in a `list` made WITH it, and the owner can revoke it) | revoke (self-destruct the agent's OWN key; it stops working immediately and is irreversible, so pass confirm:true). The relay derives identity from the caller's token, so every action acts only on the caller's own agent, and mint can never target another agent's id.",
1042
1457
  inputSchema: keyShape,
1043
1458
  // Consolidated tool: read action (list) + a mutating one (revoke
1044
1459
  // self-destructs the agent's own key). Hint reflects the destructive
@@ -1056,6 +1471,12 @@ export const TOOLS = [
1056
1471
  switch (action) {
1057
1472
  case "list":
1058
1473
  return jsonResult(await client.listKeys());
1474
+ case "mint":
1475
+ // Mints a sibling key for the CALLER's own identity (the relay
1476
+ // derives it from the bearer token, and no target field exists, so it
1477
+ // can never target another agent). The raw key is in this response
1478
+ // ONCE and never again.
1479
+ return jsonResult(await client.mintKey());
1059
1480
  case "revoke": {
1060
1481
  if (args["confirm"] !== true) {
1061
1482
  return invalidArgs("revoke is irreversible and stops your key working immediately — pass confirm:true");
@@ -1159,7 +1580,7 @@ export const TOOLS = [
1159
1580
  },
1160
1581
  {
1161
1582
  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).",
1583
+ 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. PRIVACY: an approved template's content AND its captured seed rows become PUBLIC to every platform user, so never publish an app whose seedOnInstall collections hold real personal data - seed data must be example-only. Pass attest_example_only:true to attest you checked this. Optionally give the template a per-publisher `slug` (namespaced id <your-handle>/<slug>) and a semver `version` (default 1.0.0): a republish under the same slug must bump the version. 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
1584
  inputSchema: communityShape,
1164
1585
  // Consolidated tool: read actions (list_pending/get_submission) + mutating
1165
1586
  // ones (publish/approve/reject). Hint reflects the most-privileged action.
@@ -1182,10 +1603,19 @@ export const TOOLS = [
1182
1603
  appId: String(args["app_id"]),
1183
1604
  title: str(args, "title"),
1184
1605
  description: str(args, "description"),
1606
+ longDescription: str(args, "long_description"),
1185
1607
  category: str(args, "category"),
1186
1608
  tags: Array.isArray(args["tags"])
1187
1609
  ? args["tags"]
1188
1610
  : undefined,
1611
+ slug: str(args, "slug"),
1612
+ version: str(args, "version"),
1613
+ changelogNote: str(args, "changelog_note"),
1614
+ setupSteps: Array.isArray(args["setup_steps"])
1615
+ ? args["setup_steps"]
1616
+ : undefined,
1617
+ derivedFromSnapshotId: str(args, "derived_from_snapshot_id"),
1618
+ attestExampleOnly: bool(args, "attest_example_only"),
1189
1619
  }));
1190
1620
  }
1191
1621
  case "list_pending": {
@@ -1216,6 +1646,17 @@ export const TOOLS = [
1216
1646
  }
1217
1647
  return jsonResult(await client.reviewCommunitySubmission(String(args["snapshot_id"]), { decision: "reject", note }));
1218
1648
  }
1649
+ case "set_trust_level": {
1650
+ const handle = str(args, "handle");
1651
+ if (handle === undefined) {
1652
+ return invalidArgs("set_trust_level requires `handle`");
1653
+ }
1654
+ const trustLevel = str(args, "trust_level");
1655
+ if (trustLevel !== "new" && trustLevel !== "established") {
1656
+ return invalidArgs("set_trust_level requires `trust_level` of 'new' or 'established'");
1657
+ }
1658
+ return jsonResult(await client.setPublisherTrustLevel(handle, trustLevel));
1659
+ }
1219
1660
  default:
1220
1661
  return invalidArgs(`unknown community action '${action}'`);
1221
1662
  }
@@ -1225,6 +1666,134 @@ export const TOOLS = [
1225
1666
  }
1226
1667
  },
1227
1668
  },
1669
+ {
1670
+ name: "publisher",
1671
+ description: "Manage YOUR community publisher identity: the @-handle and public profile you present in the template gallery. ONE tool with an `action` enum: claim | get | update. get returns your profile (handle, whether it is claimed yet, tenure, and the rating/template counters). claim sets your handle exactly ONCE (it is permanent afterwards) from a lowercase 3-to-32-char string; a handle that is reserved (platform or role words) or already taken is refused. update changes your public display_name, bio, or url at any time. claim and update require a verified email; an existing publisher may already have a provisional `maker-...` handle (auto-assigned) that claim renames the one allowed time.",
1672
+ inputSchema: publisherShape,
1673
+ // Consolidated tool: a read action (get) plus mutating ones (claim/update).
1674
+ // claim is irreversible (the handle is permanent), so the hint reflects the
1675
+ // most-privileged action.
1676
+ annotations: {
1677
+ title: "Publisher Profile",
1678
+ readOnlyHint: false,
1679
+ destructiveHint: true,
1680
+ idempotentHint: false,
1681
+ openWorldHint: true,
1682
+ },
1683
+ handler: async (client, args) => {
1684
+ const action = String(args["action"]);
1685
+ try {
1686
+ switch (action) {
1687
+ case "get":
1688
+ return jsonResult(await client.getPublisher());
1689
+ case "claim": {
1690
+ const handle = str(args, "handle");
1691
+ if (handle === undefined) {
1692
+ return invalidArgs("claim requires `handle`");
1693
+ }
1694
+ return jsonResult(await client.claimPublisherHandle(handle));
1695
+ }
1696
+ case "update": {
1697
+ const update = {};
1698
+ if ("display_name" in args)
1699
+ update.displayName = args["display_name"];
1700
+ if ("bio" in args)
1701
+ update.bio = args["bio"];
1702
+ if ("url" in args)
1703
+ update.url = args["url"];
1704
+ if (Object.keys(update).length === 0) {
1705
+ return invalidArgs("update requires at least one of `display_name`, `bio`, `url`");
1706
+ }
1707
+ return jsonResult(await client.updatePublisher(update));
1708
+ }
1709
+ default:
1710
+ return invalidArgs(`unknown publisher action '${action}'`);
1711
+ }
1712
+ }
1713
+ catch (e) {
1714
+ return errorResult(e);
1715
+ }
1716
+ },
1717
+ },
1718
+ {
1719
+ name: "review",
1720
+ description: "Rate and review community templates you have USED, respond to reviews of your own templates, and (relay operators only) moderate. ONE tool with an `action` enum: create | respond | report | remove | unhold. create leaves a 1-to-5 star rating plus an optional written body on a template you have installed - identify the template by `template` (\"<handle>/<slug>\") or by `handle`+`slug`; you need a verified email, and each install yields exactly one review (the aggregate carries across template versions). A body that contains a link or a contact email is AUTO-HELD for a moderator before it appears. respond replies to a review of YOUR OWN template line (review_id + response; null clears it), one editable response per review. report flags a review for the relay's moderators (review_id + reason), deduped per account. remove and unhold are limited to the relay's configured community reviewers: remove takes a review down and adjusts the rating aggregate; unhold publishes a previously auto-held review into the aggregate.",
1721
+ inputSchema: reviewShape,
1722
+ // Consolidated tool: a write action (create), publisher/reporter actions,
1723
+ // and operator moderation (remove/unhold). Hint reflects remove, the most
1724
+ // privileged / destructive action.
1725
+ annotations: {
1726
+ title: "Community Reviews",
1727
+ readOnlyHint: false,
1728
+ destructiveHint: true,
1729
+ idempotentHint: false,
1730
+ openWorldHint: true,
1731
+ },
1732
+ handler: async (client, args) => {
1733
+ const action = String(args["action"]);
1734
+ try {
1735
+ switch (action) {
1736
+ case "create": {
1737
+ if (args["stars"] === undefined) {
1738
+ return invalidArgs("create requires `stars`");
1739
+ }
1740
+ const template = str(args, "template");
1741
+ const handle = str(args, "handle");
1742
+ const slug = str(args, "slug");
1743
+ if (template === undefined &&
1744
+ (handle === undefined || slug === undefined)) {
1745
+ return invalidArgs('create requires `template` ("<handle>/<slug>") or both `handle` and `slug`');
1746
+ }
1747
+ return jsonResult(await client.createReview({
1748
+ template,
1749
+ handle,
1750
+ slug,
1751
+ stars: args["stars"],
1752
+ body: str(args, "body"),
1753
+ }));
1754
+ }
1755
+ case "respond": {
1756
+ const reviewId = str(args, "review_id");
1757
+ if (reviewId === undefined) {
1758
+ return invalidArgs("respond requires `review_id`");
1759
+ }
1760
+ const response = "response" in args ? args["response"] : null;
1761
+ return jsonResult(await client.respondToReview(reviewId, response));
1762
+ }
1763
+ case "report": {
1764
+ const reviewId = str(args, "review_id");
1765
+ if (reviewId === undefined) {
1766
+ return invalidArgs("report requires `review_id`");
1767
+ }
1768
+ const reason = str(args, "reason");
1769
+ if (reason === undefined) {
1770
+ return invalidArgs("report requires `reason`");
1771
+ }
1772
+ return jsonResult(await client.reportReview(reviewId, reason));
1773
+ }
1774
+ case "remove": {
1775
+ const reviewId = str(args, "review_id");
1776
+ if (reviewId === undefined) {
1777
+ return invalidArgs("remove requires `review_id`");
1778
+ }
1779
+ return jsonResult(await client.removeReview(reviewId));
1780
+ }
1781
+ case "unhold": {
1782
+ const reviewId = str(args, "review_id");
1783
+ if (reviewId === undefined) {
1784
+ return invalidArgs("unhold requires `review_id`");
1785
+ }
1786
+ return jsonResult(await client.unholdReview(reviewId));
1787
+ }
1788
+ default:
1789
+ return invalidArgs(`unknown review action '${action}'`);
1790
+ }
1791
+ }
1792
+ catch (e) {
1793
+ return errorResult(e);
1794
+ }
1795
+ },
1796
+ },
1228
1797
  {
1229
1798
  name: "get_skill",
1230
1799
  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/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.0.29";
1
+ export declare const VERSION = "1.4.2";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Single source of the package version, reported in the MCP server's
2
2
  // serverInfo. Kept in sync with package.json by the release tooling.
3
- export const VERSION = "0.0.29";
3
+ export const VERSION = "1.4.2";
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.1",
4
+ "version": "1.4.2",
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,9 +13,9 @@
13
13
  "relay",
14
14
  "human-in-the-loop"
15
15
  ],
16
- "homepage": "https://homespun.dev",
16
+ "homepage": "https://docs.homespun.dev",
17
17
  "bugs": {
18
- "url": "mailto:support@homespun.dev"
18
+ "url": "https://github.com/homespunapps/homespun/issues"
19
19
  },
20
20
  "engines": {
21
21
  "node": ">=20"
@@ -46,12 +46,17 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.20.0",
49
- "@homespunapps/core": "^1.0.0",
49
+ "@homespunapps/core": "^1.4.2",
50
50
  "zod": "^4.4.3"
51
51
  },
52
52
  "devDependencies": {
53
- "@types/node": "^25.9.2",
54
- "typescript": "^6.0.3",
53
+ "@types/node": "^26.1.1",
54
+ "typescript": "^7.0.2",
55
55
  "vitest": "^4.1.8"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/homespunapps/homespun.git",
60
+ "directory": "packages/mcp"
56
61
  }
57
62
  }
package/server.json CHANGED
@@ -3,14 +3,14 @@
3
3
  "name": "io.github.aerolalit/homespun",
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
- "version": "0.0.29",
6
+ "version": "1.4.2",
7
7
  "websiteUrl": "https://homespun.dev",
8
8
  "packages": [
9
9
  {
10
10
  "registryType": "npm",
11
11
  "registryBaseUrl": "https://registry.npmjs.org",
12
12
  "identifier": "@homespunapps/mcp",
13
- "version": "0.0.29",
13
+ "version": "1.4.2",
14
14
  "transport": {
15
15
  "type": "stdio"
16
16
  },