@homespunapps/mcp 1.0.0 → 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/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.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)."),
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."),
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`."),
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()
@@ -170,6 +185,21 @@ const deployAppShape = {
170
185
  .boolean()
171
186
  .optional()
172
187
  .describe("REDEPLOY only. Bypass the compat gate on a narrowing manifest change (a removed/narrowed collection is detached, never deleted)."),
188
+ assets: z
189
+ .array(z.object({
190
+ path: z
191
+ .string()
192
+ .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)."),
193
+ content_base64: z
194
+ .string()
195
+ .describe("Standard base64 of the asset's raw bytes."),
196
+ mime: z
197
+ .string()
198
+ .optional()
199
+ .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."),
200
+ }))
201
+ .optional()
202
+ .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
203
  };
174
204
  const listRowsShape = {
175
205
  app_id: z.string().min(1).describe("The app id."),
@@ -248,12 +278,22 @@ const getFeedEventsShape = {
248
278
  };
249
279
  const appsShape = {
250
280
  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)."),
281
+ .enum([
282
+ "list",
283
+ "show",
284
+ "update",
285
+ "share_link_rotate",
286
+ "delete",
287
+ "wake",
288
+ "domain_set",
289
+ "domain_status",
290
+ "domain_remove",
291
+ ])
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)."),
253
293
  app_id: z
254
294
  .string()
255
295
  .optional()
256
- .describe("Required for show/update/delete/wake."),
296
+ .describe("Required for show/update/share_link_rotate/delete/wake/domain_set/domain_status/domain_remove."),
257
297
  status: z
258
298
  .enum(["active", "dormant", "archived", "all"])
259
299
  .optional()
@@ -274,11 +314,19 @@ const appsShape = {
274
314
  .enum(["private", "link", "public"])
275
315
  .optional()
276
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."),
321
+ domain: z
322
+ .string()
323
+ .optional()
324
+ .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
325
  };
278
326
  const membersShape = {
279
327
  action: z
280
- .enum(["add", "list", "remove"])
281
- .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)."),
282
330
  app_id: z.string().min(1).describe("The app id."),
283
331
  email: z
284
332
  .string()
@@ -288,18 +336,77 @@ const membersShape = {
288
336
  .enum(["member"])
289
337
  .optional()
290
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."),
291
344
  human_id: z
292
345
  .string()
293
346
  .optional()
294
- .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."),
295
358
  };
296
359
  // ===========================================================================
297
360
  // Consolidated management tools
298
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
+ };
299
404
  const attachmentsShape = {
300
405
  action: z
301
406
  .enum([
302
407
  "upload",
408
+ "presign",
409
+ "finalize",
303
410
  "download",
304
411
  "show",
305
412
  "list",
@@ -308,7 +415,17 @@ const attachmentsShape = {
308
415
  "revoke_token",
309
416
  "list_tokens",
310
417
  ])
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."),
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."),
419
+ size: z
420
+ .number()
421
+ .int()
422
+ .positive()
423
+ .optional()
424
+ .describe("presign: the exact byte length you will PUT. Committed at presign and re-verified against the uploaded bytes at finalize."),
425
+ sha256: z
426
+ .string()
427
+ .optional()
428
+ .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
429
  attachment_id: z
313
430
  .string()
314
431
  .optional()
@@ -316,7 +433,11 @@ const attachmentsShape = {
316
433
  file_path: z
317
434
  .string()
318
435
  .optional()
319
- .describe("upload: ABSOLUTE path to the local file to upload."),
436
+ .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."),
437
+ content_base64: z
438
+ .string()
439
+ .optional()
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."),
320
441
  scope: z
321
442
  .enum(["agent", "app"])
322
443
  .optional()
@@ -329,7 +450,7 @@ const attachmentsShape = {
329
450
  mime: z
330
451
  .string()
331
452
  .optional()
332
- .describe("upload: advisory Content-Type (the relay sniffs the bytes regardless)."),
453
+ .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
454
  out_path: z
334
455
  .string()
335
456
  .optional()
@@ -368,8 +489,8 @@ const tasteShape = {
368
489
  };
369
490
  const keyShape = {
370
491
  action: z
371
- .enum(["list", "revoke"])
372
- .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)."),
373
494
  confirm: z.boolean().optional().describe("Required (true) for revoke."),
374
495
  };
375
496
  const feedbackShape = {
@@ -409,6 +530,191 @@ const agentShape = {
409
530
  .optional()
410
531
  .describe("The one-shot claim code (required for claim)."),
411
532
  };
533
+ const communityShape = {
534
+ action: z
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')."),
544
+ app_id: z
545
+ .string()
546
+ .optional()
547
+ .describe("publish only. The id of an app YOU own to publish."),
548
+ title: z
549
+ .string()
550
+ .optional()
551
+ .describe("publish only. Listing title (1 to 80 chars). Defaults to the app's manifest name."),
552
+ description: z
553
+ .string()
554
+ .optional()
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."),
560
+ category: z
561
+ .string()
562
+ .optional()
563
+ .describe("publish only. Optional single-word category (e.g. 'household')."),
564
+ tags: z
565
+ .array(z.string())
566
+ .optional()
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."),
625
+ snapshot_id: z
626
+ .string()
627
+ .optional()
628
+ .describe("Required for get_submission/approve/reject. The submission's snapshot id (from publish's response or list_pending)."),
629
+ note: z
630
+ .string()
631
+ .optional()
632
+ .describe("reject only. The required rejection note shown to the publisher (delivered to their app feed)."),
633
+ limit: z
634
+ .number()
635
+ .int()
636
+ .positive()
637
+ .max(200)
638
+ .optional()
639
+ .describe("list_pending only. Page size (1..200)."),
640
+ cursor: z
641
+ .string()
642
+ .optional()
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)."),
717
+ };
412
718
  const getSkillShape = {
413
719
  version_only: z
414
720
  .boolean()
@@ -422,7 +728,7 @@ export const TOOLS = [
422
728
  // ----- v2 app lifecycle + data (discrete, hot-path) -----------------------
423
729
  {
424
730
  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).",
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).",
426
732
  inputSchema: deployAppShape,
427
733
  annotations: {
428
734
  title: "Deploy App",
@@ -431,15 +737,44 @@ export const TOOLS = [
431
737
  idempotentHint: false,
432
738
  openWorldHint: true,
433
739
  },
434
- handler: async (client, args) => {
740
+ handler: async (client, args, env) => {
435
741
  try {
436
742
  const manifest = parseMaybeStringifiedObject(args["manifest"], "manifest");
437
743
  if ("error" in manifest)
438
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"];
439
767
  const appId = str(args, "app_id");
440
768
  if (appId === undefined) {
441
- if (str(args, "html") === undefined) {
442
- 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
+ }));
443
778
  }
444
779
  const slug = str(args, "slug");
445
780
  const visibility = args["visibility"];
@@ -447,22 +782,33 @@ export const TOOLS = [
447
782
  return invalidArgs("a `slug` is not allowed with visibility 'link' (link slugs are server-generated); drop visibility 'link', or omit slug");
448
783
  }
449
784
  return jsonResult(await client.deployApp({
450
- html: String(args["html"]),
785
+ html,
451
786
  manifest: manifest.value,
452
787
  visibility,
453
788
  slug,
789
+ assets,
454
790
  }));
455
791
  }
456
- if (str(args, "html") === undefined) {
457
- 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
+ }));
458
803
  }
459
804
  if (args["slug"] !== undefined || args["visibility"] !== undefined) {
460
805
  return invalidArgs("slug/visibility cannot change on redeploy — slug is immutable, visibility changes via the `apps` tool (action: update)");
461
806
  }
462
807
  const redeployed = await client.redeployApp(appId, {
463
- html: String(args["html"]),
808
+ html,
464
809
  manifest: manifest.value,
465
810
  force: args["force"],
811
+ assets,
466
812
  });
467
813
  return jsonResult(redeployed);
468
814
  }
@@ -611,7 +957,7 @@ export const TOOLS = [
611
957
  },
612
958
  {
613
959
  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).",
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).",
615
961
  inputSchema: appsShape,
616
962
  // Consolidated tool: read actions (list/show) + mutating ones (update/
617
963
  // delete/wake). Hint reflects delete, the most-privileged action.
@@ -643,25 +989,60 @@ export const TOOLS = [
643
989
  return invalidArgs("show requires `app_id`");
644
990
  }
645
991
  return jsonResult(await client.getApp(String(args["app_id"])));
646
- case "update":
992
+ case "update": {
647
993
  if (str(args, "app_id") === undefined) {
648
994
  return invalidArgs("update requires `app_id`");
649
995
  }
650
- if (str(args, "visibility") === undefined) {
651
- 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`");
652
999
  }
