@desplega.ai/agent-swarm 1.79.0 → 1.79.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 +2 -0
- package/openapi.json +559 -1
- package/package.json +4 -4
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +149 -0
- package/src/artifact-sdk/browser-sdk.ts +292 -0
- package/src/be/db.ts +309 -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/provider-credentials.ts +1 -1
- package/src/http/index.ts +2 -0
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +5 -0
- package/src/http/pages-public.ts +50 -6
- package/src/http/status.ts +1 -1
- package/src/providers/claude-adapter.ts +138 -7
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +20 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/claude-adapter-binary.test.ts +628 -0
- package/src/tests/context-key.test.ts +17 -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/page-proxy.test.ts +5 -1
- package/src/tests/page-session.test.ts +10 -5
- package/src/tests/pages-authed-mode.test.ts +5 -1
- package/src/tests/pages-public-html.test.ts +10 -1
- package/src/tests/pages-view-count.test.ts +220 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -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/tool-config.ts +7 -0
- package/src/types.ts +53 -0
- package/src/utils/internal-ai/complete-structured.ts +7 -10
- package/src/utils/internal-ai/credentials.ts +3 -3
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
_getInstallationIdForTests,
|
|
4
4
|
_resetTelemetryStateForTests,
|
|
5
|
+
_resolveCloudMode,
|
|
5
6
|
initTelemetry,
|
|
6
7
|
track,
|
|
7
8
|
} from "../telemetry";
|
|
@@ -13,6 +14,9 @@ process.env.ANONYMIZED_TELEMETRY = "true";
|
|
|
13
14
|
describe("initTelemetry", () => {
|
|
14
15
|
beforeEach(() => {
|
|
15
16
|
_resetTelemetryStateForTests();
|
|
17
|
+
// Tests below set MCP_BASE_URL to assert classification — clear between
|
|
18
|
+
// tests so cases that expect "unset" don't inherit a prior test's value.
|
|
19
|
+
delete process.env.MCP_BASE_URL;
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
test("without generateIfMissing + missing config → installationId stays null (track no-ops)", async () => {
|
|
@@ -241,4 +245,149 @@ describe("initTelemetry", () => {
|
|
|
241
245
|
expect(_getInstallationIdForTests()).toBe(existing);
|
|
242
246
|
expect(writesB).toEqual([]);
|
|
243
247
|
});
|
|
248
|
+
|
|
249
|
+
describe("_resolveCloudMode (URL → is_cloud)", () => {
|
|
250
|
+
test("cloud apex host → cloud=true", () => {
|
|
251
|
+
expect(_resolveCloudMode("https://agent-swarm-mcp.desplega.sh")).toEqual({ isCloud: true });
|
|
252
|
+
expect(_resolveCloudMode("https://api.agent-swarm.dev")).toEqual({ isCloud: true });
|
|
253
|
+
expect(_resolveCloudMode("https://agent-swarm.dev")).toEqual({ isCloud: true });
|
|
254
|
+
// Future cloud subdomains (suffix match)
|
|
255
|
+
expect(_resolveCloudMode("https://mcp.agent-swarm.dev/")).toEqual({ isCloud: true });
|
|
256
|
+
// Trailing path / port / auth must not change the host classification
|
|
257
|
+
expect(_resolveCloudMode("https://user:tok@api.agent-swarm.dev:443/api/foo?x=1")).toEqual({
|
|
258
|
+
isCloud: true,
|
|
259
|
+
});
|
|
260
|
+
// Case-insensitive
|
|
261
|
+
expect(_resolveCloudMode("https://API.Agent-Swarm.DEV")).toEqual({ isCloud: true });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("agent-swarm.cloud apex host → cloud=true", () => {
|
|
265
|
+
// Exact apex
|
|
266
|
+
expect(_resolveCloudMode("https://agent-swarm.cloud")).toEqual({ isCloud: true });
|
|
267
|
+
// Suffix subdomain
|
|
268
|
+
expect(_resolveCloudMode("https://api.agent-swarm.cloud")).toEqual({ isCloud: true });
|
|
269
|
+
expect(_resolveCloudMode("https://mcp.agent-swarm.cloud/")).toEqual({ isCloud: true });
|
|
270
|
+
// Trailing path / port / auth must not change classification
|
|
271
|
+
expect(_resolveCloudMode("https://user:tok@api.agent-swarm.cloud:443/api/foo?x=1")).toEqual({
|
|
272
|
+
isCloud: true,
|
|
273
|
+
});
|
|
274
|
+
// Case-insensitive
|
|
275
|
+
expect(_resolveCloudMode("https://API.Agent-Swarm.CLOUD")).toEqual({ isCloud: true });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("self-hosted hosts → cloud=false", () => {
|
|
279
|
+
expect(_resolveCloudMode("http://localhost:3013")).toEqual({ isCloud: false });
|
|
280
|
+
expect(_resolveCloudMode("https://my-internal-mcp.example.com")).toEqual({ isCloud: false });
|
|
281
|
+
// Substring trap — must NOT be treated as cloud
|
|
282
|
+
expect(_resolveCloudMode("https://agent-swarm.dev.attacker.com")).toEqual({ isCloud: false });
|
|
283
|
+
expect(_resolveCloudMode("https://agent-swarm.cloud.attacker.com")).toEqual({
|
|
284
|
+
isCloud: false,
|
|
285
|
+
});
|
|
286
|
+
// IPv4 self-host
|
|
287
|
+
expect(_resolveCloudMode("http://127.0.0.1:3013")).toEqual({ isCloud: false });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("bare hostname / unset / weird scheme → safe fallback", () => {
|
|
291
|
+
// Bare hostname (no scheme) — URL constructor throws
|
|
292
|
+
expect(_resolveCloudMode("agent-swarm-mcp.desplega.sh")).toEqual({ isCloud: false });
|
|
293
|
+
// Empty / undefined / null
|
|
294
|
+
expect(_resolveCloudMode(undefined)).toEqual({ isCloud: false });
|
|
295
|
+
expect(_resolveCloudMode(null)).toEqual({ isCloud: false });
|
|
296
|
+
expect(_resolveCloudMode("")).toEqual({ isCloud: false });
|
|
297
|
+
// Obvious garbage
|
|
298
|
+
expect(_resolveCloudMode("not a url")).toEqual({ isCloud: false });
|
|
299
|
+
// Weird scheme with no host component
|
|
300
|
+
expect(_resolveCloudMode("file:///tmp/foo")).toEqual({ isCloud: false });
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("track() ships is_cloud in properties", () => {
|
|
305
|
+
const originalFetch = globalThis.fetch;
|
|
306
|
+
let captured: Record<string, unknown> | null = null;
|
|
307
|
+
|
|
308
|
+
beforeEach(() => {
|
|
309
|
+
captured = null;
|
|
310
|
+
globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
|
|
311
|
+
captured = init?.body ? JSON.parse(init.body) : null;
|
|
312
|
+
return new Response(null, { status: 204 });
|
|
313
|
+
}) as typeof fetch;
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
afterEach(() => {
|
|
317
|
+
globalThis.fetch = originalFetch;
|
|
318
|
+
delete process.env.MCP_BASE_URL;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("cloud MCP_BASE_URL → properties.is_cloud=true", async () => {
|
|
322
|
+
process.env.MCP_BASE_URL = "https://agent-swarm-mcp.desplega.sh";
|
|
323
|
+
await initTelemetry(
|
|
324
|
+
"worker",
|
|
325
|
+
async () => "install_cloud_test",
|
|
326
|
+
async () => {},
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
track({ event: "server.started", properties: { port: 3013 } });
|
|
330
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
331
|
+
|
|
332
|
+
const properties = (captured as { properties: Record<string, unknown> }).properties;
|
|
333
|
+
expect(properties.is_cloud).toBe(true);
|
|
334
|
+
// Hostname must NOT be emitted — telemetry is anonymous.
|
|
335
|
+
expect(properties.mcp_host).toBeUndefined();
|
|
336
|
+
// Caller's properties preserved alongside the cohort signal.
|
|
337
|
+
expect(properties.port).toBe(3013);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("self-hosted MCP_BASE_URL → properties.is_cloud=false", async () => {
|
|
341
|
+
process.env.MCP_BASE_URL = "http://localhost:3013";
|
|
342
|
+
await initTelemetry(
|
|
343
|
+
"worker",
|
|
344
|
+
async () => "install_self_test",
|
|
345
|
+
async () => {},
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
track({ event: "test.event", properties: {} });
|
|
349
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
350
|
+
|
|
351
|
+
const properties = (captured as { properties: Record<string, unknown> }).properties;
|
|
352
|
+
expect(properties.is_cloud).toBe(false);
|
|
353
|
+
expect(properties.mcp_host).toBeUndefined();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("missing MCP_BASE_URL → safe fallback (false)", async () => {
|
|
357
|
+
delete process.env.MCP_BASE_URL;
|
|
358
|
+
await initTelemetry(
|
|
359
|
+
"api-server",
|
|
360
|
+
async () => "install_no_url",
|
|
361
|
+
async () => {},
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
track({ event: "test.event", properties: {} });
|
|
365
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
366
|
+
|
|
367
|
+
const properties = (captured as { properties: Record<string, unknown> }).properties;
|
|
368
|
+
expect(properties.is_cloud).toBe(false);
|
|
369
|
+
expect(properties.mcp_host).toBeUndefined();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("caller properties cannot override is_cloud", async () => {
|
|
373
|
+
// Defense-in-depth: even if a caller passes through user-supplied
|
|
374
|
+
// values, the cohort signal shipped on every event must come from
|
|
375
|
+
// initTelemetry — not from arbitrary call sites.
|
|
376
|
+
process.env.MCP_BASE_URL = "https://agent-swarm-mcp.desplega.sh";
|
|
377
|
+
await initTelemetry(
|
|
378
|
+
"worker",
|
|
379
|
+
async () => "install_override_test",
|
|
380
|
+
async () => {},
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
track({
|
|
384
|
+
event: "test.event",
|
|
385
|
+
properties: { is_cloud: false },
|
|
386
|
+
});
|
|
387
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
388
|
+
|
|
389
|
+
const properties = (captured as { properties: Record<string, unknown> }).properties;
|
|
390
|
+
expect(properties.is_cloud).toBe(true);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
244
393
|
});
|
|
@@ -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
|
+
};
|