@agentvalet/mcp-server 1.14.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gate-client.js +3 -1
- package/dist/index.js +2 -0
- package/dist/registration-keys.js +88 -0
- package/dist/tools/consent.js +145 -0
- package/dist/tools/handlers.js +212 -8
- package/dist/tools/schemas.js +29 -4
- package/package.json +1 -1
package/dist/gate-client.js
CHANGED
|
@@ -57,7 +57,9 @@ export function classifyGateResponse(status, body) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
// Identity rejection and upstream/server failures are not policy decisions.
|
|
60
|
-
|
|
60
|
+
// 424 is the proxy's upstream-failure code (a 502/504 downgraded so
|
|
61
|
+
// Cloudflare doesn't swap the diagnostic body for its own error page).
|
|
62
|
+
if (status === 401 || status === 424 || status >= 500) {
|
|
61
63
|
return {
|
|
62
64
|
decision: "error",
|
|
63
65
|
status,
|
package/dist/index.js
CHANGED
|
@@ -180,6 +180,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
180
180
|
scope: args.scope,
|
|
181
181
|
data: bodyArg,
|
|
182
182
|
...(typeof args.connection_id === "string" ? { connection_id: args.connection_id } : {}),
|
|
183
|
+
...(args.caller && typeof args.caller === "object" ? { caller: args.caller } : {}),
|
|
183
184
|
...(typeof args.reason === "string" ? { reason: args.reason } : {}),
|
|
184
185
|
}, progressToken);
|
|
185
186
|
}
|
|
@@ -213,6 +214,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
213
214
|
// No `data` alias on the new tools: new surface, no legacy.
|
|
214
215
|
...(args.body !== undefined ? { data: args.body } : {}),
|
|
215
216
|
...(typeof args.connection_id === "string" ? { connection_id: args.connection_id } : {}),
|
|
217
|
+
...(args.caller && typeof args.caller === "object" ? { caller: args.caller } : {}),
|
|
216
218
|
...(typeof args.reason === "string" ? { reason: args.reason } : {}),
|
|
217
219
|
}, progressToken);
|
|
218
220
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// apps/mcp-server/src/registration-keys.ts
|
|
2
|
+
//
|
|
3
|
+
// Local key custody for the agent_register -> owner approval -> agent_status
|
|
4
|
+
// flow (Claude Desktop / MCPB self-registration).
|
|
5
|
+
//
|
|
6
|
+
// agent_register generates the RSA keypair HERE and sends only the public key
|
|
7
|
+
// (POST /v1/register public_key_pem). The private key waits on disk, keyed by
|
|
8
|
+
// the registration token, until agent_status sees the approval and installs it
|
|
9
|
+
// as the agent's key. The private key never leaves this machine, never reaches
|
|
10
|
+
// the dashboard, and is never put in a tool result the model can see.
|
|
11
|
+
//
|
|
12
|
+
// Layout (under ~/.agentvalet, dir 0700, key files 0600):
|
|
13
|
+
// pending/<registration_token>.key private key awaiting approval
|
|
14
|
+
// agents/<agent_id>.key installed key for an approved agent
|
|
15
|
+
// agent.key + agent.json the DEFAULT identity config.ts boots
|
|
16
|
+
// from — written ONLY when neither file
|
|
17
|
+
// exists, so a registration never clobbers
|
|
18
|
+
// an identity another agent already owns
|
|
19
|
+
// (see invariant_register_clobbers_existing_agent_key).
|
|
20
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { getAgentvaletDir, getIdentityPath, getKeyPath, persistBindArtifacts } from "./bind.js";
|
|
24
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9_-]{1,128}$/;
|
|
25
|
+
export function generateRegistrationKeypair() {
|
|
26
|
+
return generateKeyPairSync("rsa", {
|
|
27
|
+
modulusLength: 2048,
|
|
28
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
29
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function ensureDir(dir) {
|
|
33
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
}
|
|
35
|
+
export function pendingKeyPath(token) {
|
|
36
|
+
if (!SAFE_SEGMENT.test(token))
|
|
37
|
+
return null;
|
|
38
|
+
return join(getAgentvaletDir(), "pending", `${token}.key`);
|
|
39
|
+
}
|
|
40
|
+
export function savePendingKey(token, privateKeyPem) {
|
|
41
|
+
const path = pendingKeyPath(token);
|
|
42
|
+
if (!path)
|
|
43
|
+
throw new Error("Unexpected registration token format; refusing to use it as a file name");
|
|
44
|
+
ensureDir(join(getAgentvaletDir(), "pending"));
|
|
45
|
+
writeFileSync(path, privateKeyPem, { mode: 0o600 });
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
export function readPendingKey(token) {
|
|
49
|
+
const path = pendingKeyPath(token);
|
|
50
|
+
if (!path || !existsSync(path))
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
return readFileSync(path, "utf-8").trim();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function deletePendingKey(token) {
|
|
60
|
+
const path = pendingKeyPath(token);
|
|
61
|
+
if (path)
|
|
62
|
+
rmSync(path, { force: true });
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Installs an approved agent's private key. Always writes
|
|
66
|
+
* agents/<agent_id>.key; additionally writes the default agent.key +
|
|
67
|
+
* agent.json when (and only when) no default identity exists yet, so the next
|
|
68
|
+
* server start picks it up with no env vars at all.
|
|
69
|
+
*/
|
|
70
|
+
export function installAgentKey(opts) {
|
|
71
|
+
if (!SAFE_SEGMENT.test(opts.agentId)) {
|
|
72
|
+
throw new Error("Unexpected agent_id format; refusing to use it as a file name");
|
|
73
|
+
}
|
|
74
|
+
const agentsDir = join(getAgentvaletDir(), "agents");
|
|
75
|
+
ensureDir(agentsDir);
|
|
76
|
+
const keyPath = join(agentsDir, `${opts.agentId}.key`);
|
|
77
|
+
writeFileSync(keyPath, opts.privateKeyPem, { mode: 0o600 });
|
|
78
|
+
const defaultTaken = existsSync(getKeyPath()) || existsSync(getIdentityPath());
|
|
79
|
+
if (!defaultTaken) {
|
|
80
|
+
persistBindArtifacts({
|
|
81
|
+
agent_id: opts.agentId,
|
|
82
|
+
owner_id: opts.ownerId,
|
|
83
|
+
proxy_url: opts.proxyUrl,
|
|
84
|
+
bound_at: new Date().toISOString(),
|
|
85
|
+
}, opts.privateKeyPem);
|
|
86
|
+
}
|
|
87
|
+
return { keyPath, becameDefault: !defaultTaken };
|
|
88
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// apps/mcp-server/src/tools/consent.ts
|
|
2
|
+
//
|
|
3
|
+
// Elicit mode for MCP server consent (CodePrompts/Affindi-Competitor/
|
|
4
|
+
// 01-elicit-consent-mcp-design.md, Wave B).
|
|
5
|
+
//
|
|
6
|
+
// The proxy answers 409 needs_consent when an agent is granted on a remote
|
|
7
|
+
// MCP OAuth server nobody has authorised yet, and (since 01) the body carries
|
|
8
|
+
// the access request that tracks the consent: request_token, poll_url. This
|
|
9
|
+
// long-lived stdio server has what the stateless remote surface lacks, a
|
|
10
|
+
// server→client channel, so it can HOLD the call: hand the human the consent
|
|
11
|
+
// link through MCP elicitation, wait for the request to flip to approved, and
|
|
12
|
+
// let the caller retry inline. No "ask the agent to try again".
|
|
13
|
+
//
|
|
14
|
+
// Mode is derived per client, never stored (design Q4):
|
|
15
|
+
// client advertises elicitation.url → elicitInput({ mode: "url" })
|
|
16
|
+
// client advertises elicitation.form → a one-field form that says where to
|
|
17
|
+
// go and asks to be told when done
|
|
18
|
+
// neither → on-demand text with the link
|
|
19
|
+
// The SDK throws when a mode is used the client did not advertise, so the
|
|
20
|
+
// capability is read first and the fallback is chosen before any request.
|
|
21
|
+
//
|
|
22
|
+
// The link is built HERE from the dashboard origin plus the token, never
|
|
23
|
+
// taken from the proxy body: the host opens it on the user's behalf and must
|
|
24
|
+
// not be steerable by anything the response could carry (inv-123's rule,
|
|
25
|
+
// applied to the second surface).
|
|
26
|
+
import { fetchWithAuth } from "../auth.js";
|
|
27
|
+
import { sleep } from "../net.js";
|
|
28
|
+
export const DASHBOARD_URL = (process.env.AGENTVALET_DASHBOARD_URL ?? "https://app.agentvalet.ai").replace(/\/$/, "");
|
|
29
|
+
/** How long a held call waits for the admin to finish the consent screen. */
|
|
30
|
+
export const CONSENT_POLL_BUDGET_MS = Number(process.env.AGENTVALET_CONSENT_POLL_BUDGET_MS ?? 90_000);
|
|
31
|
+
const CONSENT_POLL_INTERVAL_MS = 2_000;
|
|
32
|
+
export function connectUrlFor(requestToken) {
|
|
33
|
+
return `${DASHBOARD_URL}/connect?token=${encodeURIComponent(requestToken)}`;
|
|
34
|
+
}
|
|
35
|
+
export function parseConsentRefusal(status, body) {
|
|
36
|
+
if (status !== 409)
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
const j = JSON.parse(body);
|
|
40
|
+
if (j.error !== "needs_consent" || typeof j.request_token !== "string" || !j.request_token)
|
|
41
|
+
return null;
|
|
42
|
+
return {
|
|
43
|
+
request_token: j.request_token,
|
|
44
|
+
platform: typeof j.platform === "string" ? j.platform : "the MCP server",
|
|
45
|
+
...(typeof j.message === "string" ? { message: j.message } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Which elicitation the connected client advertised at initialize. */
|
|
53
|
+
export function elicitModeFor(ctx) {
|
|
54
|
+
try {
|
|
55
|
+
const caps = ctx.server
|
|
56
|
+
.getClientCapabilities?.();
|
|
57
|
+
const e = caps?.elicitation;
|
|
58
|
+
if (!e || typeof e !== "object")
|
|
59
|
+
return "none";
|
|
60
|
+
if (e.url && typeof e.url === "object")
|
|
61
|
+
return "url";
|
|
62
|
+
// The SDK normalises a bare `elicitation: {}` to `{ form: {} }`.
|
|
63
|
+
if (e.form && typeof e.form === "object")
|
|
64
|
+
return "form";
|
|
65
|
+
return "none";
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return "none";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Hold the call through the consent. Returns "approved" once the request the
|
|
73
|
+
* proxy raised reports approved (the OAuth callback flips it), so the caller
|
|
74
|
+
* can retry inline; every other outcome means the caller should answer with
|
|
75
|
+
* the on-demand text and the link.
|
|
76
|
+
*/
|
|
77
|
+
export async function elicitConsent(ctx, refusal) {
|
|
78
|
+
const mode = elicitModeFor(ctx);
|
|
79
|
+
if (mode === "none")
|
|
80
|
+
return { kind: "unsupported" };
|
|
81
|
+
const url = connectUrlFor(refusal.request_token);
|
|
82
|
+
const message = `An admin needs to authorize AgentValet with ${refusal.platform} before this agent can use it. ` +
|
|
83
|
+
`Open the link, sign in to AgentValet and press Authorize; this call resumes on its own when that lands.`;
|
|
84
|
+
const server = ctx.server;
|
|
85
|
+
let action;
|
|
86
|
+
try {
|
|
87
|
+
if (mode === "url") {
|
|
88
|
+
const res = await server.elicitInput({ mode: "url", message, url, elicitationId: refusal.request_token }, { timeout: CONSENT_POLL_BUDGET_MS });
|
|
89
|
+
action = res.action;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const res = await server.elicitInput({
|
|
93
|
+
mode: "form",
|
|
94
|
+
message: `${message}\n\n${url}`,
|
|
95
|
+
requestedSchema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
authorized: { type: "boolean", title: "I have completed the authorization", default: false },
|
|
99
|
+
},
|
|
100
|
+
required: ["authorized"],
|
|
101
|
+
},
|
|
102
|
+
}, { timeout: CONSENT_POLL_BUDGET_MS });
|
|
103
|
+
action = res.action === "accept" && res.content?.authorized === true ? "accept" : "decline";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A host that advertised the capability but failed the request is the
|
|
108
|
+
// same as one without it: fall back to the text path.
|
|
109
|
+
return { kind: "unsupported" };
|
|
110
|
+
}
|
|
111
|
+
if (action !== "accept")
|
|
112
|
+
return { kind: "declined" };
|
|
113
|
+
// URL mode returns "accept" when the user has been shown the link, not when
|
|
114
|
+
// the consent is finished; the access request is the truth, so poll it.
|
|
115
|
+
const startedAt = Date.now();
|
|
116
|
+
while (Date.now() - startedAt < CONSENT_POLL_BUDGET_MS) {
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetchWithAuth(ctx, `${ctx.PROXY_URL}/v1/access-request/status/${encodeURIComponent(refusal.request_token)}`, { method: "GET" });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
await sleep(CONSENT_POLL_INTERVAL_MS);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (res.ok) {
|
|
126
|
+
const s = (await res.json());
|
|
127
|
+
if (s.status === "approved")
|
|
128
|
+
return { kind: "approved", waited_ms: Date.now() - startedAt };
|
|
129
|
+
if (s.status === "denied" || s.status === "expired")
|
|
130
|
+
return { kind: "denied" };
|
|
131
|
+
}
|
|
132
|
+
await sleep(CONSENT_POLL_INTERVAL_MS);
|
|
133
|
+
}
|
|
134
|
+
return { kind: "timeout" };
|
|
135
|
+
}
|
|
136
|
+
/** The on-demand text every fallback returns: the link, and what to do with it. */
|
|
137
|
+
export function consentFallbackText(refusal, outcome) {
|
|
138
|
+
const url = connectUrlFor(refusal.request_token);
|
|
139
|
+
const why = outcome.kind === "declined" ? "The authorization was not completed in this session."
|
|
140
|
+
: outcome.kind === "timeout" ? `The admin has not finished authorizing within ${Math.round(CONSENT_POLL_BUDGET_MS / 1000)}s.`
|
|
141
|
+
: outcome.kind === "denied" ? "The consent request was denied or expired."
|
|
142
|
+
: `This agent has not been authorized on ${refusal.platform} yet.`;
|
|
143
|
+
return (`${why} An AgentValet admin can authorize it here: ${url} ` +
|
|
144
|
+
"Give the user that link; retry the call once they confirm it is done.");
|
|
145
|
+
}
|
package/dist/tools/handlers.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
import { errorContent, jsonContent, tryParseJson, safeJsonParse, sleep, diagnoseNetworkError, fetchWithTimeout, } from "../net.js";
|
|
8
8
|
import { requireCredentials, fetchWithAuth } from "../auth.js";
|
|
9
9
|
import { recordPermissionsVersion } from "../permissions-watcher.js";
|
|
10
|
+
import { parseConsentRefusal, elicitConsent, consentFallbackText, connectUrlFor } from "./consent.js";
|
|
11
|
+
import { importPKCS8 } from "jose";
|
|
12
|
+
import { generateRegistrationKeypair, savePendingKey, readPendingKey, deletePendingKey, installAgentKey, } from "../registration-keys.js";
|
|
10
13
|
export async function handleListPlatforms(ctx) {
|
|
11
14
|
const gate = await requireCredentials(ctx);
|
|
12
15
|
if (gate)
|
|
@@ -46,7 +49,10 @@ export async function handleListPlatforms(ctx) {
|
|
|
46
49
|
const APPROVAL_POLL_BUDGET_MS = 50_000;
|
|
47
50
|
const APPROVAL_POLL_INTERVAL_MS = 2_000;
|
|
48
51
|
const APPROVAL_PROGRESS_INTERVAL_MS = 5_000;
|
|
49
|
-
export async function handleUsePlatform(ctx, params, progressToken
|
|
52
|
+
export async function handleUsePlatform(ctx, params, progressToken,
|
|
53
|
+
// Set on the single inline retry after a completed consent, so a server
|
|
54
|
+
// that keeps answering 409 cannot loop this handler.
|
|
55
|
+
consentRetried = false) {
|
|
50
56
|
const gate = await requireCredentials(ctx);
|
|
51
57
|
if (gate)
|
|
52
58
|
return gate;
|
|
@@ -95,6 +101,7 @@ export async function handleUsePlatform(ctx, params, progressToken) {
|
|
|
95
101
|
scope: params.scope,
|
|
96
102
|
...(params.data !== undefined && { data: params.data }),
|
|
97
103
|
...(params.connection_id ? { connection_id: params.connection_id } : {}),
|
|
104
|
+
...(params.caller ? { caller: params.caller } : {}),
|
|
98
105
|
// Agent-supplied justification. Forwarded verbatim; the proxy normalizes
|
|
99
106
|
// and caps it before it reaches the approver's screen.
|
|
100
107
|
...(params.reason ? { reason: params.reason } : {}),
|
|
@@ -122,6 +129,30 @@ export async function handleUsePlatform(ctx, params, progressToken) {
|
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
if (!response.ok) {
|
|
132
|
+
// 409 needs_consent with a request token: the one refusal a human can
|
|
133
|
+
// finish from a link. Hold the call through MCP elicitation when the
|
|
134
|
+
// client supports it and retry once the consent lands (01, Wave B);
|
|
135
|
+
// otherwise answer with the link in-band (closes gap-053 for this code).
|
|
136
|
+
const consent = parseConsentRefusal(response.status, body);
|
|
137
|
+
if (consent) {
|
|
138
|
+
const outcome = consentRetried ? { kind: "unsupported" } : await elicitConsent(ctx, consent);
|
|
139
|
+
if (outcome.kind === "approved") {
|
|
140
|
+
return await handleUsePlatform(ctx, params, progressToken, true);
|
|
141
|
+
}
|
|
142
|
+
const summary = consentFallbackText(consent, outcome);
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: "text", text: `Proxy error 409: ${summary}` }],
|
|
145
|
+
structuredContent: {
|
|
146
|
+
error: "needs_consent",
|
|
147
|
+
reason_code: "needs_consent",
|
|
148
|
+
platform: consent.platform,
|
|
149
|
+
request_token: consent.request_token,
|
|
150
|
+
connect_url: connectUrlFor(consent.request_token),
|
|
151
|
+
consent_outcome: outcome.kind,
|
|
152
|
+
},
|
|
153
|
+
isError: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
125
156
|
// 403 is the access-denied / scope-not-granted code returned by the
|
|
126
157
|
// proxy's authorize pipeline (see apps/proxy/src/pipeline/authorize.ts).
|
|
127
158
|
// Point the agent at request_platform_access instead of dead-ending it.
|
|
@@ -221,13 +252,34 @@ async function sendProgress(ctx, token, progress, total, message) {
|
|
|
221
252
|
// Best-effort — never let a failed notification break the call.
|
|
222
253
|
}
|
|
223
254
|
}
|
|
255
|
+
// Unwraps the proxy's { data, _meta } envelope when present.
|
|
256
|
+
function unwrapData(parsed) {
|
|
257
|
+
if (!parsed || typeof parsed !== "object")
|
|
258
|
+
return null;
|
|
259
|
+
const obj = parsed;
|
|
260
|
+
if (obj.data && typeof obj.data === "object")
|
|
261
|
+
return obj.data;
|
|
262
|
+
return obj;
|
|
263
|
+
}
|
|
264
|
+
function structured(result) {
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
267
|
+
structuredContent: result,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// agent_register: the keypair is generated HERE and only the public key is
|
|
271
|
+
// sent, so the private key never leaves this machine (and never transits the
|
|
272
|
+
// owner's dashboard). It waits on disk until agent_status sees the approval.
|
|
224
273
|
export async function handleAgentRegister(ctx, args) {
|
|
274
|
+
const { publicKey, privateKey } = generateRegistrationKeypair();
|
|
225
275
|
let response;
|
|
226
276
|
try {
|
|
227
277
|
response = await fetchWithTimeout(`${ctx.PROXY_URL}/v1/register`, {
|
|
228
278
|
method: "POST",
|
|
229
279
|
headers: { "Content-Type": "application/json" },
|
|
230
|
-
|
|
280
|
+
// public_key_pem last so a model-supplied value can never override it —
|
|
281
|
+
// we would not hold the matching private key.
|
|
282
|
+
body: JSON.stringify({ ...args, public_key_pem: publicKey }),
|
|
231
283
|
});
|
|
232
284
|
}
|
|
233
285
|
catch (err) {
|
|
@@ -236,7 +288,65 @@ export async function handleAgentRegister(ctx, args) {
|
|
|
236
288
|
const body = await response.text();
|
|
237
289
|
if (!response.ok)
|
|
238
290
|
return errorContent(`Proxy error ${response.status}: ${body}`);
|
|
239
|
-
|
|
291
|
+
const data = unwrapData(tryParseJson(body));
|
|
292
|
+
const token = typeof data?.registration_token === "string" ? data.registration_token : null;
|
|
293
|
+
if (!data || !token)
|
|
294
|
+
return jsonContent(body);
|
|
295
|
+
// Only keep the private key if the proxy actually bound our public key. An
|
|
296
|
+
// older proxy ignores public_key_pem and issues its own keypair; agent_status
|
|
297
|
+
// then collects that one via claim-key instead.
|
|
298
|
+
let keyStoredLocally = false;
|
|
299
|
+
if (data.keypair_source === "client") {
|
|
300
|
+
try {
|
|
301
|
+
savePendingKey(token, privateKey);
|
|
302
|
+
keyStoredLocally = true;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
return errorContent(`Registration ${token} was submitted, but the private key could not be saved locally ` +
|
|
306
|
+
`(${err instanceof Error ? err.message : err}). Ask the owner to reject it and register again.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return structured({
|
|
310
|
+
...data,
|
|
311
|
+
key_stored_locally: keyStoredLocally,
|
|
312
|
+
next_step: "Ask the owner to approve this agent in the AgentValet dashboard, then call agent_status with the registration_token. " +
|
|
313
|
+
"The private key stays on this machine; agent_status installs it once approved.",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
async function claimServerIssuedKey(ctx, token) {
|
|
317
|
+
try {
|
|
318
|
+
const res = await fetchWithTimeout(`${ctx.PROXY_URL}/v1/register/claim-key/${encodeURIComponent(token)}`, {
|
|
319
|
+
method: "GET",
|
|
320
|
+
headers: { "Content-Type": "application/json" },
|
|
321
|
+
});
|
|
322
|
+
if (!res.ok)
|
|
323
|
+
return null;
|
|
324
|
+
const json = (await res.json());
|
|
325
|
+
return typeof json.private_key === "string" && json.private_key.includes("PRIVATE KEY")
|
|
326
|
+
? json.private_key
|
|
327
|
+
: null;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// If this server booted with no usable key (state B/C), adopt the newly
|
|
334
|
+
// approved identity in-process so platform tools work without a restart.
|
|
335
|
+
async function activateInProcess(ctx, agentId, ownerId, pem) {
|
|
336
|
+
if (ctx.AGENT_PRIVATE_KEY_RAW !== null)
|
|
337
|
+
return false;
|
|
338
|
+
if (ctx.AGENT_ID && ctx.AGENT_ID !== agentId)
|
|
339
|
+
return false;
|
|
340
|
+
try {
|
|
341
|
+
ctx.privateKey = await importPKCS8(pem, "RS256");
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
ctx.AGENT_ID = agentId;
|
|
347
|
+
ctx.OWNER_ID = ownerId;
|
|
348
|
+
ctx.AGENT_PRIVATE_KEY_RAW = pem;
|
|
349
|
+
return true;
|
|
240
350
|
}
|
|
241
351
|
export async function handleAgentStatus(ctx, token) {
|
|
242
352
|
let response;
|
|
@@ -252,7 +362,76 @@ export async function handleAgentStatus(ctx, token) {
|
|
|
252
362
|
const body = await response.text();
|
|
253
363
|
if (!response.ok)
|
|
254
364
|
return errorContent(`Proxy error ${response.status}: ${body}`);
|
|
255
|
-
|
|
365
|
+
const data = unwrapData(tryParseJson(body));
|
|
366
|
+
if (!data || typeof data.status !== "string")
|
|
367
|
+
return jsonContent(body);
|
|
368
|
+
if (data.status === "rejected" || data.status === "expired") {
|
|
369
|
+
deletePendingKey(token);
|
|
370
|
+
return structured(data);
|
|
371
|
+
}
|
|
372
|
+
if (data.status !== "approved")
|
|
373
|
+
return structured(data);
|
|
374
|
+
const agentId = typeof data.agent_id === "string" ? data.agent_id : "";
|
|
375
|
+
const mcpEnv = (data.mcp_config
|
|
376
|
+
?.mcpServers?.agentvalet?.env) ?? {};
|
|
377
|
+
const ownerId = typeof data.owner_id === "string" ? data.owner_id : (mcpEnv.OWNER_ID ?? "");
|
|
378
|
+
// Locate the private key: our own (client keypair) or, for a legacy
|
|
379
|
+
// server-issued keypair, the one-time claim-key pickup.
|
|
380
|
+
let pem = null;
|
|
381
|
+
let source = null;
|
|
382
|
+
if (data.keypair_source === "client") {
|
|
383
|
+
pem = readPendingKey(token);
|
|
384
|
+
source = "client";
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
pem = await claimServerIssuedKey(ctx, token);
|
|
388
|
+
source = "server";
|
|
389
|
+
}
|
|
390
|
+
const result = {
|
|
391
|
+
status: "approved",
|
|
392
|
+
agent_id: agentId,
|
|
393
|
+
owner_id: ownerId,
|
|
394
|
+
keypair_source: source,
|
|
395
|
+
};
|
|
396
|
+
if (!pem || !agentId || !ownerId) {
|
|
397
|
+
result.key_installed = false;
|
|
398
|
+
result.next_step =
|
|
399
|
+
source === "client"
|
|
400
|
+
? "Approved, but this machine does not hold the private key for this registration (it was generated by a different install, or already installed). " +
|
|
401
|
+
"Check ~/.agentvalet/agents/, or register again from this machine."
|
|
402
|
+
: "Approved, but the one-time private key could not be collected (already claimed, or the 5-minute window after approval has passed). " +
|
|
403
|
+
"Register again from this machine so the key is generated locally.";
|
|
404
|
+
return structured(result);
|
|
405
|
+
}
|
|
406
|
+
let installed;
|
|
407
|
+
try {
|
|
408
|
+
installed = installAgentKey({ agentId, ownerId, proxyUrl: ctx.PROXY_URL, privateKeyPem: pem });
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
return errorContent(`Approved as ${agentId}, but the private key could not be saved: ${err instanceof Error ? err.message : err}`);
|
|
412
|
+
}
|
|
413
|
+
deletePendingKey(token);
|
|
414
|
+
const activated = await activateInProcess(ctx, agentId, ownerId, pem);
|
|
415
|
+
result.key_installed = true;
|
|
416
|
+
result.key_path = installed.keyPath;
|
|
417
|
+
result.default_identity = installed.becameDefault;
|
|
418
|
+
result.activated = activated;
|
|
419
|
+
// The config to use this agent elsewhere: a key PATH, never the key itself.
|
|
420
|
+
result.mcp_config = {
|
|
421
|
+
mcpServers: {
|
|
422
|
+
agentvalet: {
|
|
423
|
+
command: "npx",
|
|
424
|
+
args: ["-y", "@agentvalet/mcp-server"],
|
|
425
|
+
env: { AGENT_ID: agentId, OWNER_ID: ownerId, PROXY_URL: ctx.PROXY_URL, AGENT_PRIVATE_KEY_PATH: installed.keyPath },
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
result.next_step = activated
|
|
430
|
+
? "Registered and active in this session. Call list_platforms to see what the owner granted."
|
|
431
|
+
: installed.becameDefault
|
|
432
|
+
? "Registered. The key is installed as this machine's default AgentValet identity; restart the MCP server (or Claude Desktop) to use it."
|
|
433
|
+
: "Registered. Another AgentValet identity is already configured on this machine, so it was left untouched; use the mcp_config above (AGENT_PRIVATE_KEY_PATH) to run this agent.";
|
|
434
|
+
return structured(result);
|
|
256
435
|
}
|
|
257
436
|
export async function handleAuthzenEvaluate(ctx, platformId, scope) {
|
|
258
437
|
const gate = await requireCredentials(ctx);
|
|
@@ -321,6 +500,28 @@ export async function handleRequestPlatformAccess(ctx, args) {
|
|
|
321
500
|
const token = submitted.request_token;
|
|
322
501
|
if (!token)
|
|
323
502
|
return errorContent("Access request did not return a token.");
|
|
503
|
+
// The human hand-off link, for every reason a human can fix on that page.
|
|
504
|
+
// Built here from the dashboard origin, never taken from the response.
|
|
505
|
+
// scope_denied is excluded: a policy block is not fixable there, and a link
|
|
506
|
+
// that cannot work is worse than none. (Closes gap-053 on this surface.)
|
|
507
|
+
const actionable = ["not_connected", "not_granted", "needs_consent"].includes(submitted.reason_code ?? "");
|
|
508
|
+
const connectUrl = actionable ? connectUrlFor(token) : undefined;
|
|
509
|
+
const withLink = (o) => (connectUrl ? { ...o, connect_url: connectUrl } : o);
|
|
510
|
+
// A consent request can be held open through elicitation the same way a
|
|
511
|
+
// use_platform call is: the admin gets the link now, and this tool returns
|
|
512
|
+
// approved the moment the OAuth callback flips the request.
|
|
513
|
+
if (submitted.reason_code === "needs_consent") {
|
|
514
|
+
const outcome = await elicitConsent(ctx, { request_token: token, platform });
|
|
515
|
+
if (outcome.kind === "approved") {
|
|
516
|
+
return jsonContent(JSON.stringify(withLink({ status: "approved", message: "Authorized — retry your original use_platform call." })));
|
|
517
|
+
}
|
|
518
|
+
if (outcome.kind === "denied") {
|
|
519
|
+
return jsonContent(JSON.stringify({ status: "denied", message: "An admin denied this access request." }));
|
|
520
|
+
}
|
|
521
|
+
if (outcome.kind !== "unsupported") {
|
|
522
|
+
return jsonContent(JSON.stringify(withLink({ status: "pending", reason_code: "needs_consent", message: consentFallbackText({ request_token: token, platform }, outcome) })));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
324
525
|
// Poll the request status for ~50s (2s interval), matching use_platform UX.
|
|
325
526
|
const deadline = Date.now() + 50_000;
|
|
326
527
|
while (Date.now() < deadline) {
|
|
@@ -336,16 +537,19 @@ export async function handleRequestPlatformAccess(ctx, args) {
|
|
|
336
537
|
continue;
|
|
337
538
|
const s = (await statusRes.json());
|
|
338
539
|
if (s.status === "approved") {
|
|
339
|
-
return jsonContent(JSON.stringify({ status: "approved", message: "Access granted — retry your original use_platform call." }));
|
|
540
|
+
return jsonContent(JSON.stringify(withLink({ status: "approved", message: "Access granted — retry your original use_platform call." })));
|
|
340
541
|
}
|
|
341
542
|
if (s.status === "denied") {
|
|
342
543
|
return jsonContent(JSON.stringify({ status: "denied", message: "An admin denied this access request." }));
|
|
343
544
|
}
|
|
344
545
|
}
|
|
345
|
-
return jsonContent(JSON.stringify({
|
|
546
|
+
return jsonContent(JSON.stringify(withLink({
|
|
346
547
|
status: "pending",
|
|
347
|
-
|
|
348
|
-
|
|
548
|
+
reason_code: submitted.reason_code,
|
|
549
|
+
message: connectUrl
|
|
550
|
+
? `Request submitted and awaiting admin approval. Give the user this link so an admin can finish it: ${connectUrl} Retry your call once they confirm.`
|
|
551
|
+
: "Request submitted and awaiting admin approval. Retry your call later.",
|
|
552
|
+
})));
|
|
349
553
|
}
|
|
350
554
|
export async function handleIssueChildAgent(ctx, args) {
|
|
351
555
|
const gate = await requireCredentials(ctx);
|
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, approvalScopes?, connections?, callStyle? }], version: \"<hex>\" }. A platform includes `connections` when it has 2+ usable accounts, or when one account carries context worth knowing; pass the chosen connection_id to use_platform (omit for the default). Each connection may carry `identity` (the account it actually points at, e.g. {email}) and `hint` (free text the owner wrote for you). Use them to resolve an instruction that names an account rather than a connection — \"send it from the aifirstpartner account\" means the connection whose identity.email or hint matches; if none does, ask rather than guessing 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.",
|
|
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` when it has 2+ usable accounts, or when one account carries context worth knowing; pass the chosen connection_id to use_platform (omit for the default). Each connection may carry `identity` (the account it actually points at, e.g. {email}) and `hint` (free text the owner wrote for you). Use them to resolve an instruction that names an account rather than a connection — \"send it from the aifirstpartner account\" means the connection whose identity.email or hint matches; if none does, ask rather than guessing 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.\nA platform block may carry `constraints` (recipient allow-lists: domains, addresses, channels). When present, address only those recipients; a call to anyone else is refused with 403 recipient_not_allowed.\nAuth: Bearer JWT.",
|
|
11
11
|
inputSchema: { type: "object", properties: {} },
|
|
12
12
|
outputSchema: {
|
|
13
13
|
type: "object",
|
|
@@ -98,6 +98,11 @@ export const USE_PLATFORM_TOOL = {
|
|
|
98
98
|
type: "string",
|
|
99
99
|
description: "Optional. Target a specific connection when a platform has more than one connected (e.g. two GitHub orgs). Value is the connection id shown in the dashboard. Omit to use the default connection.",
|
|
100
100
|
},
|
|
101
|
+
caller: {
|
|
102
|
+
type: "object",
|
|
103
|
+
description: "Optional. Who the human is that this call is for, when this agent serves many people: { assertion: '<JWT from an identity provider the org registered>' } (verified) or { source, id } (asserted; honoured only where the platform allows it). On a platform with per-person tokens, the person's own upstream token is used; if they have not authorized yet the call returns consent_required with a consent_url to give them.",
|
|
104
|
+
properties: { assertion: { type: "string" }, source: { type: "string" }, id: { type: "string" } },
|
|
105
|
+
},
|
|
101
106
|
reason: {
|
|
102
107
|
type: "string",
|
|
103
108
|
description: "Optional but strongly encouraged. One sentence on WHY you are making this call. If the action requires owner approval, this is shown to the human who decides — without it their approval is a blind yes/no. Max 500 characters (longer is truncated). Plain text; markup is not rendered.",
|
|
@@ -125,6 +130,11 @@ export const READ_PLATFORM_TOOL = {
|
|
|
125
130
|
type: "string",
|
|
126
131
|
description: "Optional. Target a specific connection when a platform has more than one connected. Omit to use the default.",
|
|
127
132
|
},
|
|
133
|
+
caller: {
|
|
134
|
+
type: "object",
|
|
135
|
+
description: "Optional. Who the human is that this call is for, when this agent serves many people: { assertion: '<JWT from an identity provider the org registered>' } (verified) or { source, id } (asserted; honoured only where the platform allows it). On a platform with per-person tokens, the person's own upstream token is used; if they have not authorized yet the call returns consent_required with a consent_url to give them.",
|
|
136
|
+
properties: { assertion: { type: "string" }, source: { type: "string" }, id: { type: "string" } },
|
|
137
|
+
},
|
|
128
138
|
reason: {
|
|
129
139
|
type: "string",
|
|
130
140
|
description: "Optional but strongly encouraged. One sentence on WHY you are making this call. Shown to the human approver if the call is gated. Max 500 characters; plain text.",
|
|
@@ -156,6 +166,11 @@ export const WRITE_PLATFORM_TOOL = {
|
|
|
156
166
|
type: "string",
|
|
157
167
|
description: "Optional. Target a specific connection when a platform has more than one connected. Omit to use the default.",
|
|
158
168
|
},
|
|
169
|
+
caller: {
|
|
170
|
+
type: "object",
|
|
171
|
+
description: "Optional. Who the human is that this call is for, when this agent serves many people: { assertion: '<JWT from an identity provider the org registered>' } (verified) or { source, id } (asserted; honoured only where the platform allows it). On a platform with per-person tokens, the person's own upstream token is used; if they have not authorized yet the call returns consent_required with a consent_url to give them.",
|
|
172
|
+
properties: { assertion: { type: "string" }, source: { type: "string" }, id: { type: "string" } },
|
|
173
|
+
},
|
|
159
174
|
reason: {
|
|
160
175
|
type: "string",
|
|
161
176
|
description: "Optional but strongly encouraged. One sentence on WHY you are making this call. Shown to the human approver if the call is gated. Max 500 characters; plain text.",
|
|
@@ -181,6 +196,11 @@ export const DELETE_PLATFORM_TOOL = {
|
|
|
181
196
|
type: "string",
|
|
182
197
|
description: "Optional. Target a specific connection when a platform has more than one connected. Omit to use the default.",
|
|
183
198
|
},
|
|
199
|
+
caller: {
|
|
200
|
+
type: "object",
|
|
201
|
+
description: "Optional. Who the human is that this call is for, when this agent serves many people: { assertion: '<JWT from an identity provider the org registered>' } (verified) or { source, id } (asserted; honoured only where the platform allows it). On a platform with per-person tokens, the person's own upstream token is used; if they have not authorized yet the call returns consent_required with a consent_url to give them.",
|
|
202
|
+
properties: { assertion: { type: "string" }, source: { type: "string" }, id: { type: "string" } },
|
|
203
|
+
},
|
|
184
204
|
reason: {
|
|
185
205
|
type: "string",
|
|
186
206
|
description: "Optional but strongly encouraged. One sentence on WHY you are deleting this. Shown to the human approver if the call is gated. Max 500 characters; plain text.",
|
|
@@ -191,7 +211,7 @@ export const DELETE_PLATFORM_TOOL = {
|
|
|
191
211
|
};
|
|
192
212
|
export const AGENT_REGISTER_TOOL = {
|
|
193
213
|
name: "agent_register",
|
|
194
|
-
description: "agent_register: Self-register this agent with an owner. No auth required.\nInput: owner_id (string), agent_name (string), requested_scopes (array of {platformId, scopes}).\nReturns: registration_token, poll_url, client_id, scope,
|
|
214
|
+
description: "agent_register: Self-register this agent with an owner. No auth required.\nInput: owner_id (string), agent_name (string), requested_scopes (array of {platformId, scopes}).\nThe agent's RSA keypair is generated on this machine; only the public key is sent and the private key stays here.\nReturns: registration_token, poll_url, client_id, scope, keypair_source, key_stored_locally.\nNext: once the owner approves in the dashboard, call agent_status with the registration_token to install the key.\nAuth: None.",
|
|
195
215
|
inputSchema: {
|
|
196
216
|
type: "object",
|
|
197
217
|
properties: {
|
|
@@ -226,13 +246,15 @@ export const AGENT_REGISTER_TOOL = {
|
|
|
226
246
|
client_id: { type: "string" },
|
|
227
247
|
scope: { type: "string" },
|
|
228
248
|
expires_in: { type: "number" },
|
|
249
|
+
keypair_source: { type: "string", enum: ["client", "server"] },
|
|
250
|
+
key_stored_locally: { type: "boolean" },
|
|
229
251
|
},
|
|
230
252
|
required: ["registration_token"],
|
|
231
253
|
},
|
|
232
254
|
};
|
|
233
255
|
export const AGENT_STATUS_TOOL = {
|
|
234
256
|
name: "agent_status",
|
|
235
|
-
description: "agent_status: Poll registration status using the token from agent_register.\nInput: token (string, required).\nReturns: status (\"
|
|
257
|
+
description: "agent_status: Poll registration status using the token from agent_register.\nInput: token (string, required).\nOn approval it installs the agent's private key on this machine (~/.agentvalet/agents/<agent_id>.key, plus the default identity if none exists) and activates it for this session when the server had no identity yet. The private key is never returned in the result.\nReturns: status (\"pending\"|\"approved\"|\"rejected\"|\"expired\"), agent_id, key_installed, key_path, activated, mcp_config (if approved).\nAuth: None.",
|
|
236
258
|
inputSchema: {
|
|
237
259
|
type: "object",
|
|
238
260
|
properties: {
|
|
@@ -246,8 +268,11 @@ export const AGENT_STATUS_TOOL = {
|
|
|
246
268
|
outputSchema: {
|
|
247
269
|
type: "object",
|
|
248
270
|
properties: {
|
|
249
|
-
status: { type: "string", enum: ["pending_approval", "approved", "rejected"] },
|
|
271
|
+
status: { type: "string", enum: ["pending", "pending_approval", "approved", "rejected", "expired"] },
|
|
250
272
|
agent_id: { type: "string" },
|
|
273
|
+
key_installed: { type: "boolean" },
|
|
274
|
+
key_path: { type: "string" },
|
|
275
|
+
activated: { type: "boolean" },
|
|
251
276
|
mcp_config: { type: "object" },
|
|
252
277
|
},
|
|
253
278
|
required: ["status"],
|
package/package.json
CHANGED