@desplega.ai/agent-swarm 1.78.1 → 1.79.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/openapi.json +1335 -236
- package/package.json +4 -4
- package/plugin/skills/artifacts/SKILL.md +151 -0
- package/plugin/skills/artifacts/examples/static-report.sh +1 -1
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +423 -0
- package/src/artifact-sdk/browser-sdk.ts +396 -19
- package/src/be/db.ts +548 -0
- package/src/be/migrations/059_pages.sql +34 -0
- package/src/be/migrations/060_page_versions.sql +19 -0
- package/src/be/migrations/061_kv_store.sql +34 -0
- package/src/be/migrations/062_pages_view_count.sql +9 -0
- package/src/commands/artifact.ts +17 -11
- package/src/commands/provider-credentials.ts +1 -1
- package/src/http/index.ts +9 -1
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +213 -0
- package/src/http/pages-public.ts +507 -0
- package/src/http/pages.ts +608 -0
- package/src/http/status.ts +1 -1
- package/src/http/utils.ts +68 -5
- package/src/pages/version.ts +44 -0
- package/src/prompts/session-templates.ts +51 -0
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +29 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/artifact-commands.test.ts +92 -0
- package/src/tests/artifact-sdk.test.ts +80 -74
- package/src/tests/context-key.test.ts +17 -0
- package/src/tests/create-page-tool.test.ts +197 -0
- package/src/tests/fixtures/sample-json-page.json +52 -0
- package/src/tests/kv-http.test.ts +331 -0
- package/src/tests/kv-namespace-resolution.test.ts +172 -0
- package/src/tests/kv-page-proxy.test.ts +212 -0
- package/src/tests/kv-storage.test.ts +227 -0
- package/src/tests/kv-tool.test.ts +217 -0
- package/src/tests/launch-password-rejection.test.ts +139 -0
- package/src/tests/page-proxy-authed.test.ts +146 -0
- package/src/tests/page-proxy.test.ts +270 -0
- package/src/tests/page-session.test.ts +169 -0
- package/src/tests/pages-actions-endpoint.test.ts +102 -0
- package/src/tests/pages-authed-mode.test.ts +211 -0
- package/src/tests/pages-http.test.ts +193 -0
- package/src/tests/pages-list-endpoint.test.ts +149 -0
- package/src/tests/pages-password-hash.test.ts +57 -0
- package/src/tests/pages-password-mode.test.ts +265 -0
- package/src/tests/pages-public-authed-401.test.ts +102 -0
- package/src/tests/pages-public-html.test.ts +151 -0
- package/src/tests/pages-public-json-redirect.test.ts +86 -0
- package/src/tests/pages-storage.test.ts +196 -0
- package/src/tests/pages-versioning.test.ts +231 -0
- package/src/tests/pages-view-count.test.ts +220 -0
- package/src/tests/prompt-template-session.test.ts +3 -2
- package/src/tests/skill-update-scope.test.ts +165 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -0
- package/src/tests/workflow-wait-event.test.ts +4 -7
- package/src/tools/create-page.ts +263 -0
- package/src/tools/kv/index.ts +5 -0
- package/src/tools/kv/kv-delete.ts +89 -0
- package/src/tools/kv/kv-get.ts +64 -0
- package/src/tools/kv/kv-incr.ts +116 -0
- package/src/tools/kv/kv-list.ts +81 -0
- package/src/tools/kv/kv-set.ts +194 -0
- package/src/tools/kv/resolve-namespace.ts +58 -0
- package/src/tools/skills/skill-update.ts +26 -0
- package/src/tools/tool-config.ts +10 -0
- package/src/types.ts +107 -0
- package/src/utils/internal-ai/complete-structured.ts +2 -2
- package/src/utils/internal-ai/credentials.ts +3 -3
- package/src/utils/page-session.ts +254 -0
- package/plugin/skills/artifacts/skill.md +0 -70
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create_page` MCP tool — capability-gated ("pages") agent-facing entry
|
|
3
|
+
* point for the db-backed pages feature. Creates or updates a page row in
|
|
4
|
+
* SQLite and returns shareable URLs.
|
|
5
|
+
*
|
|
6
|
+
* Upsert semantics: keyed by `(agentId, slug)`. If a row already exists,
|
|
7
|
+
* the tool calls `snapshotPage` (preserving pre-update content as a version
|
|
8
|
+
* row) then `updatePage` — mirroring the HTTP `PUT /api/pages/:id` flow.
|
|
9
|
+
* First-create skips the snapshot (no prior state).
|
|
10
|
+
*
|
|
11
|
+
* Slug derivation: explicit `slug` wins; otherwise kebab-case the title; if
|
|
12
|
+
* the title slugifies to empty (e.g. all symbols), fall back to the
|
|
13
|
+
* generated page id.
|
|
14
|
+
*
|
|
15
|
+
* Architecture note: this tool runs on the API server (per
|
|
16
|
+
* `src/server.ts:155`+ pattern) and accesses `src/be/db` directly. The
|
|
17
|
+
* worker-side DB-boundary invariant doesn't apply because `src/tools/*`
|
|
18
|
+
* lives on the API side.
|
|
19
|
+
*/
|
|
20
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
import * as z from "zod";
|
|
22
|
+
import { createPage, getPage, getPageBySlug, getPageVersions, updatePage } from "@/be/db";
|
|
23
|
+
import { snapshotPage } from "@/pages/version";
|
|
24
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
25
|
+
import { PageAuthModeSchema, PageContentTypeSchema } from "@/types";
|
|
26
|
+
|
|
27
|
+
/** Same slugifier used by the HTTP createPage handler. */
|
|
28
|
+
function slugify(input: string): string {
|
|
29
|
+
const slug = input
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.normalize("NFKD")
|
|
32
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "");
|
|
34
|
+
return slug;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getApiBaseUrl(): string {
|
|
38
|
+
const env = process.env.MCP_BASE_URL?.trim();
|
|
39
|
+
if (env) return env.replace(/\/+$/, "");
|
|
40
|
+
return `http://localhost:${process.env.PORT || "3013"}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getAppBaseUrl(): string {
|
|
44
|
+
const env = process.env.APP_URL?.trim();
|
|
45
|
+
if (env) return env.replace(/\/+$/, "");
|
|
46
|
+
return "http://localhost:5274";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Edit counter for a page — `MAX(page_versions.version) + 1`. Returned to
|
|
51
|
+
* the agent as `version` so they have a monotonic "this page has been
|
|
52
|
+
* edited N times" signal. Mirrors the value returned by
|
|
53
|
+
* `PUT /api/pages/:id` (see src/http/pages.ts:pageEditCounter).
|
|
54
|
+
*/
|
|
55
|
+
function pageEditCounter(pageId: string): number {
|
|
56
|
+
const versions = getPageVersions(pageId);
|
|
57
|
+
return versions.length > 0 ? versions[0]!.version + 1 : 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const registerCreatePageTool = (server: McpServer) => {
|
|
61
|
+
createToolRegistrar(server)(
|
|
62
|
+
"create_page",
|
|
63
|
+
{
|
|
64
|
+
title: "Create or update a page",
|
|
65
|
+
description:
|
|
66
|
+
"Stores an HTML or JSON page in the swarm and returns shareable URLs. " +
|
|
67
|
+
"Calls are upsert-by-(agent, slug): if you previously created a page " +
|
|
68
|
+
"with the same slug, its prior state is snapshotted and the row is " +
|
|
69
|
+
"updated. Use this for static reports, dashboards, or JSON action " +
|
|
70
|
+
"specs that don't need a long-lived process.",
|
|
71
|
+
annotations: { destructiveHint: false },
|
|
72
|
+
inputSchema: z.object({
|
|
73
|
+
title: z.string().min(1).describe("Human-readable title shown in listings."),
|
|
74
|
+
slug: z
|
|
75
|
+
.string()
|
|
76
|
+
.min(1)
|
|
77
|
+
.optional()
|
|
78
|
+
.describe(
|
|
79
|
+
"URL slug. Defaults to the kebab-cased title. Same slug → updates the existing row.",
|
|
80
|
+
),
|
|
81
|
+
body: z
|
|
82
|
+
.string()
|
|
83
|
+
.min(1)
|
|
84
|
+
.describe("Full page body (HTML document or JSON-render spec, per contentType)."),
|
|
85
|
+
contentType: PageContentTypeSchema.describe(
|
|
86
|
+
"'text/html' renders directly at /p/:id; 'application/json' is rendered by the SPA.",
|
|
87
|
+
),
|
|
88
|
+
authMode: PageAuthModeSchema.default("public").describe(
|
|
89
|
+
"'public' — no gate; 'authed' — requires page-session cookie; 'password' — requires key.",
|
|
90
|
+
),
|
|
91
|
+
password: z
|
|
92
|
+
.string()
|
|
93
|
+
.min(1)
|
|
94
|
+
.optional()
|
|
95
|
+
.describe(
|
|
96
|
+
"Plaintext password, hashed before storage. Only meaningful for authMode='password'.",
|
|
97
|
+
),
|
|
98
|
+
description: z
|
|
99
|
+
.string()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe("Optional short description, used in listings + OG-tag unfurl."),
|
|
102
|
+
needsCredentials: z
|
|
103
|
+
.array(z.object({ name: z.string(), description: z.string() }))
|
|
104
|
+
.optional()
|
|
105
|
+
.describe(
|
|
106
|
+
"Declared credential needs for JSON pages (renderer ignores for v1 — reserved for follow-up).",
|
|
107
|
+
),
|
|
108
|
+
}),
|
|
109
|
+
outputSchema: z.object({
|
|
110
|
+
yourAgentId: z.string(),
|
|
111
|
+
id: z.string(),
|
|
112
|
+
version: z.number(),
|
|
113
|
+
app_url: z.string(),
|
|
114
|
+
api_url: z.string(),
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
async (input, requestInfo, _meta) => {
|
|
118
|
+
if (!requestInfo.agentId) {
|
|
119
|
+
const msg = "Agent ID required. Set the X-Agent-ID header on the MCP request.";
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: msg }],
|
|
122
|
+
structuredContent: {
|
|
123
|
+
yourAgentId: "",
|
|
124
|
+
id: "",
|
|
125
|
+
version: 0,
|
|
126
|
+
app_url: "",
|
|
127
|
+
api_url: "",
|
|
128
|
+
success: false,
|
|
129
|
+
message: msg,
|
|
130
|
+
},
|
|
131
|
+
isError: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const slug = input.slug ?? slugify(input.title);
|
|
136
|
+
const finalSlug = slug || "page"; // Fallback if title slugifies to empty.
|
|
137
|
+
|
|
138
|
+
// Hash password if provided. We always hash (even for non-password
|
|
139
|
+
// modes) so the column reflects what the caller intended; the mode
|
|
140
|
+
// governs whether the hash is checked at /p/:id serve time.
|
|
141
|
+
let passwordHash: string | undefined;
|
|
142
|
+
if (input.password) {
|
|
143
|
+
passwordHash = await Bun.password.hash(input.password, "bcrypt");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// `needsCredentials` is declared as `[{name, description}]` on the
|
|
147
|
+
// wire but the DB column accepts string[] (current schema). Flatten
|
|
148
|
+
// to names for v1 — the renderer ignores it anyway. Step-8 may
|
|
149
|
+
// revisit.
|
|
150
|
+
const needsCredentialsNames = input.needsCredentials?.map((c) => c.name);
|
|
151
|
+
|
|
152
|
+
// Upsert. Look up existing row by (agentId, slug).
|
|
153
|
+
const existing = getPageBySlug(requestInfo.agentId, finalSlug);
|
|
154
|
+
|
|
155
|
+
let id: string;
|
|
156
|
+
if (existing) {
|
|
157
|
+
// Snapshot first — failure must NOT block the update.
|
|
158
|
+
try {
|
|
159
|
+
snapshotPage(existing.id, requestInfo.agentId);
|
|
160
|
+
} catch {
|
|
161
|
+
// intentional empty
|
|
162
|
+
}
|
|
163
|
+
const updated = updatePage(existing.id, {
|
|
164
|
+
title: input.title,
|
|
165
|
+
description: input.description,
|
|
166
|
+
contentType: input.contentType,
|
|
167
|
+
authMode: input.authMode,
|
|
168
|
+
passwordHash: passwordHash ?? null,
|
|
169
|
+
body: input.body,
|
|
170
|
+
needsCredentials: needsCredentialsNames ?? null,
|
|
171
|
+
});
|
|
172
|
+
if (!updated) {
|
|
173
|
+
const msg = `Failed to update existing page ${existing.id}.`;
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text: msg }],
|
|
176
|
+
structuredContent: {
|
|
177
|
+
yourAgentId: requestInfo.agentId,
|
|
178
|
+
id: existing.id,
|
|
179
|
+
version: 0,
|
|
180
|
+
app_url: "",
|
|
181
|
+
api_url: "",
|
|
182
|
+
success: false,
|
|
183
|
+
message: msg,
|
|
184
|
+
},
|
|
185
|
+
isError: true,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
id = updated.id;
|
|
189
|
+
} else {
|
|
190
|
+
try {
|
|
191
|
+
const created = createPage({
|
|
192
|
+
agentId: requestInfo.agentId,
|
|
193
|
+
slug: finalSlug,
|
|
194
|
+
title: input.title,
|
|
195
|
+
description: input.description,
|
|
196
|
+
contentType: input.contentType,
|
|
197
|
+
authMode: input.authMode,
|
|
198
|
+
passwordHash,
|
|
199
|
+
body: input.body,
|
|
200
|
+
needsCredentials: needsCredentialsNames,
|
|
201
|
+
});
|
|
202
|
+
id = created.id;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
205
|
+
const msg = `Failed to create page: ${detail}`;
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: msg }],
|
|
208
|
+
structuredContent: {
|
|
209
|
+
yourAgentId: requestInfo.agentId,
|
|
210
|
+
id: "",
|
|
211
|
+
version: 0,
|
|
212
|
+
app_url: "",
|
|
213
|
+
api_url: "",
|
|
214
|
+
success: false,
|
|
215
|
+
message: msg,
|
|
216
|
+
},
|
|
217
|
+
isError: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Re-read after write so the page exists (defensive). 1 round-trip;
|
|
223
|
+
// page row is small. If it's missing here something's badly wrong.
|
|
224
|
+
const fresh = getPage(id);
|
|
225
|
+
if (!fresh) {
|
|
226
|
+
const msg = `Page ${id} disappeared between write and read.`;
|
|
227
|
+
return {
|
|
228
|
+
content: [{ type: "text", text: msg }],
|
|
229
|
+
structuredContent: {
|
|
230
|
+
yourAgentId: requestInfo.agentId,
|
|
231
|
+
id,
|
|
232
|
+
version: 0,
|
|
233
|
+
app_url: "",
|
|
234
|
+
api_url: "",
|
|
235
|
+
success: false,
|
|
236
|
+
message: msg,
|
|
237
|
+
},
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const apiUrl = `${getApiBaseUrl()}/p/${id}`;
|
|
243
|
+
const appUrl = `${getAppBaseUrl()}/pages/${id}`;
|
|
244
|
+
const version = pageEditCounter(id);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
content: [
|
|
248
|
+
{
|
|
249
|
+
type: "text",
|
|
250
|
+
text: `Page "${input.title}" saved (slug=${finalSlug}, version=${version}).\n API: ${apiUrl}\n App: ${appUrl}`,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
structuredContent: {
|
|
254
|
+
yourAgentId: requestInfo.agentId,
|
|
255
|
+
id,
|
|
256
|
+
version,
|
|
257
|
+
app_url: appUrl,
|
|
258
|
+
api_url: apiUrl,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { deleteKv, getAgentById } from "@/be/db";
|
|
4
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
5
|
+
import { KvKeySchema, KvNamespaceSchema } from "@/types";
|
|
6
|
+
import { resolveNamespace } from "./resolve-namespace";
|
|
7
|
+
|
|
8
|
+
function authError(namespace: string, info: { agentId: string | undefined }): string | null {
|
|
9
|
+
if (namespace.startsWith("task:page:")) {
|
|
10
|
+
return "task:page:* writes require a page-proxy request, not an MCP call";
|
|
11
|
+
}
|
|
12
|
+
if (namespace.startsWith("task:agent:")) {
|
|
13
|
+
const target = namespace.slice("task:agent:".length);
|
|
14
|
+
if (info.agentId && target === info.agentId) return null;
|
|
15
|
+
if (info.agentId) {
|
|
16
|
+
const agent = getAgentById(info.agentId);
|
|
17
|
+
if (agent?.isLead) return null;
|
|
18
|
+
}
|
|
19
|
+
return "writes to another agent's namespace require lead";
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const registerKvDeleteTool = (server: McpServer) => {
|
|
25
|
+
createToolRegistrar(server)(
|
|
26
|
+
"kv-delete",
|
|
27
|
+
{
|
|
28
|
+
title: "KV Delete",
|
|
29
|
+
description:
|
|
30
|
+
"Remove a key from the swarm KV store. Returns whether a row was actually deleted. Namespace defaults to your current context.",
|
|
31
|
+
annotations: { idempotentHint: true },
|
|
32
|
+
|
|
33
|
+
inputSchema: z.object({
|
|
34
|
+
key: KvKeySchema,
|
|
35
|
+
namespace: KvNamespaceSchema.optional(),
|
|
36
|
+
}),
|
|
37
|
+
outputSchema: z.object({
|
|
38
|
+
yourAgentId: z.string().uuid().optional(),
|
|
39
|
+
success: z.boolean(),
|
|
40
|
+
message: z.string(),
|
|
41
|
+
namespace: z.string().optional(),
|
|
42
|
+
deleted: z.boolean().optional(),
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
async ({ key, namespace }, requestInfo) => {
|
|
46
|
+
const resolved = resolveNamespace(namespace, requestInfo);
|
|
47
|
+
if ("error" in resolved) {
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: "text", text: resolved.error }],
|
|
50
|
+
structuredContent: {
|
|
51
|
+
yourAgentId: requestInfo.agentId,
|
|
52
|
+
success: false,
|
|
53
|
+
message: resolved.error,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const authErr = authError(resolved.namespace, { agentId: requestInfo.agentId });
|
|
58
|
+
if (authErr) {
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: "text", text: authErr }],
|
|
61
|
+
structuredContent: {
|
|
62
|
+
yourAgentId: requestInfo.agentId,
|
|
63
|
+
success: false,
|
|
64
|
+
message: authErr,
|
|
65
|
+
namespace: resolved.namespace,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const deleted = deleteKv(resolved.namespace, key);
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: "text",
|
|
74
|
+
text: deleted
|
|
75
|
+
? `Deleted "${key}" from "${resolved.namespace}".`
|
|
76
|
+
: `No entry to delete at "${key}" in "${resolved.namespace}".`,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
structuredContent: {
|
|
80
|
+
yourAgentId: requestInfo.agentId,
|
|
81
|
+
success: true,
|
|
82
|
+
message: deleted ? "deleted" : "not found",
|
|
83
|
+
namespace: resolved.namespace,
|
|
84
|
+
deleted,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { getKv } from "@/be/db";
|
|
4
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
5
|
+
import { KvEntrySchema, KvKeySchema, KvNamespaceSchema } from "@/types";
|
|
6
|
+
import { resolveNamespace } from "./resolve-namespace";
|
|
7
|
+
|
|
8
|
+
export const registerKvGetTool = (server: McpServer) => {
|
|
9
|
+
createToolRegistrar(server)(
|
|
10
|
+
"kv-get",
|
|
11
|
+
{
|
|
12
|
+
title: "KV Get",
|
|
13
|
+
description:
|
|
14
|
+
"Read a key from the swarm KV store. Returns the entry or null if missing/expired. Namespace defaults to your current context (Slack thread / PR / Linear issue when invoked from a task; otherwise your agent scratchpad).",
|
|
15
|
+
annotations: { readOnlyHint: true },
|
|
16
|
+
|
|
17
|
+
inputSchema: z.object({
|
|
18
|
+
key: KvKeySchema.describe("KV key (≤512 chars, [a-zA-Z0-9._:/-])."),
|
|
19
|
+
namespace: KvNamespaceSchema.optional().describe(
|
|
20
|
+
"Optional explicit namespace. Defaults to the caller's contextKey.",
|
|
21
|
+
),
|
|
22
|
+
}),
|
|
23
|
+
outputSchema: z.object({
|
|
24
|
+
yourAgentId: z.string().uuid().optional(),
|
|
25
|
+
success: z.boolean(),
|
|
26
|
+
message: z.string(),
|
|
27
|
+
namespace: z.string().optional(),
|
|
28
|
+
entry: KvEntrySchema.nullable().optional(),
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
async ({ key, namespace }, requestInfo) => {
|
|
32
|
+
const resolved = resolveNamespace(namespace, requestInfo);
|
|
33
|
+
if ("error" in resolved) {
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: "text", text: resolved.error }],
|
|
36
|
+
structuredContent: {
|
|
37
|
+
yourAgentId: requestInfo.agentId,
|
|
38
|
+
success: false,
|
|
39
|
+
message: resolved.error,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const entry = getKv(resolved.namespace, key);
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: entry
|
|
50
|
+
? `Found "${key}" in "${resolved.namespace}".`
|
|
51
|
+
: `No entry for "${key}" in "${resolved.namespace}".`,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
structuredContent: {
|
|
55
|
+
yourAgentId: requestInfo.agentId,
|
|
56
|
+
success: true,
|
|
57
|
+
message: entry ? "ok" : "not found",
|
|
58
|
+
namespace: resolved.namespace,
|
|
59
|
+
entry: entry ?? null,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { getAgentById, incrKv, KvTypeCollisionError } from "@/be/db";
|
|
4
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
5
|
+
import { KvEntrySchema, KvKeySchema, KvNamespaceSchema } from "@/types";
|
|
6
|
+
import { resolveNamespace } from "./resolve-namespace";
|
|
7
|
+
|
|
8
|
+
function authError(namespace: string, info: { agentId: string | undefined }): string | null {
|
|
9
|
+
if (namespace.startsWith("task:page:")) {
|
|
10
|
+
return "task:page:* writes require a page-proxy request, not an MCP call";
|
|
11
|
+
}
|
|
12
|
+
if (namespace.startsWith("task:agent:")) {
|
|
13
|
+
const target = namespace.slice("task:agent:".length);
|
|
14
|
+
if (info.agentId && target === info.agentId) return null;
|
|
15
|
+
if (info.agentId) {
|
|
16
|
+
const agent = getAgentById(info.agentId);
|
|
17
|
+
if (agent?.isLead) return null;
|
|
18
|
+
}
|
|
19
|
+
return "writes to another agent's namespace require lead";
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const registerKvIncrTool = (server: McpServer) => {
|
|
25
|
+
createToolRegistrar(server)(
|
|
26
|
+
"kv-incr",
|
|
27
|
+
{
|
|
28
|
+
title: "KV Incr",
|
|
29
|
+
description:
|
|
30
|
+
"Atomically increment an integer KV entry. Creates the entry (set to `by`) if it doesn't exist or has expired. Fails if the existing value_type is not 'integer' (use kv-delete first if you want to switch).",
|
|
31
|
+
annotations: {},
|
|
32
|
+
|
|
33
|
+
inputSchema: z.object({
|
|
34
|
+
key: KvKeySchema,
|
|
35
|
+
by: z
|
|
36
|
+
.number()
|
|
37
|
+
.int()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("Increment (or decrement when negative). Default: 1."),
|
|
40
|
+
namespace: KvNamespaceSchema.optional(),
|
|
41
|
+
}),
|
|
42
|
+
outputSchema: z.object({
|
|
43
|
+
yourAgentId: z.string().uuid().optional(),
|
|
44
|
+
success: z.boolean(),
|
|
45
|
+
message: z.string(),
|
|
46
|
+
namespace: z.string().optional(),
|
|
47
|
+
entry: KvEntrySchema.optional(),
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
async ({ key, by, namespace }, requestInfo) => {
|
|
51
|
+
const resolved = resolveNamespace(namespace, requestInfo);
|
|
52
|
+
if ("error" in resolved) {
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: resolved.error }],
|
|
55
|
+
structuredContent: {
|
|
56
|
+
yourAgentId: requestInfo.agentId,
|
|
57
|
+
success: false,
|
|
58
|
+
message: resolved.error,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const authErr = authError(resolved.namespace, { agentId: requestInfo.agentId });
|
|
63
|
+
if (authErr) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: authErr }],
|
|
66
|
+
structuredContent: {
|
|
67
|
+
yourAgentId: requestInfo.agentId,
|
|
68
|
+
success: false,
|
|
69
|
+
message: authErr,
|
|
70
|
+
namespace: resolved.namespace,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const entry = incrKv(resolved.namespace, key, by ?? 1);
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: `"${key}" now ${entry.value} in "${resolved.namespace}".`,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
structuredContent: {
|
|
84
|
+
yourAgentId: requestInfo.agentId,
|
|
85
|
+
success: true,
|
|
86
|
+
message: "ok",
|
|
87
|
+
namespace: resolved.namespace,
|
|
88
|
+
entry,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err instanceof KvTypeCollisionError) {
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: err.message }],
|
|
95
|
+
structuredContent: {
|
|
96
|
+
yourAgentId: requestInfo.agentId,
|
|
97
|
+
success: false,
|
|
98
|
+
message: err.message,
|
|
99
|
+
namespace: resolved.namespace,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const msg = err instanceof Error ? err.message : "INCR failed";
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text", text: msg }],
|
|
106
|
+
structuredContent: {
|
|
107
|
+
yourAgentId: requestInfo.agentId,
|
|
108
|
+
success: false,
|
|
109
|
+
message: msg,
|
|
110
|
+
namespace: resolved.namespace,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { countKv, listKv } from "@/be/db";
|
|
4
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
5
|
+
import { KvEntrySchema, KvNamespaceSchema } from "@/types";
|
|
6
|
+
import { resolveNamespace } from "./resolve-namespace";
|
|
7
|
+
|
|
8
|
+
const MAX_KV_LIST_LIMIT = 1000;
|
|
9
|
+
|
|
10
|
+
export const registerKvListTool = (server: McpServer) => {
|
|
11
|
+
createToolRegistrar(server)(
|
|
12
|
+
"kv-list",
|
|
13
|
+
{
|
|
14
|
+
title: "KV List",
|
|
15
|
+
description:
|
|
16
|
+
"List KV entries in the resolved namespace (optionally filtered by key prefix). Expired entries are filtered out. Pagination via limit/offset (limit capped at 1000).",
|
|
17
|
+
annotations: { readOnlyHint: true },
|
|
18
|
+
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
prefix: z.string().optional().describe("Key prefix to filter on."),
|
|
21
|
+
limit: z
|
|
22
|
+
.number()
|
|
23
|
+
.int()
|
|
24
|
+
.positive()
|
|
25
|
+
.max(MAX_KV_LIST_LIMIT)
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Max entries to return (default 100, max 1000)."),
|
|
28
|
+
offset: z.number().int().nonnegative().optional(),
|
|
29
|
+
namespace: KvNamespaceSchema.optional(),
|
|
30
|
+
}),
|
|
31
|
+
outputSchema: z.object({
|
|
32
|
+
yourAgentId: z.string().uuid().optional(),
|
|
33
|
+
success: z.boolean(),
|
|
34
|
+
message: z.string(),
|
|
35
|
+
namespace: z.string().optional(),
|
|
36
|
+
entries: z.array(KvEntrySchema).optional(),
|
|
37
|
+
total: z.number().optional(),
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
async ({ prefix, limit, offset, namespace }, requestInfo) => {
|
|
41
|
+
const resolved = resolveNamespace(namespace, requestInfo);
|
|
42
|
+
if ("error" in resolved) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: resolved.error }],
|
|
45
|
+
structuredContent: {
|
|
46
|
+
yourAgentId: requestInfo.agentId,
|
|
47
|
+
success: false,
|
|
48
|
+
message: resolved.error,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const effectiveLimit = Math.min(limit ?? 100, MAX_KV_LIST_LIMIT);
|
|
53
|
+
const effectivePrefix = prefix && prefix.length > 0 ? prefix : undefined;
|
|
54
|
+
const entries = listKv(resolved.namespace, {
|
|
55
|
+
prefix: effectivePrefix,
|
|
56
|
+
limit: effectiveLimit,
|
|
57
|
+
offset: offset ?? 0,
|
|
58
|
+
});
|
|
59
|
+
const total = countKv(resolved.namespace, { prefix: effectivePrefix });
|
|
60
|
+
return {
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: "text",
|
|
64
|
+
text:
|
|
65
|
+
entries.length === 0
|
|
66
|
+
? `No entries in "${resolved.namespace}".`
|
|
67
|
+
: `Found ${entries.length} of ${total} entries in "${resolved.namespace}".`,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
structuredContent: {
|
|
71
|
+
yourAgentId: requestInfo.agentId,
|
|
72
|
+
success: true,
|
|
73
|
+
message: "ok",
|
|
74
|
+
namespace: resolved.namespace,
|
|
75
|
+
entries,
|
|
76
|
+
total,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
};
|