@true-and-useful/janee 0.12.0 → 0.13.1

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 (51) 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 +8 -0
  19. package/dist/cli/commands/doctor.d.ts.map +1 -0
  20. package/dist/cli/commands/doctor.js +164 -0
  21. package/dist/cli/commands/doctor.js.map +1 -0
  22. package/dist/cli/commands/serve-mcp.d.ts.map +1 -1
  23. package/dist/cli/commands/serve-mcp.js +5 -0
  24. package/dist/cli/commands/serve-mcp.js.map +1 -1
  25. package/dist/cli/commands/service-edit.d.ts +16 -0
  26. package/dist/cli/commands/service-edit.d.ts.map +1 -0
  27. package/dist/cli/commands/service-edit.js +187 -0
  28. package/dist/cli/commands/service-edit.js.map +1 -0
  29. package/dist/cli/commands/whoami.d.ts +5 -0
  30. package/dist/cli/commands/whoami.d.ts.map +1 -0
  31. package/dist/cli/commands/whoami.js +91 -0
  32. package/dist/cli/commands/whoami.js.map +1 -0
  33. package/dist/cli/config-yaml.d.ts +12 -14
  34. package/dist/cli/config-yaml.d.ts.map +1 -1
  35. package/dist/cli/config-yaml.js +209 -86
  36. package/dist/cli/config-yaml.js.map +1 -1
  37. package/dist/cli/index.js +81 -0
  38. package/dist/cli/index.js.map +1 -1
  39. package/dist/core/authority.d.ts +5 -3
  40. package/dist/core/authority.d.ts.map +1 -1
  41. package/dist/core/authority.js +76 -43
  42. package/dist/core/authority.js.map +1 -1
  43. package/dist/core/health.d.ts +24 -1
  44. package/dist/core/health.d.ts.map +1 -1
  45. package/dist/core/health.js +138 -26
  46. package/dist/core/health.js.map +1 -1
  47. package/dist/core/mcp-server.d.ts +36 -4
  48. package/dist/core/mcp-server.d.ts.map +1 -1
  49. package/dist/core/mcp-server.js +608 -238
  50. package/dist/core/mcp-server.js.map +1 -1
  51. 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,186 +163,251 @@ 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: {
323
+ type: 'string',
324
+ description: 'Agent ID to evaluate access for. Defaults to the calling agent.'
325
+ },
326
+ capability: {
251
327
  type: 'string',
252
- description: 'Service name to test (from list_services). Omit to test all services.'
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
  };
342
+ // Tool: doctor — runner self-diagnostics (only available in runner mode)
343
+ const doctorTool = {
344
+ name: "doctor",
345
+ description: "Run runner-to-authority diagnostics. Checks authority reachability, authentication, tool forwarding, and identity parity. Only available when running in runner mode.",
346
+ inputSchema: {
347
+ type: "object",
348
+ properties: {},
349
+ required: [],
350
+ },
351
+ };
258
352
  // Register tools
259
353
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
260
- const tools = [listServicesTool, executeTool, manageCredentialTool, testServiceTool];
354
+ const tools = [
355
+ listServicesTool,
356
+ executeTool,
357
+ manageCredentialTool,
358
+ testServiceTool,
359
+ whoamiTool,
360
+ explainAccessTool,
361
+ ];
261
362
  if (onExecCommand && !options.hideExecTool) {
262
363
  tools.push(execTool);
263
364
  }
264
365
  if (onReloadConfig) {
265
366
  tools.push(reloadConfigTool);
266
367
  }
368
+ if (options.onDoctorRunner) {
369
+ tools.push(doctorTool);
370
+ }
267
371
  return { tools };
268
372
  });
269
373
  // Handle tool calls
270
374
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
271
375
  const { name, arguments: args } = request.params;
