@homespunapps/mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/capabilities.d.ts +9 -0
- package/dist/capabilities.js +50 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +229 -0
- package/dist/guide.d.ts +12 -0
- package/dist/guide.js +49 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +77 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +98 -0
- package/dist/skill.d.ts +24 -0
- package/dist/skill.js +82 -0
- package/dist/tools.d.ts +51 -0
- package/dist/tools.js +1029 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +62 -0
- package/server.json +48 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
// Tool definitions for the Homespun MCP server.
|
|
2
|
+
//
|
|
3
|
+
// Each tool wraps one or more @homespunapps/core HomespunClient operations. The
|
|
4
|
+
// descriptions are written for the LLM consumer — they ARE the docs the model
|
|
5
|
+
// reads to decide when and how to call each tool. Keep them concrete and
|
|
6
|
+
// action-oriented.
|
|
7
|
+
//
|
|
8
|
+
// Surface design (v2-only — the v1 homespun-lifecycle/events/records/
|
|
9
|
+
// participant/share/query tools were removed along with the rest of the v1
|
|
10
|
+
// app API, and the v1 Template subsystem's template/template_records/trash
|
|
11
|
+
// tools were removed in PR 2c-1; see git history for the prior surfaces):
|
|
12
|
+
// - v2 app lifecycle + data are DISCRETE tools: deploy_app, list_rows,
|
|
13
|
+
// get_row, upsert_row, update_row, delete_row, get_feed_events.
|
|
14
|
+
// - Multi-verb MANAGEMENT nouns each collapse into ONE tool with a required
|
|
15
|
+
// `action` enum and per-action fields: apps, members, attachments, taste,
|
|
16
|
+
// key, feedback, agent.
|
|
17
|
+
// - skill → get_skill (no API key).
|
|
18
|
+
//
|
|
19
|
+
// MCP is request/response: there is no streaming.
|
|
20
|
+
//
|
|
21
|
+
// Schema validation uses Zod raw shapes (the shape McpServer.registerTool
|
|
22
|
+
// expects); the SDK validates arguments before the handler runs. For
|
|
23
|
+
// consolidated tools the per-action required fields are documented in the tool
|
|
24
|
+
// description and re-checked in the handler (a Zod raw shape can't express a
|
|
25
|
+
// discriminated union across a flat field set, so the handler asserts the
|
|
26
|
+
// action-specific requirements and returns a tight invalid_args error).
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
import { HomespunApiError } from "@homespunapps/core";
|
|
29
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { basename } from "node:path";
|
|
31
|
+
import { resolveUrl, describeActiveConfig, clearActiveProfile, } from "./config.js";
|
|
32
|
+
import { fetchSkill } from "./skill.js";
|
|
33
|
+
/** Wrap a JSON-able value as a single text-content tool result. */
|
|
34
|
+
function jsonResult(value) {
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Plain text result (used by get_skill for raw markdown). */
|
|
40
|
+
function textResult(text) {
|
|
41
|
+
return { content: [{ type: "text", text }] };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Turn any thrown error into a structured `isError` tool result. HomespunApiError
|
|
45
|
+
* carries the relay's `code`, HTTP `status`, and an optional remediation
|
|
46
|
+
* `hint`; surface all of it so the model can self-correct (e.g. fix an event
|
|
47
|
+
* type the schema rejected) instead of getting an opaque failure.
|
|
48
|
+
*/
|
|
49
|
+
function errorResult(e) {
|
|
50
|
+
if (e instanceof HomespunApiError) {
|
|
51
|
+
const payload = {
|
|
52
|
+
error: e.code,
|
|
53
|
+
status: e.status,
|
|
54
|
+
message: e.message,
|
|
55
|
+
};
|
|
56
|
+
if (e.hint)
|
|
57
|
+
payload["hint"] = e.hint;
|
|
58
|
+
if (e.details !== undefined)
|
|
59
|
+
payload["details"] = e.details;
|
|
60
|
+
if (e.retryable !== undefined)
|
|
61
|
+
payload["retryable"] = e.retryable;
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text",
|
|
72
|
+
text: JSON.stringify({ error: "internal", message }, null, 2),
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Structured invalid_args error for the per-action validation inside
|
|
80
|
+
* consolidated tools. Mirrors the relay's envelope so the model self-corrects.
|
|
81
|
+
*/
|
|
82
|
+
function invalidArgs(message) {
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: JSON.stringify({ error: "invalid_args", message }, null, 2),
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
isError: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Read a required string arg; returns undefined when absent/empty. */
|
|
94
|
+
function str(args, key) {
|
|
95
|
+
const v = args[key];
|
|
96
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
97
|
+
}
|
|
98
|
+
/** True for a non-null, non-array plain object (`{"type":"object"}` land). */
|
|
99
|
+
function isPlainObject(v) {
|
|
100
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Defense in depth for a client harness that serializes an object-valued
|
|
104
|
+
* argument as a JSON *string* instead of a JSON object (the reported bug). If
|
|
105
|
+
* `value` is a string that JSON-parses to an object, return the parsed object;
|
|
106
|
+
* if it is a string that does NOT parse as JSON at all, return a tight
|
|
107
|
+
* invalid_args error naming the field. Anything else (already an object /
|
|
108
|
+
* array / number / boolean / null, or a string that parses to a non-object
|
|
109
|
+
* JSON value) is passed through unchanged - we never silently coerce.
|
|
110
|
+
*/
|
|
111
|
+
function parseMaybeStringifiedObject(value, field) {
|
|
112
|
+
if (typeof value !== "string")
|
|
113
|
+
return { value };
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(value);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return {
|
|
120
|
+
error: invalidArgs(`\`${field}\` must be a JSON object, not a string; received a string that is not valid JSON`),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (isPlainObject(parsed))
|
|
124
|
+
return { value: parsed };
|
|
125
|
+
return { value };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A JSON object schema (`{"type":"object"}` in the emitted tool schema). Using
|
|
129
|
+
* z.record here - rather than z.unknown, which emits NO type keyword - is what
|
|
130
|
+
* signals harnesses that an OBJECT is expected so they stop stringifying it.
|
|
131
|
+
*/
|
|
132
|
+
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
|
133
|
+
/**
|
|
134
|
+
* A permissive "any JSON value" schema that STILL advertises what is allowed:
|
|
135
|
+
* it serializes to an `anyOf` of typed branches (object|array|string|number|
|
|
136
|
+
* boolean|null) rather than a bare, type-less `{}`. Matches what the relay
|
|
137
|
+
* actually accepts for a row body (any JSON value valid against the
|
|
138
|
+
* collection's row schema, if any).
|
|
139
|
+
*/
|
|
140
|
+
const jsonValueSchema = z.union([
|
|
141
|
+
jsonObjectSchema,
|
|
142
|
+
z.array(z.unknown()),
|
|
143
|
+
z.string(),
|
|
144
|
+
z.number(),
|
|
145
|
+
z.boolean(),
|
|
146
|
+
z.null(),
|
|
147
|
+
]);
|
|
148
|
+
// ===========================================================================
|
|
149
|
+
// v2 app lifecycle + data (discrete, hot-path)
|
|
150
|
+
// ===========================================================================
|
|
151
|
+
const deployAppShape = {
|
|
152
|
+
app_id: z
|
|
153
|
+
.string()
|
|
154
|
+
.optional()
|
|
155
|
+
.describe("Omit to CREATE a new app; pass an existing app's id to REDEPLOY it (a new version, compat-gated unless force:true)."),
|
|
156
|
+
html: z
|
|
157
|
+
.string()
|
|
158
|
+
.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."),
|
|
161
|
+
visibility: z
|
|
162
|
+
.enum(["private", "link", "public"])
|
|
163
|
+
.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`."),
|
|
165
|
+
slug: z
|
|
166
|
+
.string()
|
|
167
|
+
.optional()
|
|
168
|
+
.describe("CREATE only. Accepted with visibility private or public, including the private default; rejected with explicit visibility 'link', where the slug is always server-generated."),
|
|
169
|
+
force: z
|
|
170
|
+
.boolean()
|
|
171
|
+
.optional()
|
|
172
|
+
.describe("REDEPLOY only. Bypass the compat gate on a narrowing manifest change (a removed/narrowed collection is detached, never deleted)."),
|
|
173
|
+
};
|
|
174
|
+
const listRowsShape = {
|
|
175
|
+
app_id: z.string().min(1).describe("The app id."),
|
|
176
|
+
collection: z
|
|
177
|
+
.string()
|
|
178
|
+
.min(1)
|
|
179
|
+
.describe("The collection name declared in the app's manifest."),
|
|
180
|
+
since: z
|
|
181
|
+
.string()
|
|
182
|
+
.optional()
|
|
183
|
+
.describe("Opaque cursor from a previous call's next_cursor. Also the POLL handle: pass it back to fetch only newer/changed rows."),
|
|
184
|
+
limit: z
|
|
185
|
+
.number()
|
|
186
|
+
.int()
|
|
187
|
+
.positive()
|
|
188
|
+
.max(1000)
|
|
189
|
+
.optional()
|
|
190
|
+
.describe("Page size."),
|
|
191
|
+
};
|
|
192
|
+
const getRowShape = {
|
|
193
|
+
app_id: z.string().min(1).describe("The app id."),
|
|
194
|
+
collection: z.string().min(1).describe("The collection name."),
|
|
195
|
+
key: z.string().min(1).describe("The key of the row to fetch."),
|
|
196
|
+
};
|
|
197
|
+
const upsertRowShape = {
|
|
198
|
+
app_id: z.string().min(1).describe("The app id."),
|
|
199
|
+
collection: z.string().min(1).describe("The collection name."),
|
|
200
|
+
key: z
|
|
201
|
+
.string()
|
|
202
|
+
.optional()
|
|
203
|
+
.describe("Optional stable key. Reusing an existing key returns the existing row (deduped:true)."),
|
|
204
|
+
data: jsonValueSchema.describe("The row body - any JSON value valid against the collection's row schema (an object, or any JSON value for a schemaless collection)."),
|
|
205
|
+
};
|
|
206
|
+
const updateRowShape = {
|
|
207
|
+
app_id: z.string().min(1).describe("The app id."),
|
|
208
|
+
collection: z.string().min(1).describe("The collection name."),
|
|
209
|
+
key: z.string().min(1).describe("The key of the row to update."),
|
|
210
|
+
data: jsonValueSchema.describe("The new row body (replaces the row's data) - any JSON value valid against the collection's row schema."),
|
|
211
|
+
if_match: z
|
|
212
|
+
.number()
|
|
213
|
+
.int()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe("Optional optimistic-lock version. On mismatch the update is rejected with the current row in details.current."),
|
|
216
|
+
};
|
|
217
|
+
const deleteRowShape = {
|
|
218
|
+
app_id: z.string().min(1).describe("The app id."),
|
|
219
|
+
collection: z.string().min(1).describe("The collection name."),
|
|
220
|
+
key: z.string().min(1).describe("The key of the row to delete."),
|
|
221
|
+
if_match: z
|
|
222
|
+
.number()
|
|
223
|
+
.int()
|
|
224
|
+
.optional()
|
|
225
|
+
.describe("Optional optimistic-lock version."),
|
|
226
|
+
};
|
|
227
|
+
const getFeedEventsShape = {
|
|
228
|
+
app_id: z.string().min(1).describe("The app id."),
|
|
229
|
+
since: z
|
|
230
|
+
.number()
|
|
231
|
+
.int()
|
|
232
|
+
.nonnegative()
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("Opaque numeric cursor from a previous call's cursor. Omit (or 0) to read from the beginning."),
|
|
235
|
+
limit: z
|
|
236
|
+
.number()
|
|
237
|
+
.int()
|
|
238
|
+
.positive()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe("Max entries per page (capped server-side by FEED_PAGE_MAX)."),
|
|
241
|
+
wait: z
|
|
242
|
+
.number()
|
|
243
|
+
.int()
|
|
244
|
+
.min(0)
|
|
245
|
+
.max(30)
|
|
246
|
+
.optional()
|
|
247
|
+
.describe("Optional long-poll: how long the relay holds the request open waiting for a new entry (0-30s). Use ~25 when waiting for activity, then call again with the same cursor."),
|
|
248
|
+
};
|
|
249
|
+
const appsShape = {
|
|
250
|
+
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)."),
|
|
253
|
+
app_id: z
|
|
254
|
+
.string()
|
|
255
|
+
.optional()
|
|
256
|
+
.describe("Required for show/update/delete/wake."),
|
|
257
|
+
status: z
|
|
258
|
+
.enum(["active", "dormant", "archived", "all"])
|
|
259
|
+
.optional()
|
|
260
|
+
.describe("list only. Default: active."),
|
|
261
|
+
limit: z
|
|
262
|
+
.number()
|
|
263
|
+
.int()
|
|
264
|
+
.positive()
|
|
265
|
+
.max(200)
|
|
266
|
+
.optional()
|
|
267
|
+
.describe("list only. Page size."),
|
|
268
|
+
cursor: z
|
|
269
|
+
.string()
|
|
270
|
+
.optional()
|
|
271
|
+
.describe("list only. Opaque cursor from a previous next_cursor."),
|
|
272
|
+
slug: z.string().optional().describe("list only. Exact-match slug filter."),
|
|
273
|
+
visibility: z
|
|
274
|
+
.enum(["private", "link", "public"])
|
|
275
|
+
.optional()
|
|
276
|
+
.describe("update only. The new visibility (slug is immutable)."),
|
|
277
|
+
};
|
|
278
|
+
const membersShape = {
|
|
279
|
+
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)."),
|
|
282
|
+
app_id: z.string().min(1).describe("The app id."),
|
|
283
|
+
email: z
|
|
284
|
+
.string()
|
|
285
|
+
.optional()
|
|
286
|
+
.describe("add only. The email to invite/attach. If a Human already exists for it, the member row is attached immediately; otherwise the relay emails a magic-link invite."),
|
|
287
|
+
role: z
|
|
288
|
+
.enum(["member"])
|
|
289
|
+
.optional()
|
|
290
|
+
.describe("add only. Defaults to 'member' server-side — no other role is assignable via this API (ownership transfer is not available here)."),
|
|
291
|
+
human_id: z
|
|
292
|
+
.string()
|
|
293
|
+
.optional()
|
|
294
|
+
.describe("remove only. The Human id to remove — see list's `humanId` field. The app owner cannot be removed."),
|
|
295
|
+
};
|
|
296
|
+
// ===========================================================================
|
|
297
|
+
// Consolidated management tools
|
|
298
|
+
// ===========================================================================
|
|
299
|
+
const attachmentsShape = {
|
|
300
|
+
action: z
|
|
301
|
+
.enum([
|
|
302
|
+
"upload",
|
|
303
|
+
"download",
|
|
304
|
+
"show",
|
|
305
|
+
"list",
|
|
306
|
+
"delete",
|
|
307
|
+
"mint_token",
|
|
308
|
+
"revoke_token",
|
|
309
|
+
"list_tokens",
|
|
310
|
+
])
|
|
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."),
|
|
312
|
+
attachment_id: z
|
|
313
|
+
.string()
|
|
314
|
+
.optional()
|
|
315
|
+
.describe("Attachment id. Required for download/show/delete/mint_token/revoke_token/list_tokens."),
|
|
316
|
+
file_path: z
|
|
317
|
+
.string()
|
|
318
|
+
.optional()
|
|
319
|
+
.describe("upload: ABSOLUTE path to the local file to upload."),
|
|
320
|
+
scope: z
|
|
321
|
+
.enum(["agent", "app"])
|
|
322
|
+
.optional()
|
|
323
|
+
.describe("upload scope (default agent)."),
|
|
324
|
+
app_id: z.string().optional().describe("Required when scope=app."),
|
|
325
|
+
filename: z
|
|
326
|
+
.string()
|
|
327
|
+
.optional()
|
|
328
|
+
.describe("upload: display filename (defaults to the file's basename)."),
|
|
329
|
+
mime: z
|
|
330
|
+
.string()
|
|
331
|
+
.optional()
|
|
332
|
+
.describe("upload: advisory Content-Type (the relay sniffs the bytes regardless)."),
|
|
333
|
+
out_path: z
|
|
334
|
+
.string()
|
|
335
|
+
.optional()
|
|
336
|
+
.describe("download: ABSOLUTE path to write the bytes to. If omitted, the bytes are returned base64-encoded in the result."),
|
|
337
|
+
cursor: z.string().optional().describe("list pagination cursor."),
|
|
338
|
+
limit: z
|
|
339
|
+
.number()
|
|
340
|
+
.int()
|
|
341
|
+
.positive()
|
|
342
|
+
.max(100)
|
|
343
|
+
.optional()
|
|
344
|
+
.describe("list page size (1..100)."),
|
|
345
|
+
ttl_seconds: z
|
|
346
|
+
.number()
|
|
347
|
+
.int()
|
|
348
|
+
.positive()
|
|
349
|
+
.optional()
|
|
350
|
+
.describe("mint_token: per-token TTL (clamped by scope default)."),
|
|
351
|
+
once: z
|
|
352
|
+
.boolean()
|
|
353
|
+
.optional()
|
|
354
|
+
.describe("mint_token: token self-deletes on first GET."),
|
|
355
|
+
token_id: z
|
|
356
|
+
.string()
|
|
357
|
+
.optional()
|
|
358
|
+
.describe("revoke_token: the token id to revoke."),
|
|
359
|
+
};
|
|
360
|
+
const tasteShape = {
|
|
361
|
+
action: z
|
|
362
|
+
.enum(["get", "set", "clear"])
|
|
363
|
+
.describe("The agent's freeform UI taste notes (markdown) — presentation preferences learned from human feedback. get: read them before generating an app. set: whole-document replace (taste, non-empty). clear: delete them."),
|
|
364
|
+
taste: z
|
|
365
|
+
.string()
|
|
366
|
+
.optional()
|
|
367
|
+
.describe("The full markdown notes (required for set; whole-document replace, not append)."),
|
|
368
|
+
};
|
|
369
|
+
const keyShape = {
|
|
370
|
+
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)."),
|
|
373
|
+
confirm: z.boolean().optional().describe("Required (true) for revoke."),
|
|
374
|
+
};
|
|
375
|
+
const feedbackShape = {
|
|
376
|
+
action: z
|
|
377
|
+
.enum(["create", "list"])
|
|
378
|
+
.describe("Feedback to the relay operator. create: submit a bug|feature|note with a message (optional app_id). list: the agent's own submissions, newest first."),
|
|
379
|
+
type: z
|
|
380
|
+
.enum(["bug", "feature", "note"])
|
|
381
|
+
.optional()
|
|
382
|
+
.describe("Feedback category (required for create)."),
|
|
383
|
+
message: z
|
|
384
|
+
.string()
|
|
385
|
+
.optional()
|
|
386
|
+
.describe("Message body (required for create)."),
|
|
387
|
+
app_id: z
|
|
388
|
+
.string()
|
|
389
|
+
.optional()
|
|
390
|
+
.describe("Optional app this feedback relates to (create)."),
|
|
391
|
+
limit: z
|
|
392
|
+
.number()
|
|
393
|
+
.int()
|
|
394
|
+
.positive()
|
|
395
|
+
.max(100)
|
|
396
|
+
.optional()
|
|
397
|
+
.describe("list page size (default 50, max 100)."),
|
|
398
|
+
before: z
|
|
399
|
+
.string()
|
|
400
|
+
.optional()
|
|
401
|
+
.describe("list cursor from a prior page's next_before."),
|
|
402
|
+
};
|
|
403
|
+
const agentShape = {
|
|
404
|
+
action: z
|
|
405
|
+
.enum(["whoami", "claim", "logout"])
|
|
406
|
+
.describe("Agent identity. whoami: show the resolved relay URL, active profile, and whether a key is configured (no network, no secrets). claim: bind this agent to a human via a one-shot claim code the human generated in their Settings UI (one-way). logout: clear the locally-saved key/profile (does NOT revoke it on the relay — use the key tool's revoke for that)."),
|
|
407
|
+
code: z
|
|
408
|
+
.string()
|
|
409
|
+
.optional()
|
|
410
|
+
.describe("The one-shot claim code (required for claim)."),
|
|
411
|
+
};
|
|
412
|
+
const getSkillShape = {
|
|
413
|
+
version_only: z
|
|
414
|
+
.boolean()
|
|
415
|
+
.optional()
|
|
416
|
+
.describe("If true, return only the relay's current skill version string instead of the full SKILL.md markdown."),
|
|
417
|
+
};
|
|
418
|
+
// ===========================================================================
|
|
419
|
+
// Tool definitions
|
|
420
|
+
// ===========================================================================
|
|
421
|
+
export const TOOLS = [
|
|
422
|
+
// ----- v2 app lifecycle + data (discrete, hot-path) -----------------------
|
|
423
|
+
{
|
|
424
|
+
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).",
|
|
426
|
+
inputSchema: deployAppShape,
|
|
427
|
+
annotations: {
|
|
428
|
+
title: "Deploy App",
|
|
429
|
+
readOnlyHint: false,
|
|
430
|
+
destructiveHint: true,
|
|
431
|
+
idempotentHint: false,
|
|
432
|
+
openWorldHint: true,
|
|
433
|
+
},
|
|
434
|
+
handler: async (client, args) => {
|
|
435
|
+
try {
|
|
436
|
+
const manifest = parseMaybeStringifiedObject(args["manifest"], "manifest");
|
|
437
|
+
if ("error" in manifest)
|
|
438
|
+
return manifest.error;
|
|
439
|
+
const appId = str(args, "app_id");
|
|
440
|
+
if (appId === undefined) {
|
|
441
|
+
if (str(args, "html") === undefined) {
|
|
442
|
+
return invalidArgs("create requires `html`");
|
|
443
|
+
}
|
|
444
|
+
const slug = str(args, "slug");
|
|
445
|
+
const visibility = args["visibility"];
|
|
446
|
+
if (slug !== undefined && visibility === "link") {
|
|
447
|
+
return invalidArgs("a `slug` is not allowed with visibility 'link' (link slugs are server-generated); drop visibility 'link', or omit slug");
|
|
448
|
+
}
|
|
449
|
+
return jsonResult(await client.deployApp({
|
|
450
|
+
html: String(args["html"]),
|
|
451
|
+
manifest: manifest.value,
|
|
452
|
+
visibility,
|
|
453
|
+
slug,
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
if (str(args, "html") === undefined) {
|
|
457
|
+
return invalidArgs("redeploy requires `html`");
|
|
458
|
+
}
|
|
459
|
+
if (args["slug"] !== undefined || args["visibility"] !== undefined) {
|
|
460
|
+
return invalidArgs("slug/visibility cannot change on redeploy — slug is immutable, visibility changes via the `apps` tool (action: update)");
|
|
461
|
+
}
|
|
462
|
+
const redeployed = await client.redeployApp(appId, {
|
|
463
|
+
html: String(args["html"]),
|
|
464
|
+
manifest: manifest.value,
|
|
465
|
+
force: args["force"],
|
|
466
|
+
});
|
|
467
|
+
return jsonResult(redeployed);
|
|
468
|
+
}
|
|
469
|
+
catch (e) {
|
|
470
|
+
return errorResult(e);
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
name: "list_rows",
|
|
476
|
+
description: "List rows in a v2 app's mutable collection. This also doubles as the POLL for a collection's current state (no streaming in MCP): pass the prior next_cursor as `since` to fetch only newer/changed rows. Returns { rows, next_cursor, has_more }.",
|
|
477
|
+
inputSchema: listRowsShape,
|
|
478
|
+
annotations: {
|
|
479
|
+
title: "List Rows",
|
|
480
|
+
readOnlyHint: true,
|
|
481
|
+
openWorldHint: false,
|
|
482
|
+
},
|
|
483
|
+
handler: async (client, args) => {
|
|
484
|
+
try {
|
|
485
|
+
return jsonResult(await client.listAppRows(String(args["app_id"]), String(args["collection"]), {
|
|
486
|
+
since: args["since"],
|
|
487
|
+
limit: args["limit"],
|
|
488
|
+
}));
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
return errorResult(e);
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: "get_row",
|
|
497
|
+
description: "Fetch a single row by its key from a v2 app collection (a dedicated relay route — not a client-side scan). Returns { row } or an isError row_not_found.",
|
|
498
|
+
inputSchema: getRowShape,
|
|
499
|
+
annotations: {
|
|
500
|
+
title: "Get Row",
|
|
501
|
+
readOnlyHint: true,
|
|
502
|
+
openWorldHint: false,
|
|
503
|
+
},
|
|
504
|
+
handler: async (client, args) => {
|
|
505
|
+
try {
|
|
506
|
+
return jsonResult(await client.getAppRow(String(args["app_id"]), String(args["collection"]), String(args["key"])));
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
return errorResult(e);
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: "upsert_row",
|
|
515
|
+
description: "Create a row in a v2 app's collection, or return the existing row if `key` is already present (deduped:true) — the ONLY create-shaped verb for app rows (no separate strict create). Omit `key` to add a new row (server-generates one); pass `key` to ensure a row exists at that key. The collection must be declared in the app's manifest with 'agent' allowed to write. Returns { row, deduped? }.",
|
|
516
|
+
inputSchema: upsertRowShape,
|
|
517
|
+
annotations: {
|
|
518
|
+
title: "Upsert Row",
|
|
519
|
+
readOnlyHint: false,
|
|
520
|
+
destructiveHint: true,
|
|
521
|
+
idempotentHint: true,
|
|
522
|
+
openWorldHint: false,
|
|
523
|
+
},
|
|
524
|
+
handler: async (client, args) => {
|
|
525
|
+
try {
|
|
526
|
+
const data = parseMaybeStringifiedObject(args["data"], "data");
|
|
527
|
+
if ("error" in data)
|
|
528
|
+
return data.error;
|
|
529
|
+
const body = { data: data.value };
|
|
530
|
+
if (args["key"] !== undefined)
|
|
531
|
+
body.key = String(args["key"]);
|
|
532
|
+
return jsonResult(await client.upsertAppRow(String(args["app_id"]), String(args["collection"]), body));
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
return errorResult(e);
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
name: "update_row",
|
|
541
|
+
description: "Update an existing row in a v2 app's collection (replaces its data). Pass if_match with the row's current version for an optimistic-locked update — on a version mismatch the relay returns the current row so you can retry. Returns { row }.",
|
|
542
|
+
inputSchema: updateRowShape,
|
|
543
|
+
annotations: {
|
|
544
|
+
title: "Update Row",
|
|
545
|
+
readOnlyHint: false,
|
|
546
|
+
destructiveHint: true,
|
|
547
|
+
idempotentHint: true,
|
|
548
|
+
openWorldHint: false,
|
|
549
|
+
},
|
|
550
|
+
handler: async (client, args) => {
|
|
551
|
+
try {
|
|
552
|
+
const data = parseMaybeStringifiedObject(args["data"], "data");
|
|
553
|
+
if ("error" in data)
|
|
554
|
+
return data.error;
|
|
555
|
+
const body = {
|
|
556
|
+
data: data.value,
|
|
557
|
+
};
|
|
558
|
+
if (args["if_match"] !== undefined)
|
|
559
|
+
body.if_match = args["if_match"];
|
|
560
|
+
return jsonResult(await client.updateAppRow(String(args["app_id"]), String(args["collection"]), String(args["key"]), body));
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
return errorResult(e);
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: "delete_row",
|
|
569
|
+
description: "Soft-delete a row from a v2 app's collection. A watcher sees the deletion live (op:delete on the change feed). Pass if_match for an optimistic-locked delete. Returns { deleted: true }.",
|
|
570
|
+
inputSchema: deleteRowShape,
|
|
571
|
+
annotations: {
|
|
572
|
+
title: "Delete Row",
|
|
573
|
+
readOnlyHint: false,
|
|
574
|
+
destructiveHint: true,
|
|
575
|
+
idempotentHint: true,
|
|
576
|
+
openWorldHint: false,
|
|
577
|
+
},
|
|
578
|
+
handler: async (client, args) => {
|
|
579
|
+
try {
|
|
580
|
+
await client.deleteAppRow(String(args["app_id"]), String(args["collection"]), String(args["key"]), args["if_match"] !== undefined
|
|
581
|
+
? { ifMatch: args["if_match"] }
|
|
582
|
+
: {});
|
|
583
|
+
return jsonResult({ deleted: true, key: args["key"] });
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
return errorResult(e);
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: "get_feed_events",
|
|
592
|
+
description: "Poll a v2 app's change feed for what happened (row creates/updates/deletes, from any writer — agent or human). This is the long-poll analogue of `homespun apps watch` — there is no streaming in MCP. Poll loop: call with no `since` first; process the returned entries; remember cursor; call again passing it as `since` to get only newer entries. To WAIT for activity, pass wait (~25) so the relay holds the request open until an entry arrives or it times out. A `since` older than the retention floor returns resync_required — re-list the collection(s) with list_rows instead. Returns { entries, cursor, truncated }.",
|
|
593
|
+
inputSchema: getFeedEventsShape,
|
|
594
|
+
annotations: {
|
|
595
|
+
title: "Get App Feed Events",
|
|
596
|
+
readOnlyHint: true,
|
|
597
|
+
openWorldHint: false,
|
|
598
|
+
},
|
|
599
|
+
handler: async (client, args) => {
|
|
600
|
+
try {
|
|
601
|
+
return jsonResult(await client.getAppFeed(String(args["app_id"]), {
|
|
602
|
+
since: args["since"] ?? 0,
|
|
603
|
+
limit: args["limit"],
|
|
604
|
+
wait: args["wait"],
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
catch (e) {
|
|
608
|
+
return errorResult(e);
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
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).",
|
|
615
|
+
inputSchema: appsShape,
|
|
616
|
+
// Consolidated tool: read actions (list/show) + mutating ones (update/
|
|
617
|
+
// delete/wake). Hint reflects delete, the most-privileged action.
|
|
618
|
+
annotations: {
|
|
619
|
+
title: "Manage Apps",
|
|
620
|
+
readOnlyHint: false,
|
|
621
|
+
destructiveHint: true,
|
|
622
|
+
idempotentHint: true,
|
|
623
|
+
openWorldHint: false,
|
|
624
|
+
},
|
|
625
|
+
handler: async (client, args) => {
|
|
626
|
+
const action = String(args["action"]);
|
|
627
|
+
try {
|
|
628
|
+
switch (action) {
|
|
629
|
+
case "list": {
|
|
630
|
+
const opts = {};
|
|
631
|
+
if (args["status"] !== undefined)
|
|
632
|
+
opts["status"] = args["status"];
|
|
633
|
+
if (args["limit"] !== undefined)
|
|
634
|
+
opts["limit"] = args["limit"];
|
|
635
|
+
if (args["cursor"] !== undefined)
|
|
636
|
+
opts["cursor"] = args["cursor"];
|
|
637
|
+
if (args["slug"] !== undefined)
|
|
638
|
+
opts["slug"] = args["slug"];
|
|
639
|
+
return jsonResult(await client.listApps(opts));
|
|
640
|
+
}
|
|
641
|
+
case "show":
|
|
642
|
+
if (str(args, "app_id") === undefined) {
|
|
643
|
+
return invalidArgs("show requires `app_id`");
|
|
644
|
+
}
|
|
645
|
+
return jsonResult(await client.getApp(String(args["app_id"])));
|
|
646
|
+
case "update":
|
|
647
|
+
if (str(args, "app_id") === undefined) {
|
|
648
|
+
return invalidArgs("update requires `app_id`");
|
|
649
|
+
}
|
|
650
|
+
if (str(args, "visibility") === undefined) {
|
|
651
|
+
return invalidArgs("update requires `visibility`");
|
|
652
|
+
}
|
|
653
|
+
return jsonResult(await client.updateApp(String(args["app_id"]), args["visibility"]));
|
|
654
|
+
case "delete":
|
|
655
|
+
if (str(args, "app_id") === undefined) {
|
|
656
|
+
return invalidArgs("delete requires `app_id`");
|
|
657
|
+
}
|
|
658
|
+
await client.deleteApp(String(args["app_id"]));
|
|
659
|
+
return jsonResult({ app_id: args["app_id"], deleted: true });
|
|
660
|
+
case "wake":
|
|
661
|
+
if (str(args, "app_id") === undefined) {
|
|
662
|
+
return invalidArgs("wake requires `app_id`");
|
|
663
|
+
}
|
|
664
|
+
return jsonResult(await client.wakeApp(String(args["app_id"])));
|
|
665
|
+
default:
|
|
666
|
+
return invalidArgs(`unknown apps action '${action}'`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
catch (e) {
|
|
670
|
+
return errorResult(e);
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
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).",
|
|
677
|
+
inputSchema: membersShape,
|
|
678
|
+
// Consolidated tool: read action (list) + mutating ones (add/remove).
|
|
679
|
+
// Hint reflects remove, the most-privileged action.
|
|
680
|
+
annotations: {
|
|
681
|
+
title: "Manage App Members",
|
|
682
|
+
readOnlyHint: false,
|
|
683
|
+
destructiveHint: true,
|
|
684
|
+
idempotentHint: true,
|
|
685
|
+
openWorldHint: false,
|
|
686
|
+
},
|
|
687
|
+
handler: async (client, args) => {
|
|
688
|
+
const action = String(args["action"]);
|
|
689
|
+
if (str(args, "app_id") === undefined) {
|
|
690
|
+
return invalidArgs(`${action} requires \`app_id\``);
|
|
691
|
+
}
|
|
692
|
+
const appId = String(args["app_id"]);
|
|
693
|
+
try {
|
|
694
|
+
switch (action) {
|
|
695
|
+
case "add": {
|
|
696
|
+
if (str(args, "email") === undefined) {
|
|
697
|
+
return invalidArgs("add requires `email`");
|
|
698
|
+
}
|
|
699
|
+
return jsonResult(await client.addAppMember(appId, {
|
|
700
|
+
email: String(args["email"]),
|
|
701
|
+
...(args["role"] !== undefined
|
|
702
|
+
? { role: args["role"] }
|
|
703
|
+
: {}),
|
|
704
|
+
}));
|
|
705
|
+
}
|
|
706
|
+
case "list":
|
|
707
|
+
return jsonResult(await client.listAppMembers(appId));
|
|
708
|
+
case "remove": {
|
|
709
|
+
if (str(args, "human_id") === undefined) {
|
|
710
|
+
return invalidArgs("remove requires `human_id`");
|
|
711
|
+
}
|
|
712
|
+
await client.removeAppMember(appId, String(args["human_id"]));
|
|
713
|
+
return jsonResult({
|
|
714
|
+
app_id: appId,
|
|
715
|
+
human_id: args["human_id"],
|
|
716
|
+
removed: true,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
default:
|
|
720
|
+
return invalidArgs(`unknown members action '${action}'`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
catch (e) {
|
|
724
|
+
return errorResult(e);
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
// ----- consolidated management tools --------------------------------------
|
|
729
|
+
{
|
|
730
|
+
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.",
|
|
732
|
+
inputSchema: attachmentsShape,
|
|
733
|
+
// Consolidated tool: read actions (download/show/list/list_tokens) +
|
|
734
|
+
// mutating ones (upload/delete/mint_token/revoke_token). openWorld:true
|
|
735
|
+
// because upload pushes bytes into external relay storage + mint_token
|
|
736
|
+
// produces a publicly-fetchable capability URL.
|
|
737
|
+
annotations: {
|
|
738
|
+
title: "Manage Attachments",
|
|
739
|
+
readOnlyHint: false,
|
|
740
|
+
destructiveHint: true,
|
|
741
|
+
idempotentHint: false,
|
|
742
|
+
openWorldHint: true,
|
|
743
|
+
},
|
|
744
|
+
handler: async (client, args) => {
|
|
745
|
+
const action = String(args["action"]);
|
|
746
|
+
try {
|
|
747
|
+
switch (action) {
|
|
748
|
+
case "upload": {
|
|
749
|
+
const filePath = str(args, "file_path");
|
|
750
|
+
if (filePath === undefined)
|
|
751
|
+
return invalidArgs("upload requires `file_path` (absolute)");
|
|
752
|
+
const scope = (str(args, "scope") ?? "agent");
|
|
753
|
+
if (scope === "app" && str(args, "app_id") === undefined)
|
|
754
|
+
return invalidArgs("scope=app requires `app_id`");
|
|
755
|
+
let bytes;
|
|
756
|
+
try {
|
|
757
|
+
bytes = readFileSync(filePath);
|
|
758
|
+
}
|
|
759
|
+
catch (e) {
|
|
760
|
+
return invalidArgs(`failed to read file_path '${filePath}': ${e instanceof Error ? e.message : String(e)}`);
|
|
761
|
+
}
|
|
762
|
+
const ref = await client.uploadBlob(bytes, {
|
|
763
|
+
scope,
|
|
764
|
+
appId: str(args, "app_id"),
|
|
765
|
+
filename: str(args, "filename") ?? basename(filePath),
|
|
766
|
+
mime: str(args, "mime"),
|
|
767
|
+
});
|
|
768
|
+
return jsonResult(ref);
|
|
769
|
+
}
|
|
770
|
+
case "download": {
|
|
771
|
+
if (str(args, "attachment_id") === undefined)
|
|
772
|
+
return invalidArgs("download requires `attachment_id`");
|
|
773
|
+
const buf = await client.downloadBlob(String(args["attachment_id"]));
|
|
774
|
+
const outPath = str(args, "out_path");
|
|
775
|
+
if (outPath !== undefined) {
|
|
776
|
+
try {
|
|
777
|
+
writeFileSync(outPath, Buffer.from(buf));
|
|
778
|
+
}
|
|
779
|
+
catch (e) {
|
|
780
|
+
return invalidArgs(`failed to write out_path '${outPath}': ${e instanceof Error ? e.message : String(e)}`);
|
|
781
|
+
}
|
|
782
|
+
return jsonResult({
|
|
783
|
+
attachment_id: args["attachment_id"],
|
|
784
|
+
written: outPath,
|
|
785
|
+
bytes: buf.byteLength,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
return jsonResult({
|
|
789
|
+
attachment_id: args["attachment_id"],
|
|
790
|
+
bytes: buf.byteLength,
|
|
791
|
+
base64: Buffer.from(buf).toString("base64"),
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
case "show":
|
|
795
|
+
if (str(args, "attachment_id") === undefined)
|
|
796
|
+
return invalidArgs("show requires `attachment_id`");
|
|
797
|
+
return jsonResult(await client.getBlob(String(args["attachment_id"])));
|
|
798
|
+
case "list": {
|
|
799
|
+
const opts = {};
|
|
800
|
+
if (str(args, "cursor") !== undefined)
|
|
801
|
+
opts.cursor = String(args["cursor"]);
|
|
802
|
+
if (args["limit"] !== undefined)
|
|
803
|
+
opts.limit = args["limit"];
|
|
804
|
+
return jsonResult(await client.listBlobs(opts));
|
|
805
|
+
}
|
|
806
|
+
case "delete":
|
|
807
|
+
if (str(args, "attachment_id") === undefined)
|
|
808
|
+
return invalidArgs("delete requires `attachment_id`");
|
|
809
|
+
return jsonResult(await client.deleteBlob(String(args["attachment_id"])));
|
|
810
|
+
case "mint_token": {
|
|
811
|
+
if (str(args, "attachment_id") === undefined)
|
|
812
|
+
return invalidArgs("mint_token requires `attachment_id`");
|
|
813
|
+
return jsonResult(await client.mintBlobToken(String(args["attachment_id"]), {
|
|
814
|
+
ttlSeconds: args["ttl_seconds"],
|
|
815
|
+
once: args["once"] === true,
|
|
816
|
+
}));
|
|
817
|
+
}
|
|
818
|
+
case "revoke_token":
|
|
819
|
+
if (str(args, "attachment_id") === undefined ||
|
|
820
|
+
str(args, "token_id") === undefined)
|
|
821
|
+
return invalidArgs("revoke_token requires `attachment_id` and `token_id`");
|
|
822
|
+
return jsonResult(await client.revokeBlobToken(String(args["attachment_id"]), String(args["token_id"])));
|
|
823
|
+
case "list_tokens":
|
|
824
|
+
if (str(args, "attachment_id") === undefined)
|
|
825
|
+
return invalidArgs("list_tokens requires `attachment_id`");
|
|
826
|
+
return jsonResult(await client.listBlobTokens(String(args["attachment_id"])));
|
|
827
|
+
default:
|
|
828
|
+
return invalidArgs(`unknown attachments action '${action}'`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch (e) {
|
|
832
|
+
return errorResult(e);
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
name: "taste",
|
|
838
|
+
description: "Read / write / clear the agent's freeform UI taste notes (a small markdown document of presentation preferences learned from human feedback — 'denser layout', 'no rounded corners'). ONE tool with an `action` enum: get | set | clear. Call `get` BEFORE generating an app so prior feedback shapes the output; `set` does a whole-document replace (not append). Keep entries about UI/presentation only.",
|
|
839
|
+
inputSchema: tasteShape,
|
|
840
|
+
// Consolidated tool: read action (get) + mutating ones (set replaces the
|
|
841
|
+
// doc, clear deletes it). Hint reflects the destructive action.
|
|
842
|
+
annotations: {
|
|
843
|
+
title: "Manage UI Taste Notes",
|
|
844
|
+
readOnlyHint: false,
|
|
845
|
+
destructiveHint: true,
|
|
846
|
+
idempotentHint: false,
|
|
847
|
+
openWorldHint: false,
|
|
848
|
+
},
|
|
849
|
+
handler: async (client, args) => {
|
|
850
|
+
const action = String(args["action"]);
|
|
851
|
+
try {
|
|
852
|
+
switch (action) {
|
|
853
|
+
case "get":
|
|
854
|
+
return jsonResult(await client.getTaste());
|
|
855
|
+
case "set": {
|
|
856
|
+
const taste = str(args, "taste");
|
|
857
|
+
if (taste === undefined || taste.trim() === "")
|
|
858
|
+
return invalidArgs("set requires non-empty `taste` (use clear to delete the notes)");
|
|
859
|
+
return jsonResult(await client.setTaste(taste));
|
|
860
|
+
}
|
|
861
|
+
case "clear":
|
|
862
|
+
await client.clearTaste();
|
|
863
|
+
return jsonResult({ cleared: true });
|
|
864
|
+
default:
|
|
865
|
+
return invalidArgs(`unknown taste action '${action}'`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
catch (e) {
|
|
869
|
+
return errorResult(e);
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
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.",
|
|
876
|
+
inputSchema: keyShape,
|
|
877
|
+
// Consolidated tool: read action (list) + a mutating one (revoke
|
|
878
|
+
// self-destructs the agent's own key). Hint reflects the destructive
|
|
879
|
+
// action.
|
|
880
|
+
annotations: {
|
|
881
|
+
title: "Manage API Key",
|
|
882
|
+
readOnlyHint: false,
|
|
883
|
+
destructiveHint: true,
|
|
884
|
+
idempotentHint: false,
|
|
885
|
+
openWorldHint: false,
|
|
886
|
+
},
|
|
887
|
+
handler: async (client, args) => {
|
|
888
|
+
const action = String(args["action"]);
|
|
889
|
+
try {
|
|
890
|
+
switch (action) {
|
|
891
|
+
case "list":
|
|
892
|
+
return jsonResult(await client.listKeys());
|
|
893
|
+
case "revoke": {
|
|
894
|
+
if (args["confirm"] !== true) {
|
|
895
|
+
return invalidArgs("revoke is irreversible and stops your key working immediately — pass confirm:true");
|
|
896
|
+
}
|
|
897
|
+
const id = (await client.listKeys()).agent_id;
|
|
898
|
+
await client.revokeKey(id);
|
|
899
|
+
return jsonResult({ revoked: true, agent_id: id });
|
|
900
|
+
}
|
|
901
|
+
default:
|
|
902
|
+
return invalidArgs(`unknown key action '${action}'`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
catch (e) {
|
|
906
|
+
return errorResult(e);
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
name: "feedback",
|
|
912
|
+
description: "Send or list feedback to the relay operator. ONE tool with an `action` enum: create (a bug|feature|note with a message, optional app_id) | list (the agent's own submissions, newest first, paginated by before).",
|
|
913
|
+
inputSchema: feedbackShape,
|
|
914
|
+
// Consolidated tool: read action (list) + a side-effecting one (create
|
|
915
|
+
// submits feedback to the relay operator). Hint reflects the write action.
|
|
916
|
+
annotations: {
|
|
917
|
+
title: "Manage Feedback",
|
|
918
|
+
readOnlyHint: false,
|
|
919
|
+
destructiveHint: true,
|
|
920
|
+
idempotentHint: false,
|
|
921
|
+
openWorldHint: false,
|
|
922
|
+
},
|
|
923
|
+
handler: async (client, args) => {
|
|
924
|
+
const action = String(args["action"]);
|
|
925
|
+
try {
|
|
926
|
+
switch (action) {
|
|
927
|
+
case "create": {
|
|
928
|
+
if (str(args, "type") === undefined ||
|
|
929
|
+
str(args, "message") === undefined)
|
|
930
|
+
return invalidArgs("create requires `type` and `message`");
|
|
931
|
+
return jsonResult(await client.submitFeedback({
|
|
932
|
+
type: args["type"],
|
|
933
|
+
message: String(args["message"]),
|
|
934
|
+
...(str(args, "app_id") !== undefined
|
|
935
|
+
? { appId: String(args["app_id"]) }
|
|
936
|
+
: {}),
|
|
937
|
+
}));
|
|
938
|
+
}
|
|
939
|
+
case "list": {
|
|
940
|
+
const opts = {};
|
|
941
|
+
if (args["limit"] !== undefined)
|
|
942
|
+
opts.limit = args["limit"];
|
|
943
|
+
if (str(args, "before") !== undefined)
|
|
944
|
+
opts.before = String(args["before"]);
|
|
945
|
+
return jsonResult(await client.listFeedback(opts));
|
|
946
|
+
}
|
|
947
|
+
default:
|
|
948
|
+
return invalidArgs(`unknown feedback action '${action}'`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
catch (e) {
|
|
952
|
+
return errorResult(e);
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
name: "agent",
|
|
958
|
+
description: "Agent identity + binding. ONE tool with an `action` enum: whoami (the resolved relay URL, active profile, whether a key is configured — no network, no secrets) | claim (bind this agent to a human via a one-shot claim code from their Settings UI; one-way) | logout (clear the locally-saved key/profile; does NOT revoke it on the relay — use the `key` tool's revoke for that).",
|
|
959
|
+
inputSchema: agentShape,
|
|
960
|
+
// Consolidated tool: read action (whoami) + mutating ones (claim binds
|
|
961
|
+
// this agent to a human, logout clears the local profile). Hint reflects
|
|
962
|
+
// the state-changing action.
|
|
963
|
+
annotations: {
|
|
964
|
+
title: "Manage Agent Identity",
|
|
965
|
+
readOnlyHint: false,
|
|
966
|
+
destructiveHint: true,
|
|
967
|
+
idempotentHint: false,
|
|
968
|
+
openWorldHint: false,
|
|
969
|
+
},
|
|
970
|
+
handler: async (client, args, env) => {
|
|
971
|
+
const action = String(args["action"]);
|
|
972
|
+
try {
|
|
973
|
+
switch (action) {
|
|
974
|
+
case "whoami":
|
|
975
|
+
// No network — pure local config introspection. The relay's HTTP
|
|
976
|
+
// server injects describeConfig (active token's agent identity);
|
|
977
|
+
// the stdio server reads the CLI config store.
|
|
978
|
+
return jsonResult((env?.describeConfig ?? describeActiveConfig)());
|
|
979
|
+
case "claim":
|
|
980
|
+
if (str(args, "code") === undefined)
|
|
981
|
+
return invalidArgs("claim requires `code`");
|
|
982
|
+
return jsonResult(await client.claimAgent(String(args["code"])));
|
|
983
|
+
case "logout":
|
|
984
|
+
return jsonResult((env?.clearProfile ?? clearActiveProfile)());
|
|
985
|
+
default:
|
|
986
|
+
return invalidArgs(`unknown agent action '${action}'`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
catch (e) {
|
|
990
|
+
return errorResult(e);
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
name: "get_skill",
|
|
996
|
+
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).",
|
|
997
|
+
inputSchema: getSkillShape,
|
|
998
|
+
annotations: {
|
|
999
|
+
title: "Get Skill Guide",
|
|
1000
|
+
readOnlyHint: true,
|
|
1001
|
+
openWorldHint: false,
|
|
1002
|
+
},
|
|
1003
|
+
handler: async (_client, args, env) => {
|
|
1004
|
+
try {
|
|
1005
|
+
const versionOnly = args["version_only"] === true;
|
|
1006
|
+
// The relay's HTTP server injects getSkill so MCP consumers receive
|
|
1007
|
+
// the MCP-invocation rendering of the skill (tool-call grammar, not
|
|
1008
|
+
// `homespun ...` commands) straight from the relay image. The stdio server
|
|
1009
|
+
// falls back to fetching SKILL.md over HTTP from its configured relay.
|
|
1010
|
+
if (env?.getSkill) {
|
|
1011
|
+
const { markdown, version } = await env.getSkill(versionOnly);
|
|
1012
|
+
if (versionOnly)
|
|
1013
|
+
return jsonResult({ version });
|
|
1014
|
+
return textResult(markdown ?? "");
|
|
1015
|
+
}
|
|
1016
|
+
const url = resolveUrl();
|
|
1017
|
+
if (versionOnly) {
|
|
1018
|
+
const { version } = await fetchSkill(url, { version: true });
|
|
1019
|
+
return jsonResult({ version });
|
|
1020
|
+
}
|
|
1021
|
+
const { markdown } = await fetchSkill(url);
|
|
1022
|
+
return textResult(markdown ?? "");
|
|
1023
|
+
}
|
|
1024
|
+
catch (e) {
|
|
1025
|
+
return errorResult(e);
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
];
|