@true-and-useful/janee 0.12.0 → 0.13.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.
Files changed (48) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/commands/capability.d.ts +12 -10
  3. package/dist/cli/commands/capability.d.ts.map +1 -1
  4. package/dist/cli/commands/capability.js +77 -40
  5. package/dist/cli/commands/capability.js.map +1 -1
  6. package/dist/cli/commands/config.d.ts +7 -0
  7. package/dist/cli/commands/config.d.ts.map +1 -0
  8. package/dist/cli/commands/config.js +135 -0
  9. package/dist/cli/commands/config.js.map +1 -0
  10. package/dist/cli/commands/diagnose.d.ts +7 -0
  11. package/dist/cli/commands/diagnose.d.ts.map +1 -0
  12. package/dist/cli/commands/diagnose.js +133 -0
  13. package/dist/cli/commands/diagnose.js.map +1 -0
  14. package/dist/cli/commands/doctor-bundle.d.ts +6 -0
  15. package/dist/cli/commands/doctor-bundle.d.ts.map +1 -0
  16. package/dist/cli/commands/doctor-bundle.js +108 -0
  17. package/dist/cli/commands/doctor-bundle.js.map +1 -0
  18. package/dist/cli/commands/doctor.d.ts +6 -0
  19. package/dist/cli/commands/doctor.d.ts.map +1 -0
  20. package/dist/cli/commands/doctor.js +163 -0
  21. package/dist/cli/commands/doctor.js.map +1 -0
  22. package/dist/cli/commands/service-edit.d.ts +16 -0
  23. package/dist/cli/commands/service-edit.d.ts.map +1 -0
  24. package/dist/cli/commands/service-edit.js +187 -0
  25. package/dist/cli/commands/service-edit.js.map +1 -0
  26. package/dist/cli/commands/whoami.d.ts +5 -0
  27. package/dist/cli/commands/whoami.d.ts.map +1 -0
  28. package/dist/cli/commands/whoami.js +91 -0
  29. package/dist/cli/commands/whoami.js.map +1 -0
  30. package/dist/cli/config-yaml.d.ts +12 -14
  31. package/dist/cli/config-yaml.d.ts.map +1 -1
  32. package/dist/cli/config-yaml.js +209 -86
  33. package/dist/cli/config-yaml.js.map +1 -1
  34. package/dist/cli/index.js +81 -0
  35. package/dist/cli/index.js.map +1 -1
  36. package/dist/core/authority.d.ts +5 -3
  37. package/dist/core/authority.d.ts.map +1 -1
  38. package/dist/core/authority.js +76 -43
  39. package/dist/core/authority.js.map +1 -1
  40. package/dist/core/health.d.ts +24 -1
  41. package/dist/core/health.d.ts.map +1 -1
  42. package/dist/core/health.js +138 -26
  43. package/dist/core/health.js.map +1 -1
  44. package/dist/core/mcp-server.d.ts +31 -11
  45. package/dist/core/mcp-server.d.ts.map +1 -1
  46. package/dist/core/mcp-server.js +580 -238
  47. package/dist/core/mcp-server.js.map +1 -1
  48. package/package.json +1 -1
@@ -40,6 +40,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
40
40
  return (mod && mod.__esModule) ? mod : { "default": mod };
41
41
  };
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.DenialError = void 0;
44
+ exports.explainAccessDenial = explainAccessDenial;
43
45
  exports.createMCPServer = createMCPServer;
44
46
  exports.makeAPIRequest = makeAPIRequest;
45
47
  exports.captureClientInfo = captureClientInfo;
@@ -59,8 +61,17 @@ const exec_js_1 = require("./exec.js");
59
61
  const health_js_1 = require("./health.js");
60
62
  const rules_js_1 = require("./rules.js");
61
63
  // Read version from package.json
62
- const packageJsonPath = (0, path_1.join)(__dirname, '../../package.json');
63
- const pkgVersion = JSON.parse((0, fs_1.readFileSync)(packageJsonPath, 'utf8')).version || '0.0.0';
64
+ const packageJsonPath = (0, path_1.join)(__dirname, "../../package.json");
65
+ const pkgVersion = JSON.parse((0, fs_1.readFileSync)(packageJsonPath, "utf8")).version || "0.0.0";
66
+ class DenialError extends Error {
67
+ denial;
68
+ constructor(message, denial) {
69
+ super(message);
70
+ this.name = 'DenialError';
71
+ this.denial = denial;
72
+ }
73
+ }
74
+ exports.DenialError = DenialError;
64
75
  /**
65
76
  * Check whether an agent can access a capability.
66
77
  * Checks capability-level allowedAgents first, then falls back to
@@ -74,11 +85,40 @@ function canAccessCapability(agentId, cap, service, defaultAccessPolicy) {
74
85
  if (cap.allowedAgents && cap.allowedAgents.length > 0) {
75
86
  return cap.allowedAgents.includes(agentId);
76
87
  }
77
- if (defaultAccessPolicy === 'restricted') {
88
+ if (defaultAccessPolicy === "restricted") {
78
89
  return false;
79
90
  }
80
91
  return (0, agent_scope_js_1.canAgentAccess)(agentId, service?.ownership);
81
92
  }
93
+ /**
94
+ * Returns the specific reason access was denied, or null if access is allowed.
95
+ */
96
+ function explainAccessDenial(agentId, cap, service, defaultAccessPolicy) {
97
+ if (!agentId)
98
+ return null;
99
+ if (cap.allowedAgents && cap.allowedAgents.length > 0) {
100
+ if (!cap.allowedAgents.includes(agentId)) {
101
+ return {
102
+ reason: 'AGENT_NOT_ALLOWED',
103
+ detail: `Agent "${agentId}" is not in allowedAgents [${cap.allowedAgents.join(', ')}]`
104
+ };
105
+ }
106
+ return null;
107
+ }
108
+ if (defaultAccessPolicy === 'restricted') {
109
+ return {
110
+ reason: 'DEFAULT_ACCESS_RESTRICTED',
111
+ detail: `defaultAccess is "restricted" and capability has no allowedAgents list`
112
+ };
113
+ }
114
+ if (!(0, agent_scope_js_1.canAgentAccess)(agentId, service?.ownership)) {
115
+ return {
116
+ reason: 'OWNERSHIP_DENIED',
117
+ detail: `Agent "${agentId}" is not listed in service ownership for "${service?.baseUrl || 'unknown'}"`
118
+ };
119
+ }
120
+ return null;
121
+ }
82
122
  /**
83
123
  * Parse TTL string to seconds
84
124
  */
@@ -92,7 +132,7 @@ function parseTTL(ttl) {
92
132
  s: 1,
93
133
  m: 60,
94
134
  h: 3600,
95
- d: 86400
135
+ d: 86400,
96
136
  };
97
137
  return value * multipliers[unit];
98
138
  }
