@agentvalet/mcp-server 1.2.0 → 1.4.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 +159 -0
- package/dist/index.js +17 -2
- package/dist/tools/handlers.js +100 -2
- package/dist/tools/schemas.js +22 -0
- package/package.json +1 -1
|
@@ -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,7 +22,7 @@ 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
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
// Startup env validation
|
|
@@ -43,7 +43,16 @@ process.stderr.write(`[mcp-server] config ok | agent=${AGENT_ID} | owner=${OWNER
|
|
|
43
43
|
const server = new Server({ name: "agentvalet", version: "1.0.0" }, { capabilities: { tools: {} }, instructions: renderInstructions(undefined) });
|
|
44
44
|
// The config + server bundle threaded into auth + handlers (see context.ts) —
|
|
45
45
|
// keeps those modules free of globals.
|
|
46
|
-
const ctx = {
|
|
46
|
+
const ctx = {
|
|
47
|
+
AGENT_ID,
|
|
48
|
+
OWNER_ID,
|
|
49
|
+
PROXY_URL,
|
|
50
|
+
AGENT_PRIVATE_KEY_RAW,
|
|
51
|
+
privateKey,
|
|
52
|
+
server,
|
|
53
|
+
OBSERVE_PLATFORM: process.env.OBSERVE_PLATFORM ?? "",
|
|
54
|
+
OBSERVE_CREDENTIAL: process.env.OBSERVE_CREDENTIAL ?? "",
|
|
55
|
+
};
|
|
47
56
|
// Boot-time platform fetch — primes the proxy connection and surfaces auth
|
|
48
57
|
// failures in the stderr boot diagnostics. Best-effort and fire-and-forget so
|
|
49
58
|
// it can NEVER delay the `initialize` response (a top-level await here used to
|
|
@@ -134,6 +143,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
134
143
|
}
|
|
135
144
|
return await handleReportSelfDiagnostic(ctx, args);
|
|
136
145
|
}
|
|
146
|
+
if (name === "request_platform_access") {
|
|
147
|
+
if (!args || typeof args.platform !== "string") {
|
|
148
|
+
return errorContent("Invalid or missing argument: platform is required");
|
|
149
|
+
}
|
|
150
|
+
return await handleRequestPlatformAccess(ctx, args);
|
|
151
|
+
}
|
|
137
152
|
return {
|
|
138
153
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
139
154
|
isError: true,
|
package/dist/tools/handlers.js
CHANGED
|
@@ -46,6 +46,44 @@ export async function handleUsePlatform(ctx, params, progressToken) {
|
|
|
46
46
|
const gate = await requireCredentials(ctx);
|
|
47
47
|
if (gate)
|
|
48
48
|
return gate;
|
|
49
|
+
// Observe Mode: when a BYO credential is configured locally, route to the
|
|
50
|
+
// audit-only relay and attach the credential as a header (NEVER in the body —
|
|
51
|
+
// it must not enter model-visible tool args). Governed behaviour is unchanged
|
|
52
|
+
// when no observe credential is set.
|
|
53
|
+
//
|
|
54
|
+
// Platform-match guard: if OBSERVE_PLATFORM is set, only route to the observe
|
|
55
|
+
// relay when the requested platform matches — preventing BYO credential leakage
|
|
56
|
+
// to unrelated platforms. If OBSERVE_PLATFORM is empty, route observe for any
|
|
57
|
+
// platform (backwards-compat when only OBSERVE_CREDENTIAL is set).
|
|
58
|
+
const observePlatformMatch = ctx.OBSERVE_CREDENTIAL &&
|
|
59
|
+
(ctx.OBSERVE_PLATFORM === "" || ctx.OBSERVE_PLATFORM === params.platform);
|
|
60
|
+
if (observePlatformMatch) {
|
|
61
|
+
const observeBody = {
|
|
62
|
+
platform: params.platform,
|
|
63
|
+
endpoint: params.endpoint,
|
|
64
|
+
method: params.method,
|
|
65
|
+
action: params.scope,
|
|
66
|
+
...(params.data !== undefined && { body: params.data }),
|
|
67
|
+
};
|
|
68
|
+
let response;
|
|
69
|
+
try {
|
|
70
|
+
// fetchWithAuth signs and attaches the AV agent JWT (Authorization: Bearer …).
|
|
71
|
+
// Content-Type: application/json is added by fetchWithAuth before spreading
|
|
72
|
+
// init.headers, so X-AV-Observe-Credential survives.
|
|
73
|
+
response = await fetchWithAuth(ctx, `${ctx.PROXY_URL}/v1/observe/actions`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "X-AV-Observe-Credential": ctx.OBSERVE_CREDENTIAL },
|
|
76
|
+
body: JSON.stringify(observeBody),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return errorContent(`Network error: ${err instanceof Error ? err.message : err}`);
|
|
81
|
+
}
|
|
82
|
+
const text = await response.text();
|
|
83
|
+
if (!response.ok)
|
|
84
|
+
return errorContent(`Proxy error ${response.status}: ${text}`);
|
|
85
|
+
return jsonContent(text);
|
|
86
|
+
}
|
|
49
87
|
const requestBody = {
|
|
50
88
|
platform: params.platform,
|
|
51
89
|
endpoint: params.endpoint,
|
|
@@ -76,8 +114,15 @@ export async function handleUsePlatform(ctx, params, progressToken) {
|
|
|
76
114
|
return await waitForApproval(ctx, approvalId, params, progressToken);
|
|
77
115
|
}
|
|
78
116
|
}
|
|
79
|
-
if (!response.ok)
|
|
80
|
-
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
// 403 is the access-denied / scope-not-granted code returned by the
|
|
119
|
+
// proxy's authorize pipeline (see apps/proxy/src/pipeline/authorize.ts).
|
|
120
|
+
// Point the agent at request_platform_access instead of dead-ending it.
|
|
121
|
+
const hint = response.status === 403
|
|
122
|
+
? " You can call request_platform_access({ platform, scope, reason }) to ask an org admin to grant this."
|
|
123
|
+
: "";
|
|
124
|
+
return errorContent(`Proxy error ${response.status}: ${body}${hint}`);
|
|
125
|
+
}
|
|
81
126
|
return jsonContent(body);
|
|
82
127
|
}
|
|
83
128
|
async function waitForApproval(ctx, approvalId, originalCall, progressToken) {
|
|
@@ -242,6 +287,59 @@ export async function handleListMyPendingActions(ctx) {
|
|
|
242
287
|
return errorContent(`Proxy error ${response.status}: ${text}`);
|
|
243
288
|
return jsonContent(text);
|
|
244
289
|
}
|
|
290
|
+
export async function handleRequestPlatformAccess(ctx, args) {
|
|
291
|
+
const gate = await requireCredentials(ctx);
|
|
292
|
+
if (gate)
|
|
293
|
+
return gate;
|
|
294
|
+
const platform = typeof args.platform === "string" ? args.platform : "";
|
|
295
|
+
if (!platform)
|
|
296
|
+
return errorContent("platform is required");
|
|
297
|
+
const scope = typeof args.scope === "string" ? args.scope : undefined;
|
|
298
|
+
const reason = typeof args.reason === "string" ? args.reason : undefined;
|
|
299
|
+
let submit;
|
|
300
|
+
try {
|
|
301
|
+
submit = await fetchWithAuth(ctx, `${ctx.PROXY_URL}/v1/access-request`, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
body: JSON.stringify({ platform, scope, reason }),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
return errorContent(diagnoseNetworkError(err, ctx.PROXY_URL));
|
|
308
|
+
}
|
|
309
|
+
if (!submit.ok && submit.status !== 202 && submit.status !== 200) {
|
|
310
|
+
const t = await submit.text();
|
|
311
|
+
return errorContent(`Access request failed (${submit.status}): ${t}`);
|
|
312
|
+
}
|
|
313
|
+
const submitted = (await submit.json());
|
|
314
|
+
const token = submitted.request_token;
|
|
315
|
+
if (!token)
|
|
316
|
+
return errorContent("Access request did not return a token.");
|
|
317
|
+
// Poll the request status for ~50s (2s interval), matching use_platform UX.
|
|
318
|
+
const deadline = Date.now() + 50_000;
|
|
319
|
+
while (Date.now() < deadline) {
|
|
320
|
+
await sleep(2000);
|
|
321
|
+
let statusRes;
|
|
322
|
+
try {
|
|
323
|
+
statusRes = await fetchWithAuth(ctx, `${ctx.PROXY_URL}/v1/access-request/status/${encodeURIComponent(token)}`, { method: "GET" });
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!statusRes.ok)
|
|
329
|
+
continue;
|
|
330
|
+
const s = (await statusRes.json());
|
|
331
|
+
if (s.status === "approved") {
|
|
332
|
+
return jsonContent(JSON.stringify({ status: "approved", message: "Access granted — retry your original use_platform call." }));
|
|
333
|
+
}
|
|
334
|
+
if (s.status === "denied") {
|
|
335
|
+
return jsonContent(JSON.stringify({ status: "denied", message: "An admin denied this access request." }));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return jsonContent(JSON.stringify({
|
|
339
|
+
status: "pending",
|
|
340
|
+
message: "Request submitted and awaiting admin approval. Retry your call later.",
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
245
343
|
export async function handleReportSelfDiagnostic(ctx, args) {
|
|
246
344
|
const gate = await requireCredentials(ctx);
|
|
247
345
|
if (gate)
|
package/dist/tools/schemas.js
CHANGED
|
@@ -261,6 +261,27 @@ export const LIST_MY_PENDING_ACTIONS_TOOL = {
|
|
|
261
261
|
required: ["pending", "recently_completed"],
|
|
262
262
|
},
|
|
263
263
|
};
|
|
264
|
+
export const REQUEST_PLATFORM_ACCESS_TOOL = {
|
|
265
|
+
name: "request_platform_access",
|
|
266
|
+
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).",
|
|
267
|
+
inputSchema: {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: {
|
|
270
|
+
platform: { type: "string", description: "Platform ID (e.g. github, slack)" },
|
|
271
|
+
scope: { type: "string", description: "Optional specific scope needed (e.g. repo:read)" },
|
|
272
|
+
reason: { type: "string", description: "Optional short reason for the request" },
|
|
273
|
+
},
|
|
274
|
+
required: ["platform"],
|
|
275
|
+
},
|
|
276
|
+
outputSchema: {
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
status: { type: "string", enum: ["approved", "pending", "denied"] },
|
|
280
|
+
message: { type: "string" },
|
|
281
|
+
},
|
|
282
|
+
required: ["status"],
|
|
283
|
+
},
|
|
284
|
+
};
|
|
264
285
|
export const ALL_TOOLS = [
|
|
265
286
|
LIST_PLATFORMS_TOOL,
|
|
266
287
|
USE_PLATFORM_TOOL,
|
|
@@ -269,4 +290,5 @@ export const ALL_TOOLS = [
|
|
|
269
290
|
AUTHZEN_EVALUATE_TOOL,
|
|
270
291
|
REPORT_SELF_DIAGNOSTIC_TOOL,
|
|
271
292
|
LIST_MY_PENDING_ACTIONS_TOOL,
|
|
293
|
+
REQUEST_PLATFORM_ACCESS_TOOL,
|
|
272
294
|
];
|