653
- 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
+ }
654
1011
  case "delete":
655
1012
  if (str(args, "app_id") === undefined) {
656
1013
  return invalidArgs("delete requires `app_id`");
657
1014
  }
658
1015
  await client.deleteApp(String(args["app_id"]));
659
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"])));
660
1022
  case "wake":
661
1023
  if (str(args, "app_id") === undefined) {
662
1024
  return invalidArgs("wake requires `app_id`");
663
1025
  }
664
1026
  return jsonResult(await client.wakeApp(String(args["app_id"])));
1027
+ case "domain_set":
1028
+ if (str(args, "app_id") === undefined) {
1029
+ return invalidArgs("domain_set requires `app_id`");
1030
+ }
1031
+ if (str(args, "domain") === undefined) {
1032
+ return invalidArgs("domain_set requires `domain`");
1033
+ }
1034
+ return jsonResult(await client.setAppDomain(String(args["app_id"]), String(args["domain"])));
1035
+ case "domain_status":
1036
+ if (str(args, "app_id") === undefined) {
1037
+ return invalidArgs("domain_status requires `app_id`");
1038
+ }
1039
+ return jsonResult(await client.getAppDomain(String(args["app_id"])));
1040
+ case "domain_remove":
1041
+ if (str(args, "app_id") === undefined) {
1042
+ return invalidArgs("domain_remove requires `app_id`");
1043
+ }
1044
+ await client.deleteAppDomain(String(args["app_id"]));
1045
+ return jsonResult({ app_id: args["app_id"], domain_removed: true });
665
1046
  default:
