@agentvalet/mcp-server 1.8.0 → 1.12.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/README.md CHANGED
@@ -59,6 +59,18 @@ Add to your `.mcp.json` or equivalent:
59
59
  | `report_self_diagnostic` | JWT | Lodge a self-report (error/warning/info) with the owner |
60
60
  | `agent_register` | None | Self-register a new agent with an owner |
61
61
  | `agent_status` | None | Poll registration approval status |
62
+ | `issue_child_agent` | JWT | Mint a short-lived child identity for a subagent: its own agent id, an attenuated scope slice (child ⊆ parent), its own audit trail, individually revocable. Returns a bearer token for the subagent's `Authorization` header. |
63
+
64
+ ## Session attribution
65
+
66
+ Every assertion this server signs carries a stable per-process `session_id`
67
+ claim, which the proxy stamps on each audit row. It distinguishes *runs* of
68
+ the server, not subagents: hosts that share one server process (for example
69
+ Claude Code Task-tool subagents) share the id. HTTP callers without this
70
+ server can supply the same discriminator via an `X-AV-Session` header
71
+ (`[A-Za-z0-9._:-]{1,128}`). Both are caller-asserted forensics — the proxy
72
+ never makes an authorization decision on them. For verified per-subagent
73
+ identity, mint a child with `issue_child_agent` instead.
62
74
 
63
75
  ## Transports
64
76
 
