@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.
@@ -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 = { AGENT_ID, OWNER_ID, PROXY_URL, AGENT_PRIVATE_KEY_RAW, privateKey, server };
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,
@@ -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
- return errorContent(`Proxy error ${response.status}: ${body}`);
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)
@@ -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
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvalet/mcp-server",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "AgentValet MCP server — lets AI agents call approved platforms via the AgentValet proxy",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",