@@ -100,7 +140,7 @@ function parseTTL(ttl) {
100
140
  * Create and start MCP server
101
141
  */
102
142
  function createMCPServer(options) {
103
- const { sessionManager, auditLogger, defaultAccess, onExecute, onExecCommand, onReloadConfig, onPersistOwnership, onForwardToolCall } = options;
143
+ const { sessionManager, auditLogger, defaultAccess, onExecute, onExecCommand, onReloadConfig, onPersistOwnership, onForwardToolCall, } = options;
104
144
  // Store as mutable to support hot-reloading
105
145
  let capabilities = options.capabilities;
106
146
  let services = options.services;
@@ -108,12 +148,12 @@ function createMCPServer(options) {
108
148
  // Populated by captureClientInfo() after connecting a transport.
109
149
  const clientSessions = new Map();
110
150
  const server = new index_js_1.Server({
111
- name: 'janee',
112
- version: pkgVersion
151
+ name: "janee",
152
+ version: pkgVersion,
113
153
  }, {
114
154
  capabilities: {
115
- tools: {}
116
- }
155
+ tools: {},
156
+ },
117
157
  });
118
158
  /**
119
159
  * Resolve agent identity from the MCP session.
@@ -123,141 +163,192 @@ function createMCPServer(options) {
123
163
  * Falls back to args.agentId for legacy scenarios.
124
164
  */
125
165
  function resolveAgentFromRequest(extra, args) {
126
- const sessionKey = extra?.sessionId || '__default__';
127
- const clientName = clientSessions.get(sessionKey) || clientSessions.get('__default__');
128
- return (0, agent_scope_js_1.resolveAgentIdentity)({ agentId: extra?.sessionId, metadata: { transportAgentHint: clientName } }, args?.agentId);
166
+ const sessionKey = extra?.sessionId || "__default__";
167
+ const clientName = clientSessions.get(sessionKey) || clientSessions.get("__default__");
168
+ return (0, agent_scope_js_1.resolveAgentIdentity)({
169
+ agentId: extra?.sessionId,
170
+ metadata: { transportAgentHint: clientName },
171
+ }, args?.agentId);
129
172
  }
130
173
  // Tool: list_services
131
174
  const listServicesTool = {
132
- name: 'list_services',
133
- description: 'List available API capabilities managed by Janee',
175
+ name: "list_services",
176
+ description: "List available API capabilities managed by Janee",
134
177
  inputSchema: {
135
- type: 'object',
178
+ type: "object",
136
179
  properties: {},
137
- required: []
138
- }
180
+ required: [],
181
+ },
139
182
  };
140
183
  // Tool: execute
141
184
  const executeTool = {
142
- name: 'execute',
143
- description: 'Execute an API request through Janee proxy',
185
+ name: "execute",
186
+ description: "Execute an API request through Janee proxy",
144
187
  inputSchema: {
145
- type: 'object',
188
+ type: "object",
146
189
  properties: {
147
190
  capability: {
148
- type: 'string',
149
- description: 'Capability name to use (from list_services)'
191
+ type: "string",
192
+ description: "Capability name to use (from list_services)",
150
193
  },
151
194
  method: {
152
- type: 'string',
153
- enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
154
- description: 'HTTP method'
195
+ type: "string",
196
+ enum: ["GET", "POST", "PUT", "DELETE", "PATCH"],
197
+ description: "HTTP method",
155
198
  },
156
199
  path: {
157
- type: 'string',
158
- description: 'API path (e.g., /v1/customers)'
200
+ type: "string",
201
+ description: "API path (e.g., /v1/customers)",
159
202
  },
160
203
  body: {
161
- type: 'string',
162
- description: 'Request body (JSON string, optional)'
204
+ type: "string",
205
+ description: "Request body (JSON string, optional)",
163
206
  },
164
207
  headers: {
165
- type: 'object',
166
- description: 'Additional headers (optional)',
167
- additionalProperties: { type: 'string' }
208
+ type: "object",
209
+ description: "Additional headers (optional)",
210
+ additionalProperties: { type: "string" },
168
211
  },
169
212
  reason: {
170
- type: 'string',
171
- description: 'Reason for this request (required for some capabilities)'
172
- }
213
+ type: "string",
214
+ description: "Reason for this request (required for some capabilities)",
215
+ },
173
216
  },
174
- required: ['capability', 'method', 'path']
175
- }
217
+ required: ["capability", "method", "path"],
218
+ },
176
219
  };
177
220
  // Tool: reload_config
178
221
  const reloadConfigTool = {
179
- name: 'reload_config',
180
- description: 'Reload Janee configuration from disk without restarting the server. Use after adding new services or capabilities.',
222
+ name: "reload_config",
223
+ description: "Reload Janee configuration from disk without restarting the server. Use after adding new services or capabilities.",
181
224
  inputSchema: {
182
- type: 'object',
225
+ type: "object",
183
226
  properties: {},
184
- required: []
185
- }
227
+ required: [],
228
+ },
186
229
  };
187
230
  // Tool: janee_exec (RFC 0001 - Secure CLI Execution)
188
231
  const execTool = {
189
- name: 'janee_exec',
190
- description: 'Execute a CLI command with credentials injected via environment variables. The agent never sees the actual credential — Janee injects it and scrubs output.',
232
+ name: "janee_exec",
233
+ description: "Execute a CLI command with credentials injected via environment variables. The agent never sees the actual credential — Janee injects it and scrubs output.",
191
234
  inputSchema: {
192
- type: 'object',
235
+ type: "object",
193
236
  properties: {
194
237
  capability: {
195
- type: 'string',
196
- description: 'Capability name (must be exec mode, from list_services)'
238
+ type: "string",
239
+ description: "Capability name (must be exec mode, from list_services)",
197
240
  },
198
241
  command: {
199
- type: 'array',
200
- items: { type: 'string' },
201
- description: 'Command and arguments as array, e.g. ["gh", "issue", "list"]'
242
+ type: "array",
243
+ items: { type: "string" },
244
+ description: 'Command and arguments as array, e.g. ["gh", "issue", "list"]',
202
245
  },
203
246
  cwd: {
204
- type: 'string',
205
- description: 'Working directory for the command (defaults to process cwd)'
247
+ type: "string",
248
+ description: "Working directory for the command (defaults to process cwd)",
206
249
  },
207
250
  stdin: {
208
- type: 'string',
209
- description: 'Optional stdin input to pipe to the command'
251
+ type: "string",
252
+ description: "Optional stdin input to pipe to the command",
210
253
  },
211
254
  reason: {
212
- type: 'string',
213
- description: 'Reason for this execution (required for some capabilities)'
214
- }
255
+ type: "string",
256
+ description: "Reason for this execution (required for some capabilities)",
257
+ },
215
258
  },
216
- required: ['capability', 'command']
217
- }
259
+ required: ["capability", "command"],
260
+ },
218
261
  };
219
262
  // Tool: manage_credential (agent-scoped credential access control)
220
263
  const manageCredentialTool = {
221
- name: 'manage_credential',
222
- description: 'View or manage access policies for agent-scoped credentials. Agents can check who has access, grant access to other agents, or revoke access.',
264
+ name: "manage_credential",
265
+ description: "View or manage access policies for agent-scoped credentials. Agents can check who has access, grant access to other agents, or revoke access.",
223
266
  inputSchema: {
224
- type: 'object',
267
+ type: "object",
225
268
  properties: {
226
269
  action: {
227
- type: 'string',
228
- enum: ['view', 'grant', 'revoke'],
229
- description: 'Action to perform: view ownership info, grant access to another agent, or revoke access'
270
+ type: "string",
271
+ enum: ["view", "grant", "revoke"],
272
+ description: "Action to perform: view ownership info, grant access to another agent, or revoke access",
230
273
  },
231
274
  service: {
232
- type: 'string',
233
- description: 'Service name to manage'
275
+ type: "string",
276
+ description: "Service name to manage",
234
277
  },
235
278
  targetAgentId: {
236
- type: 'string',
237
- description: 'Agent ID to grant/revoke access for (required for grant/revoke actions)'
238
- }
279
+ type: "string",
280
+ description: "Agent ID to grant/revoke access for (required for grant/revoke actions)",
281
+ },
239
282
  },
240
- required: ['action', 'service']
241
- }
283
+ required: ["action", "service"],
284
+ },
242
285
  };