package/dist/auth.js CHANGED
@@ -9,7 +9,7 @@ import { fetchWithTimeout } from "./net.js";
9
9
  export async function signJWT(ctx) {
10
10
  if (!ctx.privateKey)
11
11
  throw new Error("Private key not loaded");
12
- return new SignJWT({ agent_id: ctx.AGENT_ID, owner_id: ctx.OWNER_ID })
12
+ return new SignJWT({ agent_id: ctx.AGENT_ID, owner_id: ctx.OWNER_ID, session_id: ctx.SESSION_ID })
13
13
  .setProtectedHeader({ alg: "RS256" })
14
14
  .setIssuedAt()
15
15
  .setExpirationTime("60s")
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ process.on("unhandledRejection", (err) => {
15
15
  process.stderr.write(`[mcp-server] FATAL unhandledRejection: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
16
16
  process.exit(1);
17
17
  });
18
+ import { randomBytes } from "node:crypto";
18
19
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
20
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
21
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
@@ -22,9 +23,9 @@ import { validateConfig } from "./config.js";
22
23
  import { renderInstructions } from "./instructions.js";
23
24
  import { signJWT } from "./auth.js";
24
25
  import { ALLOWED_METHODS, ALL_TOOLS } from "./tools/schemas.js";
25
- import { handleListPlatforms, handleUsePlatform, handleAgentRegister, handleAgentStatus, handleAuthzenEvaluate, handleListMyPendingActions, handleReportSelfDiagnostic, handleRequestPlatformAccess, } from "./tools/handlers.js";
26
+ import { handleListPlatforms, handleUsePlatform, handleAgentRegister, handleAgentStatus, handleAuthzenEvaluate, handleListMyPendingActions, handleReportSelfDiagnostic, handleRequestPlatformAccess, handleIssueChildAgent, } from "./tools/handlers.js";
26
27
  import { errorContent } from "./net.js";
27
- import { getPermissionsVersion, recordPermissionsVersion, startPermissionsWatcher, } from "./permissions-watcher.js";
28
+ import { getPermissionsVersion, noteAgentActivity, recordPermissionsVersion, startPermissionsWatcher, } from "./permissions-watcher.js";
28
29
  // ---------------------------------------------------------------------------
29
30
  // Startup env validation
30
31
  // ---------------------------------------------------------------------------
@@ -47,12 +48,18 @@ const server = new Server({ name: "agentvalet", version: "1.0.0" },
47
48
  // permissions-watcher.ts). Declaring the capability is what makes the SDK
48
49
  // allow that notification.
49
50
  { capabilities: { tools: { listChanged: true } }, instructions: renderInstructions(undefined) });
51
+ // One id per server process. Discriminates runs in audit_log.session_id;
52
+ // forensics only — the proxy never branches enforcement on it. Note: Claude
53
+ // Code Task-tool subagents share the parent process, so this discriminates
54
+ // processes/runs, not subagents.
55
+ const SESSION_ID = "sess_" + randomBytes(8).toString("hex");
50
56
  // The config + server bundle threaded into auth + handlers (see context.ts) —
51
57
  // keeps those modules free of globals.
52
58
  const ctx = {
53
59
  AGENT_ID,
54
60
  OWNER_ID,
55
61
  PROXY_URL,
62
+ SESSION_ID,
56
63
  AGENT_PRIVATE_KEY_RAW,
57
64
  privateKey,
58
65
  server,
@@ -110,6 +117,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
110
117
  });
111
118
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
112
119
  const { name, arguments: args } = request.params;
120
+ // The agent is doing something, so its view of the spec is about to matter.
121
+ // This is what un-idles the permissions watcher (and resets its backoff); an
122
+ // editor window nobody is using never reaches here and never revalidates.
123
+ noteAgentActivity();
113
124
  if (name === "list_platforms") {
114
125
  return await handleListPlatforms(ctx);
115
126
  }
@@ -213,6 +224,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
213
224
  }
214
225
  return await handleRequestPlatformAccess(ctx, args);
215
226
  }
227
+ if (name === "issue_child_agent") {
228
+ if (!args || !Array.isArray(args.grants)) {
229
+ return errorContent("Invalid or missing argument: grants is required");
230
+ }
231
+ return await handleIssueChildAgent(ctx, args);
232
+ }
216
233
  return {
217
234
  content: [{ type: "text", text: `Unknown tool: ${name}` }],
218
235
  isError: true,
@@ -22,14 +22,66 @@
22
22
  // (apps/proxy/src/routes/mcp.ts) is deliberately stateless — a fresh Server per
23
23
  // request — so it has no server→client channel and cannot push at all; there,
24
24
  // cheap revalidation via the 304 path is the whole story.
25
+ //
26
+ // WHEN we revalidate, and why it is not simply "every 60 seconds":
27
+ //
28
+ // A fixed timer polls a resource that almost never changes, on behalf of a
29
+ // session that is usually doing nothing. On 2026-09-01 that shape turned 210
30
+ // real governed calls into ~7,000 revalidations in a day — mostly from editor
31
+ // windows left open overnight — and each one cost the proxy a full spec
32
+ // resolution. The spec only matters when the agent is about to ACT on it, so
33
+ // the timer now defers to two signals:
34
+ //
35
+ // 1. ACTIVITY. noteAgentActivity() is called from the tool dispatch on every
36
+ // tool call. A server that has not served one in IDLE_AFTER_MS stops
37
+ // revalidating entirely — an idle window costs nothing — and resumes the
38
+ // moment the agent does anything.
39
+ // 2. STABILITY. Each consecutive 304 backs the interval off (doubling, to a
40
+ // cap). A spec that has not moved in twenty minutes is not about to be
41
+ // checked every minute. Any observed change, and any tool call, resets it.
42
+ //
43
+ // What this trades away: after an idle stretch, the FIRST tool call may still
44
+ // reason from a spec up to one poll old. That is the pre-watcher behaviour and
45
+ // it is safe by construction — the proxy authorises every call against the live
46
+ // database regardless, so the worst case is one clean 403 rather than a wrongly
47
+ // permitted action. An agent that wants certainty calls list_platforms, which
48
+ // revalidates synchronously.
25
49
  import { signJWT } from "./auth.js";
26
50
  import { fetchWithTimeout } from "./net.js";
27
51
  const DEFAULT_POLL_MS = 60_000;
28
52
  // Floor so a typo in the env var can't turn an agent into a polling hammer
29
53
  // against the proxy.
30
54
  const MIN_POLL_MS = 10_000;
55
+ // No tool call in this long ⇒ nobody is relying on the spec, so stop asking for
56
+ // it. Generous on purpose: the point is to catch the window left open all
57
+ // night, not to punish a user who paused to read something.
58
+ const IDLE_AFTER_MS = 15 * 60 * 1000;
59
+ // Ceiling on the stability backoff, counted in base intervals. At the 60s
60
+ // default this settles an unchanging spec at one revalidation every ~16 min.
61
+ const MAX_BACKOFF_TICKS = 15;
31
62
  let currentVersion = null;
32
63
  let timer = null;
64
+ // Boot counts as activity: a server that has just started is about to be used.
65
+ let lastActivityAt = Date.now();
66
+ // Base intervals still to be skipped before the next revalidation.
67
+ let ticksToSkip = 0;
68
+ // Consecutive revalidations that found nothing changed; drives the backoff.
69
+ let unchangedStreak = 0;
70
+ /**
71
+ * Record that the agent just did something. Called from the tool dispatch on
72
+ * EVERY tool call — the cheapest possible signal that the spec is about to
73
+ * matter again. Resets both gates, so a session waking up revalidates on the
74
+ * next tick rather than at the end of a long backoff.
75
+ */
76
+ export function noteAgentActivity() {
77
+ lastActivityAt = Date.now();
78
+ ticksToSkip = 0;
79
+ unchangedStreak = 0;
80
+ }
81
+ /** Whether the watcher would skip a tick right now. Exported for tests. */
82
+ export function isIdle(now = Date.now()) {
83
+ return now - lastActivityAt > IDLE_AFTER_MS;
84
+ }
33
85
  export function getPermissionsVersion() {
34
86
  return currentVersion;
35
87
  }
@@ -49,6 +101,9 @@ export function recordPermissionsVersion(version) {
49
101
  /** Test seam: forget everything the watcher has learned. */
50
102
  export function resetPermissionsVersion() {
51
103
  currentVersion = null;
104
+ lastActivityAt = Date.now();
105
+ ticksToSkip = 0;
106
+ unchangedStreak = 0;
52
107
  }
53
108
  export function pollIntervalMs(raw = process.env.AV_PERMISSIONS_POLL_MS) {
54
109
  if (raw === undefined || raw === "")
@@ -107,6 +162,36 @@ export async function checkPermissionsVersion(ctx) {
107
162
  }
108
163
  return true;
109
164
  }
165
+ /**
166
+ * One timer tick: decide whether this is a moment worth spending a request on,
167
+ * and revalidate if so. Exported for tests. Returns true when it actually went
168
+ * to the network.
169
+ */
170
+ export async function tick(ctx) {
171
+ // Nobody is using this session. The spec can drift freely — the next tool
172
+ // call resets the gate, and the proxy authorises against the live database
173
+ // whatever this server happens to believe.
174
+ if (isIdle())
175
+ return false;
176
+ // Backing off after a run of unchanged reads.
177
+ if (ticksToSkip > 0) {
178
+ ticksToSkip -= 1;
179
+ return false;
180
+ }
181
+ const changed = await checkPermissionsVersion(ctx);
182
+ if (changed) {
183
+ // Something moved. Watch closely again — an owner mid-edit often makes
184
+ // several changes in a row.
185
+ unchangedStreak = 0;
186
+ ticksToSkip = 0;
187
+ }
188
+ else {
189
+ // 1, 3, 7, 15, 15, … base intervals of silence.
190
+ unchangedStreak += 1;
191
+ ticksToSkip = Math.min(2 ** unchangedStreak - 1, MAX_BACKOFF_TICKS);
192
+ }
193
+ return true;
194
+ }
110
195
  /**
111
196
  * Start background revalidation. No-op without credentials or when polling is
112
197
  * disabled. The timer is unref'd so it can never hold the process open.
@@ -120,7 +205,7 @@ export function startPermissionsWatcher(ctx) {
120
205
  if (interval === 0)
121
206
  return;
122
207
  timer = setInterval(() => {
123
- void checkPermissionsVersion(ctx).catch(() => { });
208
+ void tick(ctx).catch(() => { });
124
209
  }, interval);
125
210
  // `unref` is absent in some non-Node runtimes; guard rather than assume.
126
211
  timer.unref?.();
@@ -347,6 +347,17 @@ export async function handleRequestPlatformAccess(ctx, args) {
347
347
  message: "Request submitted and awaiting admin approval. Retry your call later.",
348
348
  }));
349
349
  }
350
+ export async function handleIssueChildAgent(ctx, args) {
351
+ const gate = await requireCredentials(ctx);
352
+ if (gate)
353
+ return gate;
354
+ const resp = await fetchWithAuth(ctx, `${ctx.PROXY_URL}/v1/agents/children`, {
355
+ method: "POST",
356
+ body: JSON.stringify(args ?? {}),
357
+ });
358
+ const json = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
359
+ return { content: [{ type: "text", text: JSON.stringify(json) }], isError: !resp.ok };
360
+ }
350
361
  export async function handleReportSelfDiagnostic(ctx, args) {
351
362
  const gate = await requireCredentials(ctx);
352
363
  if (gate)
@@ -7,7 +7,7 @@
7
7
  export const ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
8
8
  export const LIST_PLATFORMS_TOOL = {
9
9
  name: "list_platforms",
10
- description: "list_platforms: List the platforms and permission scopes this agent has access to.\nInput: None.\nReturns: { platforms: [{ platformId, platformName, scopes, requireApproval, approvalScopes?, connections? }], version: \"<hex>\" }. A platform includes `connections` only when it has 2+ usable accounts; pass the chosen connection_id to use_platform (omit for the default).\nApproval: a call needs human approval if the platform's requireApproval is true (grant-level: every call prompts), if the chosen connection's requireApproval is true, or if the scope is listed in approvalScopes (policy-level: just those scopes prompt). approvalScopes is omitted when no scope is policy-gated.\nVersion: a deterministic hash over the effective spec — it changes whenever the platform set, the scopes, the approval requirements (grant or policy), or the connections change. Compare it against the `Current permissions version` stamped on this description: if they differ, your cached result is stale and you must call list_platforms again before trusting it.\nAuth: Bearer JWT.",
10
+ description: "list_platforms: List the platforms and permission scopes this agent has access to.\nInput: None.\nReturns: { platforms: [{ platformId, platformName, scopes, requireApproval, approvalScopes?, connections?, callStyle? }], version: \"<hex>\" }. A platform includes `connections` only when it has 2+ usable accounts; pass the chosen connection_id to use_platform (omit for the default).\nCall style: `callStyle` is present only as \"graphql\", and says how to ADDRESS the platform rather than what you may do there: that platform has ONE endpoint taking {query, variables} via POST, so pass \"/\" as the endpoint and put the operation in the body. Its path and HTTP method carry no meaning, read_platform cannot be used on it at all, and a GET returns an upstream error naming neither the platform nor the cause. Absent means a conventional REST API.\nApproval: a call needs human approval if the platform's requireApproval is true (grant-level: every call prompts), if the chosen connection's requireApproval is true, or if the scope is listed in approvalScopes (policy-level: just those scopes prompt). approvalScopes is omitted when no scope is policy-gated.\nVersion: a deterministic hash over the effective spec — it changes whenever the platform set, the scopes, the approval requirements (grant or policy), or the connections change. Compare it against the `Current permissions version` stamped on this description: if they differ, your cached result is stale and you must call list_platforms again before trusting it.\nAuth: Bearer JWT.",
11
11
  inputSchema: { type: "object", properties: {} },
12
12
  outputSchema: {
13
13
  type: "object",
@@ -101,7 +101,7 @@ export const USE_PLATFORM_TOOL = {
101
101
  // readOnlyHint annotation enforced rather than merely promised.
102
102
  export const READ_PLATFORM_TOOL = {
103
103
  name: "read_platform",
104
- description: "read_platform: Read data from a platform the owner has connected, through the AgentValet proxy. Calls the platform's own public REST API (for example the GitHub REST API, the Slack Web API, the Stripe API); `endpoint` is a path in that API exactly as its documentation defines it. Always issues GET — use write_platform to create or update, and delete_platform to remove. Input: platform, endpoint, scope, connection_id (optional), reason (optional). Returns: the upstream API response body. Auth: Bearer JWT.",
104
+ description: "read_platform: Read data from a platform the owner has connected, through the AgentValet proxy. Calls the platform's own public REST API (for example the GitHub REST API, the Slack Web API, the Stripe API); `endpoint` is a path in that API exactly as its documentation defines it. A platform that list_platforms reports as callStyle 'graphql' cannot be read with this tool at all: it exposes a SINGLE endpoint that accepts only POST {query, variables}, so send the read query through write_platform (or use_platform with method POST) even though it is a read. Issuing a GET there returns an upstream error that names neither the platform nor the cause — it is not a broker fault. Always issues GET — use write_platform to create or update, and delete_platform to remove. Input: platform, endpoint, scope, connection_id (optional), reason (optional). Returns: the upstream API response body. Auth: Bearer JWT.",
105
105
  inputSchema: {
106
106
  type: "object",
107
107
  properties: {
@@ -122,7 +122,7 @@ export const READ_PLATFORM_TOOL = {
122
122
  };
123
123
  export const WRITE_PLATFORM_TOOL = {
124
124
  name: "write_platform",
125
- description: "write_platform: Create or update data on a platform the owner has connected, through the AgentValet proxy. Calls the platform's own public REST API (for example the GitHub REST API, the Slack Web API, the Stripe API); `endpoint` is a path in that API exactly as its documentation defines it. Accepts POST, PUT or PATCH only — use read_platform for GET and delete_platform for DELETE. May block up to 50s for owner approval, then returns the approved result; otherwise returns a pending_approval envelope. Input: platform, endpoint, scope, method (optional, default POST), body (optional), connection_id (optional), reason (optional). Auth: Bearer JWT.",
125
+ description: "write_platform: Create or update data on a platform the owner has connected, through the AgentValet proxy. Calls the platform's own public REST API (for example the GitHub REST API, the Slack Web API, the Stripe API); `endpoint` is a path in that API exactly as its documentation defines it. For a platform whose callStyle is 'graphql', pass \"/\" as the endpoint and put the operation in body as {query, variables}: the path and the HTTP method carry no meaning on that platform. Accepts POST, PUT or PATCH only — use read_platform for GET and delete_platform for DELETE. May block up to 50s for owner approval, then returns the approved result; otherwise returns a pending_approval envelope. Input: platform, endpoint, scope, method (optional, default POST), body (optional), connection_id (optional), reason (optional). Auth: Bearer JWT.",
126
126
  inputSchema: {
127
127
  type: "object",
128
128
  properties: {
@@ -153,7 +153,7 @@ export const WRITE_PLATFORM_TOOL = {
153
153
  };
154
154
  export const DELETE_PLATFORM_TOOL = {
155
155
  name: "delete_platform",
156
- description: "delete_platform: Delete data on a platform the owner has connected, through the AgentValet proxy. Calls the platform's own public REST API (for example the GitHub REST API, the Slack Web API, the Stripe API); `endpoint` is a path in that API exactly as its documentation defines it. Always issues DELETE. This removes data and is not reversible by AgentValet. May block up to 50s for owner approval. Input: platform, endpoint, scope, body (optional), connection_id (optional), reason (optional). Auth: Bearer JWT.",
156
+ description: "delete_platform: Delete data on a platform the owner has connected, through the AgentValet proxy. Calls the platform's own public REST API (for example the GitHub REST API, the Slack Web API, the Stripe API); `endpoint` is a path in that API exactly as its documentation defines it. For a platform whose callStyle is 'graphql', pass \"/\" as the endpoint and put the operation in body as {query, variables}: the path and the HTTP method carry no meaning on that platform. Always issues DELETE. This removes data and is not reversible by AgentValet. May block up to 50s for owner approval. Input: platform, endpoint, scope, body (optional), connection_id (optional), reason (optional). Auth: Bearer JWT.",
157
157
  inputSchema: {
158
158
  type: "object",
159
159
  properties: {
@@ -375,6 +375,34 @@ export const REQUEST_PLATFORM_ACCESS_TOOL = {
375
375
  required: ["status"],
376
376
  },
377
377
  };
378
+ export const ISSUE_CHILD_AGENT_TOOL = {
379
+ name: "issue_child_agent",
380
+ description: "Issue a short-lived child agent identity for a subagent you are about to spawn. The child gets its own agent id, its own attenuated permission slice (child scopes must be a subset of yours), and its own audit trail; it can be revoked alone without touching you or its siblings. Returns a bearer token the subagent must send as its Authorization header. TTL 60-3600s (default 900). Inject ONLY the bearer_token into the subagent's context — never your own credentials.",
381
+ inputSchema: {
382
+ type: "object",
383
+ properties: {
384
+ name: { type: "string", description: "Optional human-readable name for the child agent (1-200 chars)." },
385
+ ttl_seconds: {
386
+ type: "number",
387
+ description: "Optional lifetime in seconds for the child's bearer token. 60-3600, default 900.",
388
+ },
389
+ grants: {
390
+ type: "array",
391
+ description: "Array of platform grants to attenuate onto the child. Each scope must be a subset of your own granted scopes.",
392
+ items: {
393
+ type: "object",
394
+ properties: {
395
+ platform: { type: "string", description: "Platform ID (e.g. github, slack)" },
396
+ scopes: { type: "array", items: { type: "string" }, description: "Scopes to grant the child for this platform." },
397
+ connection_id: { type: "string", description: "Optional. Target a specific connection when a platform has more than one connected." },
398
+ },
399
+ required: ["platform", "scopes"],
400
+ },
401
+ },
402
+ },
403
+ required: ["grants"],
404
+ },
405
+ };
378
406
  export const ALL_TOOLS = [
379
407
  LIST_PLATFORMS_TOOL,
380
408
  USE_PLATFORM_TOOL,
@@ -387,4 +415,5 @@ export const ALL_TOOLS = [
387
415
  REPORT_SELF_DIAGNOSTIC_TOOL,
388
416
  LIST_MY_PENDING_ACTIONS_TOOL,
389
417
  REQUEST_PLATFORM_ACCESS_TOOL,
418
+ ISSUE_CHILD_AGENT_TOOL,
390
419
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvalet/mcp-server",
3
- "version": "1.8.0",
3
+ "version": "1.12.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",