272
376
  try {
273
- // Runner proxy: forward non-exec tools to the Authority
274
- if (onForwardToolCall && name !== 'janee_exec') {
377
+ // Runner proxy: forward all non-exec/non-doctor tools to the Authority
378
+ if (onForwardToolCall && name !== "janee_exec" && name !== "doctor") {
275
379
  const forwardAgentId = resolveAgentFromRequest(extra, args);
276
380
  const result = await onForwardToolCall(name, (args || {}), forwardAgentId);
277
381
  return result;
278
382
  }
279
383
  switch (name) {
280
- case 'list_services': {
384
+ case "list_services": {
281
385
  const listAgentId = resolveAgentFromRequest(extra, args);
282
386
  return {
283
- content: [{
284
- type: 'text',
285
- text: JSON.stringify(capabilities
286
- .map(cap => ({
387
+ content: [
388
+ {
389
+ type: "text",
390
+ text: JSON.stringify(capabilities.map((cap) => ({
287
391
  accessible: canAccessCapability(listAgentId, cap, services.get(cap.service), defaultAccess),
288
392
  name: cap.name,
289
393
  service: cap.service,
290
- mode: cap.mode || 'proxy',
394
+ mode: cap.mode || "proxy",
291
395
  ttl: cap.ttl,
292
396
  autoApprove: cap.autoApprove,
293
397
  requiresReason: cap.requiresReason,
294
398
  rules: cap.rules,
295
- ...(cap.mode === 'exec' && {
399
+ ...(cap.mode === "exec" && {
296
400
  allowCommands: cap.allowCommands,
297
401
  env: cap.env ? Object.keys(cap.env) : undefined,
298
- })
299
- })), null, 2)
300
- }]
402
+ }),
403
+ })), null, 2),
404
+ },
405
+ ],
301
406
  };
302
407
  }
303
- case 'reload_config': {
408
+ case "reload_config": {
304
409
  if (!onReloadConfig) {
305
- throw new Error('Config reload not supported');
410
+ throw new Error("Config reload not supported");
306
411
  }
307
412
  try {
308
413
  const result = onReloadConfig();
@@ -311,63 +416,93 @@ function createMCPServer(options) {
311
416
  capabilities = result.capabilities;
312
417
  services = result.services;
313
418
  return {
314
- content: [{
315
- type: 'text',
419
+ content: [
420
+ {
421
+ type: "text",
316
422
  text: JSON.stringify({
317
423
  success: true,
318
- message: 'Configuration reloaded successfully',
424
+ message: "Configuration reloaded successfully",
319
425
  services: services.size,
320
426
  capabilities: capabilities.length,
321
427
  changes: {
322
428
  services: services.size - prevServiceCount,
323
- capabilities: capabilities.length - prevCapCount
324
- }
325
- }, null, 2)
326
- }]
429
+ capabilities: capabilities.length - prevCapCount,
430
+ },
431
+ }, null, 2),
432
+ },
433
+ ],
327
434
  };
328
435
  }
329
436
  catch (error) {
330
- throw new Error(`Failed to reload config: ${error instanceof Error ? error.message : 'Unknown error'}`);
437
+ throw new Error(`Failed to reload config: ${error instanceof Error ? error.message : "Unknown error"}`);
331
438
  }
332
439
  }
333
- case 'execute': {
440
+ case "execute": {
334
441
  const { capability, method, path, body, headers, reason } = args;
335
442
  // Validate required arguments
336
443
  if (!capability) {
337
- throw new Error('Missing required argument: capability');
444
+ throw new Error("Missing required argument: capability");
338
445
  }
339
446
  if (!method) {
340
- throw new Error('Missing required argument: method (GET, POST, PUT, DELETE, etc.)');
447
+ throw new Error("Missing required argument: method (GET, POST, PUT, DELETE, etc.)");
341
448
  }
342
449
  if (!path) {
343
- throw new Error('Missing required argument: path');
450
+ throw new Error("Missing required argument: path");
344
451
  }
345
452
  // Find capability
346
- const cap = capabilities.find(c => c.name === capability);
453
+ const cap = capabilities.find((c) => c.name === capability);
347
454
  if (!cap) {
348
- throw new Error(`Unknown capability: ${capability}`);
455
+ throw new DenialError(`Unknown capability: ${capability}`, {
456
+ reasonCode: 'CAPABILITY_NOT_FOUND',
457
+ capability,
458
+ nextStep: `Run 'janee cap list' to see available capabilities, or add one with 'janee cap add'.`
459
+ });
349
460
  }
350
461
  // 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.`);
462
+ if (cap.mode === "exec") {
463
+ throw new DenialError(`Capability "${capability}" is an exec-mode capability. Use the 'janee_exec' tool instead.`, {
464
+ reasonCode: "MODE_MISMATCH",
465
+ capability,
466
+ nextStep: `Use the 'janee_exec' tool for exec-mode capabilities.`,
467
+ });
353
468
  }
354
469
  // Check if reason required
355
470
  if (cap.requiresReason && !reason) {
356
- throw new Error(`Capability "${capability}" requires a reason`);
471
+ throw new DenialError(`Capability "${capability}" requires a reason`, {
472
+ reasonCode: 'REASON_REQUIRED',
473
+ capability,
474
+ nextStep: `Include a 'reason' argument explaining why you need this access.`
475
+ });
357
476
  }
358
477
  // Check rules (path-based policies)
359
478
  const ruleCheck = (0, rules_js_1.checkRules)(cap.rules, method, path);
360
479
  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');
480
+ auditLogger.logDenied(cap.service, method, path, ruleCheck.reason || "Request denied by policy", reason);
481
+ throw new DenialError(ruleCheck.reason || "Request denied by policy", {
482
+ reasonCode: "RULE_DENY",
483
+ capability,
484
+ agentId: resolveAgentFromRequest(extra, args),
485
+ evaluatedPolicy: `rules for ${method} ${path}`,
486
+ nextStep: `Check capability rules with 'janee cap list --json' — the path/method may be explicitly denied.`,
487
+ });
364
488
  }
365
489
  // Check agent-scoped access (capability-level allowedAgents, then service-level ownership)
366
490
  const executeAgentId = resolveAgentFromRequest(extra, args);
367
491
  const executeSvc = services.get(cap.service);
368
492
  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`);
493
+ const denialDetail = explainAccessDenial(executeAgentId, cap, executeSvc, defaultAccess);
494
+ auditLogger.logDenied(cap.service, method, path, "Agent does not have access to this capability", reason);
495
+ throw new DenialError(`Access denied: capability "${capability}" is not accessible to this agent`, {
496
+ reasonCode: denialDetail?.reason || "AGENT_NOT_ALLOWED",
497
+ capability,
498
+ agentId: executeAgentId,
499
+ evaluatedPolicy: denialDetail?.detail,
500
+ nextStep: denialDetail?.reason === "AGENT_NOT_ALLOWED"
501
+ ? `Add this agent to allowedAgents: 'janee cap edit ${capability} --allowed-agents ${executeAgentId}'`
502
+ : denialDetail?.reason === "DEFAULT_ACCESS_RESTRICTED"
503
+ ? `Either add allowedAgents to the capability or change defaultAccess to 'open'.`
504
+ : `Check service ownership settings for the backing service.`,
505
+ });
371
506
  }
372
507
  // Get or create session
373
508
  const ttlSeconds = parseTTL(cap.ttl);
@@ -378,34 +513,38 @@ function createMCPServer(options) {
378
513
  path,
379
514
  method,
380
515
  headers: headers || {},
381
- body
516
+ body,
382
517
  };
383
518
  // Execute
384
519
  const response = await onExecute(session, apiReq);
385
520
  return {
386
- content: [{
387
- type: 'text',
521
+ content: [
522
+ {
523
+ type: "text",
388
524
  text: JSON.stringify({
389
525
  status: response.statusCode,
390
- body: response.body
391
- }, null, 2)
392
- }]
526
+ body: response.body,
527
+ }, null, 2),
528
+ },
529
+ ],
393
530
  };
394
531
  }
395
- case 'janee_exec': {
532
+ case "janee_exec": {
396
533
  if (!onExecCommand) {
397
- throw new Error('CLI execution not supported in this configuration');
534
+ throw new Error("CLI execution not supported in this configuration");
398
535
  }
399
- const { capability: execCapName, command: rawExecCommand, cwd: execCwd, stdin: execStdin, reason: execReason } = args;
536
+ const { capability: execCapName, command: rawExecCommand, cwd: execCwd, stdin: execStdin, reason: execReason, } = args;
400
537
  if (!execCapName) {
401
- throw new Error('Missing required argument: capability');
538
+ throw new Error("Missing required argument: capability");
402
539
  }
403
- if (!rawExecCommand || (Array.isArray(rawExecCommand) && rawExecCommand.length === 0) || (typeof rawExecCommand === 'string' && rawExecCommand.trim() === '')) {
404
- throw new Error('Missing required argument: command');
540
+ if (!rawExecCommand ||
541
+ (Array.isArray(rawExecCommand) && rawExecCommand.length === 0) ||
542
+ (typeof rawExecCommand === "string" && rawExecCommand.trim() === "")) {
543
+ throw new Error("Missing required argument: command");
405
544
  }
406
545
  const execCommand = Array.isArray(rawExecCommand)
407
546
  ? rawExecCommand
408
- : typeof rawExecCommand === 'string'
547
+ : typeof rawExecCommand === "string"
409
548
  ? rawExecCommand.trim().split(/\s+/)
410
549
  : [];
411
550
  let execCap;
@@ -413,32 +552,67 @@ function createMCPServer(options) {
413
552
  if (onForwardToolCall) {
414
553
  // Runner mode: Authority handles validation and credential injection.
415
554
  // Build a minimal capability stub so onExecCommand has the name.
416
- execCap = { name: execCapName, service: '', ttl: '1h', mode: 'exec', workDir: execCwd };
555
+ execCap = {
556
+ name: execCapName,
557
+ service: "",
558
+ ttl: "1h",
559
+ mode: "exec",
560
+ workDir: execCwd,
561
+ };
417
562
  execSession = { agentId: resolveAgentFromRequest(extra, args) };
418
563
  }
419
564
  else {
420
565
  // Standalone mode: validate locally
421
- const foundCap = capabilities.find(c => c.name === execCapName);
566
+ const foundCap = capabilities.find((c) => c.name === execCapName);
422
567
  if (!foundCap) {
423
- throw new Error(`Unknown capability: ${execCapName}`);
568
+ throw new DenialError(`Unknown capability: ${execCapName}`, {
569
+ reasonCode: 'CAPABILITY_NOT_FOUND',
570
+ capability: execCapName,
571
+ nextStep: `Run 'janee cap list' to see available capabilities, or add one with 'janee cap add'.`
572
+ });
424
573
  }
425
574
  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.`);
575
+ if (execCap.mode !== "exec") {
576
+ throw new DenialError(`Capability "${execCapName}" is not an exec-mode capability. Use the 'execute' tool for API proxy capabilities.`, {
577
+ reasonCode: "MODE_MISMATCH",
578
+ capability: execCapName,
579
+ nextStep: `Use the 'execute' tool for proxy-mode capabilities.`,
580
+ });
428
581
  }
429
582
  const execAgentId = resolveAgentFromRequest(extra, args);
430
583
  const execSvc = services.get(execCap.service);
431
584
  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`);
585
+ const execDenialDetail = explainAccessDenial(execAgentId, execCap, execSvc, defaultAccess);
586
+ auditLogger.logDenied(execCap.service, "EXEC", execCommand.join(" "), "Agent does not have access to this capability", execReason);
587
+ throw new DenialError(`Access denied: capability "${execCapName}" is not accessible to this agent`, {
588
+ reasonCode: execDenialDetail?.reason || "AGENT_NOT_ALLOWED",
589
+ capability: execCapName,
590
+ agentId: execAgentId,
591
+ evaluatedPolicy: execDenialDetail?.detail,
592
+ nextStep: execDenialDetail?.reason === "AGENT_NOT_ALLOWED"
593
+ ? `Add this agent to allowedAgents: 'janee cap edit ${execCapName} --allowed-agents ${execAgentId}'`
594
+ : execDenialDetail?.reason === "DEFAULT_ACCESS_RESTRICTED"
595
+ ? `Either add allowedAgents to the capability or change defaultAccess to 'open'.`
596
+ : `Check service ownership settings for the backing service.`,
597
+ });
434
598
  }
435
599
  if (execCap.requiresReason && !execReason) {
436
- throw new Error(`Capability "${execCapName}" requires a reason`);
600
+ throw new DenialError(`Capability "${execCapName}" requires a reason`, {
601
+ reasonCode: 'REASON_REQUIRED',
602
+ capability: execCapName,
603
+ nextStep: `Include a 'reason' argument explaining why you need this access.`
604
+ });
437
605
  }
438
606
  const cmdValidation = (0, exec_js_1.validateCommand)(execCommand, execCap.allowCommands || []);
439
607
  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');
608
+ auditLogger.logDenied(execCap.service, "EXEC", execCommand.join(" "), cmdValidation.reason || "Command not allowed", execReason);
609
+ throw new DenialError(cmdValidation.reason || "Command not allowed", {
610
+ reasonCode: "COMMAND_NOT_ALLOWED",
611
+ capability: execCapName,
612
+ agentId: execAgentId,
613
+ evaluatedPolicy: `allowCommands: [${(execCap.allowCommands || []).join(", ")}]`,
614
+ nextStep: `Update allowed commands: 'janee cap edit ${execCapName} --allow-commands "new-pattern"'`,
615
+ });
442
616
  }
443
617
  const execTtlSeconds = parseTTL(execCap.ttl);
444
618
  execSession = sessionManager.createSession(execCap.name, execCap.service, execTtlSeconds, { reason: execReason });
@@ -447,107 +621,120 @@ function createMCPServer(options) {
447
621
  // Log to audit
448
622
  auditLogger.log({
449
623
  service: execCap.service,
450
- path: execCommand.join(' '),
451
- method: 'EXEC',
452
- headers: { 'x-janee-reason': execReason || '' },
624
+ path: execCommand.join(" "),
625
+ method: "EXEC",
626
+ headers: { "x-janee-reason": execReason || "" },
453
627
  }, {
454
628
  statusCode: execResult.exitCode === 0 ? 200 : 500,
455
629
  headers: {},
456
630
  body: execResult.stdout,
457
631
  }, execResult.executionTimeMs);
458
632
  return {
459
- content: [{
460
- type: 'text',
633
+ content: [
634
+ {
635
+ type: "text",
461
636
  text: JSON.stringify({
462
637
  exitCode: execResult.exitCode,
463
638
  stdout: execResult.stdout,
464
639
  stderr: execResult.stderr,
465
640
  executionTimeMs: execResult.executionTimeMs,
466
- executionTarget: 'runner',
467
- }, null, 2)
468
- }]
641
+ executionTarget: "runner",
642
+ }, null, 2),
643
+ },
644
+ ],
469
645
  };
470
646
  }
471
- case 'manage_credential': {
472
- const { action: credAction, service: credService, targetAgentId: credTarget } = args;
647
+ case "manage_credential": {
648
+ const { action: credAction, service: credService, targetAgentId: credTarget, } = args;
473
649
  const credAgentId = resolveAgentFromRequest(extra, args);
474
650
  if (!credService) {
475
- throw new Error('Missing required argument: service');
651
+ throw new Error("Missing required argument: service");
476
652
  }
477
653
  const svc = services.get(credService);
478
654
  if (!svc) {
479
655
  throw new Error(`Unknown service: ${credService}`);
480
656
  }
481
- if (credAction === 'view') {
657
+ if (credAction === "view") {
482
658
  return {
483
- content: [{
484
- type: 'text',
659
+ content: [
660
+ {
661
+ type: "text",
485
662
  text: JSON.stringify({
486
663
  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
- }]
664
+ ownership: svc.ownership || {
665
+ accessPolicy: "all-agents",
666
+ note: "No ownership metadata (legacy credential)",
667
+ },
668
+ yourAccess: (0, agent_scope_js_1.canAgentAccess)(credAgentId, svc.ownership),
669
+ }, null, 2),
670
+ },
671
+ ],
491
672
  };
492
673
  }
493
674
  // Grant/revoke require ownership verification
494
675
  if (!credAgentId) {
495
- throw new Error('agentId is required for grant/revoke actions');
676
+ throw new Error("agentId is required for grant/revoke actions");
496
677
  }
497
678
  if (!svc.ownership) {
498
- throw new Error('Cannot manage access for legacy credentials without ownership metadata. Re-add the service to enable scoping.');
679
+ throw new Error("Cannot manage access for legacy credentials without ownership metadata. Re-add the service to enable scoping.");
499
680
  }
500
681
  if (svc.ownership.createdBy !== credAgentId) {
501
- throw new Error('Only the credential owner can grant or revoke access');
682
+ throw new Error("Only the credential owner can grant or revoke access");
502
683
  }
503
- if (credAction === 'grant') {
684
+ if (credAction === "grant") {
504
685
  if (!credTarget) {
505
- throw new Error('targetAgentId is required for grant action');
686
+ throw new Error("targetAgentId is required for grant action");
506
687
  }
507
- const { grantAccess } = await Promise.resolve().then(() => __importStar(require('./agent-scope.js')));
688
+ const { grantAccess } = await Promise.resolve().then(() => __importStar(require("./agent-scope.js")));
508
689
  svc.ownership = grantAccess(svc.ownership, credTarget);
509
690
  // Persist ownership change to config storage
510
691
  if (onPersistOwnership) {
511
692
  onPersistOwnership(credService, svc.ownership);
512
693
  }
513
694
  return {
514
- content: [{
515
- type: 'text',
695
+ content: [
696
+ {
697
+ type: "text",
516
698
  text: JSON.stringify({
517
699
  success: true,
518
700
  message: `Granted access to ${credTarget}`,
519
701
  ownership: svc.ownership,
520
- persisted: !!onPersistOwnership
521
- }, null, 2)
522
- }]
702
+ persisted: !!onPersistOwnership,
703
+ }, null, 2),
704
+ },
705
+ ],
523
706
  };
524
707
  }
525
- if (credAction === 'revoke') {
708
+ if (credAction === "revoke") {
526
709
  if (!credTarget) {
527
- throw new Error('targetAgentId is required for revoke action');
710
+ throw new Error("targetAgentId is required for revoke action");
528
711
  }
529
- const { revokeAccess } = await Promise.resolve().then(() => __importStar(require('./agent-scope.js')));
712
+ const { revokeAccess } = await Promise.resolve().then(() => __importStar(require("./agent-scope.js")));
530
713
  svc.ownership = revokeAccess(svc.ownership, credTarget);
531
714
  // Persist ownership change to config storage
532
715
  if (onPersistOwnership) {
533
716
  onPersistOwnership(credService, svc.ownership);
534
717
  }
535
718
  return {
536
- content: [{
537
- type: 'text',
719
+ content: [
720
+ {
721
+ type: "text",
538
722
  text: JSON.stringify({
539
723
  success: true,
540
724
  message: `Revoked access from ${credTarget}`,
541
725
  ownership: svc.ownership,
542
- persisted: !!onPersistOwnership
543
- }, null, 2)
544
- }]
726
+ persisted: !!onPersistOwnership,
727
+ }, null, 2),
728
+ },
729
+ ],
545
730
  };
546
731
  }
547
732
  throw new Error(`Unknown action: ${credAction}. Use 'view', 'grant', or 'revoke'.`);
548
733
  }
549
- case 'test_service': {
550
- const { service: testSvcName } = (args || {});
734
+ case "test_service": {
735
+ const { service: testSvcName, timeout: testTimeout } = (args ||
736
+ {});
737
+ const testOpts = testTimeout ? { timeout: testTimeout } : {};
551
738
  let targets;
552
739
  if (testSvcName) {
553
740
  const svc = services.get(testSvcName);
@@ -560,29 +747,185 @@ function createMCPServer(options) {
560
747
  targets = Array.from(services.entries());
561
748
  }
562
749
  if (targets.length === 0) {
563
- throw new Error('No services configured');
750
+ throw new Error("No services configured");
751
+ }
752
+ const results = await Promise.all(targets.map(([name, config]) => (0, health_js_1.testServiceConnection)(name, config, testOpts)));
753
+ return {
754
+ content: [
755
+ {
756
+ type: "text",
757
+ text: JSON.stringify(results.length === 1 ? results[0] : results, null, 2),
758
+ },
759
+ ],
760
+ };
761
+ }
762
+ case 'explain_access': {
763
+ const { agent: explainAgent, capability: explainCapName, method: explainMethod, path: explainPath } = args;
764
+ const targetAgentId = explainAgent || resolveAgentFromRequest(extra, args);
765
+ const trace = [];
766
+ const explainCap = capabilities.find(c => c.name === explainCapName);
767
+ if (!explainCap) {
768
+ trace.push({ check: 'capability_exists', result: 'fail', detail: `Capability "${explainCapName}" not found` });
769
+ return {
770
+ content: [{
771
+ type: 'text',
772
+ text: JSON.stringify({
773
+ agent: targetAgentId ?? null,
774
+ capability: explainCapName,
775
+ allowed: false,
776
+ trace,
777
+ nextStep: `Run 'janee cap list' to see available capabilities.`
778
+ }, null, 2)
779
+ }]
780
+ };
781
+ }
782
+ trace.push({ check: 'capability_exists', result: 'pass', detail: `Capability "${explainCapName}" exists (service: ${explainCap.service})` });
783
+ // Mode check
784
+ if (explainMethod && explainCap.mode === 'exec') {
785
+ trace.push({ check: 'mode', result: 'fail', detail: `Capability is exec-mode but method/path were provided (use janee_exec)` });
786
+ }
787
+ else if (!explainMethod && explainCap.mode !== 'exec') {
788
+ trace.push({ check: 'mode', result: 'pass', detail: `Capability mode: ${explainCap.mode || 'proxy'}` });
789
+ }
790
+ else {
791
+ trace.push({ check: 'mode', result: 'pass', detail: `Capability mode: ${explainCap.mode || 'proxy'}` });
792
+ }
793
+ // allowedAgents
794
+ if (explainCap.allowedAgents && explainCap.allowedAgents.length > 0) {
795
+ if (!targetAgentId) {
796
+ trace.push({ check: 'allowed_agents', result: 'pass', detail: `No agent ID (admin/CLI) — bypasses allowedAgents` });
797
+ }
798
+ else if (explainCap.allowedAgents.includes(targetAgentId)) {
799
+ trace.push({ check: 'allowed_agents', result: 'pass', detail: `Agent "${targetAgentId}" is in allowedAgents [${explainCap.allowedAgents.join(', ')}]` });
800
+ }
801
+ else {
802
+ trace.push({ check: 'allowed_agents', result: 'fail', detail: `Agent "${targetAgentId}" is NOT in allowedAgents [${explainCap.allowedAgents.join(', ')}]` });
803
+ }
804
+ }
805
+ else {
806
+ trace.push({ check: 'allowed_agents', result: 'skip', detail: `No allowedAgents restriction on this capability` });
807
+ }
808
+ // defaultAccess
809
+ if (targetAgentId && (!explainCap.allowedAgents || explainCap.allowedAgents.length === 0)) {
810
+ if (defaultAccess === 'restricted') {
811
+ trace.push({ check: 'default_access', result: 'fail', detail: `defaultAccess is "restricted" and no allowedAgents list — agent blocked` });
812
+ }
813
+ else {
814
+ trace.push({ check: 'default_access', result: 'pass', detail: `defaultAccess is "${defaultAccess ?? 'open'}" — agent allowed` });
815
+ }
816
+ }
817
+ else {
818
+ trace.push({ check: 'default_access', result: 'skip', detail: targetAgentId ? `allowedAgents list takes precedence` : `No agent ID (admin/CLI)` });
819
+ }
820
+ // Ownership
821
+ const explainSvc = services.get(explainCap.service);
822
+ if (targetAgentId && explainSvc?.ownership) {
823
+ if ((0, agent_scope_js_1.canAgentAccess)(targetAgentId, explainSvc.ownership)) {
824
+ trace.push({ check: 'ownership', result: 'pass', detail: `Agent can access service (ownership: ${JSON.stringify(explainSvc.ownership)})` });
825
+ }
826
+ else {
827
+ trace.push({ check: 'ownership', result: 'fail', detail: `Agent cannot access service (ownership: ${JSON.stringify(explainSvc.ownership)})` });
828
+ }
829
+ }
830
+ else {
831
+ trace.push({ check: 'ownership', result: 'skip', detail: explainSvc?.ownership ? `No agent ID (admin/CLI)` : `No ownership restrictions on service` });
832
+ }
833
+ // Rules check (only if method/path provided)
834
+ if (explainMethod && explainPath && explainCap.mode !== 'exec') {
835
+ const ruleResult = (0, rules_js_1.checkRules)(explainCap.rules, explainMethod, explainPath);
836
+ if (ruleResult.allowed) {
837
+ trace.push({ check: 'rules', result: 'pass', detail: `${explainMethod} ${explainPath} is allowed by rules` });
838
+ }
839
+ else {
840
+ trace.push({ check: 'rules', result: 'fail', detail: ruleResult.reason || `${explainMethod} ${explainPath} is denied by rules` });
841
+ }
842
+ }
843
+ else if (explainCap.mode === 'exec') {
844
+ trace.push({ check: 'rules', result: 'skip', detail: `Exec-mode capabilities use allowCommands, not path rules` });
564
845
  }
565
- const results = await Promise.all(targets.map(([name, config]) => (0, health_js_1.testServiceConnection)(name, config)));
846
+ else {
847
+ trace.push({ check: 'rules', result: 'skip', detail: `No method/path provided for rules evaluation` });
848
+ }
849
+ // Command validation for exec mode
850
+ if (explainCap.mode === 'exec') {
851
+ trace.push({ check: 'allow_commands', result: 'skip', detail: `allowCommands: [${(explainCap.allowCommands || []).join(', ')}] — provide a specific command to validate` });
852
+ }
853
+ const hasFail = trace.some(t => t.result === 'fail');
854
+ const firstFail = trace.find(t => t.result === 'fail');
566
855
  return {
567
856
  content: [{
568
857
  type: 'text',
569
- text: JSON.stringify(results.length === 1 ? results[0] : results, null, 2)
858
+ text: JSON.stringify({
859
+ agent: targetAgentId ?? null,
860
+ capability: explainCapName,
861
+ allowed: !hasFail,
862
+ trace,
863
+ ...(hasFail && firstFail ? { nextStep: firstFail.detail } : {})
864
+ }, null, 2)
570
865
  }]
571
866
  };
572
867
  }
868
+ case 'whoami': {
869
+ const whoamiAgentId = resolveAgentFromRequest(extra, args);
870
+ const accessibleCaps = capabilities
871
+ .filter(cap => canAccessCapability(whoamiAgentId, cap, services.get(cap.service), defaultAccess))
872
+ .map(cap => cap.name);
873
+ const deniedCaps = capabilities
874
+ .filter(cap => !canAccessCapability(whoamiAgentId, cap, services.get(cap.service), defaultAccess))
875
+ .map(cap => cap.name);
876
+ return {
877
+ content: [{
878
+ type: 'text',
879
+ text: JSON.stringify({
880
+ agentId: whoamiAgentId ?? null,
881
+ identitySource: whoamiAgentId
882
+ ? ((extra?.sessionId && clientSessions.has(extra.sessionId)) || clientSessions.has('__default__')
883
+ ? 'transport (clientInfo.name)'
884
+ : 'client-asserted (untrusted)')
885
+ : 'none',
886
+ defaultAccessPolicy: defaultAccess ?? 'open',
887
+ capabilities: {
888
+ accessible: accessibleCaps,
889
+ denied: deniedCaps,
890
+ },
891
+ }, null, 2)
892
+ }]
893
+ };
894
+ }
895
+ case "doctor": {
896
+ if (!options.onDoctorRunner) {
897
+ throw new Error("Doctor diagnostics only available in runner mode.");
898
+ }
899
+ const doctorAgentId = resolveAgentFromRequest(extra, args);
900
+ const doctorResult = await options.onDoctorRunner(doctorAgentId);
901
+ return {
902
+ content: [
903
+ {
904
+ type: "text",
905
+ text: JSON.stringify(doctorResult, null, 2),
906
+ },
907
+ ],
908
+ };
909
+ }
573
910
  default:
574
911
  throw new Error(`Unknown tool: ${name}`);
575
912
  }
576
913
  }
577
914
  catch (error) {
915
+ const payload = {
916
+ error: error instanceof Error ? error.message : "Unknown error",
917
+ };
918
+ if (error instanceof DenialError) {
919
+ payload.denial = error.denial;
920
+ }
578
921
  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
922
+ content: [
923
+ {
924
+ type: "text",
925
+ text: JSON.stringify(payload, null, 2),
926
+ },
927
+ ],
928
+ isError: true,
586
929
  };
587
930
  }
588
931
  });
@@ -600,31 +943,31 @@ function createMCPServer(options) {
600
943
  */
601
944
  function makeAPIRequest(targetUrl, request) {
602
945
  return new Promise((resolve, reject) => {
603
- const client = targetUrl.protocol === 'https:' ? https_1.default : http_1.default;
946
+ const client = targetUrl.protocol === "https:" ? https_1.default : http_1.default;
604
947
  const options = {
605
948
  hostname: targetUrl.hostname,
606
949
  port: targetUrl.port,
607
950
  path: targetUrl.pathname + targetUrl.search,
608
951
  method: request.method,
609
952
  headers: {
610
- 'User-Agent': 'janee/' + pkgVersion,
611
- ...request.headers
612
- }
953
+ "User-Agent": "janee/" + pkgVersion,
954
+ ...request.headers,
955
+ },
613
956
  };
614
957
  const req = client.request(options, (res) => {
615
- let body = '';
616
- res.on('data', (chunk) => {
958
+ let body = "";
959
+ res.on("data", (chunk) => {
617
960
  body += chunk;
618
961
  });
619
- res.on('end', () => {
962
+ res.on("end", () => {
620
963
  resolve({
621
964
  statusCode: res.statusCode || 500,
622
965
  headers: res.headers,
623
- body
966
+ body,
624
967
  });
625
968
  });
626
969
  });
627
- req.on('error', (error) => {
970
+ req.on("error", (error) => {
628
971
  reject(error);
629
972
  });
630
973
  if (request.body) {
@@ -641,8 +984,8 @@ function makeAPIRequest(targetUrl, request) {
641
984
  function captureClientInfo(transport, clientSessions) {
642
985
  const original = transport.onmessage;
643
986
  transport.onmessage = (message, extra) => {
644
- if (message?.method === 'initialize' && message?.params?.clientInfo?.name) {
645
- const key = extra?.sessionId || '__default__';
987
+ if (message?.method === "initialize" && message?.params?.clientInfo?.name) {
988
+ const key = extra?.sessionId || "__default__";
646
989
  clientSessions.set(key, message.params.clientInfo.name);
647
990
  }
648
991
  return original?.call(transport, message, extra);
@@ -657,7 +1000,7 @@ async function startMCPServer(serverOptions) {
657
1000
  const transport = new stdio_js_1.StdioServerTransport();
658
1001
  await server.connect(transport);
659
1002
  captureClientInfo(transport, clientSessions);
660
- console.error('Janee MCP server started (stdio)');
1003
+ console.error("Janee MCP server started (stdio)");
661
1004
  return mcpResult;
662
1005
  }
663
1006
  /**
@@ -679,83 +1022,103 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
679
1022
  const sessions = new Map();
680
1023
  // Authority REST endpoints -- active when runnerKey is provided
681
1024
  if (httpOptions.runnerKey && httpOptions.authorityHooks) {
682
- const { timingSafeEqual } = await Promise.resolve().then(() => __importStar(require('crypto')));
1025
+ const { timingSafeEqual } = await Promise.resolve().then(() => __importStar(require("crypto")));
683
1026
  const runnerKey = httpOptions.runnerKey;
684
1027
  const hooks = httpOptions.authorityHooks;
685
1028
  const authMiddleware = (req, res, next) => {
686
- const provided = req.header('x-janee-runner-key');
687
- if (!provided || provided.length !== runnerKey.length ||
1029
+ const provided = req.header("x-janee-runner-key");
1030
+ if (!provided ||
1031
+ provided.length !== runnerKey.length ||
688
1032
  !timingSafeEqual(Buffer.from(provided), Buffer.from(runnerKey))) {
689
- res.status(401).json({ error: 'Unauthorized runner request' });
1033
+ res.status(401).json({ error: "Unauthorized runner request" });
690
1034
  return;
691
1035
  }
692
1036
  next();
693
1037
  };
694
- app.get('/v1/health', (_req, res) => {
695
- res.status(200).json({ ok: true, mode: 'authority' });
1038
+ app.get("/v1/health", (_req, res) => {
1039
+ res.status(200).json({ ok: true, mode: "authority" });
696
1040
  });
697
- app.post('/v1/exec/authorize', authMiddleware, async (req, res) => {
1041
+ app.post("/v1/exec/authorize", authMiddleware, async (req, res) => {
698
1042
  try {
699
1043
  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' });
1044
+ if (!body?.runner?.runnerId ||
1045
+ !Array.isArray(body?.command) ||
1046
+ body.command.length === 0 ||
1047
+ !body.capabilityId) {
1048
+ res.status(400).json({ error: "Invalid authorize request" });
702
1049
  return;
703
1050
  }
704
1051
  const response = await hooks.authorizeExec(body);
705
1052
  res.status(200).json(response);
706
1053
  }
707
1054
  catch (error) {
708
- res.status(403).json({ error: error instanceof Error ? error.message : 'Authorization failed' });
1055
+ res
1056
+ .status(403)
1057
+ .json({
1058
+ error: error instanceof Error ? error.message : "Authorization failed",
1059
+ });
709
1060
  }
710
1061
  });
711
- app.post('/v1/exec/complete', authMiddleware, async (req, res) => {
1062
+ app.post("/v1/exec/complete", authMiddleware, async (req, res) => {
712
1063
  try {
713
1064
  if (!req.body?.grantId) {
714
- res.status(400).json({ error: 'grantId is required' });
1065
+ res.status(400).json({ error: "grantId is required" });
715
1066
  return;
716
1067
  }
717
1068
  await hooks.completeExec(req.body);
718
1069
  res.status(200).json({ ok: true });
719
1070
  }
720
1071
  catch (error) {
721
- res.status(500).json({ error: error instanceof Error ? error.message : 'completion failed' });
1072
+ res
1073
+ .status(500)
1074
+ .json({
1075
+ error: error instanceof Error ? error.message : "completion failed",
1076
+ });
722
1077
  }
723
1078
  });
724
1079
  if (hooks.testService) {
725
- app.post('/v1/test', authMiddleware, async (req, res) => {
1080
+ app.post("/v1/test", authMiddleware, async (req, res) => {
726
1081
  try {
727
- const result = await hooks.testService(req.body?.service);
1082
+ const result = await hooks.testService(req.body?.service, {
1083
+ timeout: req.body?.timeout,
1084
+ });
728
1085
  res.status(200).json(result);
729
1086
  }
730
1087
  catch (error) {
731
- res.status(500).json({ error: error instanceof Error ? error.message : 'Test failed' });
1088
+ res
1089
+ .status(500)
1090
+ .json({
1091
+ error: error instanceof Error ? error.message : "Test failed",
1092
+ });
732
1093
  }
733
1094
  });
734
1095
  }
735
1096
  }
736
1097
  // 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
1098
+ const idleSweepInterval = idleTimeoutMs > 0
1099
+ ? setInterval(async () => {
1100
+ const now = Date.now();
1101
+ for (const [sid, session] of sessions.entries()) {
1102
+ if (now - session.lastActivityAt > idleTimeoutMs) {
1103
+ console.error(`Closing idle session ${sid} (inactive for ${Math.round((now - session.lastActivityAt) / 1000)}s)`);
1104
+ sessions.delete(sid);
1105
+ try {
1106
+ await session.transport.close?.();
1107
+ await session.server.close();
1108
+ }
1109
+ catch {
1110
+ // best-effort cleanup
1111
+ }
749
1112
  }
750
1113
  }
751
- }
752
- }, Math.min(idleTimeoutMs, 60_000)) : undefined;
1114
+ }, Math.min(idleTimeoutMs, 60_000))
1115
+ : undefined;
753
1116
  if (idleSweepInterval) {
754
1117
  idleSweepInterval.unref?.(); // Don't prevent Node from exiting
755
1118
  }
756
- app.all('/mcp', async (req, res) => {
1119
+ app.all("/mcp", async (req, res) => {
757
1120
  try {
758
- const sessionId = req.headers['mcp-session-id'];
1121
+ const sessionId = req.headers["mcp-session-id"];
759
1122
  if (sessionId && sessions.has(sessionId)) {
760
1123
  const session = sessions.get(sessionId);
761
1124
  session.lastActivityAt = Date.now();
@@ -768,7 +1131,11 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
768
1131
  const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
769
1132
  sessionIdGenerator: () => crypto.randomUUID(),
770
1133
  onsessioninitialized: (sid) => {
771
- sessions.set(sid, { transport, server, lastActivityAt: Date.now() });
1134
+ sessions.set(sid, {
1135
+ transport,
1136
+ server,
1137
+ lastActivityAt: Date.now(),
1138
+ });
772
1139
  if (clientName)
773
1140
  clientSessions.set(sid, clientName);
774
1141
  },
@@ -788,25 +1155,28 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
788
1155
  }
789
1156
  else if (sessionId) {
790
1157
  res.status(404).json({
791
- jsonrpc: '2.0',
792
- error: { code: -32001, message: 'Session not found' },
1158
+ jsonrpc: "2.0",
1159
+ error: { code: -32001, message: "Session not found" },
793
1160
  id: null,
794
1161
  });
795
1162
  }
796
1163
  else {
797
1164
  res.status(400).json({
798
- jsonrpc: '2.0',
799
- error: { code: -32600, message: 'Bad Request: Missing session ID or not an initialize request' },
1165
+ jsonrpc: "2.0",
1166
+ error: {
1167
+ code: -32600,
1168
+ message: "Bad Request: Missing session ID or not an initialize request",
1169
+ },
800
1170
  id: null,
801
1171
  });
802
1172
  }
803
1173
  }
804
1174
  catch (error) {
805
- console.error('Error handling MCP request:', error);
1175
+ console.error("Error handling MCP request:", error);
806
1176
  if (!res.headersSent) {
807
1177
  res.status(500).json({
808
- jsonrpc: '2.0',
809
- error: { code: -32603, message: 'Internal server error' },
1178
+ jsonrpc: "2.0",
1179
+ error: { code: -32603, message: "Internal server error" },
810
1180
  id: null,
811
1181
  });
812
1182
  }