@agentvalet/mcp-server 1.3.0 → 1.6.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
@@ -1,67 +1,70 @@
1
- # @agentvalet/mcp-server
2
-
3
- MCP server that lets AI agents (Claude Code, Cursor, Codex CLI, etc.) call approved external platforms through the AgentValet proxy.
4
-
5
- ## Quick start
6
-
7
- ```bash
8
- npx @agentvalet/register # registers this machine as an agent, writes config
9
- ```
10
-
11
- The CLI writes env vars and optionally updates `.mcp.json` or `.cursor/mcp.json` automatically.
12
-
13
- ## Manual configuration
14
-
15
- Add to your `.mcp.json` or equivalent:
16
-
17
- ```json
18
- {
19
- "mcpServers": {
20
- "agentvalet": {
21
- "command": "npx",
22
- "args": ["-y", "@agentvalet/mcp-server"],
23
- "env": {
24
- "AGENT_ID": "agt_...",
25
- "OWNER_ID": "...",
26
- "PROXY_URL": "https://api.agentvalet.ai",
27
- "AGENT_PRIVATE_KEY_PATH": "/path/to/agent.key"
28
- }
29
- }
30
- }
31
- }
32
- ```
33
-
34
- ## Environment variables
35
-
36
- | Variable | Required | Description |
37
- |----------|----------|-------------|
38
- | `AGENT_ID` | Yes | Agent ID from registration |
39
- | `OWNER_ID` | Yes | Owner ID from registration |
40
- | `PROXY_URL` | Yes | AgentValet proxy URL |
41
- | `AGENT_PRIVATE_KEY_PATH` | One of three | Path to PEM private key file |
42
- | `AGENT_PRIVATE_KEY` | One of three | Raw PEM or `\n`-escaped single-line PEM |
43
- | `AGENT_PRIVATE_KEY_B64` | One of three | Base64-encoded PEM |
44
- | `MCP_TRANSPORT` | No | Set to `http` to use HTTP transport instead of STDIO |
45
- | `MCP_PORT` | No | Port for HTTP transport (default: `3100`) |
46
-
47
- ## Tools
48
-
49
- | Tool | Auth | Description |
50
- |------|------|-------------|
51
- | `list_platforms` | JWT | List platforms and scopes this agent has access to |
52
- | `use_platform` | JWT | Call an external platform API through the proxy |
53
- | `agent_register` | None | Self-register a new agent with an owner |
54
- | `agent_status` | None | Poll registration approval status |
55
- | `authzen_evaluate` | None | Check if this agent has access to a platform scope |
56
-
57
- ## Transports
58
-
59
- **STDIO (default)** — compatible with all MCP hosts (Claude Code, Cursor, Codex CLI).
60
-
61
- **HTTP** — set `MCP_TRANSPORT=http` and optionally `MCP_PORT=3100`. Uses `StreamableHTTPServerTransport`.
62
-
63
- ## Architecture
64
-
65
- Each tool call signs a short-lived RS256 JWT (`exp: 60s`) using the agent's private key. The JWT is verified by the AgentValet proxy, which checks permissions and forwards the request to the target platform using stored credentials.
66
-
67
- The agent never sees platform credentials. The proxy decrypts them in-memory at call time and discards them after the upstream request completes.
1
+ # @agentvalet/mcp-server
2
+
3
+ MCP server that lets AI agents (Claude Code, Cursor, Codex CLI, etc.) call approved external platforms through the AgentValet proxy.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npx @agentvalet/register # registers this machine as an agent, writes config
9
+ ```
10
+
11
+ The CLI writes env vars and optionally updates `.mcp.json` or `.cursor/mcp.json` automatically.
12
+
13
+ ## Manual configuration
14
+
15
+ Add to your `.mcp.json` or equivalent:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "agentvalet": {
21
+ "command": "npx",
22
+ "args": ["-y", "@agentvalet/mcp-server"],
23
+ "env": {
24
+ "AGENT_ID": "agt_...",
25
+ "OWNER_ID": "...",
26
+ "PROXY_URL": "https://api.agentvalet.ai",
27
+ "AGENT_PRIVATE_KEY_PATH": "/path/to/agent.key"
28
+ }
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ ## Environment variables
35
+
36
+ | Variable | Required | Description |
37
+ |----------|----------|-------------|
38
+ | `AGENT_ID` | Yes | Agent ID from registration |
39
+ | `OWNER_ID` | Yes | Owner ID from registration |
40
+ | `PROXY_URL` | Yes | AgentValet proxy URL |
41
+ | `AGENT_PRIVATE_KEY_PATH` | One of three | Path to PEM private key file |
42
+ | `AGENT_PRIVATE_KEY` | One of three | Raw PEM or `\n`-escaped single-line PEM |
43
+ | `AGENT_PRIVATE_KEY_B64` | One of three | Base64-encoded PEM |
44
+ | `MCP_TRANSPORT` | No | Set to `http` to use HTTP transport instead of STDIO |
45
+ | `MCP_PORT` | No | Port for HTTP transport (default: `3100`) |
46
+
47
+ ## Tools
48
+
49
+ | Tool | Auth | Description |
50
+ |------|------|-------------|
51
+ | `list_platforms` | JWT | List platforms and scopes this agent has access to |
52
+ | `use_platform` | JWT | Call an external platform API through the proxy (blocks for owner approval when required) |
53
+ | `authzen_evaluate` | JWT | Pre-check whether this agent has access to a platform scope, without making the call |
54
+ | `list_my_pending_actions` | JWT | List this agent's pending approvals and actions completed in the last 24h |
55
+ | `request_platform_access` | JWT | Ask an org admin to grant access to a platform the agent is blocked from |
56
+ | `report_self_diagnostic` | JWT | Lodge a self-report (error/warning/info) with the owner |
57
+ | `agent_register` | None | Self-register a new agent with an owner |
58
+ | `agent_status` | None | Poll registration approval status |
59
+
60
+ ## Transports
61
+
62
+ **STDIO (default)** — compatible with all MCP hosts (Claude Code, Cursor, Codex CLI).
63
+
64
+ **HTTP** — set `MCP_TRANSPORT=http` and optionally `MCP_PORT=3100`. Uses `StreamableHTTPServerTransport`.
65
+
66
+ ## Architecture
67
+
68
+ Each tool call signs a short-lived RS256 JWT (`exp: 60s`) using the agent's private key. The JWT is verified by the AgentValet proxy, which checks permissions and forwards the request to the target platform using stored credentials.
69
+
70
+ The agent never sees platform credentials. The proxy decrypts them in-memory at call time and discards them after the upstream request completes.
@@ -0,0 +1,159 @@
1
+ // apps/mcp-server/src/gate-client.ts
2
+ //
3
+ // A thin, dependency-free client over AgentValet's public governance contract.
4
+ // It does NOT implement policy, brokering, or audit. Those live in the proxy.
5
+ // It only does two things:
6
+ //
7
+ // 1. Send a request to the public gate (POST /v1/actions) or the Observe
8
+ // relay (POST /v1/observe/actions).
9
+ // 2. Map the wire response to exactly one of the six documented decisions, so
10
+ // a connector switches on a single discriminated union instead of
11
+ // re-deriving status-code semantics in every integration.
12
+ //
13
+ // The full wire contract these mappings depend on is documented in
14
+ // docs/contract/public-governance-gate.md. Keep this file and that doc in step.
15
+ //
16
+ // fetch is injectable (fetchImpl) so the mapping is testable without a network;
17
+ // it defaults to the global fetch.
18
+ /** Agent states that mean "the agent itself is halted", surfaced as a 403 by the
19
+ * permission check. Distinct from a policy denial of an otherwise-healthy agent. */
20
+ const SUSPENDED_REASONS = new Set(["agent_suspended", "circuit_breaker_open"]);
21
+ function readField(body, key) {
22
+ if (body && typeof body === "object" && key in body) {
23
+ const v = body[key];
24
+ return typeof v === "string" ? v : undefined;
25
+ }
26
+ return undefined;
27
+ }
28
+ /**
29
+ * Pure mapper: a public-gate response (HTTP status + parsed body) to a decision.
30
+ * Exported so connectors and tests can classify a response they already hold.
31
+ *
32
+ * Mapping (POST /v1/actions):
33
+ * 2xx -> allowed
34
+ * 202 -> pending_approval
35
+ * 403 reason in {agent_suspended,
36
+ * circuit_breaker_open} -> suspended
37
+ * 401 / 5xx -> error
38
+ * other 4xx (402/403/412/429/...) -> denied
39
+ */
40
+ export function classifyGateResponse(status, body) {
41
+ if (status === 202) {
42
+ return {
43
+ decision: "pending_approval",
44
+ status,
45
+ approvalId: readField(body, "approval_id") ?? null,
46
+ message: readField(body, "message"),
47
+ raw: body,
48
+ };
49
+ }
50
+ if (status >= 200 && status < 300) {
51
+ return { decision: "allowed", status, data: body };
52
+ }
53
+ if (status === 403) {
54
+ const reason = readField(body, "reason");
55
+ if (reason && SUSPENDED_REASONS.has(reason)) {
56
+ return { decision: "suspended", status, reason, raw: body };
57
+ }
58
+ }
59
+ // Identity rejection and upstream/server failures are not policy decisions.
60
+ if (status === 401 || status >= 500) {
61
+ return {
62
+ decision: "error",
63
+ status,
64
+ code: readField(body, "code") ?? (status === 401 ? "unauthorized" : null),
65
+ correlationId: readField(body, "correlation_id"),
66
+ raw: body,
67
+ };
68
+ }
69
+ // Every other 4xx (402 billing, 403 policy, 412 not-connected, 429 rate) is a
70
+ // governance refusal of an otherwise-healthy agent.
71
+ return {
72
+ decision: "denied",
73
+ status,
74
+ reason: readField(body, "reason") ?? readField(body, "error") ?? null,
75
+ correlationId: readField(body, "correlation_id"),
76
+ raw: body,
77
+ };
78
+ }
79
+ async function parseBody(res) {
80
+ const text = await res.text();
81
+ try {
82
+ return JSON.parse(text);
83
+ }
84
+ catch {
85
+ return text;
86
+ }
87
+ }
88
+ /**
89
+ * Call the public governance gate (POST /v1/actions) and return a classified
90
+ * outcome. Never throws for a gate response or a transport failure: a transport
91
+ * failure resolves to a GateError with code "network_error" so callers handle
92
+ * every path through the same switch.
93
+ */
94
+ export async function callGate(client, req) {
95
+ const doFetch = client.fetchImpl ?? fetch;
96
+ const requestBody = {
97
+ platform: req.platform,
98
+ endpoint: req.endpoint,
99
+ method: req.method,
100
+ scope: req.scope,
101
+ ...(req.data !== undefined ? { data: req.data } : {}),
102
+ ...(req.connection_id ? { connection_id: req.connection_id } : {}),
103
+ };
104
+ let res;
105
+ try {
106
+ res = await doFetch(`${client.proxyUrl}/v1/actions`, {
107
+ method: "POST",
108
+ headers: { Authorization: `Bearer ${client.token}`, "Content-Type": "application/json" },
109
+ body: JSON.stringify(requestBody),
110
+ });
111
+ }
112
+ catch (err) {
113
+ return {
114
+ decision: "error",
115
+ status: 0,
116
+ code: "network_error",
117
+ raw: { error: err instanceof Error ? err.message : String(err) },
118
+ };
119
+ }
120
+ return classifyGateResponse(res.status, await parseBody(res));
121
+ }
122
+ /**
123
+ * Call the Observe relay (POST /v1/observe/actions). The relay is audit-only and
124
+ * passes the upstream status through, so a completed relay always resolves to
125
+ * GateObserved regardless of the upstream status. The BYO credential rides as a
126
+ * header and never enters the request body. A transport failure resolves to a
127
+ * GateError with code "network_error".
128
+ */
129
+ export async function observe(client, req) {
130
+ const doFetch = client.fetchImpl ?? fetch;
131
+ const relayBody = {
132
+ platform: req.platform,
133
+ endpoint: req.endpoint,
134
+ method: req.method,
135
+ ...(req.action !== undefined ? { action: req.action } : {}),
136
+ ...(req.body !== undefined ? { body: req.body } : {}),
137
+ };
138
+ let res;
139
+ try {
140
+ res = await doFetch(`${client.proxyUrl}/v1/observe/actions`, {
141
+ method: "POST",
142
+ headers: {
143
+ Authorization: `Bearer ${client.token}`,
144
+ "Content-Type": "application/json",
145
+ "X-AV-Observe-Credential": req.credential,
146
+ },
147
+ body: JSON.stringify(relayBody),
148
+ });
149
+ }
150
+ catch (err) {
151
+ return {
152
+ decision: "error",
153
+ status: 0,
154
+ code: "network_error",
155
+ raw: { error: err instanceof Error ? err.message : String(err) },
156
+ };
157
+ }
158
+ return { decision: "observed", status: res.status, data: await parseBody(res) };
159
+ }
package/dist/index.js CHANGED
@@ -22,8 +22,9 @@ import { validateConfig } from "./config.js";
22
22
  import { renderInstructions } from "./instructions.js";
23
23
  import { signJWT } from "./auth.js";
24
24
  import { ALLOWED_METHODS, ALL_TOOLS } from "./tools/schemas.js";
25
- import { handleListPlatforms, handleUsePlatform, handleAgentRegister, handleAgentStatus, handleAuthzenEvaluate, handleListMyPendingActions, handleReportSelfDiagnostic, } from "./tools/handlers.js";
25
+ import { handleListPlatforms, handleUsePlatform, handleAgentRegister, handleAgentStatus, handleAuthzenEvaluate, handleListMyPendingActions, handleReportSelfDiagnostic, handleRequestPlatformAccess, } from "./tools/handlers.js";
26
26
  import { errorContent } from "./net.js";
27
+ import { getPermissionsVersion, recordPermissionsVersion, startPermissionsWatcher, } from "./permissions-watcher.js";
27
28
  // ---------------------------------------------------------------------------
28
29
  // Startup env validation
29
30
  // ---------------------------------------------------------------------------
@@ -40,7 +41,12 @@ process.stderr.write(`[mcp-server] config ok | agent=${AGENT_ID} | owner=${OWNER
40
41
  // ---------------------------------------------------------------------------
41
42
  // MCP server setup
42
43
  // ---------------------------------------------------------------------------
43
- const server = new Server({ name: "agentvalet", version: "1.0.0" }, { capabilities: { tools: {} }, instructions: renderInstructions(undefined) });
44
+ const server = new Server({ name: "agentvalet", version: "1.0.0" },
45
+ // listChanged: the permissions watcher pushes notifications/tools/list_changed
46
+ // when the agent's effective platform spec moves under it (see
47
+ // permissions-watcher.ts). Declaring the capability is what makes the SDK
48
+ // allow that notification.
49
+ { capabilities: { tools: { listChanged: true } }, instructions: renderInstructions(undefined) });
44
50
  // The config + server bundle threaded into auth + handlers (see context.ts) —
45
51
  // keeps those modules free of globals.
46
52
  const ctx = {
@@ -65,19 +71,42 @@ void (async () => {
65
71
  const token = await signJWT(ctx);
66
72
  const ac = new AbortController();
67
73
  const timer = setTimeout(() => ac.abort(), 4_000);
68
- await fetch(`${PROXY_URL}/v1/agent/permissions`, {
74
+ const res = await fetch(`${PROXY_URL}/v1/agent/permissions`, {
69
75
  method: "GET",
70
76
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
71
77
  signal: ac.signal,
72
78
  }).finally(() => clearTimeout(timer));
79
+ // Seed the watcher's baseline so the first poll compares against the spec
80
+ // as it was at boot, not against "unknown".
81
+ if (res.ok) {
82
+ const parsed = (await res.json());
83
+ recordPermissionsVersion(parsed?.data?.version ?? parsed?.version);
84
+ }
73
85
  }
74
86
  catch {
75
87
  // best-effort warmup only
76
88
  }
77
89
  })().catch(() => { });
78
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
79
- tools: ALL_TOOLS,
80
- }));
90
+ // Stamp list_platforms with the current permissions version. This is the half
91
+ // of the staleness fix that actually reaches the model: after a
92
+ // tools/list_changed push the host re-reads tools/list, and a description that
93
+ // genuinely differs from the one already in context is what tells the model its
94
+ // cached platform spec is no longer the live one.
95
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
96
+ const version = getPermissionsVersion();
97
+ if (!version)
98
+ return { tools: ALL_TOOLS };
99
+ return {
100
+ tools: ALL_TOOLS.map((tool) => tool.name === "list_platforms"
101
+ ? {
102
+ ...tool,
103
+ description: `${tool.description}\nCurrent permissions version: ${version}. If you are holding a ` +
104
+ `list_platforms result from a different version, it is STALE — call list_platforms ` +
105
+ `again before relying on its scopes or requireApproval flags.`,
106
+ }
107
+ : tool),
108
+ };
109
+ });
81
110
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
82
111
  const { name, arguments: args } = request.params;
