@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 +70 -67
- package/dist/gate-client.js +159 -0
- package/dist/index.js +45 -6
- package/dist/instructions.js +1 -1
- package/dist/permissions-watcher.js +132 -0
- package/dist/tools/handlers.js +69 -2
- package/dist/tools/schemas.js +28 -2
- package/package.json +12 -12
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
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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" },
|
|
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
|
-
|
|
79
|
-
|
|
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);
|
package/dist/instructions.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/dist/tools/handlers.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/tools/schemas.js
CHANGED
|
@@ -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
|
|
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
|
+
"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": "
|
|
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
|
+
}
|