@agentvalet/mcp-server 1.1.1 → 1.3.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/index.js CHANGED
@@ -18,9 +18,12 @@ process.on("unhandledRejection", (err) => {
18
18
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
19
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
20
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
21
- import { SignJWT } from "jose";
22
21
  import { validateConfig } from "./config.js";
23
22
  import { renderInstructions } from "./instructions.js";
23
+ import { signJWT } from "./auth.js";
24
+ import { ALLOWED_METHODS, ALL_TOOLS } from "./tools/schemas.js";
25
+ import { handleListPlatforms, handleUsePlatform, handleAgentRegister, handleAgentStatus, handleAuthzenEvaluate, handleListMyPendingActions, handleReportSelfDiagnostic, } from "./tools/handlers.js";
26
+ import { errorContent } from "./net.js";
24
27
  // ---------------------------------------------------------------------------
25
28
  // Startup env validation
26
29
  // ---------------------------------------------------------------------------
@@ -35,376 +38,50 @@ catch (err) {
35
38
  const { agentId: AGENT_ID, ownerId: OWNER_ID, proxyUrl: PROXY_URL, privateKeyPem: AGENT_PRIVATE_KEY_RAW, privateKey } = configResult;
36
39
  process.stderr.write(`[mcp-server] config ok | agent=${AGENT_ID} | owner=${OWNER_ID} | proxy=${PROXY_URL} | has_key=${!!privateKey}\n`);
37
40
  // ---------------------------------------------------------------------------
38
- // JWT signing
39
- // ---------------------------------------------------------------------------
40
- async function signJWT() {
41
- if (!privateKey)
42
- throw new Error("Private key not loaded");
43
- return new SignJWT({ agent_id: AGENT_ID, owner_id: OWNER_ID })
44
- .setProtectedHeader({ alg: "RS256" })
45
- .setIssuedAt()
46
- .setExpirationTime("60s")
47
- .sign(privateKey);
48
- }
49
- async function notifyBindSecret() {
50
- try {
51
- await fetchWithTimeout(`${PROXY_URL}/v1/bind-secret`, {
52
- method: "POST",
53
- headers: { "Content-Type": "application/json" },
54
- body: JSON.stringify({ agent_id: AGENT_ID, owner_id: OWNER_ID }),
55
- }, 8_000);
56
- }
57
- catch {
58
- // best-effort — don't block the error response
59
- }
60
- }
61
- function pendingFirstCallResponse() {
62
- return {
63
- content: [{
64
- type: "text",
65
- text: JSON.stringify({
66
- error: "Agent not yet activated — owner confirmation pending. Retry in 60 seconds.",
67
- }),
68
- }],
69
- isError: true,
70
- };
71
- }
72
- // State C — no credentials at all (empty env / Glama-style sandbox). The
73
- // server still boots and answers introspection; an authed tool call lands
74
- // here and gets an actionable message instead of a crash. Distinct from the
75
- // state-B "owner confirmation pending" response above, which means an identity
76
- // IS configured but its key hasn't arrived yet.
77
- function credentialsNotConfiguredResponse() {
78
- return {
79
- content: [{
80
- type: "text",
81
- text: "AgentValet credentials are not configured. Set AGENTVALET_AGENT_ID, " +
82
- "AGENTVALET_OWNER_ID, and the agent private key (and optionally " +
83
- "AGENTVALET_PROXY_URL). Run npx @agentvalet/register to create an agent. " +
84
- "Docs: https://github.com/AgentValet/AgentValet#quickstart",
85
- }],
86
- isError: true,
87
- };
88
- }
89
- // Credential gate for authed tools/call. Returns null when the call may
90
- // proceed (state A — a JWT can be signed), or a ready-to-return MCP tool
91
- // result for the two no-key states:
92
- // • state B (identity present, key pending) → existing invite-bind pending
93
- // response, preserving the MCPB first-run flow.
94
- // • state C (no identity at all) → credentials-not-configured.
95
- // The no-auth tools (agent_register / agent_status) intentionally never call
96
- // this — you must be able to register in order to OBTAIN credentials.
97
- async function requireCredentials() {
98
- if (AGENT_PRIVATE_KEY_RAW !== null)
99
- return null;
100
- if (AGENT_ID && OWNER_ID) {
101
- await notifyBindSecret();
102
- return pendingFirstCallResponse();
103
- }
104
- return credentialsNotConfiguredResponse();
105
- }
106
- // ---------------------------------------------------------------------------
107
- // Tool definitions
41
+ // MCP server setup
108
42
  // ---------------------------------------------------------------------------
109
- const ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
110
- const LIST_PLATFORMS_TOOL = {
111
- name: "list_platforms",
112
- description: "list_platforms: List the platforms and permission scopes this agent has access to.\nInput: None.\nReturns: { platforms: [{ platformId, platformName, scopes, requireApproval }], version: \"<hex>\" }.\nVersion: a deterministic hash that only changes when the platform set or scopes change. Cache the value across calls in the same session — only refresh when you suspect platforms have changed (e.g. user mentions a new connection).\nAuth: Bearer JWT.",
113
- inputSchema: { type: "object", properties: {} },
114
- outputSchema: {
115
- type: "object",
116
- properties: {
117
- platforms: {
118
- type: "array",
119
- items: {
120
- type: "object",
121
- properties: {
122
- platformId: { type: "string" },
123
- platformName: { type: "string" },
124
- scopes: { type: "array", items: { type: "string" } },
125
- requireApproval: { type: "boolean" },
126
- },
127
- required: ["platformId", "platformName", "scopes"],
128
- },
129
- },
130
- version: { type: "string" },
131
- },
132
- required: ["platforms"],
133
- },
134
- };
135
- const USE_PLATFORM_TOOL = {
136
- name: "use_platform",
137
- 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.",
138
- inputSchema: {
139
- type: "object",
140
- properties: {
141
- platform: {
142
- type: "string",
143
- description: "Platform ID (e.g. airtable, github, slack, metabase)",
144
- },
145
- endpoint: {
146
- type: "string",
147
- description: "API path on the target platform (e.g. /v0/meta/bases or /api/dataset)",
148
- },
149
- method: {
150
- type: "string",
151
- enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
152
- description: "HTTP method to use",
153
- },
154
- scope: {
155
- type: "string",
156
- description: "Permission scope required for this action (e.g. records:read)",
157
- },
158
- body: {
159
- type: "object",
160
- description: "JSON request body for POST/PUT/PATCH/DELETE. Optional. Forwarded verbatim to the upstream API.",
161
- },
162
- data: {
163
- type: "object",
164
- description: "Deprecated alias for `body` — prefer `body`. Kept for backwards compatibility.",
165
- },
166
- },
167
- required: ["platform", "endpoint", "method", "scope"],
168
- },
169
- };
170
- const AGENT_REGISTER_TOOL = {
171
- name: "agent_register",
172
- 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, expires_in.\nAuth: None.",
173
- inputSchema: {
174
- type: "object",
175
- properties: {
176
- owner_id: {
177
- type: "string",
178
- description: "The owner ID to register this agent under",
179
- },
180
- agent_name: {
181
- type: "string",
182
- description: "Human-readable name for this agent",
183
- },
184
- requested_scopes: {
185
- type: "array",
186
- description: "Array of platform scope requests",
187
- items: {
188
- type: "object",
189
- properties: {
190
- platformId: { type: "string" },
191
- scopes: { type: "array", items: { type: "string" } },
192
- },
193
- required: ["platformId", "scopes"],
194
- },
195
- },
196
- },
197
- required: ["owner_id", "agent_name", "requested_scopes"],
198
- },
199
- outputSchema: {
200
- type: "object",
201
- properties: {
202
- registration_token: { type: "string" },
203
- poll_url: { type: "string" },
204
- client_id: { type: "string" },
205
- scope: { type: "string" },
206
- expires_in: { type: "number" },
207
- },
208
- required: ["registration_token"],
209
- },
210
- };
211
- const AGENT_STATUS_TOOL = {
212
- name: "agent_status",
213
- description: "agent_status: Poll registration status using the token from agent_register.\nInput: token (string, required).\nReturns: status (\"pending_approval\"|\"approved\"|\"rejected\"), agent_id (if approved), mcp_config (if approved).\nAuth: None.",
214
- inputSchema: {
215
- type: "object",
216
- properties: {
217
- token: {
218
- type: "string",
219
- description: "Registration token returned by agent_register",
220
- },
221
- },
222
- required: ["token"],
223
- },
224
- outputSchema: {
225
- type: "object",
226
- properties: {
227
- status: { type: "string", enum: ["pending_approval", "approved", "rejected"] },
228
- agent_id: { type: "string" },
229
- mcp_config: { type: "object" },
230
- },
231
- required: ["status"],
232
- },
233
- };
234
- const AUTHZEN_EVALUATE_TOOL = {
235
- name: "authzen_evaluate",
236
- description: "authzen_evaluate: Evaluate whether this agent has access to a specific platform scope. Call this BEFORE use_platform when you want to pre-check without making the upstream call.\nInput: platform_id (string), scope (string).\nReturns: decision (boolean), reason (\"approved\"|\"denied\"|\"revoked\"|\"scope_not_granted\").\nAuth: Bearer agent JWT (sent automatically by this MCP server).",
237
- inputSchema: {
238
- type: "object",
239
- properties: {
240
- platform_id: {
241
- type: "string",
242
- description: "The platform identifier (e.g. airtable, github)",
243
- },
244
- scope: {
245
- type: "string",
246
- description: "The permission scope to evaluate (e.g. records:read)",
247
- },
248
- },
249
- required: ["platform_id", "scope"],
250
- },
251
- outputSchema: {
252
- type: "object",
253
- properties: {
254
- decision: { type: "boolean" },
255
- context: { type: "object", properties: { reason: { type: "string" } } },
256
- },
257
- required: ["decision"],
258
- },
259
- };
260
- const REPORT_SELF_DIAGNOSTIC_TOOL = {
261
- name: "report_self_diagnostic",
262
- description: "report_self_diagnostic: Lodge a self-report (error/warning/info) with the AgentValet owner. Use after a use_platform error returns a report_hint, OR proactively when you encounter a problem the user should know about.\nInput: severity (debug|info|warn|error|critical), message (string, required, max 4096 bytes), code (string, optional, max 128 chars), platform (string, optional), endpoint (string, optional), correlation_id (uuid string, optional — copy from the failing call's report_hint to stitch this report to the broker-side audit row), context (object, optional, JSON-serialised must be < 16 KiB).\nReturns: { id, received_at } on success.\nAuth: Bearer agent JWT (sent automatically).",
263
- inputSchema: {
264
- type: "object",
265
- properties: {
266
- severity: {
267
- type: "string",
268
- enum: ["debug", "info", "warn", "error", "critical"],
269
- description: "Severity level. error/critical trigger an owner notification.",
270
- },
271
- message: {
272
- type: "string",
273
- description: "One-sentence agent narrative describing what happened.",
274
- },
275
- code: {
276
- type: "string",
277
- description: "Optional short machine code (e.g. 'permission_denied').",
278
- },
279
- platform: {
280
- type: "string",
281
- description: "Optional platform id this report relates to.",
282
- },
283
- endpoint: {
284
- type: "string",
285
- description: "Optional endpoint that failed.",
286
- },
287
- correlation_id: {
288
- type: "string",
289
- description: "Optional UUID — copy from a use_platform error's report_hint to stitch this report to the audit row.",
290
- },
291
- context: {
292
- type: "object",
293
- description: "Optional structured context (request params, error details). Avoid secrets.",
294
- },
295
- },
296
- required: ["severity", "message"],
297
- },
298
- outputSchema: {
299
- type: "object",
300
- properties: {
301
- id: { type: "string" },
302
- received_at: { type: "string" },
303
- },
304
- required: ["id"],
305
- },
306
- };
307
- const LIST_MY_PENDING_ACTIONS_TOOL = {
308
- name: "list_my_pending_actions",
309
- description: "list_my_pending_actions: Returns this agent's currently-pending approval requests AND any that completed in the last 24 hours. Use this at session start when the user mentions an earlier action, or when use_platform's long-poll timed out and the user comes back asking what happened.\nInput: None.\nReturns: { pending: [{approval_id, platform_id, scope, created_at, expires_at}], recently_completed: [{approval_id, platform_id, scope, status, executed_at, result_summary, execution_error}] }.\nAuth: Bearer agent JWT (sent automatically).",
310
- inputSchema: { type: "object", properties: {} },
311
- outputSchema: {
312
- type: "object",
313
- properties: {
314
- pending: {
315
- type: "array",
316
- items: {
317
- type: "object",
318
- properties: {
319
- approval_id: { type: "string" },
320
- platform_id: { type: "string" },
321
- scope: { type: "string" },
322
- created_at: { type: "string" },
323
- expires_at: { type: "string" },
324
- },
325
- required: ["approval_id", "platform_id", "scope"],
326
- },
327
- },
328
- recently_completed: {
329
- type: "array",
330
- items: {
331
- type: "object",
332
- properties: {
333
- approval_id: { type: "string" },
334
- platform_id: { type: "string" },
335
- scope: { type: "string" },
336
- status: { type: "string" },
337
- executed_at: { type: "string" },
338
- result_summary: { type: "string" },
339
- execution_error: { type: "string" },
340
- },
341
- required: ["approval_id", "status"],
342
- },
343
- },
344
- },
345
- required: ["pending", "recently_completed"],
346
- },
43
+ const server = new Server({ name: "agentvalet", version: "1.0.0" }, { capabilities: { tools: {} }, instructions: renderInstructions(undefined) });
44
+ // The config + server bundle threaded into auth + handlers (see context.ts) —
45
+ // keeps those modules free of globals.
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 ?? "",
347
55
  };
348
- // TODO: intent_resolve toolplanned for future release
349
- // ---------------------------------------------------------------------------
350
- // Boot-time platform fetch primes the instruction block with the live
351
- // platform list so the host LLM sees an exact catalogue. Best-effort: if the
352
- // fetch fails (agent not activated, network down), we fall back to the static
353
- // instructions and rely on the agent calling list_platforms at runtime.
354
- // ---------------------------------------------------------------------------
355
- async function fetchPlatformNamesForInstructions() {
56
+ // Boot-time platform fetchprimes the proxy connection and surfaces auth
57
+ // failures in the stderr boot diagnostics. Best-effort and fire-and-forget so
58
+ // it can NEVER delay the `initialize` response (a top-level await here used to
59
+ // block Claude Desktop for seconds on cold Azure CA and cause host timeouts).
60
+ // The host LLM learns the catalogue from list_platforms at runtime.
61
+ void (async () => {
356
62
  if (AGENT_PRIVATE_KEY_RAW === null)
357
- return undefined;
63
+ return;
358
64
  try {
359
- const token = await signJWT();
65
+ const token = await signJWT(ctx);
360
66
  const ac = new AbortController();
361
67
  const timer = setTimeout(() => ac.abort(), 4_000);
362
- const res = await fetch(`${PROXY_URL}/v1/agent/permissions`, {
68
+ await fetch(`${PROXY_URL}/v1/agent/permissions`, {
363
69
  method: "GET",
364
70
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
365
71
  signal: ac.signal,
366
72
  }).finally(() => clearTimeout(timer));
367
- if (!res.ok)
368
- return undefined;
369
- const body = (await res.json());
370
- const names = (body.platforms ?? [])
371
- .map((p) => p.platformName ?? p.platformId)
372
- .filter((n) => typeof n === "string" && n.length > 0);
373
- return names.length > 0 ? names : undefined;
374
73
  }
375
74
  catch {
376
- return undefined;
75
+ // best-effort warmup only
377
76
  }
378
- }
379
- // NOTE: We intentionally do NOT prefetch platform names at boot. Doing so
380
- // added a top-level await on the proxy and blocked the `initialize` response
381
- // to Claude Desktop for several seconds (worse on cold Azure CA), causing
382
- // host-side timeouts. The LLM learns the catalogue from list_platforms at
383
- // runtime — boot-time enrichment was nice-to-have, not load-bearing.
384
- // ---------------------------------------------------------------------------
385
- // MCP server setup
386
- // ---------------------------------------------------------------------------
387
- const server = new Server({ name: "agentvalet", version: "1.0.0" }, { capabilities: { tools: {} }, instructions: renderInstructions(undefined) });
388
- // Fire the platform-names fetch in the background after the server is up so
389
- // it can't delay initialize. The result isn't surfaced to the host (MCP has
390
- // no instructions-update message), but the network warmup primes the proxy
391
- // and surfaces auth failures in the stderr boot diagnostics path.
392
- void fetchPlatformNamesForInstructions().catch(() => { });
77
+ })().catch(() => { });
393
78
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
394
- tools: [
395
- LIST_PLATFORMS_TOOL,
396
- USE_PLATFORM_TOOL,
397
- AGENT_REGISTER_TOOL,
398
- AGENT_STATUS_TOOL,
399
- AUTHZEN_EVALUATE_TOOL,
400
- REPORT_SELF_DIAGNOSTIC_TOOL,
401
- LIST_MY_PENDING_ACTIONS_TOOL,
402
- ],
79
+ tools: ALL_TOOLS,
403
80
  }));
404
81
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
405
82
  const { name, arguments: args } = request.params;
406
83
  if (name === "list_platforms") {
407
- return await handleListPlatforms();
84
+ return await handleListPlatforms(ctx);
408
85
  }
409
86
  if (name === "use_platform") {
410
87
  if (!args ||
@@ -430,40 +107,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
430
107
  // schema only declared `data` which made every POST get silently
431
108
  // dropped to an empty body. See the use_platform tool description.
432
109
  const bodyArg = (args.body ?? args.data);
433
- return await handleUsePlatform({
110
+ return await handleUsePlatform(ctx, {
434
111
  platform: args.platform,
435
112
  endpoint: args.endpoint,
436
113
  method: args.method,
437
114
  scope: args.scope,
438
115
  data: bodyArg,
116
+ ...(typeof args.connection_id === "string" ? { connection_id: args.connection_id } : {}),
439
117
  }, progressToken);
440
118
  }
441
119
  if (name === "agent_register") {
442
120
  if (!args || typeof args.owner_id !== "string" || typeof args.agent_name !== "string" || !Array.isArray(args.requested_scopes)) {
443
121
  return errorContent("Invalid or missing arguments: owner_id, agent_name, requested_scopes are required");
444
122
  }
445
- return await handleAgentRegister(args);
123
+ return await handleAgentRegister(ctx, args);
446
124
  }
447
125
  if (name === "agent_status") {
448
126
  if (!args || typeof args.token !== "string") {
449
127
  return errorContent("Invalid or missing argument: token is required");
450
128
  }
451
- return await handleAgentStatus(args.token);
129
+ return await handleAgentStatus(ctx, args.token);
452
130
  }
453
131
  if (name === "authzen_evaluate") {
454
132
  if (!args || typeof args.platform_id !== "string" || typeof args.scope !== "string") {
455
133
  return errorContent("Invalid or missing arguments: platform_id and scope are required");
456
134
  }
457
- return await handleAuthzenEvaluate(args.platform_id, args.scope);
135
+ return await handleAuthzenEvaluate(ctx, args.platform_id, args.scope);
458
136
  }
459
137
  if (name === "list_my_pending_actions") {
460
- return await handleListMyPendingActions();
138
+ return await handleListMyPendingActions(ctx);
461
139
  }
462
140
  if (name === "report_self_diagnostic") {
463
141
  if (!args || typeof args.severity !== "string" || typeof args.message !== "string") {
464
142
  return errorContent("Invalid or missing arguments: severity and message are required");
465
143
  }
466
- return await handleReportSelfDiagnostic(args);
144
+ return await handleReportSelfDiagnostic(ctx, args);
467
145
  }
468
146
  return {
469
147
  content: [{ type: "text", text: `Unknown tool: ${name}` }],
@@ -471,365 +149,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
471
149
  };
472
150
  });
473
151
  // ---------------------------------------------------------------------------
474
- // Helpers
475
- // ---------------------------------------------------------------------------
476
- function fetchWithTimeout(url, init, timeoutMs = 15_000) {
477
- const ac = new AbortController();
478
- const timer = setTimeout(() => ac.abort(), timeoutMs);
479
- return fetch(url, { ...init, signal: ac.signal }).finally(() => clearTimeout(timer));
480
- }
481
- async function fetchWithAuth(url, init) {
482
- const makeRequest = async () => {
483
- const token = await signJWT();
484
- return fetchWithTimeout(url, {
485
- ...init,
486
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init.headers },
487
- });
488
- };
489
- const response = await makeRequest();
490
- // Retry once on 401 — the JWT may have been issued just before clock skew threshold
491
- if (response.status === 401) {
492
- return makeRequest();
493
- }
494
- return response;
495
- }
496
- function errorContent(message) {
497
- return { content: [{ type: "text", text: message }], isError: true };
498
- }
499
- // Return helper that gives an MCP-aware host the structured content directly
500
- // (no parse step), while still emitting the text-content envelope every MCP
501
- // client understands. Avoids the "list of length 1 with empty fields → let me
502
- // parse the wrapper" round-trip Claude does on tool results that are raw
503
- // JSON strings — every read-heavy use_platform call pays that cost otherwise.
504
- //
505
- // If `body` isn't valid JSON, we just return the text envelope unchanged —
506
- // callers that emit prose (summaries, error strings) get the old behaviour.
507
- function jsonContent(body) {
508
- const parsed = tryParseJson(body);
509
- if (parsed === undefined) {
510
- return { content: [{ type: "text", text: body }] };
511
- }
512
- return {
513
- content: [{ type: "text", text: body }],
514
- structuredContent: parsed,
515
- };
516
- }
517
- function tryParseJson(text) {
518
- const trimmed = text.trim();
519
- // Only attempt parse if it looks structured. Avoids parsing a stray "true"
520
- // / number / etc. into structuredContent and confusing the host.
521
- if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
522
- return undefined;
523
- try {
524
- const v = JSON.parse(trimmed);
525
- if (v && typeof v === "object")
526
- return v;
527
- return undefined;
528
- }
529
- catch {
530
- return undefined;
531
- }
532
- }
533
- // ---------------------------------------------------------------------------
534
- // Tool handlers
535
- // ---------------------------------------------------------------------------
536
- async function handleListPlatforms() {
537
- const gate = await requireCredentials();
538
- if (gate)
539
- return gate;
540
- let response;
541
- try {
542
- response = await fetchWithAuth(`${PROXY_URL}/v1/agent/permissions`, { method: "GET", headers: {} });
543
- }
544
- catch (err) {
545
- return errorContent(diagnoseNetworkError(err, PROXY_URL));
546
- }
547
- const body = await response.text();
548
- if (!response.ok)
549
- return errorContent(`Proxy error ${response.status}: ${body}`);
550
- // Proxy wraps the payload as { data: { platforms, version }, _meta: {...} }
551
- // but outputSchema declares the flat { platforms, version } shape, so the
552
- // wrapped envelope fails strict structuredContent validation and the host
553
- // surfaces "tool execution failed (no upstream body)". Unwrap .data.
554
- const parsed = tryParseJson(body);
555
- const inner = parsed && typeof parsed === "object" && parsed !== null && "data" in parsed
556
- ? parsed.data
557
- : parsed;
558
- if (inner && typeof inner === "object") {
559
- return {
560
- content: [{ type: "text", text: body }],
561
- structuredContent: inner,
562
- };
563
- }
564
- return jsonContent(body);
565
- }
566
- // Translates a fetch() failure into something an end-user can actually act on.
567
- // The default "Network error: fetch failed" message tells a non-developer
568
- // nothing. Look at the underlying cause keyword and map to a concrete fix.
569
- function diagnoseNetworkError(err, proxyUrl) {
570
- const raw = err instanceof Error ? err.message : String(err);
571
- const lower = raw.toLowerCase();
572
- // Node's undici surfaces DNS failures as "getaddrinfo ENOTFOUND <host>".
573
- if (lower.includes("enotfound") || lower.includes("getaddrinfo")) {
574
- return `Network error: cannot resolve ${proxyUrl}. Check your DNS / corporate proxy / VPN, or confirm the PROXY_URL setting is correct. Raw: ${raw}`;
575
- }
576
- // Connection refused / unreachable / TLS handshake failure.
577
- if (lower.includes("econnrefused") || lower.includes("econnreset")) {
578
- return `Network error: connection to ${proxyUrl} was refused or reset. The proxy may be down — check https://status.agentvalet.ai — or a firewall is blocking the request. Raw: ${raw}`;
579
- }
580
- if (lower.includes("etimedout") || lower.includes("timeout") || lower.includes("aborterror")) {
581
- return `Network error: request to ${proxyUrl} timed out. Likely causes: VPN routing, corporate proxy buffering, or slow network. Try again or confirm api.agentvalet.ai is reachable from this machine. Raw: ${raw}`;
582
- }
583
- if (lower.includes("self signed") || lower.includes("cert") || lower.includes("ssl") || lower.includes("tls")) {
584
- return `Network error: TLS / certificate problem talking to ${proxyUrl}. A corporate MITM proxy may be intercepting traffic. Raw: ${raw}`;
585
- }
586
- return `Network error reaching ${proxyUrl}: ${raw}. Check VPN, corporate proxy, and firewall rules for api.agentvalet.ai. If the proxy itself is down, see https://status.agentvalet.ai.`;
587
- }
588
- // Long-poll budget — under Claude Desktop's hardcoded 60s tool timeout
589
- // (with 10s of safety). After this we return a graceful "queued" message and
590
- // the action lands in the user's pending-actions list (Layer 4).
591
- const APPROVAL_POLL_BUDGET_MS = 50_000;
592
- const APPROVAL_POLL_INTERVAL_MS = 2_000;
593
- const APPROVAL_PROGRESS_INTERVAL_MS = 5_000;
594
- async function handleUsePlatform(params, progressToken) {
595
- const gate = await requireCredentials();
596
- if (gate)
597
- return gate;
598
- const requestBody = {
599
- platform: params.platform,
600
- endpoint: params.endpoint,
601
- method: params.method,
602
- scope: params.scope,
603
- ...(params.data !== undefined && { data: params.data }),
604
- };
605
- let response;
606
- try {
607
- response = await fetchWithAuth(`${PROXY_URL}/v1/actions`, {
608
- method: "POST",
609
- body: JSON.stringify(requestBody),
610
- });
611
- }
612
- catch (err) {
613
- return errorContent(`Network error: ${err instanceof Error ? err.message : err}`);
614
- }
615
- const body = await response.text();
616
- // 202 + approval_id → enter long-poll. The proxy returns this when the
617
- // owner needs to approve the call. We poll /v1/approvals/:id every ~2s
618
- // for up to 50s; if the owner approves in time the proxy re-runs the
619
- // upstream call and we return its result to the agent transparently.
620
- if (response.status === 202) {
621
- const parsed = safeJsonParse(body);
622
- const approvalId = parsed?.approval_id;
623
- if (approvalId) {
624
- return await waitForApproval(approvalId, params, progressToken);
625
- }
626
- }
627
- if (!response.ok)
628
- return errorContent(`Proxy error ${response.status}: ${body}`);
629
- return jsonContent(body);
630
- }
631
- async function waitForApproval(approvalId, originalCall, progressToken) {
632
- const startedAt = Date.now();
633
- let lastProgressAt = 0;
634
- // Initial progress so the user sees activity immediately.
635
- await sendProgress(progressToken, 0, APPROVAL_POLL_BUDGET_MS, `Owner approval required for ${originalCall.platform}:${originalCall.scope} — waiting…`);
636
- while (Date.now() - startedAt < APPROVAL_POLL_BUDGET_MS) {
637
- await sleep(APPROVAL_POLL_INTERVAL_MS);
638
- const elapsed = Date.now() - startedAt;
639
- let res;
640
- try {
641
- res = await fetchWithAuth(`${PROXY_URL}/v1/approvals/${approvalId}`, { method: "GET" });
642
- }
643
- catch {
644
- // Transient network error — try again next tick.
645
- continue;
646
- }
647
- if (!res.ok) {
648
- // 404/410 etc. — treat as terminal. Surface back to the agent.
649
- const text = await res.text();
650
- return errorContent(`Approval status error ${res.status}: ${text}`);
651
- }
652
- const status = (await res.json());
653
- if (status.status === "approved" && status.executed_at && status.result) {
654
- // Re-execution complete. Return the upstream response transparently —
655
- // the agent sees this as if the original use_platform call just succeeded.
656
- const r = status.result;
657
- // Mirror the non-2xx-from-upstream behaviour of the regular path.
658
- if (r.status < 200 || r.status >= 300) {
659
- return errorContent(`Upstream returned ${r.status}: ${JSON.stringify(r.data)}`);
660
- }
661
- // Two-channel return: the text envelope carries the human-readable
662
- // "[Owner approved after Ns]" prefix (visible to non-structured-aware
663
- // clients), and structuredContent carries the parsed upstream JSON
664
- // directly so MCP-aware hosts (Claude Desktop ≥ MCP spec 2025-06-18)
665
- // never have to strip-the-prefix-and-reparse. Without the second
666
- // channel the prefix forces Claude into the same wrapper-parse
667
- // round-trip the bare /v1/actions path used to.
668
- const data = r.data ?? null;
669
- const summary = `[Owner approved after ${Math.round(elapsed / 1000)}s]\n` +
670
- JSON.stringify(data);
671
- const result = {
672
- content: [{ type: "text", text: summary }],
673
- };
674
- if (data && typeof data === "object" && !Array.isArray(data)) {
675
- result.structuredContent = data;
676
- }
677
- return result;
678
- }
679
- if (status.status === "denied") {
680
- return errorContent(`Owner denied this action${status.execution_error ? `: ${status.execution_error}` : "."}`);
681
- }
682
- if (status.status === "expired") {
683
- return errorContent("Approval request expired before the owner responded.");
684
- }
685
- if (status.execution_error) {
686
- // Approved but re-execution failed (network / upstream / agent revoked).
687
- return errorContent(`Approved but re-execution failed: ${status.execution_error}`);
688
- }
689
- // Still pending — emit a progress update at most once per ~5s so the
690
- // user can see the wait advancing in the Claude Desktop tool UI.
691
- if (Date.now() - lastProgressAt >= APPROVAL_PROGRESS_INTERVAL_MS) {
692
- await sendProgress(progressToken, elapsed, APPROVAL_POLL_BUDGET_MS, `Waiting for owner approval — ${Math.round(elapsed / 1000)}s elapsed`);
693
- lastProgressAt = Date.now();
694
- }
695
- }
696
- // Timed out — fall through to async-recap path. The action is still
697
- // queued and will run when the owner approves; the user is notified via
698
- // push/email at that point. Layer 4's list_my_pending_actions surfaces it.
699
- return jsonContent(JSON.stringify({
700
- status: "pending_approval",
701
- approval_id: approvalId,
702
- message: `Owner hasn't approved within ${APPROVAL_POLL_BUDGET_MS / 1000}s. ` +
703
- `The action is queued — your owner will be notified, and you'll see it ` +
704
- `complete next time we chat (or you can ask me to check pending actions).`,
705
- }));
706
- }
707
- function safeJsonParse(text) {
708
- try {
709
- return JSON.parse(text);
710
- }
711
- catch {
712
- return null;
713
- }
714
- }
715
- function sleep(ms) {
716
- return new Promise((resolve) => setTimeout(resolve, ms));
717
- }
718
- async function sendProgress(token, progress, total, message) {
719
- if (token === undefined)
720
- return;
721
- try {
722
- await server.notification({
723
- method: "notifications/progress",
724
- params: { progressToken: token, progress, total, message },
725
- });
726
- }
727
- catch {
728
- // Best-effort — never let a failed notification break the call.
729
- }
730
- }
731
- async function handleAgentRegister(args) {
732
- let response;
733
- try {
734
- response = await fetchWithTimeout(`${PROXY_URL}/v1/register`, {
735
- method: "POST",
736
- headers: { "Content-Type": "application/json" },
737
- body: JSON.stringify(args),
738
- });
739
- }
740
- catch (err) {
741
- return errorContent(`Network error: ${err instanceof Error ? err.message : err}`);
742
- }
743
- const body = await response.text();
744
- if (!response.ok)
745
- return errorContent(`Proxy error ${response.status}: ${body}`);
746
- return jsonContent(body);
747
- }
748
- async function handleAgentStatus(token) {
749
- let response;
750
- try {
751
- response = await fetchWithTimeout(`${PROXY_URL}/v1/register/status/${encodeURIComponent(token)}`, {
752
- method: "GET",
753
- headers: { "Content-Type": "application/json" },
754
- });
755
- }
756
- catch (err) {
757
- return errorContent(`Network error: ${err instanceof Error ? err.message : err}`);
758
- }
759
- const body = await response.text();
760
- if (!response.ok)
761
- return errorContent(`Proxy error ${response.status}: ${body}`);
762
- return jsonContent(body);
763
- }
764
- async function handleAuthzenEvaluate(platformId, scope) {
765
- const gate = await requireCredentials();
766
- if (gate)
767
- return gate;
768
- const authzenBody = {
769
- subject: { type: "agent", id: AGENT_ID },
770
- action: { name: "tool_call" },
771
- resource: { type: "platform_scope", id: `${platformId}:${scope}` },
772
- };
773
- let response;
774
- try {
775
- response = await fetchWithAuth(`${PROXY_URL}/v1/authzen/access`, {
776
- method: "POST",
777
- body: JSON.stringify(authzenBody),
778
- });
779
- }
780
- catch (err) {
781
- return errorContent(`Network error: ${err instanceof Error ? err.message : err}`);
782
- }
783
- const body = await response.text();
784
- if (!response.ok)
785
- return errorContent(`Proxy error ${response.status}: ${body}`);
786
- return jsonContent(body);
787
- }
788
- async function handleListMyPendingActions() {
789
- const gate = await requireCredentials();
790
- if (gate)
791
- return gate;
792
- let response;
793
- try {
794
- response = await fetchWithAuth(`${PROXY_URL}/v1/agents/me/pending-actions`, { method: "GET" });
795
- }
796
- catch (err) {
797
- return errorContent(`Network error: ${err instanceof Error ? err.message : err}`);
798
- }
799
- const text = await response.text();
800
- if (!response.ok)
801
- return errorContent(`Proxy error ${response.status}: ${text}`);
802
- return jsonContent(text);
803
- }
804
- async function handleReportSelfDiagnostic(args) {
805
- const gate = await requireCredentials();
806
- if (gate)
807
- return gate;
808
- // Whitelist body fields — never forward owner_id/agent_id (proxy derives those from JWT).
809
- const body = {
810
- severity: args.severity,
811
- message: args.message,
812
- };
813
- for (const k of ["code", "platform", "endpoint", "correlation_id", "context"]) {
814
- if (args[k] !== undefined)
815
- body[k] = args[k];
816
- }
817
- let response;
818
- try {
819
- response = await fetchWithAuth(`${PROXY_URL}/v1/agents/self/diagnostics`, {
820
- method: "POST",
821
- body: JSON.stringify(body),
822
- });
823
- }
824
- catch (err) {
825
- return errorContent(`Network error: ${err instanceof Error ? err.message : err}`);
826
- }
827
- const text = await response.text();
828
- if (!response.ok)
829
- return errorContent(`Proxy error ${response.status}: ${text}`);
830
- return jsonContent(text);
831
- }
832
- // ---------------------------------------------------------------------------
833
152
  // Connect transport
834
153
  // ---------------------------------------------------------------------------
835
154
  if (process.env.MCP_TRANSPORT === "http") {