243
286
  // Tool: test_service
244
287
  const testServiceTool = {
245
- name: 'test_service',
246
- description: 'Test connectivity and authentication for a configured service. Verifies that Janee can reach the service and that credentials are valid.',
288
+ name: "test_service",
289
+ description: "Test connectivity and authentication for a configured service. Verifies that Janee can reach the service and that credentials are valid.",
247
290
  inputSchema: {
248
- type: 'object',
291
+ type: "object",
249
292
  properties: {
250
293
  service: {
294
+ type: "string",
295
+ description: "Service name to test (from list_services). Omit to test all services.",
296
+ },
297
+ timeout: {
298
+ type: "number",
299
+ description: "Timeout in milliseconds for the health check (default: 10000).",
300
+ },
301
+ },
302
+ required: [],
303
+ },
304
+ };
305
+ // Tool: whoami — lets agents discover their resolved identity
306
+ const whoamiTool = {
307
+ name: 'whoami',
308
+ description: 'Show your resolved agent identity as Janee sees it, which capabilities you can access, and the server access policy. Useful for understanding allowedAgents restrictions.',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {},
312
+ required: []
313
+ }
314
+ };
315
+ // Tool: explain_access — full policy evaluation trace
316
+ const explainAccessTool = {
317
+ name: 'explain_access',
318
+ description: 'Trace exactly why a given agent can or cannot access a capability. Returns step-by-step policy evaluation (capability exists, mode, allowedAgents, defaultAccess, ownership, rules). Use for debugging access issues.',
319
+ inputSchema: {
320
+ type: 'object',
321
+ properties: {
322
+ agent: {
251
323
  type: 'string',
252
- description: 'Service name to test (from list_services). Omit to test all services.'
324
+ description: 'Agent ID to evaluate access for. Defaults to the calling agent.'
325
+ },
326
+ capability: {
327
+ type: 'string',
328
+ description: 'Capability name to check access for.'
329
+ },
330
+ method: {
331
+ type: 'string',
332
+ description: 'HTTP method for rules evaluation (optional, only applies to proxy capabilities).'
333
+ },
334
+ path: {
335
+ type: 'string',
336
+ description: 'Request path for rules evaluation (optional, only applies to proxy capabilities).'
253
337
  }
254
338
  },
255
- required: []
339
+ required: ['capability']
256
340
  }
257
341
  };
258
342
  // Register tools
259
343
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
260
- const tools = [listServicesTool, executeTool, manageCredentialTool, testServiceTool];
344
+ const tools = [
345
+ listServicesTool,
346
+ executeTool,
347
+ manageCredentialTool,
348
+ testServiceTool,
349
+ whoamiTool,
350
+ explainAccessTool,
351
+ ];
261
352
  if (onExecCommand && !options.hideExecTool) {
262
353
  tools.push(execTool);
263
354
  }
