@agentvalet/mcp-server 1.15.0 → 1.17.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/dist/index.js CHANGED
@@ -180,6 +180,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
180
180
  scope: args.scope,
181
181
  data: bodyArg,
182
182
  ...(typeof args.connection_id === "string" ? { connection_id: args.connection_id } : {}),
183
+ ...(args.caller && typeof args.caller === "object" ? { caller: args.caller } : {}),
183
184
  ...(typeof args.reason === "string" ? { reason: args.reason } : {}),
184
185
  }, progressToken);
185
186
  }
@@ -213,6 +214,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
213
214
  // No `data` alias on the new tools: new surface, no legacy.
214
215
  ...(args.body !== undefined ? { data: args.body } : {}),
215
216
  ...(typeof args.connection_id === "string" ? { connection_id: args.connection_id } : {}),
217
+ ...(args.caller && typeof args.caller === "object" ? { caller: args.caller } : {}),
216
218
  ...(typeof args.reason === "string" ? { reason: args.reason } : {}),
217
219
  }, progressToken);
218
220
  }
@@ -0,0 +1,88 @@
1
+ // apps/mcp-server/src/registration-keys.ts
2
+ //
3
+ // Local key custody for the agent_register -> owner approval -> agent_status
4
+ // flow (Claude Desktop / MCPB self-registration).
5
+ //
6
+ // agent_register generates the RSA keypair HERE and sends only the public key
7
+ // (POST /v1/register public_key_pem). The private key waits on disk, keyed by
8
+ // the registration token, until agent_status sees the approval and installs it
9
+ // as the agent's key. The private key never leaves this machine, never reaches
10
+ // the dashboard, and is never put in a tool result the model can see.
11
+ //
12
+ // Layout (under ~/.agentvalet, dir 0700, key files 0600):
13
+ // pending/<registration_token>.key private key awaiting approval
14
+ // agents/<agent_id>.key installed key for an approved agent
15
+ // agent.key + agent.json the DEFAULT identity config.ts boots
16
+ // from — written ONLY when neither file
17
+ // exists, so a registration never clobbers
18
+ // an identity another agent already owns
19
+ // (see invariant_register_clobbers_existing_agent_key).
20
+ import { generateKeyPairSync } from "node:crypto";
21
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { getAgentvaletDir, getIdentityPath, getKeyPath, persistBindArtifacts } from "./bind.js";
24
+ const SAFE_SEGMENT = /^[A-Za-z0-9_-]{1,128}$/;
25
+ export function generateRegistrationKeypair() {
26
+ return generateKeyPairSync("rsa", {
27
+ modulusLength: 2048,
28
+ publicKeyEncoding: { type: "spki", format: "pem" },
29
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
30
+ });
31
+ }
32
+ function ensureDir(dir) {
33
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
34
+ }
35
+ export function pendingKeyPath(token) {
36
+ if (!SAFE_SEGMENT.test(token))
37
+ return null;
38
+ return join(getAgentvaletDir(), "pending", `${token}.key`);
39
+ }
40
+ export function savePendingKey(token, privateKeyPem) {
41
+ const path = pendingKeyPath(token);
42
+ if (!path)
43
+ throw new Error("Unexpected registration token format; refusing to use it as a file name");
44
+ ensureDir(join(getAgentvaletDir(), "pending"));
45
+ writeFileSync(path, privateKeyPem, { mode: 0o600 });
46
+ return path;
47
+ }
48
+ export function readPendingKey(token) {
49
+ const path = pendingKeyPath(token);
50
+ if (!path || !existsSync(path))
51
+ return null;
52
+ try {
53
+ return readFileSync(path, "utf-8").trim();
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ export function deletePendingKey(token) {
60
+ const path = pendingKeyPath(token);
61
+ if (path)
62
+ rmSync(path, { force: true });
63
+ }
64
+ /**
65
+ * Installs an approved agent's private key. Always writes
66
+ * agents/<agent_id>.key; additionally writes the default agent.key +
67
+ * agent.json when (and only when) no default identity exists yet, so the next
68
+ * server start picks it up with no env vars at all.
69
+ */
70
+ export function installAgentKey(opts) {
71
+ if (!SAFE_SEGMENT.test(opts.agentId)) {
72
+ throw new Error("Unexpected agent_id format; refusing to use it as a file name");
73
+ }
74
+ const agentsDir = join(getAgentvaletDir(), "agents");
75
+ ensureDir(agentsDir);
76
+ const keyPath = join(agentsDir, `${opts.agentId}.key`);
77
+ writeFileSync(keyPath, opts.privateKeyPem, { mode: 0o600 });
78
+ const defaultTaken = existsSync(getKeyPath()) || existsSync(getIdentityPath());
79
+ if (!defaultTaken) {
80
+ persistBindArtifacts({
81
+ agent_id: opts.agentId,
82
+ owner_id: opts.ownerId,
83
+ proxy_url: opts.proxyUrl,
84
+ bound_at: new Date().toISOString(),
85
+ }, opts.privateKeyPem);
86
+ }
87
+ return { keyPath, becameDefault: !defaultTaken };
88
+ }
@@ -8,6 +8,8 @@ import { errorContent, jsonContent, tryParseJson, safeJsonParse, sleep, diagnose
8
8
  import { requireCredentials, fetchWithAuth } from "../auth.js";
9
9
  import { recordPermissionsVersion } from "../permissions-watcher.js";
10
10
  import { parseConsentRefusal, elicitConsent, consentFallbackText, connectUrlFor } from "./consent.js";
11
+ import { importPKCS8 } from "jose";
12
+ import { generateRegistrationKeypair, savePendingKey, readPendingKey, deletePendingKey, installAgentKey, } from "../registration-keys.js";
11
13
  export async function handleListPlatforms(ctx) {
12
14
  const gate = await requireCredentials(ctx);
13
15
  if (gate)
@@ -99,6 +101,7 @@ consentRetried = false) {
99
101
  scope: params.scope,
100
102
  ...(params.data !== undefined && { data: params.data }),
101
103
  ...(params.connection_id ? { connection_id: params.connection_id } : {}),
104
+ ...(params.caller ? { caller: params.caller } : {}),
102
105
  // Agent-supplied justification. Forwarded verbatim; the proxy normalizes
103
106
  // and caps it before it reaches the approver's screen.
104
107
  ...(params.reason ? { reason: params.reason } : {}),
@@ -249,13 +252,34 @@ async function sendProgress(ctx, token, progress, total, message) {
249
252
  // Best-effort — never let a failed notification break the call.
250
253
  }
251
254
  }
255
+ // Unwraps the proxy's { data, _meta } envelope when present.
256
+ function unwrapData(parsed) {
257
+ if (!parsed || typeof parsed !== "object")
258
+ return null;
259
+ const obj = parsed;
260
+ if (obj.data && typeof obj.data === "object")
261
+ return obj.data;
262
+ return obj;
263
+ }
264
+ function structured(result) {
265
+ return {
266
+ content: [{ type: "text", text: JSON.stringify(result) }],
267
+ structuredContent: result,
268
+ };
269
+ }
270
+ // agent_register: the keypair is generated HERE and only the public key is
271
+ // sent, so the private key never leaves this machine (and never transits the
272
+ // owner's dashboard). It waits on disk until agent_status sees the approval.
252
273
  export async function handleAgentRegister(ctx, args) {
274
+ const { publicKey, privateKey } = generateRegistrationKeypair();
253
275
  let response;
254
276
  try {
255
277
  response = await fetchWithTimeout(`${ctx.PROXY_URL}/v1/register`, {
256
278
  method: "POST",
257
279
  headers: { "Content-Type": "application/json" },
258
- body: JSON.stringify(args),
280
+ // public_key_pem last so a model-supplied value can never override it —
281
+ // we would not hold the matching private key.
282
+ body: JSON.stringify({ ...args, public_key_pem: publicKey }),
259
283
  });
260
284
  }
261
285
  catch (err) {
@@ -264,7 +288,65 @@ export async function handleAgentRegister(ctx, args) {
264
288
  const body = await response.text();
265
289
  if (!response.ok)
266
290
  return errorContent(`Proxy error ${response.status}: ${body}`);
267
- return jsonContent(body);
291
+ const data = unwrapData(tryParseJson(body));
292
+ const token = typeof data?.registration_token === "string" ? data.registration_token : null;
293
+ if (!data || !token)
294
+ return jsonContent(body);
295
+ // Only keep the private key if the proxy actually bound our public key. An
296
+ // older proxy ignores public_key_pem and issues its own keypair; agent_status
297
+ // then collects that one via claim-key instead.
298
+ let keyStoredLocally = false;
299
+ if (data.keypair_source === "client") {
300
+ try {
301
+ savePendingKey(token, privateKey);
302
+ keyStoredLocally = true;
303
+ }
304
+ catch (err) {
305
+ return errorContent(`Registration ${token} was submitted, but the private key could not be saved locally ` +
306
+ `(${err instanceof Error ? err.message : err}). Ask the owner to reject it and register again.`);
307
+ }
308
+ }
309
+ return structured({
310
+ ...data,
311
+ key_stored_locally: keyStoredLocally,
312
+ next_step: "Ask the owner to approve this agent in the AgentValet dashboard, then call agent_status with the registration_token. " +
313
+ "The private key stays on this machine; agent_status installs it once approved.",
314
+ });
315
+ }
316
+ async function claimServerIssuedKey(ctx, token) {
317
+ try {
318
+ const res = await fetchWithTimeout(`${ctx.PROXY_URL}/v1/register/claim-key/${encodeURIComponent(token)}`, {
319
+ method: "GET",
320
+ headers: { "Content-Type": "application/json" },
321
+ });
322
+ if (!res.ok)
323
+ return null;
324
+ const json = (await res.json());
325
+ return typeof json.private_key === "string" && json.private_key.includes("PRIVATE KEY")
326
+ ? json.private_key
327
+ : null;
328
+ }
329
+ catch {
330
+ return null;
331
+ }
332
+ }
333
+ // If this server booted with no usable key (state B/C), adopt the newly
334
+ // approved identity in-process so platform tools work without a restart.
335
+ async function activateInProcess(ctx, agentId, ownerId, pem) {
336
+ if (ctx.AGENT_PRIVATE_KEY_RAW !== null)
337
+ return false;
338
+ if (ctx.AGENT_ID && ctx.AGENT_ID !== agentId)
339
+ return false;
340
+ try {
341
+ ctx.privateKey = await importPKCS8(pem, "RS256");
342
+ }
343
+ catch {
344
+ return false;
345
+ }
346
+ ctx.AGENT_ID = agentId;
347
+ ctx.OWNER_ID = ownerId;
348
+ ctx.AGENT_PRIVATE_KEY_RAW = pem;
349
+ return true;
268
350
  }
269
351
  export async function handleAgentStatus(ctx, token) {
270
352
  let response;
@@ -280,7 +362,76 @@ export async function handleAgentStatus(ctx, token) {
280
362
  const body = await response.text();
281
363
  if (!response.ok)
282
364
  return errorContent(`Proxy error ${response.status}: ${body}`);
283
- return jsonContent(body);
365
+ const data = unwrapData(tryParseJson(body));
366
+ if (!data || typeof data.status !== "string")
367
+ return jsonContent(body);
368
+ if (data.status === "rejected" || data.status === "expired") {
369
+ deletePendingKey(token);
370
+ return structured(data);
371
+ }
372
+ if (data.status !== "approved")
373
+ return structured(data);
374
+ const agentId = typeof data.agent_id === "string" ? data.agent_id : "";
375
+ const mcpEnv = (data.mcp_config
376
+ ?.mcpServers?.agentvalet?.env) ?? {};
377
+ const ownerId = typeof data.owner_id === "string" ? data.owner_id : (mcpEnv.OWNER_ID ?? "");
378
+ // Locate the private key: our own (client keypair) or, for a legacy
379
+ // server-issued keypair, the one-time claim-key pickup.
380
+ let pem = null;
381
+ let source = null;
382
+ if (data.keypair_source === "client") {
383
+ pem = readPendingKey(token);
384
+ source = "client";
385
+ }
386
+ else {
387
+ pem = await claimServerIssuedKey(ctx, token);
388
+ source = "server";
389
+ }
390
+ const result = {
391
+ status: "approved",
392
+ agent_id: agentId,
393
+ owner_id: ownerId,
394
+ keypair_source: source,
395
+ };
396
+ if (!pem || !agentId || !ownerId) {
397
+ result.key_installed = false;
398
+ result.next_step =
399
+ source === "client"
400
+ ? "Approved, but this machine does not hold the private key for this registration (it was generated by a different install, or already installed). " +
401
+ "Check ~/.agentvalet/agents/, or register again from this machine."
402
+ : "Approved, but the one-time private key could not be collected (already claimed, or the 5-minute window after approval has passed). " +
403
+ "Register again from this machine so the key is generated locally.";
404
+ return structured(result);
405
+ }
406
+ let installed;
407
+ try {
408
+ installed = installAgentKey({ agentId, ownerId, proxyUrl: ctx.PROXY_URL, privateKeyPem: pem });
409
+ }
410
+ catch (err) {
411
+ return errorContent(`Approved as ${agentId}, but the private key could not be saved: ${err instanceof Error ? err.message : err}`);
412
+ }
413
+ deletePendingKey(token);
414
+ const activated = await activateInProcess(ctx, agentId, ownerId, pem);
415
+ result.key_installed = true;
416
+ result.key_path = installed.keyPath;
417
+ result.default_identity = installed.becameDefault;
418
+ result.activated = activated;
419
+ // The config to use this agent elsewhere: a key PATH, never the key itself.
420
+ result.mcp_config = {
421
+ mcpServers: {
422
+ agentvalet: {
423
+ command: "npx",
424
+ args: ["-y", "@agentvalet/mcp-server"],
425
+ env: { AGENT_ID: agentId, OWNER_ID: ownerId, PROXY_URL: ctx.PROXY_URL, AGENT_PRIVATE_KEY_PATH: installed.keyPath },
426
+ },
427
+ },
428
+ };
429
+ result.next_step = activated
430
+ ? "Registered and active in this session. Call list_platforms to see what the owner granted."
431
+ : installed.becameDefault
432
+ ? "Registered. The key is installed as this machine's default AgentValet identity; restart the MCP server (or Claude Desktop) to use it."
433
+ : "Registered. Another AgentValet identity is already configured on this machine, so it was left untouched; use the mcp_config above (AGENT_PRIVATE_KEY_PATH) to run this agent.";
434
+ return structured(result);
284
435
  }
285
436
  export async function handleAuthzenEvaluate(ctx, platformId, scope) {
286
437
  const gate = await requireCredentials(ctx);
@@ -98,6 +98,11 @@ export const USE_PLATFORM_TOOL = {
98
98
  type: "string",
99
99
  description: "Optional. Target a specific connection when a platform has more than one connected (e.g. two GitHub orgs). Value is the connection id shown in the dashboard. Omit to use the default connection.",
100
100
  },
101
+ caller: {
102
+ type: "object",
103
+ description: "Optional. Who the human is that this call is for, when this agent serves many people: { assertion: '<JWT from an identity provider the org registered>' } (verified) or { source, id } (asserted; honoured only where the platform allows it). On a platform with per-person tokens, the person's own upstream token is used; if they have not authorized yet the call returns consent_required with a consent_url to give them.",
104
+ properties: { assertion: { type: "string" }, source: { type: "string" }, id: { type: "string" } },
105
+ },
101
106
  reason: {
102
107
  type: "string",
103
108
  description: "Optional but strongly encouraged. One sentence on WHY you are making this call. If the action requires owner approval, this is shown to the human who decides — without it their approval is a blind yes/no. Max 500 characters (longer is truncated). Plain text; markup is not rendered.",
@@ -125,6 +130,11 @@ export const READ_PLATFORM_TOOL = {
125
130
  type: "string",
126
131
  description: "Optional. Target a specific connection when a platform has more than one connected. Omit to use the default.",
127
132
  },
133
+ caller: {
134
+ type: "object",
135
+ description: "Optional. Who the human is that this call is for, when this agent serves many people: { assertion: '<JWT from an identity provider the org registered>' } (verified) or { source, id } (asserted; honoured only where the platform allows it). On a platform with per-person tokens, the person's own upstream token is used; if they have not authorized yet the call returns consent_required with a consent_url to give them.",
136
+ properties: { assertion: { type: "string" }, source: { type: "string" }, id: { type: "string" } },
137
+ },
128
138
  reason: {
129
139
  type: "string",
130
140
  description: "Optional but strongly encouraged. One sentence on WHY you are making this call. Shown to the human approver if the call is gated. Max 500 characters; plain text.",
@@ -156,6 +166,11 @@ export const WRITE_PLATFORM_TOOL = {
156
166
  type: "string",
157
167
  description: "Optional. Target a specific connection when a platform has more than one connected. Omit to use the default.",
158
168
  },
169
+ caller: {
170
+ type: "object",
171
+ description: "Optional. Who the human is that this call is for, when this agent serves many people: { assertion: '<JWT from an identity provider the org registered>' } (verified) or { source, id } (asserted; honoured only where the platform allows it). On a platform with per-person tokens, the person's own upstream token is used; if they have not authorized yet the call returns consent_required with a consent_url to give them.",
172
+ properties: { assertion: { type: "string" }, source: { type: "string" }, id: { type: "string" } },
173
+ },
159
174
  reason: {
160
175
  type: "string",
161
176
  description: "Optional but strongly encouraged. One sentence on WHY you are making this call. Shown to the human approver if the call is gated. Max 500 characters; plain text.",
@@ -181,6 +196,11 @@ export const DELETE_PLATFORM_TOOL = {
181
196
  type: "string",
182
197
  description: "Optional. Target a specific connection when a platform has more than one connected. Omit to use the default.",
183
198
  },
199
+ caller: {
200
+ type: "object",
201
+ description: "Optional. Who the human is that this call is for, when this agent serves many people: { assertion: '<JWT from an identity provider the org registered>' } (verified) or { source, id } (asserted; honoured only where the platform allows it). On a platform with per-person tokens, the person's own upstream token is used; if they have not authorized yet the call returns consent_required with a consent_url to give them.",
202
+ properties: { assertion: { type: "string" }, source: { type: "string" }, id: { type: "string" } },
203
+ },
184
204
  reason: {
185
205
  type: "string",
186
206
  description: "Optional but strongly encouraged. One sentence on WHY you are deleting this. Shown to the human approver if the call is gated. Max 500 characters; plain text.",
@@ -191,7 +211,7 @@ export const DELETE_PLATFORM_TOOL = {
191
211
  };
192
212
  export const AGENT_REGISTER_TOOL = {
193
213
  name: "agent_register",
194
- description: "agent_register: Self-register this agent with an owner. No auth required.\nInput: owner_id (string), agent_name (string), requested_scopes (array of {platformId, scopes}).\nReturns: registration_token, poll_url, client_id, scope, expires_in.\nAuth: None.",
214
+ description: "agent_register: Self-register this agent with an owner. No auth required.\nInput: owner_id (string), agent_name (string), requested_scopes (array of {platformId, scopes}).\nThe agent's RSA keypair is generated on this machine; only the public key is sent and the private key stays here.\nReturns: registration_token, poll_url, client_id, scope, keypair_source, key_stored_locally.\nNext: once the owner approves in the dashboard, call agent_status with the registration_token to install the key.\nAuth: None.",
195
215
  inputSchema: {
196
216
  type: "object",
197
217
  properties: {
@@ -226,13 +246,15 @@ export const AGENT_REGISTER_TOOL = {
226
246
  client_id: { type: "string" },
227
247
  scope: { type: "string" },
228
248
  expires_in: { type: "number" },
249
+ keypair_source: { type: "string", enum: ["client", "server"] },
250
+ key_stored_locally: { type: "boolean" },
229
251
  },
230
252
  required: ["registration_token"],
231
253
  },
232
254
  };
233
255
  export const AGENT_STATUS_TOOL = {
234
256
  name: "agent_status",
235
- description: "agent_status: Poll registration status using the token from agent_register.\nInput: token (string, required).\nReturns: status (\"pending_approval\"|\"approved\"|\"rejected\"), agent_id (if approved), mcp_config (if approved).\nAuth: None.",
257
+ description: "agent_status: Poll registration status using the token from agent_register.\nInput: token (string, required).\nOn approval it installs the agent's private key on this machine (~/.agentvalet/agents/<agent_id>.key, plus the default identity if none exists) and activates it for this session when the server had no identity yet. The private key is never returned in the result.\nReturns: status (\"pending\"|\"approved\"|\"rejected\"|\"expired\"), agent_id, key_installed, key_path, activated, mcp_config (if approved).\nAuth: None.",
236
258
  inputSchema: {
237
259
  type: "object",
238
260
  properties: {
@@ -246,8 +268,11 @@ export const AGENT_STATUS_TOOL = {
246
268
  outputSchema: {
247
269
  type: "object",
248
270
  properties: {
249
- status: { type: "string", enum: ["pending_approval", "approved", "rejected"] },
271
+ status: { type: "string", enum: ["pending", "pending_approval", "approved", "rejected", "expired"] },
250
272
  agent_id: { type: "string" },
273
+ key_installed: { type: "boolean" },
274
+ key_path: { type: "string" },
275
+ activated: { type: "boolean" },
251
276
  mcp_config: { type: "object" },
252
277
  },
253
278
  required: ["status"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvalet/mcp-server",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "AgentValet MCP server — lets AI agents call approved platforms via the AgentValet proxy",
5
5
  "mcpName": "io.github.agentvalet/mcp-server",
6
6
  "type": "module",