@agentvalet/mcp-server 1.10.0 → 1.12.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/dist/index.js CHANGED
@@ -1,11 +1,36 @@
1
1
  #!/usr/bin/env node
2
+ // Builtin, hoisted above the diagnostics below on purpose: node:module cannot
3
+ // fail to resolve, so importing it does not weaken the "banner prints no matter
4
+ // what" guarantee the way a package or local import would.
5
+ import { createRequire } from "node:module";
2
6
  // ── BOOT DIAGNOSTICS — must be FIRST executable code ─────────────────────
3
7
  // Claude Desktop captures whatever the MCP server writes to stderr before
4
8
  // the stdio transport handshake. If startup crashes silently (top-level
5
9
  // await rejection, import error, unhandled native crash), Claude Desktop
6
10
  // only logs "Server transport closed unexpectedly" with no detail. These
7
11
  // handlers guarantee a stack lands in the log no matter what fails next.
8
- process.stderr.write(`[mcp-server] boot v0.2.x | node=${process.version} | platform=${process.platform} | ` +
12
+ //
13
+ // The version below is READ, never written here. It was the literal "v0.2.x"
14
+ // and stayed frozen through every release since — so the one place a running
15
+ // server states its own build reported a version it had not been for a year.
16
+ // That misleads exactly when it is hardest to check another way: an operator
17
+ // reading a stdio log, asking whether this session picked up the release they
18
+ // just published. Resolved with builtins only and wrapped, because the rule
19
+ // above is absolute — finding the version must never be the thing that stops
20
+ // the banner from printing.
21
+ const BOOT_VERSION = (() => {
22
+ try {
23
+ // dist/index.js → ../package.json and src/index.ts → ../package.json both
24
+ // land on the package root, so this holds for the published build and for
25
+ // running straight from source.
26
+ const pkg = createRequire(import.meta.url)("../package.json");
27
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
28
+ }
29
+ catch {
30
+ return "unknown";
31
+ }
32
+ })();
33
+ process.stderr.write(`[mcp-server] boot v${BOOT_VERSION} | node=${process.version} | platform=${process.platform} | ` +
9
34
  `env_keys=${Object.keys(process.env).filter((k) => k.startsWith("AGENT_") || k === "OWNER_ID" || k === "PROXY_URL" || k === "INVITE_BIND_SECRET").join(",") || "(none of expected)"}\n`);
10
35
  process.on("uncaughtException", (err) => {
11
36
  process.stderr.write(`[mcp-server] FATAL uncaughtException: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
@@ -25,7 +50,7 @@ import { signJWT } from "./auth.js";
25
50
  import { ALLOWED_METHODS, ALL_TOOLS } from "./tools/schemas.js";
26
51
  import { handleListPlatforms, handleUsePlatform, handleAgentRegister, handleAgentStatus, handleAuthzenEvaluate, handleListMyPendingActions, handleReportSelfDiagnostic, handleRequestPlatformAccess, handleIssueChildAgent, } from "./tools/handlers.js";
27
52
  import { errorContent } from "./net.js";
28
- import { getPermissionsVersion, recordPermissionsVersion, startPermissionsWatcher, } from "./permissions-watcher.js";
53
+ import { getPermissionsVersion, noteAgentActivity, recordPermissionsVersion, startPermissionsWatcher, } from "./permissions-watcher.js";
29
54
  // ---------------------------------------------------------------------------
30
55
  // Startup env validation
31
56
  // ---------------------------------------------------------------------------
@@ -117,6 +142,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
117
142
  });
118
143
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
119
144
  const { name, arguments: args } = request.params;
145
+ // The agent is doing something, so its view of the spec is about to matter.
146
+ // This is what un-idles the permissions watcher (and resets its backoff); an
147
+ // editor window nobody is using never reaches here and never revalidates.
148
+ noteAgentActivity();
120
149
  if (name === "list_platforms") {
121
150
  return await handleListPlatforms(ctx);
122
151
  }
@@ -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?.();
@@ -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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvalet/mcp-server",
3
- "version": "1.10.0",
3
+ "version": "1.12.1",
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",