@@ -270,39 +361,40 @@ function createMCPServer(options) {
270
361
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
271
362
  const { name, arguments: args } = request.params;
272
363
  try {
273
- // Runner proxy: forward non-exec tools to the Authority
274
- if (onForwardToolCall && name !== 'janee_exec') {
364
+ // Runner proxy: forward all non-exec tools to the Authority
365
+ if (onForwardToolCall && name !== "janee_exec") {
275
366
  const forwardAgentId = resolveAgentFromRequest(extra, args);
276
367
  const result = await onForwardToolCall(name, (args || {}), forwardAgentId);
277
368
  return result;
278
369
  }
279
370
  switch (name) {
280
- case 'list_services': {
371
+ case "list_services": {
281
372
  const listAgentId = resolveAgentFromRequest(extra, args);
282
373
  return {
283
- content: [{
284
- type: 'text',
285
- text: JSON.stringify(capabilities
286
- .map(cap => ({
374
+ content: [
375
+ {
376
+ type: "text",
377
+ text: JSON.stringify(capabilities.map((cap) => ({
287
378
  accessible: canAccessCapability(listAgentId, cap, services.get(cap.service), defaultAccess),
288
379
  name: cap.name,
289
380
  service: cap.service,
290
- mode: cap.mode || 'proxy',
381
+ mode: cap.mode || "proxy",
291
382
  ttl: cap.ttl,
292
383
  autoApprove: cap.autoApprove,
293
384
  requiresReason: cap.requiresReason,
294
385
  rules: cap.rules,
295
- ...(cap.mode === 'exec' && {
386
+ ...(cap.mode === "exec" && {
296
387
  allowCommands: cap.allowCommands,
297
388
  env: cap.env ? Object.keys(cap.env) : undefined,
298
- })
299
- })), null, 2)
300
- }]
389
+ }),
390
+ })), null, 2),
391
+ },
392
+ ],
301
393
  };
302
394
  }
303
- case 'reload_config': {
395
+ case "reload_config": {
304
396
  if (!onReloadConfig) {
305
- throw new Error('Config reload not supported');
397
+ throw new Error("Config reload not supported");
306
398
  }
307
399
  try {
308
400
  const result = onReloadConfig();
@@ -311,63 +403,93 @@ function createMCPServer(options) {
311
403
  capabilities = result.capabilities;
312
404
  services = result.services;
313
405
  return {
314
- content: [{
315
- type: 'text',
406
+ content: [
407
+ {
408
+ type: "text",
316
409
  text: JSON.stringify({
317
410
  success: true,
318
- message: 'Configuration reloaded successfully',
411
+ message: "Configuration reloaded successfully",
319
412
  services: services.size,
320
413
  capabilities: capabilities.length,
321
414
  changes: {
322
415
  services: services.size - prevServiceCount,
323
- capabilities: capabilities.length - prevCapCount
324
- }
325
- }, null, 2)
326
- }]
416
+ capabilities: capabilities.length - prevCapCount,
417
+ },
418
+ }, null, 2),
419
+ },
420
+ ],
327
421
  };
328
422
  }
329
423
  catch (error) {
330
- throw new Error(`Failed to reload config: ${error instanceof Error ? error.message : 'Unknown error'}`);
424
+ throw new Error(`Failed to reload config: ${error instanceof Error ? error.message : "Unknown error"}`);
331
425
  }
332
426
  }
333
- case 'execute': {
427
+ case "execute": {
334
428
  const { capability, method, path, body, headers, reason } = args;
335
429
  // Validate required arguments
336
430
  if (!capability) {
337
- throw new Error('Missing required argument: capability');
431
+ throw new Error("Missing required argument: capability");
338
432
  }
339
433
  if (!method) {
340
- throw new Error('Missing required argument: method (GET, POST, PUT, DELETE, etc.)');
434
+ throw new Error("Missing required argument: method (GET, POST, PUT, DELETE, etc.)");
341
435
  }
342
436
  if (!path) {
343
- throw new Error('Missing required argument: path');
437
+ throw new Error("Missing required argument: path");
344
438
  }
345
439
  // Find capability
346
- const cap = capabilities.find(c => c.name === capability);
440
+ const cap = capabilities.find((c) => c.name === capability);
347
441
  if (!cap) {
348
- throw new Error(`Unknown capability: ${capability}`);
442
+ throw new DenialError(`Unknown capability: ${capability}`, {
443
+ reasonCode: 'CAPABILITY_NOT_FOUND',
444
+ capability,
445
+ nextStep: `Run 'janee cap list' to see available capabilities, or add one with 'janee cap add'.`
446
+ });
349
447
  }
350
448
  // Reject exec-mode capabilities — they should use janee_exec instead
351
- if (cap.mode === 'exec') {
352
- throw new Error(`Capability "${capability}" is an exec-mode capability. Use the 'janee_exec' tool instead.`);
449
+ if (cap.mode === "exec") {
450
+ throw new DenialError(`Capability "${capability}" is an exec-mode capability. Use the 'janee_exec' tool instead.`, {
451
+ reasonCode: "MODE_MISMATCH",
452
+ capability,
453
+ nextStep: `Use the 'janee_exec' tool for exec-mode capabilities.`,
454
+ });
353
455
  }
354
456
  // Check if reason required
355
457
  if (cap.requiresReason && !reason) {
356
- throw new Error(`Capability "${capability}" requires a reason`);
458
+ throw new DenialError(`Capability "${capability}" requires a reason`, {
459
+ reasonCode: 'REASON_REQUIRED',
460
+ capability,
461
+ nextStep: `Include a 'reason' argument explaining why you need this access.`
462
+ });
357
463
  }
358
464
  // Check rules (path-based policies)
359
465
  const ruleCheck = (0, rules_js_1.checkRules)(cap.rules, method, path);
360
466
  if (!ruleCheck.allowed) {
361
- // Log denied request
362
- auditLogger.logDenied(cap.service, method, path, ruleCheck.reason || 'Request denied by policy', reason);
363
- throw new Error(ruleCheck.reason || 'Request denied by policy');
467
+ auditLogger.logDenied(cap.service, method, path, ruleCheck.reason || "Request denied by policy", reason);
468
+ throw new DenialError(ruleCheck.reason || "Request denied by policy", {
469
+ reasonCode: "RULE_DENY",
470
+ capability,
471
+ agentId: resolveAgentFromRequest(extra, args),
472
+ evaluatedPolicy: `rules for ${method} ${path}`,
473
+ nextStep: `Check capability rules with 'janee cap list --json' — the path/method may be explicitly denied.`,
474
+ });
364
475
  }
365
476
  // Check agent-scoped access (capability-level allowedAgents, then service-level ownership)
366
477
  const executeAgentId = resolveAgentFromRequest(extra, args);
367
478
  const executeSvc = services.get(cap.service);
368
479
  if (!canAccessCapability(executeAgentId, cap, executeSvc, defaultAccess)) {
369
- auditLogger.logDenied(cap.service, method, path, 'Agent does not have access to this capability', reason);
370
- throw new Error(`Access denied: capability "${capability}" is not accessible to this agent`);
480
+ const denialDetail = explainAccessDenial(executeAgentId, cap, executeSvc, defaultAccess);
481
+ auditLogger.logDenied(cap.service, method, path, "Agent does not have access to this capability", reason);
482
+ throw new DenialError(`Access denied: capability "${capability}" is not accessible to this agent`, {
483
+ reasonCode: denialDetail?.reason || "AGENT_NOT_ALLOWED",
484
+ capability,
485
+ agentId: executeAgentId,
486
+ evaluatedPolicy: denialDetail?.detail,
487
+ nextStep: denialDetail?.reason === "AGENT_NOT_ALLOWED"
488
+ ? `Add this agent to allowedAgents: 'janee cap edit ${capability} --allowed-agents ${executeAgentId}'`
489
+ : denialDetail?.reason === "DEFAULT_ACCESS_RESTRICTED"
490
+ ? `Either add allowedAgents to the capability or change defaultAccess to 'open'.`
491
+ : `Check service ownership settings for the backing service.`,
492
+ });
371
493
  }
372
494
  // Get or create session
373
495
  const ttlSeconds = parseTTL(cap.ttl);
@@ -378,34 +500,38 @@ function createMCPServer(options) {
378
500
  path,
379
501
  method,
380
502
  headers: headers || {},
381
- body
503
+ body,
382
504
  };
383
505
  // Execute
384
506
  const response = await onExecute(session, apiReq);
385
507
  return {
386
- content: [{
387
- type: 'text',
508
+ content: [
509
+ {
510
+ type: "text",
388
511
  text: JSON.stringify({
389
512
  status: response.statusCode,
390
- body: response.body
391
- }, null, 2)
392
- }]
513
+ body: response.body,
514
+ }, null, 2),
515
+ },
516
+ ],
393
517
  };
394
518
  }
395
- case 'janee_exec': {
519
+ case "janee_exec": {
396
520
  if (!onExecCommand) {
397
- throw new Error('CLI execution not supported in this configuration');
521
+ throw new Error("CLI execution not supported in this configuration");
398
522
  }
399
- const { capability: execCapName, command: rawExecCommand, cwd: execCwd, stdin: execStdin, reason: execReason } = args;
523
+ const { capability: execCapName, command: rawExecCommand, cwd: execCwd, stdin: execStdin, reason: execReason, } = args;
400
524
  if (!execCapName) {
401
- throw new Error('Missing required argument: capability');
525
+ throw new Error("Missing required argument: capability");
402
526
  }
403
- if (!rawExecCommand || (Array.isArray(rawExecCommand) && rawExecCommand.length === 0) || (typeof rawExecCommand === 'string' && rawExecCommand.trim() === '')) {
404
- throw new Error('Missing required argument: command');
527
+ if (!rawExecCommand ||
528
+ (Array.isArray(rawExecCommand) && rawExecCommand.length === 0) ||
529
+ (typeof rawExecCommand === "string" && rawExecCommand.trim() === "")) {
530
+ throw new Error("Missing required argument: command");
405
531
  }
406
532
  const execCommand = Array.isArray(rawExecCommand)
407
533
  ? rawExecCommand
408
- : typeof rawExecCommand === 'string'
534
+ : typeof rawExecCommand === "string"
409
535
  ? rawExecCommand.trim().split(/\s+/)
410
536
  : [];
411
537
  let execCap;
@@ -413,32 +539,67 @@ function createMCPServer(options) {
413
539
  if (onForwardToolCall) {
414
540
  // Runner mode: Authority handles validation and credential injection.
415
541
  // Build a minimal capability stub so onExecCommand has the name.
416
- execCap = { name: execCapName, service: '', ttl: '1h', mode: 'exec', workDir: execCwd };
542
+ execCap = {
543
+ name: execCapName,
544
+ service: "",
545
+ ttl: "1h",
546
+ mode: "exec",
547
+ workDir: execCwd,
548
+ };
417
549
  execSession = { agentId: resolveAgentFromRequest(extra, args) };
418
550
  }
419
551
  else {
420
552
  // Standalone mode: validate locally
421
- const foundCap = capabilities.find(c => c.name === execCapName);
553
+ const foundCap = capabilities.find((c) => c.name === execCapName);
422
554
  if (!foundCap) {
423
- throw new Error(`Unknown capability: ${execCapName}`);
555
+ throw new DenialError(`Unknown capability: ${execCapName}`, {
556
+ reasonCode: 'CAPABILITY_NOT_FOUND',
557
+ capability: execCapName,
558
+ nextStep: `Run 'janee cap list' to see available capabilities, or add one with 'janee cap add'.`
559
+ });
424
560
  }
425
561
  execCap = execCwd ? { ...foundCap, workDir: execCwd } : foundCap;
426
- if (execCap.mode !== 'exec') {
427
- throw new Error(`Capability "${execCapName}" is not an exec-mode capability. Use the 'execute' tool for API proxy capabilities.`);
562
+ if (execCap.mode !== "exec") {
563
+ throw new DenialError(`Capability "${execCapName}" is not an exec-mode capability. Use the 'execute' tool for API proxy capabilities.`, {
564
+ reasonCode: "MODE_MISMATCH",
565
+ capability: execCapName,
566
+ nextStep: `Use the 'execute' tool for proxy-mode capabilities.`,
567
+ });
428
568
  }
429
569
  const execAgentId = resolveAgentFromRequest(extra, args);
430
570
  const execSvc = services.get(execCap.service);
431
571
  if (!canAccessCapability(execAgentId, execCap, execSvc, defaultAccess)) {
432
- auditLogger.logDenied(execCap.service, 'EXEC', execCommand.join(' '), 'Agent does not have access to this capability', execReason);
433
- throw new Error(`Access denied: capability "${execCapName}" is not accessible to this agent`);
572
+ const execDenialDetail = explainAccessDenial(execAgentId, execCap, execSvc, defaultAccess);
573
+ auditLogger.logDenied(execCap.service, "EXEC", execCommand.join(" "), "Agent does not have access to this capability", execReason);
574
+ throw new DenialError(`Access denied: capability "${execCapName}" is not accessible to this agent`, {
575
+ reasonCode: execDenialDetail?.reason || "AGENT_NOT_ALLOWED",
576
+ capability: execCapName,
577
+ agentId: execAgentId,
578
+ evaluatedPolicy: execDenialDetail?.detail,
579
+ nextStep: execDenialDetail?.reason === "AGENT_NOT_ALLOWED"
580
+ ? `Add this agent to allowedAgents: 'janee cap edit ${execCapName} --allowed-agents ${execAgentId}'`
581
+ : execDenialDetail?.reason === "DEFAULT_ACCESS_RESTRICTED"
582
+ ? `Either add allowedAgents to the capability or change defaultAccess to 'open'.`
583
+ : `Check service ownership settings for the backing service.`,
584
+ });
434
585
  }
435
586
  if (execCap.requiresReason && !execReason) {
436
- throw new Error(`Capability "${execCapName}" requires a reason`);
587
+ throw new DenialError(`Capability "${execCapName}" requires a reason`, {
588
+ reasonCode: 'REASON_REQUIRED',
589
+ capability: execCapName,
590
+ nextStep: `Include a 'reason' argument explaining why you need this access.`
591
+ });
437
592
  }
438
593
  const cmdValidation = (0, exec_js_1.validateCommand)(execCommand, execCap.allowCommands || []);
439
594
  if (!cmdValidation.allowed) {
440
- auditLogger.logDenied(execCap.service, 'EXEC', execCommand.join(' '), cmdValidation.reason || 'Command not allowed', execReason);
441
- throw new Error(cmdValidation.reason || 'Command not allowed');
595
+ auditLogger.logDenied(execCap.service, "EXEC", execCommand.join(" "), cmdValidation.reason || "Command not allowed", execReason);
596
+ throw new DenialError(cmdValidation.reason || "Command not allowed", {
597
+ reasonCode: "COMMAND_NOT_ALLOWED",
598
+ capability: execCapName,
599
+ agentId: execAgentId,
600
+ evaluatedPolicy: `allowCommands: [${(execCap.allowCommands || []).join(", ")}]`,
601
+ nextStep: `Update allowed commands: 'janee cap edit ${execCapName} --allow-commands "new-pattern"'`,
602
+ });
442
603
  }
443
604
  const execTtlSeconds = parseTTL(execCap.ttl);
444
605
  execSession = sessionManager.createSession(execCap.name, execCap.service, execTtlSeconds, { reason: execReason });
@@ -447,107 +608,120 @@ function createMCPServer(options) {
447
608
  // Log to audit
448
609
  auditLogger.log({
449
610
  service: execCap.service,
450
- path: execCommand.join(' '),
451
- method: 'EXEC',
452
- headers: { 'x-janee-reason': execReason || '' },
611
+ path: execCommand.join(" "),
612
+ method: "EXEC",
613
+ headers: { "x-janee-reason": execReason || "" },
453
614
  }, {
454
615
  statusCode: execResult.exitCode === 0 ? 200 : 500,
455
616
  headers: {},
456
617
  body: execResult.stdout,
457
618
  }, execResult.executionTimeMs);
458
619
  return {
459
- content: [{
460
- type: 'text',
620
+ content: [
621
+ {
622
+ type: "text",
461
623
  text: JSON.stringify({
462
624
  exitCode: execResult.exitCode,
463
625
  stdout: execResult.stdout,
464
626
  stderr: execResult.stderr,
465
627
  executionTimeMs: execResult.executionTimeMs,
466
- executionTarget: 'runner',
467
- }, null, 2)
468
- }]
628
+ executionTarget: "runner",
629
+ }, null, 2),
630
+ },
631
+ ],
469
632
  };
470
633
  }
471
- case 'manage_credential': {
472
- const { action: credAction, service: credService, targetAgentId: credTarget } = args;
634
+ case "manage_credential": {
635
+ const { action: credAction, service: credService, targetAgentId: credTarget, } = args;
473
636
  const credAgentId = resolveAgentFromRequest(extra, args);
474
637
  if (!credService) {
475
- throw new Error('Missing required argument: service');
638
+ throw new Error("Missing required argument: service");
476
639
  }
477
640
  const svc = services.get(credService);
478
641
  if (!svc) {
479
642
  throw new Error(`Unknown service: ${credService}`);
480
643
  }
481
- if (credAction === 'view') {
644
+ if (credAction === "view") {
482
645
  return {
483
- content: [{
484
- type: 'text',
646
+ content: [
647
+ {
648
+ type: "text",
485
649
  text: JSON.stringify({
486
650
  service: credService,
487
- ownership: svc.ownership || { accessPolicy: 'all-agents', note: 'No ownership metadata (legacy credential)' },
488
- yourAccess: (0, agent_scope_js_1.canAgentAccess)(credAgentId, svc.ownership)
489
- }, null, 2)
490
- }]
651
+ ownership: svc.ownership || {
652
+ accessPolicy: "all-agents",
653
+ note: "No ownership metadata (legacy credential)",
654
+ },
655
+ yourAccess: (0, agent_scope_js_1.canAgentAccess)(credAgentId, svc.ownership),
656
+ }, null, 2),
657
+ },
658
+ ],
491
659
  };
492
660
  }
493
661
  // Grant/revoke require ownership verification
494
662
  if (!credAgentId) {
495
- throw new Error('agentId is required for grant/revoke actions');
663
+ throw new Error("agentId is required for grant/revoke actions");
496
664
  }
497
665
  if (!svc.ownership) {
498
- throw new Error('Cannot manage access for legacy credentials without ownership metadata. Re-add the service to enable scoping.');
666
+ throw new Error("Cannot manage access for legacy credentials without ownership metadata. Re-add the service to enable scoping.");
499
667
  }
500
668
  if (svc.ownership.createdBy !== credAgentId) {
501
- throw new Error('Only the credential owner can grant or revoke access');
669
+ throw new Error("Only the credential owner can grant or revoke access");
502
670
  }
503
- if (credAction === 'grant') {
671
+ if (credAction === "grant") {
504
672
  if (!credTarget) {
505
- throw new Error('targetAgentId is required for grant action');
673
+ throw new Error("targetAgentId is required for grant action");
506
674
  }
507
- const { grantAccess } = await Promise.resolve().then(() => __importStar(require('./agent-scope.js')));
675
+ const { grantAccess } = await Promise.resolve().then(() => __importStar(require("./agent-scope.js")));
508
676
  svc.ownership = grantAccess(svc.ownership, credTarget);
509
677
  // Persist ownership change to config storage
510
678
  if (onPersistOwnership) {
511
679
  onPersistOwnership(credService, svc.ownership);
512
680
  }
513
681
  return {
514
- content: [{
515
- type: 'text',
682
+ content: [
683
+ {
684
+ type: "text",
516
685
  text: JSON.stringify({
517
686
  success: true,
518
687
  message: `Granted access to ${credTarget}`,
519
688
  ownership: svc.ownership,
520
- persisted: !!onPersistOwnership
521
- }, null, 2)
522
- }]
689
+ persisted: !!onPersistOwnership,
690
+ }, null, 2),
691
+ },
692
+ ],
523
693
  };
524
694
  }
525
- if (credAction === 'revoke') {
695
+ if (credAction === "revoke") {
526
696
  if (!credTarget) {
527
- throw new Error('targetAgentId is required for revoke action');
697
+ throw new Error("targetAgentId is required for revoke action");
528
698
  }
529
- const { revokeAccess } = await Promise.resolve().then(() => __importStar(require('./agent-scope.js')));
699
+ const { revokeAccess } = await Promise.resolve().then(() => __importStar(require("./agent-scope.js")));
530
700
  svc.ownership = revokeAccess(svc.ownership, credTarget);
531
701
  // Persist ownership change to config storage
532
702
  if (onPersistOwnership) {
533
703
  onPersistOwnership(credService, svc.ownership);
534
704
  }
535
705
  return {
536
- content: [{
537
- type: 'text',
706
+ content: [
707
+ {
708
+ type: "text",
538
709
  text: JSON.stringify({
539
710
  success: true,
540
711
  message: `Revoked access from ${credTarget}`,
541
712
  ownership: svc.ownership,
542
- persisted: !!onPersistOwnership
543
- }, null, 2)
544
- }]
713
+ persisted: !!onPersistOwnership,
714
+ }, null, 2),
715
+ },
716
+ ],
545
717
  };
546
718
  }
547
719
  throw new Error(`Unknown action: ${credAction}. Use 'view', 'grant', or 'revoke'.`);
548
720
  }
549
- case 'test_service': {
550
- const { service: testSvcName } = (args || {});
721
+ case "test_service": {
722
+ const { service: testSvcName, timeout: testTimeout } = (args ||
723
+ {});
724
+ const testOpts = testTimeout ? { timeout: testTimeout } : {};
551
725
  let targets;
552
726
  if (testSvcName) {
553
727
  const svc = services.get(testSvcName);
@@ -560,13 +734,148 @@ function createMCPServer(options) {
560
734
  targets = Array.from(services.entries());
561
735
  }
562
736
  if (targets.length === 0) {
563
- throw new Error('No services configured');
737
+ throw new Error("No services configured");
564
738
  }
565
- const results = await Promise.all(targets.map(([name, config]) => (0, health_js_1.testServiceConnection)(name, config)));
739
+ const results = await Promise.all(targets.map(([name, config]) => (0, health_js_1.testServiceConnection)(name, config, testOpts)));
740
+ return {
741
+ content: [
742
+ {
743
+ type: "text",
744
+ text: JSON.stringify(results.length === 1 ? results[0] : results, null, 2),
745
+ },
746
+ ],
747
+ };
748
+ }
749
+ case 'explain_access': {
750
+ const { agent: explainAgent, capability: explainCapName, method: explainMethod, path: explainPath } = args;
751
+ const targetAgentId = explainAgent || resolveAgentFromRequest(extra, args);
752
+ const trace = [];
753
+ const explainCap = capabilities.find(c => c.name === explainCapName);
754
+ if (!explainCap) {
755
+ trace.push({ check: 'capability_exists', result: 'fail', detail: `Capability "${explainCapName}" not found` });
756
+ return {
757
+ content: [{
758
+ type: 'text',
759
+ text: JSON.stringify({
760
+ agent: targetAgentId ?? null,
761
+ capability: explainCapName,
762
+ allowed: false,
763
+ trace,
764
+ nextStep: `Run 'janee cap list' to see available capabilities.`
765
+ }, null, 2)
766
+ }]
767
+ };
768
+ }
769
+ trace.push({ check: 'capability_exists', result: 'pass', detail: `Capability "${explainCapName}" exists (service: ${explainCap.service})` });
770
+ // Mode check
771
+ if (explainMethod && explainCap.mode === 'exec') {
772
+ trace.push({ check: 'mode', result: 'fail', detail: `Capability is exec-mode but method/path were provided (use janee_exec)` });
773
+ }
774
+ else if (!explainMethod && explainCap.mode !== 'exec') {
775
+ trace.push({ check: 'mode', result: 'pass', detail: `Capability mode: ${explainCap.mode || 'proxy'}` });
776
+ }
777
+ else {
778
+ trace.push({ check: 'mode', result: 'pass', detail: `Capability mode: ${explainCap.mode || 'proxy'}` });
779
+ }
780
+ // allowedAgents
781
+ if (explainCap.allowedAgents && explainCap.allowedAgents.length > 0) {
782
+ if (!targetAgentId) {
783
+ trace.push({ check: 'allowed_agents', result: 'pass', detail: `No agent ID (admin/CLI) — bypasses allowedAgents` });
784
+ }
785
+ else if (explainCap.allowedAgents.includes(targetAgentId)) {
786
+ trace.push({ check: 'allowed_agents', result: 'pass', detail: `Agent "${targetAgentId}" is in allowedAgents [${explainCap.allowedAgents.join(', ')}]` });
787
+ }
788
+ else {
789
+ trace.push({ check: 'allowed_agents', result: 'fail', detail: `Agent "${targetAgentId}" is NOT in allowedAgents [${explainCap.allowedAgents.join(', ')}]` });
790
+ }
791
+ }
792
+ else {
793
+ trace.push({ check: 'allowed_agents', result: 'skip', detail: `No allowedAgents restriction on this capability` });
794
+ }
795
+ // defaultAccess
796
+ if (targetAgentId && (!explainCap.allowedAgents || explainCap.allowedAgents.length === 0)) {
797
+ if (defaultAccess === 'restricted') {
798
+ trace.push({ check: 'default_access', result: 'fail', detail: `defaultAccess is "restricted" and no allowedAgents list — agent blocked` });
799
+ }
800
+ else {
801
+ trace.push({ check: 'default_access', result: 'pass', detail: `defaultAccess is "${defaultAccess ?? 'open'}" — agent allowed` });
802
+ }
803
+ }
804
+ else {
805
+ trace.push({ check: 'default_access', result: 'skip', detail: targetAgentId ? `allowedAgents list takes precedence` : `No agent ID (admin/CLI)` });
806
+ }
807
+ // Ownership
808
+ const explainSvc = services.get(explainCap.service);
809
+ if (targetAgentId && explainSvc?.ownership) {
810
+ if ((0, agent_scope_js_1.canAgentAccess)(targetAgentId, explainSvc.ownership)) {
811
+ trace.push({ check: 'ownership', result: 'pass', detail: `Agent can access service (ownership: ${JSON.stringify(explainSvc.ownership)})` });
812
+ }
813
+ else {
814
+ trace.push({ check: 'ownership', result: 'fail', detail: `Agent cannot access service (ownership: ${JSON.stringify(explainSvc.ownership)})` });
815
+ }
816
+ }
817
+ else {
818
+ trace.push({ check: 'ownership', result: 'skip', detail: explainSvc?.ownership ? `No agent ID (admin/CLI)` : `No ownership restrictions on service` });
819
+ }
820
+ // Rules check (only if method/path provided)
821
+ if (explainMethod && explainPath && explainCap.mode !== 'exec') {
822
+ const ruleResult = (0, rules_js_1.checkRules)(explainCap.rules, explainMethod, explainPath);
823
+ if (ruleResult.allowed) {
824
+ trace.push({ check: 'rules', result: 'pass', detail: `${explainMethod} ${explainPath} is allowed by rules` });
825
+ }
826
+ else {
827
+ trace.push({ check: 'rules', result: 'fail', detail: ruleResult.reason || `${explainMethod} ${explainPath} is denied by rules` });
828
+ }
829
+ }
830
+ else if (explainCap.mode === 'exec') {
831
+ trace.push({ check: 'rules', result: 'skip', detail: `Exec-mode capabilities use allowCommands, not path rules` });
832
+ }
833
+ else {
834
+ trace.push({ check: 'rules', result: 'skip', detail: `No method/path provided for rules evaluation` });
835
+ }
836
+ // Command validation for exec mode
837
+ if (explainCap.mode === 'exec') {
838
+ trace.push({ check: 'allow_commands', result: 'skip', detail: `allowCommands: [${(explainCap.allowCommands || []).join(', ')}] — provide a specific command to validate` });
839
+ }
840
+ const hasFail = trace.some(t => t.result === 'fail');
841
+ const firstFail = trace.find(t => t.result === 'fail');
842
+ return {
843
+ content: [{
844
+ type: 'text',
845
+ text: JSON.stringify({
846
+ agent: targetAgentId ?? null,
847
+ capability: explainCapName,
848
+ allowed: !hasFail,
849
+ trace,
850
+ ...(hasFail && firstFail ? { nextStep: firstFail.detail } : {})
851
+ }, null, 2)
852
+ }]
853
+ };
854
+ }
855
+ case 'whoami': {
856
+ const whoamiAgentId = resolveAgentFromRequest(extra, args);
857
+ const accessibleCaps = capabilities
858
+ .filter(cap => canAccessCapability(whoamiAgentId, cap, services.get(cap.service), defaultAccess))
859
+ .map(cap => cap.name);
860
+ const deniedCaps = capabilities
861
+ .filter(cap => !canAccessCapability(whoamiAgentId, cap, services.get(cap.service), defaultAccess))
862
+ .map(cap => cap.name);
566
863
  return {
567
864
  content: [{
568
865
  type: 'text',
569
- text: JSON.stringify(results.length === 1 ? results[0] : results, null, 2)
866
+ text: JSON.stringify({
867
+ agentId: whoamiAgentId ?? null,
868
+ identitySource: whoamiAgentId
869
+ ? ((extra?.sessionId && clientSessions.has(extra.sessionId)) || clientSessions.has('__default__')
870
+ ? 'transport (clientInfo.name)'
871
+ : 'client-asserted (untrusted)')
872
+ : 'none',
873
+ defaultAccessPolicy: defaultAccess ?? 'open',
874
+ capabilities: {
875
+ accessible: accessibleCaps,
876
+ denied: deniedCaps,
877
+ },
878
+ }, null, 2)
570
879
  }]
571
880
  };
572
881
  }
@@ -575,14 +884,20 @@ function createMCPServer(options) {
575
884
  }
576
885
  }
577
886
  catch (error) {
887
+ const payload = {
888
+ error: error instanceof Error ? error.message : "Unknown error",
889
+ };
890
+ if (error instanceof DenialError) {
891
+ payload.denial = error.denial;
892
+ }
578
893
  return {
579
- content: [{
580
- type: 'text',
581
- text: JSON.stringify({
582
- error: error instanceof Error ? error.message : 'Unknown error'
583
- }, null, 2)
584
- }],
585
- isError: true
894
+ content: [
895
+ {
896
+ type: "text",
897
+ text: JSON.stringify(payload, null, 2),
898
+ },
899
+ ],
900
+ isError: true,
586
901
  };
587
902
  }
588
903
  });
@@ -600,31 +915,31 @@ function createMCPServer(options) {
600
915
  */
601
916
  function makeAPIRequest(targetUrl, request) {
602
917
  return new Promise((resolve, reject) => {
603
- const client = targetUrl.protocol === 'https:' ? https_1.default : http_1.default;
918
+ const client = targetUrl.protocol === "https:" ? https_1.default : http_1.default;
604
919
  const options = {
605
920
  hostname: targetUrl.hostname,
606
921
  port: targetUrl.port,
607
922
  path: targetUrl.pathname + targetUrl.search,
608
923
  method: request.method,
609
924
  headers: {
610
- 'User-Agent': 'janee/' + pkgVersion,
611
- ...request.headers
612
- }
925
+ "User-Agent": "janee/" + pkgVersion,
926
+ ...request.headers,
927
+ },
613
928
  };
614
929
  const req = client.request(options, (res) => {
615
- let body = '';
616
- res.on('data', (chunk) => {
930
+ let body = "";
931
+ res.on("data", (chunk) => {
617
932
  body += chunk;
618
933
  });
619
- res.on('end', () => {
934
+ res.on("end", () => {
620
935
  resolve({
621
936
  statusCode: res.statusCode || 500,
622
937
  headers: res.headers,
623
- body
938
+ body,
624
939
  });
625
940
  });
626
941
  });
627
- req.on('error', (error) => {
942
+ req.on("error", (error) => {
628
943
  reject(error);
629
944
  });
630
945
  if (request.body) {
@@ -641,8 +956,8 @@ function makeAPIRequest(targetUrl, request) {
641
956
  function captureClientInfo(transport, clientSessions) {
642
957
  const original = transport.onmessage;
643
958
  transport.onmessage = (message, extra) => {
644
- if (message?.method === 'initialize' && message?.params?.clientInfo?.name) {
645
- const key = extra?.sessionId || '__default__';
959
+ if (message?.method === "initialize" && message?.params?.clientInfo?.name) {
960
+ const key = extra?.sessionId || "__default__";
646
961
  clientSessions.set(key, message.params.clientInfo.name);
647
962
  }
648
963
  return original?.call(transport, message, extra);
@@ -657,7 +972,7 @@ async function startMCPServer(serverOptions) {
657
972
  const transport = new stdio_js_1.StdioServerTransport();
658
973
  await server.connect(transport);
659
974
  captureClientInfo(transport, clientSessions);
660
- console.error('Janee MCP server started (stdio)');
975
+ console.error("Janee MCP server started (stdio)");
661
976
  return mcpResult;
662
977
  }
663
978
  /**
@@ -679,83 +994,103 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
679
994
  const sessions = new Map();
680
995
  // Authority REST endpoints -- active when runnerKey is provided
681
996
  if (httpOptions.runnerKey && httpOptions.authorityHooks) {
682
- const { timingSafeEqual } = await Promise.resolve().then(() => __importStar(require('crypto')));
997
+ const { timingSafeEqual } = await Promise.resolve().then(() => __importStar(require("crypto")));
683
998
  const runnerKey = httpOptions.runnerKey;
684
999
  const hooks = httpOptions.authorityHooks;
685
1000
  const authMiddleware = (req, res, next) => {
686
- const provided = req.header('x-janee-runner-key');
687
- if (!provided || provided.length !== runnerKey.length ||
1001
+ const provided = req.header("x-janee-runner-key");
1002
+ if (!provided ||
1003
+ provided.length !== runnerKey.length ||
688
1004
  !timingSafeEqual(Buffer.from(provided), Buffer.from(runnerKey))) {
689
- res.status(401).json({ error: 'Unauthorized runner request' });
1005
+ res.status(401).json({ error: "Unauthorized runner request" });
690
1006
  return;
691
1007
  }
692
1008
  next();
693
1009
  };
694
- app.get('/v1/health', (_req, res) => {
695
- res.status(200).json({ ok: true, mode: 'authority' });
1010
+ app.get("/v1/health", (_req, res) => {
1011
+ res.status(200).json({ ok: true, mode: "authority" });
696
1012
  });
697
- app.post('/v1/exec/authorize', authMiddleware, async (req, res) => {
1013
+ app.post("/v1/exec/authorize", authMiddleware, async (req, res) => {
698
1014
  try {
699
1015
  const body = req.body;
700
- if (!body?.runner?.runnerId || !Array.isArray(body?.command) || body.command.length === 0 || !body.capabilityId) {
701
- res.status(400).json({ error: 'Invalid authorize request' });
1016
+ if (!body?.runner?.runnerId ||
1017
+ !Array.isArray(body?.command) ||
1018
+ body.command.length === 0 ||
1019
+ !body.capabilityId) {
1020
+ res.status(400).json({ error: "Invalid authorize request" });
702
1021
  return;
703
1022
  }
704
1023
  const response = await hooks.authorizeExec(body);
705
1024
  res.status(200).json(response);
706
1025
  }
707
1026
  catch (error) {
708
- res.status(403).json({ error: error instanceof Error ? error.message : 'Authorization failed' });
1027
+ res
1028
+ .status(403)
1029
+ .json({
1030
+ error: error instanceof Error ? error.message : "Authorization failed",
1031
+ });
709
1032
  }
710
1033
  });
711
- app.post('/v1/exec/complete', authMiddleware, async (req, res) => {
1034
+ app.post("/v1/exec/complete", authMiddleware, async (req, res) => {
712
1035
  try {
713
1036
  if (!req.body?.grantId) {
714
- res.status(400).json({ error: 'grantId is required' });
1037
+ res.status(400).json({ error: "grantId is required" });
715
1038
  return;
716
1039
  }
717
1040
  await hooks.completeExec(req.body);
718
1041
  res.status(200).json({ ok: true });
719
1042
  }
720
1043
  catch (error) {
721
- res.status(500).json({ error: error instanceof Error ? error.message : 'completion failed' });
1044
+ res
1045
+ .status(500)
1046
+ .json({
1047
+ error: error instanceof Error ? error.message : "completion failed",
1048
+ });
722
1049
  }
723
1050
  });
724
1051
  if (hooks.testService) {
725
- app.post('/v1/test', authMiddleware, async (req, res) => {
1052
+ app.post("/v1/test", authMiddleware, async (req, res) => {
726
1053
  try {
727
- const result = await hooks.testService(req.body?.service);
1054
+ const result = await hooks.testService(req.body?.service, {
1055
+ timeout: req.body?.timeout,
1056
+ });
728
1057
  res.status(200).json(result);
729
1058
  }
730
1059
  catch (error) {
731
- res.status(500).json({ error: error instanceof Error ? error.message : 'Test failed' });
1060
+ res
1061
+ .status(500)
1062
+ .json({
1063
+ error: error instanceof Error ? error.message : "Test failed",
1064
+ });
732
1065
  }
733
1066
  });
734
1067
  }
735
1068
  }
736
1069
  // Sweep idle sessions every 60 seconds
737
- const idleSweepInterval = idleTimeoutMs > 0 ? setInterval(async () => {
738
- const now = Date.now();
739
- for (const [sid, session] of sessions.entries()) {
740
- if (now - session.lastActivityAt > idleTimeoutMs) {
741
- console.error(`Closing idle session ${sid} (inactive for ${Math.round((now - session.lastActivityAt) / 1000)}s)`);
742
- sessions.delete(sid);
743
- try {
744
- await session.transport.close?.();
745
- await session.server.close();
746
- }
747
- catch {
748
- // best-effort cleanup
1070
+ const idleSweepInterval = idleTimeoutMs > 0
1071
+ ? setInterval(async () => {
1072
+ const now = Date.now();
1073
+ for (const [sid, session] of sessions.entries()) {
1074
+ if (now - session.lastActivityAt > idleTimeoutMs) {
1075
+ console.error(`Closing idle session ${sid} (inactive for ${Math.round((now - session.lastActivityAt) / 1000)}s)`);
1076
+ sessions.delete(sid);
1077
+ try {
1078
+ await session.transport.close?.();
1079
+ await session.server.close();
1080
+ }
1081
+ catch {
1082
+ // best-effort cleanup
1083
+ }
749
1084
  }
750
1085
  }
751
- }
752
- }, Math.min(idleTimeoutMs, 60_000)) : undefined;
1086
+ }, Math.min(idleTimeoutMs, 60_000))
1087
+ : undefined;
753
1088
  if (idleSweepInterval) {
754
1089
  idleSweepInterval.unref?.(); // Don't prevent Node from exiting
755
1090
  }
756
- app.all('/mcp', async (req, res) => {
1091
+ app.all("/mcp", async (req, res) => {
757
1092
  try {
758
- const sessionId = req.headers['mcp-session-id'];
1093
+ const sessionId = req.headers["mcp-session-id"];
759
1094
  if (sessionId && sessions.has(sessionId)) {
760
1095
  const session = sessions.get(sessionId);
761
1096
  session.lastActivityAt = Date.now();
@@ -768,7 +1103,11 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
768
1103
  const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
769
1104
  sessionIdGenerator: () => crypto.randomUUID(),
770
1105
  onsessioninitialized: (sid) => {
771
- sessions.set(sid, { transport, server, lastActivityAt: Date.now() });
1106
+ sessions.set(sid, {
1107
+ transport,
1108
+ server,
1109
+ lastActivityAt: Date.now(),
1110
+ });
772
1111
  if (clientName)
773
1112
  clientSessions.set(sid, clientName);
774
1113
  },
@@ -788,25 +1127,28 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
788
1127
  }
789
1128
  else if (sessionId) {
790
1129
  res.status(404).json({
791
- jsonrpc: '2.0',
792
- error: { code: -32001, message: 'Session not found' },
1130
+ jsonrpc: "2.0",
1131
+ error: { code: -32001, message: "Session not found" },
793
1132
  id: null,
794
1133
  });
795
1134
  }
796
1135
  else {
797
1136
  res.status(400).json({
798
- jsonrpc: '2.0',
799
- error: { code: -32600, message: 'Bad Request: Missing session ID or not an initialize request' },
1137
+ jsonrpc: "2.0",
1138
+ error: {
1139
+ code: -32600,
1140
+ message: "Bad Request: Missing session ID or not an initialize request",
1141
+ },
800
1142
  id: null,
801
1143
  });
802
1144
  }
803
1145
  }
804
1146
  catch (error) {
805
- console.error('Error handling MCP request:', error);
1147
+ console.error("Error handling MCP request:", error);
806
1148
  if (!res.headersSent) {
807
1149
  res.status(500).json({
808
- jsonrpc: '2.0',
809
- error: { code: -32603, message: 'Internal server error' },
1150
+ jsonrpc: "2.0",
1151
+ error: { code: -32603, message: "Internal server error" },
810
1152
  id: null,
811
1153
  });
812
1154
  }