666
1047
  return invalidArgs(`unknown apps action '${action}'`);
667
1048
  }
@@ -673,7 +1054,7 @@ export const TOOLS = [
673
1054
  },
674
1055
  {
675
1056
  name: "members",
676
- 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).",
677
1058
  inputSchema: membersShape,
678
1059
  // Consolidated tool: read action (list) + mutating ones (add/remove).
679
1060
  // Hint reflects remove, the most-privileged action.
@@ -701,10 +1082,29 @@ export const TOOLS = [
701
1082
  ...(args["role"] !== undefined
702
1083
  ? { role: args["role"] }
703
1084
  : {}),
1085
+ ...(args["custom_role"] !== undefined
1086
+ ? { customRole: String(args["custom_role"]) }
1087
+ : {}),
704
1088
  }));
705
1089
  }
706
1090
  case "list":
707
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
+ }
708
1108
  case "remove": {
709
1109
  if (str(args, "human_id") === undefined) {
710
1110
  return invalidArgs("remove requires `human_id`");
@@ -726,9 +1126,127 @@ export const TOOLS = [
726
1126
  },
727
1127
  },
728
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 --------------------------------------
729
1247
  {
730
1248
  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.",
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.",
732
1250
  inputSchema: attachmentsShape,
733
1251
  // Consolidated tool: read actions (download/show/list/list_tokens) +
734
1252
  // mutating ones (upload/delete/mint_token/revoke_token). openWorld:true
@@ -741,23 +1259,46 @@ export const TOOLS = [
741
1259
  idempotentHint: false,
742
1260
  openWorldHint: true,
743
1261
  },
744
- handler: async (client, args) => {
1262
+ handler: async (client, args, env) => {
745
1263
  const action = String(args["action"]);
746
1264
  try {
747
1265
  switch (action) {
748
1266
  case "upload": {
1267
+ // `content_base64` is the documented field; `content` is a silent
1268
+ // alias for callers that used the earlier name.
1269
+ const contentBase64 = str(args, "content_base64") ?? str(args, "content");
749
1270
  const filePath = str(args, "file_path");
750
- if (filePath === undefined)
751
- return invalidArgs("upload requires `file_path` (absolute)");
1271
+ if (contentBase64 === undefined && filePath === undefined)
1272
+ return invalidArgs("upload requires `content_base64` (base64 bytes) or `file_path` (a path local to the relay host)");
752
1273
  const scope = (str(args, "scope") ?? "agent");
753
1274
  if (scope === "app" && str(args, "app_id") === undefined)
754
1275
  return invalidArgs("scope=app requires `app_id`");
1276
+ // Inline bytes win when both are given: an explicit `content_base64`
1277
+ // is a deliberate no-filesystem upload, so never fall back to reading
1278
+ // a file the caller also happened to name. No readFileSync on this
1279
+ // path; the base64 is sent straight to the relay's inline route.
1280
+ if (contentBase64 !== undefined) {
1281
+ const ref = await client.uploadBlobInline(contentBase64, {
1282
+ scope,
1283
+ appId: str(args, "app_id"),
1284
+ filename: str(args, "filename"),
1285
+ mime: str(args, "mime"),
1286
+ });
1287
+ return jsonResult(ref);
1288
+ }
755
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
+ }
756
1293
  try {
757
1294
  bytes = readFileSync(filePath);
758
1295
  }
759
1296
  catch (e) {
760
- return invalidArgs(`failed to read file_path '${filePath}': ${e instanceof Error ? e.message : String(e)}`);
1297
+ // `file_path` is read on the MCP server / relay host, NOT the
1298
+ // calling agent's machine. For a hosted connector that host is
1299
+ // Homespun's infra, so a remote agent's path always ENOENTs even
1300
+ // when the file exists on its side. Say so, and point at the fix.
1301
+ 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
1302
  }
762
1303
  const ref = await client.uploadBlob(bytes, {
763
1304
  scope,
@@ -767,12 +1308,52 @@ export const TOOLS = [
767
1308
  });
768
1309
  return jsonResult(ref);
769
1310
  }
1311
+ case "presign": {
1312
+ // Large-file direct-to-storage: reserve a pending attachment + get a
1313
+ // PUT URL. The caller PUTs the bytes to put_url over HTTP, then calls
1314
+ // finalize. `mime` is advisory (re-sniffed at finalize); size +
1315
+ // sha256 are the commitment the finalize re-verifies against the
1316
+ // uploaded bytes.
1317
+ const mime = str(args, "mime");
1318
+ const size = args["size"];
1319
+ const sha256 = str(args, "sha256");
1320
+ if (mime === undefined ||
1321
+ typeof size !== "number" ||
1322
+ sha256 === undefined)
1323
+ return invalidArgs("presign requires `mime`, `size` (positive integer byte length), and `sha256` (hex sha-256 of the exact bytes you will PUT)");
1324
+ const scope = (str(args, "scope") ?? "agent");
1325
+ if (scope === "app" && str(args, "app_id") === undefined)
1326
+ return invalidArgs("scope=app requires `app_id`");
1327
+ const res = await client.presignBlob({
1328
+ mime,
1329
+ size,
1330
+ sha256,
1331
+ scope,
1332
+ appId: str(args, "app_id"),
1333
+ filename: str(args, "filename"),
1334
+ });
1335
+ // Surface it as { put_url, attachment_id, expires_at }; `put_url`
1336
+ // is the name the flow docs use for the out-of-band PUT target.
1337
+ return jsonResult({
1338
+ put_url: res.upload_url,
1339
+ attachment_id: res.attachment_id,
1340
+ expires_at: res.expires_at,
1341
+ });
1342
+ }
1343
+ case "finalize": {
1344
+ if (str(args, "attachment_id") === undefined)
1345
+ return invalidArgs("finalize requires `attachment_id`");
1346
+ return jsonResult(await client.finalizeBlob(String(args["attachment_id"])));
1347
+ }
770
1348
  case "download": {
771
1349
  if (str(args, "attachment_id") === undefined)
772
1350
  return invalidArgs("download requires `attachment_id`");
773
1351
  const buf = await client.downloadBlob(String(args["attachment_id"]));
774
1352
  const outPath = str(args, "out_path");
775
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
+ }
776
1357
  try {
777
1358
  writeFileSync(outPath, Buffer.from(buf));
778
1359
  }
@@ -872,7 +1453,7 @@ export const TOOLS = [
872
1453
  },
873
1454
  {
874
1455
  name: "key",
875
- 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.",
876
1457
  inputSchema: keyShape,
877
1458
  // Consolidated tool: read action (list) + a mutating one (revoke
878
1459
  // self-destructs the agent's own key). Hint reflects the destructive
@@ -890,6 +1471,12 @@ export const TOOLS = [
890
1471
  switch (action) {
891
1472
  case "list":
892
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());
893
1480
  case "revoke": {
894
1481
  if (args["confirm"] !== true) {
895
1482
  return invalidArgs("revoke is irreversible and stops your key working immediately — pass confirm:true");
@@ -991,6 +1578,222 @@ export const TOOLS = [
991
1578
  }
992
1579
  },
993
1580
  },
1581
+ {
1582
+ name: "community",
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).",
1584
+ inputSchema: communityShape,
1585
+ // Consolidated tool: read actions (list_pending/get_submission) + mutating
1586
+ // ones (publish/approve/reject). Hint reflects the most-privileged action.
1587
+ annotations: {
1588
+ title: "Community Templates",
1589
+ readOnlyHint: false,
1590
+ destructiveHint: true,
1591
+ idempotentHint: false,
1592
+ openWorldHint: true,
1593
+ },
1594
+ handler: async (client, args) => {
1595
+ const action = String(args["action"]);
1596
+ try {
1597
+ switch (action) {
1598
+ case "publish": {
1599
+ if (str(args, "app_id") === undefined) {
1600
+ return invalidArgs("publish requires `app_id`");
1601
+ }
1602
+ return jsonResult(await client.publishCommunityTemplate({
1603
+ appId: String(args["app_id"]),
1604
+ title: str(args, "title"),
1605
+ description: str(args, "description"),
1606
+ longDescription: str(args, "long_description"),
1607
+ category: str(args, "category"),
1608
+ tags: Array.isArray(args["tags"])
1609
+ ? args["tags"]
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"),
1619
+ }));
1620
+ }
1621
+ case "list_pending": {
1622
+ const opts = {};
1623
+ if (args["limit"] !== undefined)
1624
+ opts.limit = args["limit"];
1625
+ if (str(args, "cursor") !== undefined)
1626
+ opts.cursor = String(args["cursor"]);
1627
+ return jsonResult(await client.listCommunitySubmissions(opts));
1628
+ }
1629
+ case "get_submission":
1630
+ if (str(args, "snapshot_id") === undefined) {
1631
+ return invalidArgs("get_submission requires `snapshot_id`");
1632
+ }
1633
+ return jsonResult(await client.getCommunitySubmission(String(args["snapshot_id"])));
1634
+ case "approve":
1635
+ if (str(args, "snapshot_id") === undefined) {
1636
+ return invalidArgs("approve requires `snapshot_id`");
1637
+ }
1638
+ return jsonResult(await client.reviewCommunitySubmission(String(args["snapshot_id"]), { decision: "approve" }));
1639
+ case "reject": {
1640
+ if (str(args, "snapshot_id") === undefined) {
1641
+ return invalidArgs("reject requires `snapshot_id`");
1642
+ }
1643
+ const note = str(args, "note");
1644
+ if (note === undefined) {
1645
+ return invalidArgs("reject requires a non-empty `note`");
1646
+ }
1647
+ return jsonResult(await client.reviewCommunitySubmission(String(args["snapshot_id"]), { decision: "reject", note }));
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
+ }
1660
+ default:
1661
+ return invalidArgs(`unknown community action '${action}'`);
1662
+ }
1663
+ }
1664
+ catch (e) {
1665
+ return errorResult(e);
1666
+ }
1667
+ },
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
+ },
994
1797
  {
995
1798
  name: "get_skill",
996
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.0",
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,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://docs.homespun.dev",
22
17
  "bugs": {
23
- "url": "https://github.com/aerolalit/homespun/issues"
18
+ "url": "https://github.com/homespunapps/homespun/issues"
24
19
  },
25
20
  "engines": {
26
21
  "node": ">=20"
@@ -51,12 +46,17 @@
51
46
  },
52
47
  "dependencies": {
53
48
  "@modelcontextprotocol/sdk": "^1.20.0",
54
- "@homespunapps/core": "^1.0.0",
49
+ "@homespunapps/core": "^1.4.2",
55
50
  "zod": "^4.4.3"
56
51
  },
57
52
  "devDependencies": {
58
- "@types/node": "^25.9.2",
59
- "typescript": "^6.0.3",
53
+ "@types/node": "^26.1.1",
54
+ "typescript": "^7.0.2",
60
55
  "vitest": "^4.1.8"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/homespunapps/homespun.git",
60
+ "directory": "packages/mcp"
61
61
  }
62
62
  }
package/server.json CHANGED
@@ -3,17 +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",
7
- "repository": {
8
- "url": "https://github.com/aerolalit/homespun",
9
- "source": "github"
10
- },
6
+ "version": "1.4.2",
7
+ "websiteUrl": "https://homespun.dev",
11
8
  "packages": [
12
9
  {
13
10
  "registryType": "npm",
14
11
  "registryBaseUrl": "https://registry.npmjs.org",
15
12
  "identifier": "@homespunapps/mcp",
16
- "version": "0.0.29",
13
+ "version": "1.4.2",
17
14
  "transport": {
18
15
  "type": "stdio"
19
16
  },
@@ -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
  },