@agentkv/cli 0.1.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.
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ VERSION,
4
+ clientFromConfig,
5
+ forbiddenEnvKey,
6
+ getOnrampProvider,
7
+ peekStoredAccount,
8
+ readConfigFile,
9
+ readEnvSecret,
10
+ readFileSecret,
11
+ resolveConfig,
12
+ runWithSecret,
13
+ scrubSensitiveEnv,
14
+ writeSecretFile
15
+ } from "./chunk-34AG6FLC.js";
16
+
17
+ // src/mcp.ts
18
+ import { existsSync } from "fs";
19
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
+ import { z } from "zod";
22
+ var toolError = (error, code) => ({
23
+ isError: true,
24
+ content: [{ type: "text", text: JSON.stringify({ error, code }) }]
25
+ });
26
+ function buildMcpServer(client, onramp, accountMode = false) {
27
+ const server = new McpServer({ name: "agentkv", version: VERSION });
28
+ server.tool(
29
+ "agentkv_set",
30
+ "Store an encrypted value under a key (costs $0.005 USD per write, or 5 credits \u2248 $0.0005 via prepay). NOTE: the value passes through this agent's model context \u2014 do NOT use for secrets; use agentkv_set_from_env or agentkv_set_from_file instead.",
31
+ {
32
+ key: z.string().describe("The key to store the value under"),
33
+ // .refine(v !== undefined) marks `value` REQUIRED in the advertised JSON schema. A bare
34
+ // z.unknown() is optional (isOptional() === true), so the schema omitted `value` from
35
+ // `required` and the SDK accepted {key} — then client.set(key, undefined) threw a
36
+ // confusing runtime invalid_value instead of a protocol-level InvalidParams rejection.
37
+ value: z.unknown().refine((v) => v !== void 0, { message: "value is required" }).describe("The value to encrypt and store"),
38
+ ttl_days: z.number().optional().describe("Time-to-live in days"),
39
+ strict_ttl: z.boolean().optional().describe("If true, reads do not slide the expiry"),
40
+ idempotency_key: z.string().optional().describe(
41
+ "Stable key making a retried write exactly-once \u2014 reuse the same value across retries so the server dedupes instead of double-charging"
42
+ )
43
+ },
44
+ {
45
+ title: "Set value",
46
+ readOnlyHint: false,
47
+ destructiveHint: true,
48
+ idempotentHint: false,
49
+ openWorldHint: true
50
+ },
51
+ async (args) => ({
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: JSON.stringify(
56
+ await client.set(args.key, args.value, {
57
+ ttlDays: args.ttl_days,
58
+ strictTtl: args.strict_ttl,
59
+ idempotencyKey: args.idempotency_key
60
+ })
61
+ )
62
+ }
63
+ ]
64
+ })
65
+ );
66
+ server.tool(
67
+ "agentkv_get",
68
+ "Read and decrypt a value by key (costs $0.003 USD per read, or 3 credits \u2248 $0.0003 via prepay); returns null if absent. NOTE: the decrypted value is returned into this agent's model context \u2014 do NOT use for secrets; use agentkv_get_to_file or agentkv_run_with_secret instead.",
69
+ {
70
+ key: z.string().describe("The key to retrieve"),
71
+ idempotency_key: z.string().optional().describe(
72
+ "Stable key making a retried read exactly-once \u2014 reuse the same value across retries so the server dedupes instead of double-charging"
73
+ )
74
+ },
75
+ { title: "Get value", readOnlyHint: true, openWorldHint: true },
76
+ async (args) => ({
77
+ content: [
78
+ {
79
+ type: "text",
80
+ text: JSON.stringify(
81
+ await client.get(
82
+ args.key,
83
+ args.idempotency_key ? { idempotencyKey: args.idempotency_key } : {}
84
+ )
85
+ )
86
+ }
87
+ ]
88
+ })
89
+ );
90
+ server.tool(
91
+ "agentkv_delete",
92
+ "Delete a key (free operation)",
93
+ { key: z.string().describe("The key to delete") },
94
+ {
95
+ title: "Delete key",
96
+ readOnlyHint: false,
97
+ destructiveHint: true,
98
+ idempotentHint: true,
99
+ openWorldHint: true
100
+ },
101
+ async (args) => ({
102
+ content: [{ type: "text", text: JSON.stringify(await client.delete(args.key)) }]
103
+ })
104
+ );
105
+ server.tool(
106
+ "agentkv_deposit",
107
+ "Buy credits with USDC (any amount \u2265 $1; credits are 1/10 the pay-per-op price). Real payment \u2014 $amount_usd is charged from the wallet.",
108
+ { amount_usd: z.number().describe("Amount in USD to deposit as credits") },
109
+ {
110
+ title: "Deposit credits",
111
+ readOnlyHint: false,
112
+ destructiveHint: false,
113
+ idempotentHint: false,
114
+ openWorldHint: true
115
+ },
116
+ async (args) => {
117
+ if (accountMode) {
118
+ return toolError(
119
+ `Account-key mode has no signing wallet to pay from. Account credits are added by depositing USDC to ${client.endpoint}/account/deposit from a wallet that can sign (e.g. awal), not via this tool.`,
120
+ "account_mode"
121
+ );
122
+ }
123
+ return {
124
+ content: [
125
+ { type: "text", text: JSON.stringify(await client.deposit(args.amount_usd)) }
126
+ ]
127
+ };
128
+ }
129
+ );
130
+ server.tool(
131
+ "agentkv_balance",
132
+ "Read the current credit balance (free)",
133
+ {},
134
+ { title: "Read balance", readOnlyHint: true, openWorldHint: true },
135
+ async () => ({
136
+ content: [
137
+ { type: "text", text: JSON.stringify({ balance: await client.balance() }) }
138
+ ]
139
+ })
140
+ );
141
+ server.tool(
142
+ "agentkv_wallet_address",
143
+ "Return this agent's wallet address (its namespace in AgentKV)",
144
+ {},
145
+ { title: "Wallet address", readOnlyHint: true, openWorldHint: false },
146
+ async () => ({
147
+ content: [
148
+ {
149
+ type: "text",
150
+ // Account-key mode has no wallet; client.address is the zero-address sentinel, so
151
+ // returning it verbatim would misrepresent identity. Report account-key mode instead.
152
+ text: JSON.stringify(
153
+ accountMode ? {
154
+ mode: "account-key",
155
+ address: null,
156
+ note: "account-key mode has no wallet address; the account is identified by its bearer key"
157
+ } : { address: client.address }
158
+ )
159
+ }
160
+ ]
161
+ })
162
+ );
163
+ server.tool(
164
+ "agentkv_fund",
165
+ "Return a card\u2192USDC onramp URL that delivers USDC to this agent's wallet (its namespace). Read-only \u2014 builds a URL, no payment is made. Open the URL to buy USDC and have it sent to the wallet on Base; then use agentkv_deposit to convert USDC to credits.",
166
+ {
167
+ amount_usd: z.number().optional().describe("Optional preset fiat (USD) amount to pre-fill in the onramp")
168
+ },
169
+ { title: "Fund via onramp", readOnlyHint: true, openWorldHint: true },
170
+ async (args) => {
171
+ if (accountMode) {
172
+ return toolError(
173
+ `Account-key mode has no single wallet to onramp into. Account credits are funded by depositing USDC to ${client.endpoint}/account/deposit from a wallet that can sign (e.g. awal). An onramp can fund such a signing wallet: fund a wallet first (e.g. send USDC to it, \`awal address\`, or set AGENTKV_PRIVATE_KEY and re-run the funding flow), then deposit to the account.`,
174
+ "account_mode"
175
+ );
176
+ }
177
+ if (!onramp) {
178
+ return toolError(
179
+ "onramp is not configured for this server (no provider/config available)",
180
+ "onramp_unavailable"
181
+ );
182
+ }
183
+ let provider;
184
+ try {
185
+ provider = getOnrampProvider(onramp.provider);
186
+ } catch (e) {
187
+ return toolError(e instanceof Error ? e.message : String(e), "unknown_provider");
188
+ }
189
+ let url;
190
+ try {
191
+ url = provider.buildUrl({
192
+ address: client.address,
193
+ network: onramp.network,
194
+ amountUsd: args.amount_usd,
195
+ config: onramp.config
196
+ });
197
+ } catch (e) {
198
+ return toolError(e instanceof Error ? e.message : String(e), "onramp_config");
199
+ }
200
+ return {
201
+ content: [
202
+ {
203
+ type: "text",
204
+ text: JSON.stringify({ provider: provider.id, url, address: client.address })
205
+ }
206
+ ]
207
+ };
208
+ }
209
+ );
210
+ server.tool(
211
+ "agentkv_list_keys",
212
+ "List this wallet's stored keys \u2014 the real key NAMES, decrypted locally. The server only ever sees opaque per-wallet digests + ciphertext, never plaintext key names. Free (identity-signed). Paginated: pass the returned cursor to fetch the next page.",
213
+ {
214
+ cursor: z.string().optional().describe("Opaque pagination cursor from a previous call"),
215
+ limit: z.number().optional().describe("Max keys per page")
216
+ },
217
+ { title: "List keys", readOnlyHint: true, openWorldHint: true },
218
+ async (args) => {
219
+ const res = await client.listKeys({ cursor: args.cursor ?? null, limit: args.limit });
220
+ return { content: [{ type: "text", text: JSON.stringify(res) }] };
221
+ }
222
+ );
223
+ server.tool(
224
+ "agentkv_set_from_env",
225
+ "Store a secret read from a LOCAL environment variable, without the value entering this agent's model context. Pass the env var NAME (not its value); the server reads it locally, encrypts, and stores it. Use this (not agentkv_set) for credentials.",
226
+ {
227
+ key: z.string().describe("The key to store under"),
228
+ env_var: z.string().describe(
229
+ "Name of the local environment variable whose value to store (never sent to the model)"
230
+ ),
231
+ ttl_days: z.number().optional().describe("Time-to-live in days"),
232
+ strict_ttl: z.boolean().optional().describe("If true, reads do not slide the expiry"),
233
+ idempotency_key: z.string().optional().describe("Stable key for exactly-once retried writes")
234
+ },
235
+ {
236
+ title: "Set from env var",
237
+ readOnlyHint: false,
238
+ destructiveHint: true,
239
+ idempotentHint: false,
240
+ openWorldHint: true
241
+ },
242
+ async (args) => {
243
+ const r = readEnvSecret(args.env_var);
244
+ if (!r.ok) return toolError(r.error, r.code);
245
+ const res = await client.set(args.key, r.value, {
246
+ ttlDays: args.ttl_days,
247
+ strictTtl: args.strict_ttl,
248
+ idempotencyKey: args.idempotency_key
249
+ });
250
+ return { content: [{ type: "text", text: JSON.stringify(res) }] };
251
+ }
252
+ );
253
+ server.tool(
254
+ "agentkv_set_from_file",
255
+ "Store a secret read from a LOCAL file, without the contents entering this agent's model context. Pass the file PATH; the server reads it locally (as UTF-8 text, trimming one trailing newline unless trim:false \u2014 not for binary key material), encrypts, and stores it. Use this (not agentkv_set) for credentials.",
256
+ {
257
+ key: z.string().describe("The key to store under"),
258
+ path: z.string().describe("Local file path to read (contents never sent to the model)"),
259
+ trim: z.boolean().optional().describe("Trim a single trailing newline (default true)"),
260
+ ttl_days: z.number().optional().describe("Time-to-live in days"),
261
+ strict_ttl: z.boolean().optional().describe("If true, reads do not slide the expiry"),
262
+ idempotency_key: z.string().optional().describe("Stable key for exactly-once retried writes")
263
+ },
264
+ {
265
+ title: "Set from file",
266
+ readOnlyHint: false,
267
+ destructiveHint: true,
268
+ idempotentHint: false,
269
+ openWorldHint: true
270
+ },
271
+ async (args) => {
272
+ const r = readFileSecret(args.path, { trim: args.trim });
273
+ if (!r.ok) return toolError(r.error, r.code);
274
+ const res = await client.set(args.key, r.value, {
275
+ ttlDays: args.ttl_days,
276
+ strictTtl: args.strict_ttl,
277
+ idempotencyKey: args.idempotency_key
278
+ });
279
+ return { content: [{ type: "text", text: JSON.stringify(res) }] };
280
+ }
281
+ );
282
+ server.tool(
283
+ "agentkv_get_to_file",
284
+ "Read a secret and write the decrypted value to a LOCAL FILE, without the value entering this agent's model context. Returns { found, path, bytes } \u2014 the file path and byte count, never the value. Performs a paid read each call. The destination must NOT already exist (a fresh path is created \u2014 this prevents overwriting or symlink redirection); omit `path` for a private temp file. Use this (not agentkv_get) for credentials, then delete the file when done.",
285
+ {
286
+ key: z.string().describe("The key to read"),
287
+ path: z.string().optional().describe("Destination file path; if omitted, a private temp file is created"),
288
+ idempotency_key: z.string().optional().describe("Stable key for exactly-once retried reads")
289
+ },
290
+ {
291
+ title: "Get to file",
292
+ readOnlyHint: false,
293
+ destructiveHint: false,
294
+ idempotentHint: false,
295
+ openWorldHint: true
296
+ },
297
+ async (args) => {
298
+ if (args.path && existsSync(args.path)) {
299
+ return toolError(
300
+ "destination already exists; choose a fresh path that does not already exist",
301
+ "dest_exists"
302
+ );
303
+ }
304
+ const v = await client.get(
305
+ args.key,
306
+ args.idempotency_key ? { idempotencyKey: args.idempotency_key } : {}
307
+ );
308
+ if (v === null || v === void 0) {
309
+ return { content: [{ type: "text", text: JSON.stringify({ found: false }) }] };
310
+ }
311
+ const text = typeof v === "string" ? v : JSON.stringify(v);
312
+ let written;
313
+ try {
314
+ written = writeSecretFile(text, args.path);
315
+ } catch (e) {
316
+ const code = e?.code;
317
+ if (code === "EEXIST")
318
+ return toolError("destination already exists; choose a fresh path", "dest_exists");
319
+ if (code === "ENOENT")
320
+ return toolError("destination parent directory does not exist", "dest_parent_missing");
321
+ if (code === "EACCES" || code === "EPERM")
322
+ return toolError("destination is not writable", "dest_unwritable");
323
+ return toolError("could not write file", "write_failed");
324
+ }
325
+ return {
326
+ content: [
327
+ {
328
+ type: "text",
329
+ text: JSON.stringify({ found: true, path: written.path, bytes: written.bytes })
330
+ }
331
+ ]
332
+ };
333
+ }
334
+ );
335
+ server.tool(
336
+ "agentkv_run_with_secret",
337
+ "Run a command with a stored secret injected into the child process's environment ONLY, without the value entering this agent's model context. Pass the env var NAME + command + args; the server decrypts the secret, sets it in the child env, runs the command, and returns its exit code + output (not the secret). Performs a paid read each call. The command must not echo the secret. Use this to USE a credential without the model ever seeing it.",
338
+ {
339
+ key: z.string().describe("The key holding the secret"),
340
+ env_var: z.string().describe("Env var name to expose the secret as in the child process"),
341
+ command: z.string().describe("Executable to run (no shell; pass a real executable)"),
342
+ args: z.array(z.string()).optional().describe("Arguments passed as argv (no shell expansion)"),
343
+ cwd: z.string().optional().describe("Working directory"),
344
+ timeout_ms: z.number().optional().describe(
345
+ "Kill the command after this many ms (default 120000; values \u22640 use the default)"
346
+ ),
347
+ extra_env: z.record(z.string()).optional().describe("Additional NON-secret env vars to set for the child process"),
348
+ idempotency_key: z.string().optional().describe("Stable key for exactly-once retried reads")
349
+ },
350
+ {
351
+ title: "Run with secret",
352
+ readOnlyHint: false,
353
+ destructiveHint: true,
354
+ idempotentHint: false,
355
+ openWorldHint: true
356
+ },
357
+ async (args) => {
358
+ const badEnv = forbiddenEnvKey([args.env_var, ...Object.keys(args.extra_env ?? {})]);
359
+ if (badEnv) return toolError(`refusing process-hijack env var: ${badEnv}`, "forbidden_env");
360
+ const v = await client.get(
361
+ args.key,
362
+ args.idempotency_key ? { idempotencyKey: args.idempotency_key } : {}
363
+ );
364
+ if (v === null || v === void 0) return toolError(`key ${args.key} not found`, "not_found");
365
+ const secret = typeof v === "string" ? v : JSON.stringify(v);
366
+ let result;
367
+ try {
368
+ result = await runWithSecret({
369
+ secret,
370
+ envVar: args.env_var,
371
+ command: args.command,
372
+ args: args.args,
373
+ cwd: args.cwd,
374
+ timeoutMs: args.timeout_ms,
375
+ extraEnv: args.extra_env
376
+ });
377
+ } catch (e) {
378
+ return toolError(e instanceof Error ? e.message : String(e), "spawn_failed");
379
+ }
380
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
381
+ }
382
+ );
383
+ return server;
384
+ }
385
+ async function startMcp(deps = {}) {
386
+ const env = deps.env ?? process.env;
387
+ const cfg = resolveConfig({}, env, () => readConfigFile(env));
388
+ const client = deps.client ?? clientFromConfig(cfg, {
389
+ env,
390
+ // CRITICAL: notice to stderr only — stdout is the MCP JSON-RPC channel.
391
+ notify: (m) => process.stderr.write(`agentkv: ${m}
392
+ `)
393
+ });
394
+ const accountMode = cfg.accountKey != null || cfg.privateKey == null && peekStoredAccount(env) != null;
395
+ scrubSensitiveEnv(env);
396
+ const server = buildMcpServer(
397
+ client,
398
+ {
399
+ provider: cfg.onrampProvider ?? "coinbase",
400
+ network: cfg.network,
401
+ config: cfg.onrampConfig ?? {}
402
+ },
403
+ accountMode
404
+ );
405
+ const transport = new StdioServerTransport();
406
+ await server.connect(transport);
407
+ await new Promise((resolve) => {
408
+ server.server.onclose = resolve;
409
+ process.stdin.once("close", resolve);
410
+ process.stdin.once("end", resolve);
411
+ });
412
+ }
413
+ export {
414
+ buildMcpServer,
415
+ startMcp
416
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@agentkv/cli",
3
+ "version": "0.1.0",
4
+ "description": "AgentKV CLI and MCP server: read/write encrypted, x402-paid key-value data from the terminal or any MCP client",
5
+ "license": "MIT",
6
+ "author": "The AgentKV Authors <contact@agentx402.ai>",
7
+ "homepage": "https://github.com/agentx402-ai/agentkv#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/agentx402-ai/agentkv.git",
11
+ "directory": "cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/agentx402-ai/agentkv/issues"
15
+ },
16
+ "keywords": [
17
+ "agentkv",
18
+ "x402",
19
+ "cli",
20
+ "mcp",
21
+ "key-value",
22
+ "encrypted",
23
+ "zero-knowledge",
24
+ "usdc",
25
+ "agent",
26
+ "wallet"
27
+ ],
28
+ "type": "module",
29
+ "bin": {
30
+ "agentkv": "./dist/cli.js"
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "test": "tsc --noEmit && vitest run",
41
+ "test:coverage": "vitest run --coverage",
42
+ "typecheck": "tsc --noEmit"
43
+ },
44
+ "dependencies": {
45
+ "@agentkv/client": "^0.1.0",
46
+ "@modelcontextprotocol/sdk": "^1.0.0",
47
+ "viem": "^2.21.0",
48
+ "zod": "^3.25.76"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^20.0.0",
52
+ "tsup": "^8.3.0",
53
+ "typescript": "^5.6.0",
54
+ "vitest": "^4.1.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ }
59
+ }