83
112
  if (name === "list_platforms") {
@@ -114,6 +143,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
114
143
  scope: args.scope,
115
144
  data: bodyArg,
116
145
  ...(typeof args.connection_id === "string" ? { connection_id: args.connection_id } : {}),
146
+ ...(typeof args.reason === "string" ? { reason: args.reason } : {}),
117
147
  }, progressToken);
118
148
  }
119
149
  if (name === "agent_register") {
@@ -143,6 +173,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
143
173
  }
144
174
  return await handleReportSelfDiagnostic(ctx, args);
145
175
  }
176
+ if (name === "request_platform_access") {
177
+ if (!args || typeof args.platform !== "string") {
178
+ return errorContent("Invalid or missing argument: platform is required");
179
+ }
180
+ return await handleRequestPlatformAccess(ctx, args);
181
+ }
146
182
  return {
147
183
  content: [{ type: "text", text: `Unknown tool: ${name}` }],
148
184
  isError: true,
@@ -160,3 +196,6 @@ else {
160
196
  const transport = new StdioServerTransport();
161
197
  await server.connect(transport);
162
198
  }
199
+ // Started only after the transport is connected — notifications have nowhere to
200
+ // go before that. No-op without credentials or when AV_PERMISSIONS_POLL_MS=0.
201
+ startPermissionsWatcher(ctx);
@@ -12,7 +12,7 @@ When the user says "use AV", "ask Valet", "via agentvalet", or names this broker
12
12
  If \`list_platforms\` does NOT return the requested platform, tell the user "AgentValet doesn't cover <platform> yet — want me to use a different MCP, or request the integration via your AgentValet owner?" Do not silently fall back to another MCP server or to direct API calls.
13
13
 
14
14
  Tool selection:
15
- 1. Call \`list_platforms\` first to discover approved platforms and scopes for this agent. Cache the result for the session — re-call only if the user mentions a new connection or after a \`use_platform\` failure.
15
+ 1. Call \`list_platforms\` first to discover approved platforms and scopes for this agent. You may reuse the result within a turn, but do NOT treat it as good for the whole session — an owner can change a grant (scopes, or whether an action needs their approval) at any moment, and the tool description carries a \`Current permissions version\` that tells you when yours is stale. Re-call \`list_platforms\` if that version changes, if the user mentions a connection or permission change, or after any \`use_platform\` failure. Revalidation is cheap.
16
16
  2. Call \`authzen_evaluate\` before destructive scopes (\`*.delete\`, \`stripe:charge\`, \`mail:send\`) — these always require human approval. Tell the user approval may be required before invoking them.
17
17
  3. Call \`use_platform\` with the exact platform, endpoint, method, and scope returned by \`list_platforms\`.
18
18
 
@@ -0,0 +1,132 @@
1
+ // apps/mcp-server/src/permissions-watcher.ts
2
+ //
3
+ // Keeps the host's view of the agent's platform spec from going stale.
4
+ //
5
+ // The problem this exists for: an owner flips a grant in the dashboard (say,
6
+ // require_approval on Slack) mid-session. The agent had already called
7
+ // list_platforms and cached the answer in its context, so it kept reasoning
8
+ // from the pre-change spec — the proxy enforced the new rule at call time, but
9
+ // nothing ever told the agent the spec had moved.
10
+ //
11
+ // GET /v1/agent/permissions returns a deterministic `version` hash over the
12
+ // effective spec (scopes + requireApproval + connections). We poll it with
13
+ // If-None-Match, so an unchanged spec costs a 304 with no body. When the hash
14
+ // does move we emit notifications/tools/list_changed; the host re-reads
15
+ // tools/list, and because the list_platforms description is stamped with the
16
+ // current version (see index.ts), the description the model sees actually
17
+ // differs — which is what gets the stale cache invalidated in practice. A bare
18
+ // list_changed on an otherwise-identical tool list would be a no-op.
19
+ //
20
+ // Note this only works on a long-lived transport. The in-proxy MCP endpoint
21
+ // (apps/proxy/src/routes/mcp.ts) is deliberately stateless — a fresh Server per
22
+ // request — so it has no server→client channel and cannot push at all; there,
23
+ // cheap revalidation via the 304 path is the whole story.
24
+ import { signJWT } from "./auth.js";
25
+ import { fetchWithTimeout } from "./net.js";
26
+ const DEFAULT_POLL_MS = 60_000;
27
+ // Floor so a typo in the env var can't turn an agent into a polling hammer
28
+ // against the proxy.
29
+ const MIN_POLL_MS = 10_000;
30
+ let currentVersion = null;
31
+ let timer = null;
32
+ export function getPermissionsVersion() {
33
+ return currentVersion;
34
+ }
35
+ /**
36
+ * Record a version observed elsewhere (a normal list_platforms tool call).
37
+ * Returns true when this is a CHANGE from a previously-known version — the
38
+ * first observation only establishes the baseline and is not a change.
39
+ */
40
+ export function recordPermissionsVersion(version) {
41
+ if (!version || typeof version !== "string")
42
+ return false;
43
+ const hadBaseline = currentVersion !== null;
44
+ const changed = hadBaseline && version !== currentVersion;
45
+ currentVersion = version;
46
+ return changed;
47
+ }
48
+ /** Test seam: forget everything the watcher has learned. */
49
+ export function resetPermissionsVersion() {
50
+ currentVersion = null;
51
+ }
52
+ export function pollIntervalMs(raw = process.env.AV_PERMISSIONS_POLL_MS) {
53
+ if (raw === undefined || raw === "")
54
+ return DEFAULT_POLL_MS;
55
+ const parsed = Number(raw);
56
+ if (!Number.isFinite(parsed) || parsed <= 0)
57
+ return 0; // 0 / invalid ⇒ disabled
58
+ return Math.max(parsed, MIN_POLL_MS);
59
+ }
60
+ /**
61
+ * One revalidation round-trip. Exported for tests. Returns true when the spec
62
+ * changed (and a notification was emitted).
63
+ */
64
+ export async function checkPermissionsVersion(ctx) {
65
+ if (ctx.AGENT_PRIVATE_KEY_RAW === null)
66
+ return false;
67
+ let response;
68
+ try {
69
+ const token = await signJWT(ctx);
70
+ const headers = {
71
+ Authorization: `Bearer ${token}`,
72
+ "Content-Type": "application/json",
73
+ };
74
+ if (currentVersion)
75
+ headers["If-None-Match"] = `"${currentVersion}"`;
76
+ response = await fetchWithTimeout(`${ctx.PROXY_URL}/v1/agent/permissions`, { method: "GET", headers }, 10_000);
77
+ }
78
+ catch {
79
+ return false; // transient — try again next tick
80
+ }
81
+ // Unchanged: the whole point of the conditional request.
82
+ if (response.status === 304)
83
+ return false;
84
+ if (!response.ok)
85
+ return false;
86
+ let version;
87
+ try {
88
+ const parsed = (await response.json());
89
+ const inner = (parsed && typeof parsed === "object" && "data" in parsed
90
+ ? parsed.data
91
+ : parsed);
92
+ if (inner && typeof inner.version === "string")
93
+ version = inner.version;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ if (!recordPermissionsVersion(version))
99
+ return false;
100
+ try {
101
+ await ctx.server.notification({ method: "notifications/tools/list_changed" });
102
+ }
103
+ catch {
104
+ // Best-effort: a host that doesn't accept the notification still gets the
105
+ // corrected spec the next time it calls list_platforms.
106
+ }
107
+ return true;
108
+ }
109
+ /**
110
+ * Start background revalidation. No-op without credentials or when polling is
111
+ * disabled. The timer is unref'd so it can never hold the process open.
112
+ */
113
+ export function startPermissionsWatcher(ctx) {
114
+ if (timer)
115
+ return;
116
+ if (ctx.AGENT_PRIVATE_KEY_RAW === null)
117
+ return;
118
+ const interval = pollIntervalMs();
119
+ if (interval === 0)
120
+ return;
121
+ timer = setInterval(() => {
122
+ void checkPermissionsVersion(ctx).catch(() => { });
123
+ }, interval);
124
+ // `unref` is absent in some non-Node runtimes; guard rather than assume.
125
+ timer.unref?.();
126
+ }
127
+ export function stopPermissionsWatcher() {
128
+ if (!timer)
129
+ return;
130
+ clearInterval(timer);
131
+ timer = null;
132
+ }
@@ -6,6 +6,7 @@
6
6
  // module globals. Behavior is unchanged — pinned by test/introspection.test.ts.
7
7
  import { errorContent, jsonContent, tryParseJson, safeJsonParse, sleep, diagnoseNetworkError, fetchWithTimeout, } from "../net.js";
8
8
  import { requireCredentials, fetchWithAuth } from "../auth.js";
9
+ import { recordPermissionsVersion } from "../permissions-watcher.js";
9
10
  export async function handleListPlatforms(ctx) {
10
11
  const gate = await requireCredentials(ctx);
11
12
  if (gate)
@@ -29,6 +30,9 @@ export async function handleListPlatforms(ctx) {
29
30
  ? parsed.data
30
31
  : parsed;
31
32
  if (inner && typeof inner === "object") {
33
+ // Keep the watcher's baseline in step with what the agent was just told, so
34
+ // a manual refresh doesn't trigger a spurious "changed" notification.
35
+ recordPermissionsVersion(inner.version);
32
36
  return {
33
37
  content: [{ type: "text", text: body }],
34
38
  structuredContent: inner,
@@ -91,6 +95,9 @@ export async function handleUsePlatform(ctx, params, progressToken) {
91
95
  scope: params.scope,
92
96
  ...(params.data !== undefined && { data: params.data }),
93
97
  ...(params.connection_id ? { connection_id: params.connection_id } : {}),
98
+ // Agent-supplied justification. Forwarded verbatim; the proxy normalizes
99
+ // and caps it before it reaches the approver's screen.
100
+ ...(params.reason ? { reason: params.reason } : {}),
94
101
  };
95
102
  let response;
96
103
  try {
@@ -114,8 +121,15 @@ export async function handleUsePlatform(ctx, params, progressToken) {
114
121
  return await waitForApproval(ctx, approvalId, params, progressToken);
115
122
  }
116
123
  }
117
- if (!response.ok)
118
- return errorContent(`Proxy error ${response.status}: ${body}`);
124
+ if (!response.ok) {
125
+ // 403 is the access-denied / scope-not-granted code returned by the
126
+ // proxy's authorize pipeline (see apps/proxy/src/pipeline/authorize.ts).
127
+ // Point the agent at request_platform_access instead of dead-ending it.
128
+ const hint = response.status === 403
129
+ ? " You can call request_platform_access({ platform, scope, reason }) to ask an org admin to grant this."
130
+ : "";
131
+ return errorContent(`Proxy error ${response.status}: ${body}${hint}`);
132
+ }
119
133
  return jsonContent(body);
120
134
  }
121
135
  async function waitForApproval(ctx, approvalId, originalCall, progressToken) {
@@ -280,6 +294,59 @@ export async function handleListMyPendingActions(ctx) {
280
294
  return errorContent(`Proxy error ${response.status}: ${text}`);
281
295
  return jsonContent(text);
282
296
  }
297
+ export async function handleRequestPlatformAccess(ctx, args) {
298
+ const gate = await requireCredentials(ctx);
299
+ if (gate)
300
+ return gate;
301
+ const platform = typeof args.platform === "string" ? args.platform : "";
302
+ if (!platform)
303
+ return errorContent("platform is required");
304
+ const scope = typeof args.scope === "string" ? args.scope : undefined;
305
+ const reason = typeof args.reason === "string" ? args.reason : undefined;
306
+ let submit;
307
+ try {
308
+ submit = await fetchWithAuth(ctx, `${ctx.PROXY_URL}/v1/access-request`, {
309
+ method: "POST",
310
+ body: JSON.stringify({ platform, scope, reason }),
311
+ });
312
+ }
313
+ catch (err) {
314
+ return errorContent(diagnoseNetworkError(err, ctx.PROXY_URL));
315
+ }
316
+ if (!submit.ok && submit.status !== 202 && submit.status !== 200) {
317
+ const t = await submit.text();
318
+ return errorContent(`Access request failed (${submit.status}): ${t}`);
319
+ }
320
+ const submitted = (await submit.json());
321
+ const token = submitted.request_token;
322
+ if (!token)
323
+ return errorContent("Access request did not return a token.");
324
+ // Poll the request status for ~50s (2s interval), matching use_platform UX.
325
+ const deadline = Date.now() + 50_000;
326
+ while (Date.now() < deadline) {
327
+ await sleep(2000);
328
+ let statusRes;
329
+ try {
330
+ statusRes = await fetchWithAuth(ctx, `${ctx.PROXY_URL}/v1/access-request/status/${encodeURIComponent(token)}`, { method: "GET" });
331
+ }
332
+ catch {
333
+ continue;
334
+ }
335
+ if (!statusRes.ok)
336
+ continue;
337
+ const s = (await statusRes.json());
338
+ if (s.status === "approved") {
339
+ return jsonContent(JSON.stringify({ status: "approved", message: "Access granted — retry your original use_platform call." }));
340
+ }
341
+ if (s.status === "denied") {
342
+ return jsonContent(JSON.stringify({ status: "denied", message: "An admin denied this access request." }));
343
+ }
344
+ }
345
+ return jsonContent(JSON.stringify({
346
+ status: "pending",
347
+ message: "Request submitted and awaiting admin approval. Retry your call later.",
348
+ }));
349
+ }
283
350
  export async function handleReportSelfDiagnostic(ctx, args) {
284
351
  const gate = await requireCredentials(ctx);
285
352
  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, 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).\nVersion: a deterministic hash that only changes when the platform set or scopes change. Cache the value across calls in the same session — only refresh when you suspect platforms have changed (e.g. user mentions a new connection).\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, 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).\nVersion: a deterministic hash over the effective spec — it changes whenever the platform set, the scopes, or the requireApproval flags 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",
@@ -46,7 +46,7 @@ export const LIST_PLATFORMS_TOOL = {
46
46
  };
47
47
  export const USE_PLATFORM_TOOL = {
48
48
  name: "use_platform",
49
- description: "use_platform: Call an external platform API (Airtable, GitHub, Slack, Metabase, etc.) through the AgentValet proxy.\nInput: platform (string), endpoint (string), method (GET|POST|PUT|PATCH|DELETE), scope (string), body (object, optional — JSON request body for POST/PUT/PATCH/DELETE).\nReturns: upstream API response body. May take up to 50 seconds when the action requires owner approval — the call will block while we wait, then return the approved result transparently. If approval doesn't land in time, returns a `pending_approval` envelope and the action runs asynchronously; the user is notified when it completes.\nAuth: Bearer JWT.\nNote: legacy clients passing `data` instead of `body` are still accepted for backwards compatibility, but `body` is the canonical name.",
49
+ description: "use_platform: Call an external platform API (Airtable, GitHub, Slack, Metabase, etc.) through the AgentValet proxy.\nInput: platform (string), endpoint (string), method (GET|POST|PUT|PATCH|DELETE), scope (string), body (object, optional — JSON request body for POST/PUT/PATCH/DELETE), reason (string, optional — why you are making this call; shown to the human approver when the action is gated).\nReturns: upstream API response body. May take up to 50 seconds when the action requires owner approval — the call will block while we wait, then return the approved result transparently. If approval doesn't land in time, returns a `pending_approval` envelope and the action runs asynchronously; the user is notified when it completes.\nAuth: Bearer JWT.\nNote: legacy clients passing `data` instead of `body` are still accepted for backwards compatibility, but `body` is the canonical name.",
50
50
  inputSchema: {
51
51
  type: "object",
52
52
  properties: {
@@ -79,6 +79,10 @@ export const USE_PLATFORM_TOOL = {
79
79
  type: "string",
80
80
  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.",
81
81
  },
82
+ reason: {
83
+ type: "string",
84
+ 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.",
85
+ },
82
86
  },
83
87
  required: ["platform", "endpoint", "method", "scope"],
84
88
  },
@@ -261,6 +265,27 @@ export const LIST_MY_PENDING_ACTIONS_TOOL = {
261
265
  required: ["pending", "recently_completed"],
262
266
  },
263
267
  };
268
+ export const REQUEST_PLATFORM_ACCESS_TOOL = {
269
+ name: "request_platform_access",
270
+ description: "request_platform_access: Ask an org admin to grant this agent access to a platform it is currently blocked from. Call this when use_platform returns an access-denied error. Input: platform (string, required), scope (string, optional — the specific scope you need), reason (string, optional — why you need it). The call waits up to ~50s for an admin decision. Returns: { status: \"approved\"|\"pending\"|\"denied\", message }. On \"approved\", retry your original use_platform call. On \"pending\", the request is queued; retry later. Auth: Bearer agent JWT (sent automatically).",
271
+ inputSchema: {
272
+ type: "object",
273
+ properties: {
274
+ platform: { type: "string", description: "Platform ID (e.g. github, slack)" },
275
+ scope: { type: "string", description: "Optional specific scope needed (e.g. repo:read)" },
276
+ reason: { type: "string", description: "Optional short reason for the request" },
277
+ },
278
+ required: ["platform"],
279
+ },
280
+ outputSchema: {
281
+ type: "object",
282
+ properties: {
283
+ status: { type: "string", enum: ["approved", "pending", "denied"] },
284
+ message: { type: "string" },
285
+ },
286
+ required: ["status"],
287
+ },
288
+ };
264
289
  export const ALL_TOOLS = [
265
290
  LIST_PLATFORMS_TOOL,
266
291
  USE_PLATFORM_TOOL,
@@ -269,4 +294,5 @@ export const ALL_TOOLS = [
269
294
  AUTHZEN_EVALUATE_TOOL,
270
295
  REPORT_SELF_DIAGNOSTIC_TOOL,
271
296
  LIST_MY_PENDING_ACTIONS_TOOL,
297
+ REQUEST_PLATFORM_ACCESS_TOOL,
272
298
  ];
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@agentvalet/mcp-server",
3
- "version": "1.3.0",
3
+ "version": "1.6.0",
4
4
  "description": "AgentValet MCP server — lets AI agents call approved platforms via the AgentValet proxy",
5
+ "mcpName": "io.github.agentvalet/mcp-server",
5
6
  "type": "module",
6
7
  "main": "dist/index.js",
7
8
  "bin": {
@@ -11,14 +12,6 @@
11
12
  "dist",
12
13
  "README.md"
13
14
  ],
14
- "scripts": {
15
- "dev": "tsx src/index.ts",
16
- "build": "tsc",
17
- "start": "node dist/index.js",
18
- "test": "vitest run",
19
- "typecheck": "tsc --noEmit",
20
- "prepublishOnly": "npm run build"
21
- },
22
15
  "dependencies": {
23
16
  "@modelcontextprotocol/sdk": "^1.0.0",
24
17
  "jose": "^5.10.0"
@@ -35,7 +28,7 @@
35
28
  "repository": {
36
29
  "type": "git",
37
30
  "url": "git+https://github.com/MCSEdwin/agentvalet.git",
38
- "directory": "apps/mcp-server"
31
+ "directory": "packages/mcp-server"
39
32
  },
40
33
  "keywords": [
41
34
  "ai",
@@ -43,5 +36,12 @@
43
36
  "mcp",
44
37
  "agentvalet"
45
38
  ],
46
- "license": "MIT"
47
- }
39
+ "license": "MIT",
40
+ "scripts": {
41
+ "dev": "tsx src/index.ts",
42
+ "build": "tsc",
43
+ "start": "node dist/index.js",
44
+ "test": "vitest run",
45
+ "typecheck": "tsc --noEmit"
46
+ }
47
+ }