@true-and-useful/janee 0.11.3 → 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 (71) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/commands/add.d.ts +1 -0
  3. package/dist/cli/commands/add.d.ts.map +1 -1
  4. package/dist/cli/commands/add.js +23 -5
  5. package/dist/cli/commands/add.js.map +1 -1
  6. package/dist/cli/commands/capability.d.ts +12 -10
  7. package/dist/cli/commands/capability.d.ts.map +1 -1
  8. package/dist/cli/commands/capability.js +77 -40
  9. package/dist/cli/commands/capability.js.map +1 -1
  10. package/dist/cli/commands/config.d.ts +7 -0
  11. package/dist/cli/commands/config.d.ts.map +1 -0
  12. package/dist/cli/commands/config.js +135 -0
  13. package/dist/cli/commands/config.js.map +1 -0
  14. package/dist/cli/commands/diagnose.d.ts +7 -0
  15. package/dist/cli/commands/diagnose.d.ts.map +1 -0
  16. package/dist/cli/commands/diagnose.js +133 -0
  17. package/dist/cli/commands/diagnose.js.map +1 -0
  18. package/dist/cli/commands/doctor-bundle.d.ts +6 -0
  19. package/dist/cli/commands/doctor-bundle.d.ts.map +1 -0
  20. package/dist/cli/commands/doctor-bundle.js +108 -0
  21. package/dist/cli/commands/doctor-bundle.js.map +1 -0
  22. package/dist/cli/commands/doctor.d.ts +6 -0
  23. package/dist/cli/commands/doctor.d.ts.map +1 -0
  24. package/dist/cli/commands/doctor.js +163 -0
  25. package/dist/cli/commands/doctor.js.map +1 -0
  26. package/dist/cli/commands/serve-mcp.d.ts.map +1 -1
  27. package/dist/cli/commands/serve-mcp.js +46 -97
  28. package/dist/cli/commands/serve-mcp.js.map +1 -1
  29. package/dist/cli/commands/service-edit.d.ts +16 -0
  30. package/dist/cli/commands/service-edit.d.ts.map +1 -0
  31. package/dist/cli/commands/service-edit.js +187 -0
  32. package/dist/cli/commands/service-edit.js.map +1 -0
  33. package/dist/cli/commands/test.d.ts +6 -0
  34. package/dist/cli/commands/test.d.ts.map +1 -0
  35. package/dist/cli/commands/test.js +101 -0
  36. package/dist/cli/commands/test.js.map +1 -0
  37. package/dist/cli/commands/whoami.d.ts +5 -0
  38. package/dist/cli/commands/whoami.d.ts.map +1 -0
  39. package/dist/cli/commands/whoami.js +91 -0
  40. package/dist/cli/commands/whoami.js.map +1 -0
  41. package/dist/cli/config-yaml.d.ts +15 -15
  42. package/dist/cli/config-yaml.d.ts.map +1 -1
  43. package/dist/cli/config-yaml.js +213 -90
  44. package/dist/cli/config-yaml.js.map +1 -1
  45. package/dist/cli/index.js +97 -8
  46. package/dist/cli/index.js.map +1 -1
  47. package/dist/core/auth.d.ts +25 -0
  48. package/dist/core/auth.d.ts.map +1 -0
  49. package/dist/core/auth.js +136 -0
  50. package/dist/core/auth.js.map +1 -0
  51. package/dist/core/authority.d.ts +5 -1
  52. package/dist/core/authority.d.ts.map +1 -1
  53. package/dist/core/authority.js +93 -38
  54. package/dist/core/authority.js.map +1 -1
  55. package/dist/core/directory.d.ts +2 -0
  56. package/dist/core/directory.d.ts.map +1 -1
  57. package/dist/core/directory.js +23 -0
  58. package/dist/core/directory.js.map +1 -1
  59. package/dist/core/health.d.ts +62 -1
  60. package/dist/core/health.d.ts.map +1 -1
  61. package/dist/core/health.js +242 -8
  62. package/dist/core/health.js.map +1 -1
  63. package/dist/core/mcp-server.d.ts +36 -12
  64. package/dist/core/mcp-server.d.ts.map +1 -1
  65. package/dist/core/mcp-server.js +635 -235
  66. package/dist/core/mcp-server.js.map +1 -1
  67. package/dist/index.d.ts +12 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +28 -0
  70. package/dist/index.js.map +1 -0
  71. package/package.json +8 -1
@@ -40,26 +40,38 @@ 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;
46
48
  exports.startMCPServer = startMCPServer;
47
49
  exports.startMCPServerHTTP = startMCPServerHTTP;
50
+ const express_1 = __importDefault(require("express"));
51
+ const fs_1 = require("fs");
52
+ const http_1 = __importDefault(require("http"));
53
+ const https_1 = __importDefault(require("https"));
54
+ const path_1 = require("path");
48
55
  const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
49
56
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
50
57
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
51
58
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
52
- const rules_js_1 = require("./rules.js");
53
- const http_1 = __importDefault(require("http"));
54
- const https_1 = __importDefault(require("https"));
55
- const express_1 = __importDefault(require("express"));
56
- const exec_js_1 = require("./exec.js");
57
- const fs_1 = require("fs");
58
59
  const agent_scope_js_1 = require("./agent-scope.js");
59
- const path_1 = require("path");
60
+ const exec_js_1 = require("./exec.js");
61
+ const health_js_1 = require("./health.js");
62
+ const rules_js_1 = require("./rules.js");
60
63
  // Read version from package.json
61
- const packageJsonPath = (0, path_1.join)(__dirname, '../../package.json');
62
- 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;
63
75
  /**
64
76
  * Check whether an agent can access a capability.
65
77
  * Checks capability-level allowedAgents first, then falls back to
@@ -73,11 +85,40 @@ function canAccessCapability(agentId, cap, service, defaultAccessPolicy) {
73
85
  if (cap.allowedAgents && cap.allowedAgents.length > 0) {
74
86
  return cap.allowedAgents.includes(agentId);
75
87
  }
76
- if (defaultAccessPolicy === 'restricted') {
88
+ if (defaultAccessPolicy === "restricted") {
77
89
  return false;
78
90
  }
79
91
  return (0, agent_scope_js_1.canAgentAccess)(agentId, service?.ownership);
80
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
+ }
81
122
  /**
82
123
  * Parse TTL string to seconds
83
124
  */
@@ -91,7 +132,7 @@ function parseTTL(ttl) {
91
132
  s: 1,
92
133
  m: 60,
93
134
  h: 3600,
94
- d: 86400
135
+ d: 86400,
95
136
  };
96
137
  return value * multipliers[unit];
97
138
  }
@@ -99,7 +140,7 @@ function parseTTL(ttl) {
99
140
  * Create and start MCP server
100
141
  */
101
142
  function createMCPServer(options) {
102
- const { sessionManager, auditLogger, defaultAccess, onExecute, onExecCommand, onReloadConfig, onPersistOwnership, onForwardToolCall } = options;
143
+ const { sessionManager, auditLogger, defaultAccess, onExecute, onExecCommand, onReloadConfig, onPersistOwnership, onForwardToolCall, } = options;
103
144
  // Store as mutable to support hot-reloading
104
145
  let capabilities = options.capabilities;
105
146
  let services = options.services;
@@ -107,12 +148,12 @@ function createMCPServer(options) {
107
148
  // Populated by captureClientInfo() after connecting a transport.
108
149
  const clientSessions = new Map();
109
150
  const server = new index_js_1.Server({
110
- name: 'janee',
111
- version: pkgVersion
151
+ name: "janee",
152
+ version: pkgVersion,
112
153
  }, {
113
154
  capabilities: {
114
- tools: {}
115
- }
155
+ tools: {},
156
+ },
116
157
  });
117
158
  /**
118
159
  * Resolve agent identity from the MCP session.
@@ -122,126 +163,192 @@ function createMCPServer(options) {
122
163
  * Falls back to args.agentId for legacy scenarios.
123
164
  */
124
165
  function resolveAgentFromRequest(extra, args) {
125
- const sessionKey = extra?.sessionId || '__default__';
126
- const clientName = clientSessions.get(sessionKey) || clientSessions.get('__default__');
127
- 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);
128
172
  }
129
173
  // Tool: list_services
130
174
  const listServicesTool = {
131
- name: 'list_services',
132
- description: 'List available API capabilities managed by Janee',
175
+ name: "list_services",
176
+ description: "List available API capabilities managed by Janee",
133
177
  inputSchema: {
134
- type: 'object',
178
+ type: "object",
135
179
  properties: {},
136
- required: []
137
- }
180
+ required: [],
181
+ },
138
182
  };
139
183
  // Tool: execute
140
184
  const executeTool = {
141
- name: 'execute',
142
- description: 'Execute an API request through Janee proxy',
185
+ name: "execute",
186
+ description: "Execute an API request through Janee proxy",
143
187
  inputSchema: {
144
- type: 'object',
188
+ type: "object",
145
189
  properties: {
146
190
  capability: {
147
- type: 'string',
148
- description: 'Capability name to use (from list_services)'
191
+ type: "string",
192
+ description: "Capability name to use (from list_services)",
149
193
  },
150
194
  method: {
151
- type: 'string',
152
- enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
153
- description: 'HTTP method'
195
+ type: "string",
196
+ enum: ["GET", "POST", "PUT", "DELETE", "PATCH"],
197
+ description: "HTTP method",
154
198
  },
155
199
  path: {
156
- type: 'string',
157
- description: 'API path (e.g., /v1/customers)'
200
+ type: "string",
201
+ description: "API path (e.g., /v1/customers)",
158
202
  },
159
203
  body: {
160
- type: 'string',
161
- description: 'Request body (JSON string, optional)'
204
+ type: "string",
205
+ description: "Request body (JSON string, optional)",
162
206
  },
163
207
  headers: {
164
- type: 'object',
165
- description: 'Additional headers (optional)',
166
- additionalProperties: { type: 'string' }
208
+ type: "object",
209
+ description: "Additional headers (optional)",
210
+ additionalProperties: { type: "string" },
167
211
  },
168
212
  reason: {
169
- type: 'string',
170
- description: 'Reason for this request (required for some capabilities)'
171
- }
213
+ type: "string",
214
+ description: "Reason for this request (required for some capabilities)",
215
+ },
172
216
  },
173
- required: ['capability', 'method', 'path']
174
- }
217
+ required: ["capability", "method", "path"],
218
+ },
175
219
  };
176
220
  // Tool: reload_config
177
221
  const reloadConfigTool = {
178
- name: 'reload_config',
179
- 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.",
180
224
  inputSchema: {
181
- type: 'object',
225
+ type: "object",
182
226
  properties: {},
183
- required: []
184
- }
227
+ required: [],
228
+ },
185
229
  };
186
230
  // Tool: janee_exec (RFC 0001 - Secure CLI Execution)
187
231
  const execTool = {
188
- name: 'janee_exec',
189
- 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.",
190
234
  inputSchema: {
191
- type: 'object',
235
+ type: "object",
192
236
  properties: {
193
237
  capability: {
194
- type: 'string',
195
- description: 'Capability name (must be exec mode, from list_services)'
238
+ type: "string",
239
+ description: "Capability name (must be exec mode, from list_services)",
196
240
  },
197
241
  command: {
198
- type: 'array',
199
- items: { type: 'string' },
200
- 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"]',
201
245
  },
202
246
  cwd: {
203
- type: 'string',
204
- description: 'Working directory for the command (defaults to process cwd)'
247
+ type: "string",
248
+ description: "Working directory for the command (defaults to process cwd)",
205
249
  },
206
250
  stdin: {
207
- type: 'string',
208
- description: 'Optional stdin input to pipe to the command'
251
+ type: "string",
252
+ description: "Optional stdin input to pipe to the command",
209
253
  },
210
254
  reason: {
211
- type: 'string',
212
- description: 'Reason for this execution (required for some capabilities)'
213
- }
255
+ type: "string",
256
+ description: "Reason for this execution (required for some capabilities)",
257
+ },
214
258
  },
215
- required: ['capability', 'command']
216
- }
259
+ required: ["capability", "command"],
260
+ },
217
261
  };
218
262
  // Tool: manage_credential (agent-scoped credential access control)
219
263
  const manageCredentialTool = {
220
- name: 'manage_credential',
221
- 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.",
222
266
  inputSchema: {
223
- type: 'object',
267
+ type: "object",
224
268
  properties: {
225
269
  action: {
226
- type: 'string',
227
- enum: ['view', 'grant', 'revoke'],
228
- 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",
229
273
  },
230
274
  service: {
231
- type: 'string',
232
- description: 'Service name to manage'
275
+ type: "string",
276
+ description: "Service name to manage",
233
277
  },
234
278
  targetAgentId: {
279
+ type: "string",
280
+ description: "Agent ID to grant/revoke access for (required for grant/revoke actions)",
281
+ },
282
+ },
283
+ required: ["action", "service"],
284
+ },
285
+ };
286
+ // Tool: test_service
287
+ const testServiceTool = {
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.",
290
+ inputSchema: {
291
+ type: "object",
292
+ properties: {
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: {
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: {
235
335
  type: 'string',
236
- description: 'Agent ID to grant/revoke access for (required for grant/revoke actions)'
336
+ description: 'Request path for rules evaluation (optional, only applies to proxy capabilities).'
237
337
  }
238
338
  },
239
- required: ['action', 'service']
339
+ required: ['capability']
240
340
  }
241
341
  };
242
342
  // Register tools
243
343
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
244
- const tools = [listServicesTool, executeTool, manageCredentialTool];
344
+ const tools = [
345
+ listServicesTool,
346
+ executeTool,
347
+ manageCredentialTool,
348
+ testServiceTool,
349
+ whoamiTool,
350
+ explainAccessTool,
351
+ ];
245
352
  if (onExecCommand && !options.hideExecTool) {
246
353
  tools.push(execTool);
247
354
  }
@@ -254,42 +361,40 @@ function createMCPServer(options) {
254
361
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
255
362
  const { name, arguments: args } = request.params;
256
363
  try {
257
- // Runner proxy: forward non-exec tools to the Authority
258
- if (onForwardToolCall && name !== 'janee_exec') {
364
+ // Runner proxy: forward all non-exec tools to the Authority
365
+ if (onForwardToolCall && name !== "janee_exec") {
259
366
  const forwardAgentId = resolveAgentFromRequest(extra, args);
260
367
  const result = await onForwardToolCall(name, (args || {}), forwardAgentId);
261
368
  return result;
262
369
  }
263
370
  switch (name) {
264
- case 'list_services': {
371
+ case "list_services": {
265
372
  const listAgentId = resolveAgentFromRequest(extra, args);
266
373
  return {
267
- content: [{
268
- type: 'text',
269
- text: JSON.stringify(capabilities
270
- .filter(cap => {
271
- const svc = services.get(cap.service);
272
- return canAccessCapability(listAgentId, cap, svc, defaultAccess);
273
- })
274
- .map(cap => ({
374
+ content: [
375
+ {
376
+ type: "text",
377
+ text: JSON.stringify(capabilities.map((cap) => ({
378
+ accessible: canAccessCapability(listAgentId, cap, services.get(cap.service), defaultAccess),
275
379
  name: cap.name,
276
380
  service: cap.service,
277
- mode: cap.mode || 'proxy',
381
+ mode: cap.mode || "proxy",
278
382
  ttl: cap.ttl,
279
383
  autoApprove: cap.autoApprove,
280
384
  requiresReason: cap.requiresReason,
281
385
  rules: cap.rules,
282
- ...(cap.mode === 'exec' && {
386
+ ...(cap.mode === "exec" && {
283
387
  allowCommands: cap.allowCommands,
284
388
  env: cap.env ? Object.keys(cap.env) : undefined,
285
- })
286
- })), null, 2)
287
- }]
389
+ }),
390
+ })), null, 2),
391
+ },
392
+ ],
288
393
  };
289
394
  }
290
- case 'reload_config': {
395
+ case "reload_config": {
291
396
  if (!onReloadConfig) {
292
- throw new Error('Config reload not supported');
397
+ throw new Error("Config reload not supported");
293
398
  }
294
399
  try {
295
400
  const result = onReloadConfig();
@@ -298,63 +403,93 @@ function createMCPServer(options) {
298
403
  capabilities = result.capabilities;
299
404
  services = result.services;
300
405
  return {
301
- content: [{
302
- type: 'text',
406
+ content: [
407
+ {
408
+ type: "text",
303
409
  text: JSON.stringify({
304
410
  success: true,
305
- message: 'Configuration reloaded successfully',
411
+ message: "Configuration reloaded successfully",
306
412
  services: services.size,
307
413
  capabilities: capabilities.length,
308
414
  changes: {
309
415
  services: services.size - prevServiceCount,
310
- capabilities: capabilities.length - prevCapCount
311
- }
312
- }, null, 2)
313
- }]
416
+ capabilities: capabilities.length - prevCapCount,
417
+ },
418
+ }, null, 2),
419
+ },
420
+ ],
314
421
  };
315
422
  }
316
423
  catch (error) {
317
- 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"}`);
318
425
  }
319
426
  }
320
- case 'execute': {
427
+ case "execute": {
321
428
  const { capability, method, path, body, headers, reason } = args;
322
429
  // Validate required arguments
323
430
  if (!capability) {
324
- throw new Error('Missing required argument: capability');
431
+ throw new Error("Missing required argument: capability");
325
432
  }
326
433
  if (!method) {
327
- 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.)");
328
435
  }
329
436
  if (!path) {
330
- throw new Error('Missing required argument: path');
437
+ throw new Error("Missing required argument: path");
331
438
  }
332
439
  // Find capability
333
- const cap = capabilities.find(c => c.name === capability);
440
+ const cap = capabilities.find((c) => c.name === capability);
334
441
  if (!cap) {
335
- 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
+ });
336
447
  }
337
448
  // Reject exec-mode capabilities — they should use janee_exec instead
338
- if (cap.mode === 'exec') {
339
- 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
+ });
340
455
  }
341
456
  // Check if reason required
342
457
  if (cap.requiresReason && !reason) {
343
- 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
+ });
344
463
  }
345
464
  // Check rules (path-based policies)
346
465
  const ruleCheck = (0, rules_js_1.checkRules)(cap.rules, method, path);
347
466
  if (!ruleCheck.allowed) {
348
- // Log denied request
349
- auditLogger.logDenied(cap.service, method, path, ruleCheck.reason || 'Request denied by policy', reason);
350
- 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
+ });
351
475
  }
352
476
  // Check agent-scoped access (capability-level allowedAgents, then service-level ownership)
353
477
  const executeAgentId = resolveAgentFromRequest(extra, args);
354
478
  const executeSvc = services.get(cap.service);
355
479
  if (!canAccessCapability(executeAgentId, cap, executeSvc, defaultAccess)) {
356
- auditLogger.logDenied(cap.service, method, path, 'Agent does not have access to this capability', reason);
357
- 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
+ });
358
493
  }
359
494
  // Get or create session
360
495
  const ttlSeconds = parseTTL(cap.ttl);
@@ -365,34 +500,38 @@ function createMCPServer(options) {
365
500
  path,
366
501
  method,
367
502
  headers: headers || {},
368
- body
503
+ body,
369
504
  };
370
505
  // Execute
371
506
  const response = await onExecute(session, apiReq);
372
507
  return {
373
- content: [{
374
- type: 'text',
508
+ content: [
509
+ {
510
+ type: "text",
375
511
  text: JSON.stringify({
376
512
  status: response.statusCode,
377
- body: response.body
378
- }, null, 2)
379
- }]
513
+ body: response.body,
514
+ }, null, 2),
515
+ },
516
+ ],
380
517
  };
381
518
  }
382
- case 'janee_exec': {
519
+ case "janee_exec": {
383
520
  if (!onExecCommand) {
384
- throw new Error('CLI execution not supported in this configuration');
521
+ throw new Error("CLI execution not supported in this configuration");
385
522
  }
386
- 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;
387
524
  if (!execCapName) {
388
- throw new Error('Missing required argument: capability');
525
+ throw new Error("Missing required argument: capability");
389
526
  }
390
- if (!rawExecCommand || (Array.isArray(rawExecCommand) && rawExecCommand.length === 0) || (typeof rawExecCommand === 'string' && rawExecCommand.trim() === '')) {
391
- 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");
392
531
  }
393
532
  const execCommand = Array.isArray(rawExecCommand)
394
533
  ? rawExecCommand
395
- : typeof rawExecCommand === 'string'
534
+ : typeof rawExecCommand === "string"
396
535
  ? rawExecCommand.trim().split(/\s+/)
397
536
  : [];
398
537
  let execCap;
@@ -400,32 +539,67 @@ function createMCPServer(options) {
400
539
  if (onForwardToolCall) {
401
540
  // Runner mode: Authority handles validation and credential injection.
402
541
  // Build a minimal capability stub so onExecCommand has the name.
403
- 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
+ };
404
549
  execSession = { agentId: resolveAgentFromRequest(extra, args) };
405
550
  }
406
551
  else {
407
552
  // Standalone mode: validate locally
408
- const foundCap = capabilities.find(c => c.name === execCapName);
553
+ const foundCap = capabilities.find((c) => c.name === execCapName);
409
554
  if (!foundCap) {
410
- 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
+ });
411
560
  }
412
561
  execCap = execCwd ? { ...foundCap, workDir: execCwd } : foundCap;
413
- if (execCap.mode !== 'exec') {
414
- 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
+ });
415
568
  }
416
569
  const execAgentId = resolveAgentFromRequest(extra, args);
417
570
  const execSvc = services.get(execCap.service);
418
571
  if (!canAccessCapability(execAgentId, execCap, execSvc, defaultAccess)) {
419
- auditLogger.logDenied(execCap.service, 'EXEC', execCommand.join(' '), 'Agent does not have access to this capability', execReason);
420
- 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
+ });
421
585
  }
422
586
  if (execCap.requiresReason && !execReason) {
423
- 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
+ });
424
592
  }
425
593
  const cmdValidation = (0, exec_js_1.validateCommand)(execCommand, execCap.allowCommands || []);
426
594
  if (!cmdValidation.allowed) {
427
- auditLogger.logDenied(execCap.service, 'EXEC', execCommand.join(' '), cmdValidation.reason || 'Command not allowed', execReason);
428
- 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
+ });
429
603
  }
430
604
  const execTtlSeconds = parseTTL(execCap.ttl);
431
605
  execSession = sessionManager.createSession(execCap.name, execCap.service, execTtlSeconds, { reason: execReason });
@@ -434,153 +608,338 @@ function createMCPServer(options) {
434
608
  // Log to audit
435
609
  auditLogger.log({
436
610
  service: execCap.service,
437
- path: execCommand.join(' '),
438
- method: 'EXEC',
439
- headers: { 'x-janee-reason': execReason || '' },
611
+ path: execCommand.join(" "),
612
+ method: "EXEC",
613
+ headers: { "x-janee-reason": execReason || "" },
440
614
  }, {
441
615
  statusCode: execResult.exitCode === 0 ? 200 : 500,
442
616
  headers: {},
443
617
  body: execResult.stdout,
444
618
  }, execResult.executionTimeMs);
445
619
  return {
446
- content: [{
447
- type: 'text',
620
+ content: [
621
+ {
622
+ type: "text",
448
623
  text: JSON.stringify({
449
624
  exitCode: execResult.exitCode,
450
625
  stdout: execResult.stdout,
451
626
  stderr: execResult.stderr,
452
627
  executionTimeMs: execResult.executionTimeMs,
453
- executionTarget: 'runner',
454
- }, null, 2)
455
- }]
628
+ executionTarget: "runner",
629
+ }, null, 2),
630
+ },
631
+ ],
456
632
  };
457
633
  }
458
- case 'manage_credential': {
459
- const { action: credAction, service: credService, targetAgentId: credTarget } = args;
634
+ case "manage_credential": {
635
+ const { action: credAction, service: credService, targetAgentId: credTarget, } = args;
460
636
  const credAgentId = resolveAgentFromRequest(extra, args);
461
637
  if (!credService) {
462
- throw new Error('Missing required argument: service');
638
+ throw new Error("Missing required argument: service");
463
639
  }
464
640
  const svc = services.get(credService);
465
641
  if (!svc) {
466
642
  throw new Error(`Unknown service: ${credService}`);
467
643
  }
468
- if (credAction === 'view') {
644
+ if (credAction === "view") {
469
645
  return {
470
- content: [{
471
- type: 'text',
646
+ content: [
647
+ {
648
+ type: "text",
472
649
  text: JSON.stringify({
473
650
  service: credService,
474
- ownership: svc.ownership || { accessPolicy: 'all-agents', note: 'No ownership metadata (legacy credential)' },
475
- yourAccess: (0, agent_scope_js_1.canAgentAccess)(credAgentId, svc.ownership)
476
- }, null, 2)
477
- }]
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
+ ],
478
659
  };
479
660
  }
480
661
  // Grant/revoke require ownership verification
481
662
  if (!credAgentId) {
482
- throw new Error('agentId is required for grant/revoke actions');
663
+ throw new Error("agentId is required for grant/revoke actions");
483
664
  }
484
665
  if (!svc.ownership) {
485
- 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.");
486
667
  }
487
668
  if (svc.ownership.createdBy !== credAgentId) {
488
- 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");
489
670
  }
490
- if (credAction === 'grant') {
671
+ if (credAction === "grant") {
491
672
  if (!credTarget) {
492
- throw new Error('targetAgentId is required for grant action');
673
+ throw new Error("targetAgentId is required for grant action");
493
674
  }
494
- const { grantAccess } = await Promise.resolve().then(() => __importStar(require('./agent-scope.js')));
675
+ const { grantAccess } = await Promise.resolve().then(() => __importStar(require("./agent-scope.js")));
495
676
  svc.ownership = grantAccess(svc.ownership, credTarget);
496
677
  // Persist ownership change to config storage
497
678
  if (onPersistOwnership) {
498
679
  onPersistOwnership(credService, svc.ownership);
499
680
  }
500
681
  return {
501
- content: [{
502
- type: 'text',
682
+ content: [
683
+ {
684
+ type: "text",
503
685
  text: JSON.stringify({
504
686
  success: true,
505
687
  message: `Granted access to ${credTarget}`,
506
688
  ownership: svc.ownership,
507
- persisted: !!onPersistOwnership
508
- }, null, 2)
509
- }]
689
+ persisted: !!onPersistOwnership,
690
+ }, null, 2),
691
+ },
692
+ ],
510
693
  };
511
694
  }
512
- if (credAction === 'revoke') {
695
+ if (credAction === "revoke") {
513
696
  if (!credTarget) {
514
- throw new Error('targetAgentId is required for revoke action');
697
+ throw new Error("targetAgentId is required for revoke action");
515
698
  }
516
- const { revokeAccess } = await Promise.resolve().then(() => __importStar(require('./agent-scope.js')));
699
+ const { revokeAccess } = await Promise.resolve().then(() => __importStar(require("./agent-scope.js")));
517
700
  svc.ownership = revokeAccess(svc.ownership, credTarget);
518
701
  // Persist ownership change to config storage
519
702
  if (onPersistOwnership) {
520
703
  onPersistOwnership(credService, svc.ownership);
521
704
  }
522
705
  return {
523
- content: [{
524
- type: 'text',
706
+ content: [
707
+ {
708
+ type: "text",
525
709
  text: JSON.stringify({
526
710
  success: true,
527
711
  message: `Revoked access from ${credTarget}`,
528
712
  ownership: svc.ownership,
529
- persisted: !!onPersistOwnership
713
+ persisted: !!onPersistOwnership,
714
+ }, null, 2),
715
+ },
716
+ ],
717
+ };
718
+ }
719
+ throw new Error(`Unknown action: ${credAction}. Use 'view', 'grant', or 'revoke'.`);
720
+ }
721
+ case "test_service": {
722
+ const { service: testSvcName, timeout: testTimeout } = (args ||
723
+ {});
724
+ const testOpts = testTimeout ? { timeout: testTimeout } : {};
725
+ let targets;
726
+ if (testSvcName) {
727
+ const svc = services.get(testSvcName);
728
+ if (!svc) {
729
+ throw new Error(`Unknown service: ${testSvcName}. Use list_services to see available services.`);
730
+ }
731
+ targets = [[testSvcName, svc]];
732
+ }
733
+ else {
734
+ targets = Array.from(services.entries());
735
+ }
736
+ if (targets.length === 0) {
737
+ throw new Error("No services configured");
738
+ }
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.`
530
765
  }, null, 2)
531
766
  }]
532
767
  };
533
768
  }
534
- throw new Error(`Unknown action: ${credAction}. Use 'view', 'grant', or 'revoke'.`);
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);
863
+ return {
864
+ content: [{
865
+ type: 'text',
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)
879
+ }]
880
+ };
535
881
  }
536
882
  default:
537
883
  throw new Error(`Unknown tool: ${name}`);
538
884
  }
539
885
  }
540
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
+ }
541
893
  return {
542
- content: [{
543
- type: 'text',
544
- text: JSON.stringify({
545
- error: error instanceof Error ? error.message : 'Unknown error'
546
- }, null, 2)
547
- }],
548
- isError: true
894
+ content: [
895
+ {
896
+ type: "text",
897
+ text: JSON.stringify(payload, null, 2),
898
+ },
899
+ ],
900
+ isError: true,
549
901
  };
550
902
  }
551
903
  });
552
- return { server, clientSessions };
904
+ return {
905
+ server,
906
+ clientSessions,
907
+ reloadConfig: (result) => {
908
+ capabilities = result.capabilities;
909
+ services = result.services;
910
+ },
911
+ };
553
912
  }
554
913
  /**
555
914
  * Make HTTP/HTTPS request to real API
556
915
  */
557
916
  function makeAPIRequest(targetUrl, request) {
558
917
  return new Promise((resolve, reject) => {
559
- const client = targetUrl.protocol === 'https:' ? https_1.default : http_1.default;
918
+ const client = targetUrl.protocol === "https:" ? https_1.default : http_1.default;
560
919
  const options = {
561
920
  hostname: targetUrl.hostname,
562
921
  port: targetUrl.port,
563
922
  path: targetUrl.pathname + targetUrl.search,
564
923
  method: request.method,
565
924
  headers: {
566
- 'User-Agent': 'janee/' + pkgVersion,
567
- ...request.headers
568
- }
925
+ "User-Agent": "janee/" + pkgVersion,
926
+ ...request.headers,
927
+ },
569
928
  };
570
929
  const req = client.request(options, (res) => {
571
- let body = '';
572
- res.on('data', (chunk) => {
930
+ let body = "";
931
+ res.on("data", (chunk) => {
573
932
  body += chunk;
574
933
  });
575
- res.on('end', () => {
934
+ res.on("end", () => {
576
935
  resolve({
577
936
  statusCode: res.statusCode || 500,
578
937
  headers: res.headers,
579
- body
938
+ body,
580
939
  });
581
940
  });
582
941
  });
583
- req.on('error', (error) => {
942
+ req.on("error", (error) => {
584
943
  reject(error);
585
944
  });
586
945
  if (request.body) {
@@ -597,8 +956,8 @@ function makeAPIRequest(targetUrl, request) {
597
956
  function captureClientInfo(transport, clientSessions) {
598
957
  const original = transport.onmessage;
599
958
  transport.onmessage = (message, extra) => {
600
- if (message?.method === 'initialize' && message?.params?.clientInfo?.name) {
601
- const key = extra?.sessionId || '__default__';
959
+ if (message?.method === "initialize" && message?.params?.clientInfo?.name) {
960
+ const key = extra?.sessionId || "__default__";
602
961
  clientSessions.set(key, message.params.clientInfo.name);
603
962
  }
604
963
  return original?.call(transport, message, extra);
@@ -608,11 +967,13 @@ function captureClientInfo(transport, clientSessions) {
608
967
  * Start MCP server with stdio transport (single session).
609
968
  */
610
969
  async function startMCPServer(serverOptions) {
611
- const { server, clientSessions } = createMCPServer(serverOptions);
970
+ const mcpResult = createMCPServer(serverOptions);
971
+ const { server, clientSessions } = mcpResult;
612
972
  const transport = new stdio_js_1.StdioServerTransport();
613
973
  await server.connect(transport);
614
974
  captureClientInfo(transport, clientSessions);
615
- console.error('Janee MCP server started (stdio)');
975
+ console.error("Janee MCP server started (stdio)");
976
+ return mcpResult;
616
977
  }
617
978
  /**
618
979
  * Start MCP server with StreamableHTTP transport over HTTP.
@@ -633,72 +994,103 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
633
994
  const sessions = new Map();
634
995
  // Authority REST endpoints -- active when runnerKey is provided
635
996
  if (httpOptions.runnerKey && httpOptions.authorityHooks) {
636
- const { timingSafeEqual } = await Promise.resolve().then(() => __importStar(require('crypto')));
997
+ const { timingSafeEqual } = await Promise.resolve().then(() => __importStar(require("crypto")));
637
998
  const runnerKey = httpOptions.runnerKey;
638
999
  const hooks = httpOptions.authorityHooks;
639
1000
  const authMiddleware = (req, res, next) => {
640
- const provided = req.header('x-janee-runner-key');
641
- if (!provided || provided.length !== runnerKey.length ||
1001
+ const provided = req.header("x-janee-runner-key");
1002
+ if (!provided ||
1003
+ provided.length !== runnerKey.length ||
642
1004
  !timingSafeEqual(Buffer.from(provided), Buffer.from(runnerKey))) {
643
- res.status(401).json({ error: 'Unauthorized runner request' });
1005
+ res.status(401).json({ error: "Unauthorized runner request" });
644
1006
  return;
645
1007
  }
646
1008
  next();
647
1009
  };
648
- app.get('/v1/health', (_req, res) => {
649
- res.status(200).json({ ok: true, mode: 'authority' });
1010
+ app.get("/v1/health", (_req, res) => {
1011
+ res.status(200).json({ ok: true, mode: "authority" });
650
1012
  });
651
- app.post('/v1/exec/authorize', authMiddleware, async (req, res) => {
1013
+ app.post("/v1/exec/authorize", authMiddleware, async (req, res) => {
652
1014
  try {
653
1015
  const body = req.body;
654
- if (!body?.runner?.runnerId || !Array.isArray(body?.command) || body.command.length === 0 || !body.capabilityId) {
655
- 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" });
656
1021
  return;
657
1022
  }
658
1023
  const response = await hooks.authorizeExec(body);
659
1024
  res.status(200).json(response);
660
1025
  }
661
1026
  catch (error) {
662
- 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
+ });
663
1032
  }
664
1033
  });
665
- app.post('/v1/exec/complete', authMiddleware, async (req, res) => {
1034
+ app.post("/v1/exec/complete", authMiddleware, async (req, res) => {
666
1035
  try {
667
1036
  if (!req.body?.grantId) {
668
- res.status(400).json({ error: 'grantId is required' });
1037
+ res.status(400).json({ error: "grantId is required" });
669
1038
  return;
670
1039
  }
671
1040
  await hooks.completeExec(req.body);
672
1041
  res.status(200).json({ ok: true });
673
1042
  }
674
1043
  catch (error) {
675
- 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
+ });
676
1049
  }
677
1050
  });
678
- }
679
- // Sweep idle sessions every 60 seconds
680
- const idleSweepInterval = idleTimeoutMs > 0 ? setInterval(async () => {
681
- const now = Date.now();
682
- for (const [sid, session] of sessions.entries()) {
683
- if (now - session.lastActivityAt > idleTimeoutMs) {
684
- console.error(`Closing idle session ${sid} (inactive for ${Math.round((now - session.lastActivityAt) / 1000)}s)`);
685
- sessions.delete(sid);
1051
+ if (hooks.testService) {
1052
+ app.post("/v1/test", authMiddleware, async (req, res) => {
686
1053
  try {
687
- await session.transport.close?.();
688
- await session.server.close();
1054
+ const result = await hooks.testService(req.body?.service, {
1055
+ timeout: req.body?.timeout,
1056
+ });
1057
+ res.status(200).json(result);
689
1058
  }
690
- catch {
691
- // best-effort cleanup
1059
+ catch (error) {
1060
+ res
1061
+ .status(500)
1062
+ .json({
1063
+ error: error instanceof Error ? error.message : "Test failed",
1064
+ });
692
1065
  }
693
- }
1066
+ });
694
1067
  }
695
- }, Math.min(idleTimeoutMs, 60_000)) : undefined;
1068
+ }
1069
+ // Sweep idle sessions every 60 seconds
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
+ }
1084
+ }
1085
+ }
1086
+ }, Math.min(idleTimeoutMs, 60_000))
1087
+ : undefined;
696
1088
  if (idleSweepInterval) {
697
1089
  idleSweepInterval.unref?.(); // Don't prevent Node from exiting
698
1090
  }
699
- app.all('/mcp', async (req, res) => {
1091
+ app.all("/mcp", async (req, res) => {
700
1092
  try {
701
- const sessionId = req.headers['mcp-session-id'];
1093
+ const sessionId = req.headers["mcp-session-id"];
702
1094
  if (sessionId && sessions.has(sessionId)) {
703
1095
  const session = sessions.get(sessionId);
704
1096
  session.lastActivityAt = Date.now();
@@ -706,11 +1098,16 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
706
1098
  }
707
1099
  else if (!sessionId && (0, types_js_1.isInitializeRequest)(req.body)) {
708
1100
  const clientName = req.body?.params?.clientInfo?.name;
709
- const { server, clientSessions } = createMCPServer(serverOptions);
1101
+ const mcpResult = createMCPServer(serverOptions);
1102
+ const { server, clientSessions } = mcpResult;
710
1103
  const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
711
1104
  sessionIdGenerator: () => crypto.randomUUID(),
712
1105
  onsessioninitialized: (sid) => {
713
- sessions.set(sid, { transport, server, lastActivityAt: Date.now() });
1106
+ sessions.set(sid, {
1107
+ transport,
1108
+ server,
1109
+ lastActivityAt: Date.now(),
1110
+ });
714
1111
  if (clientName)
715
1112
  clientSessions.set(sid, clientName);
716
1113
  },
@@ -730,25 +1127,28 @@ async function startMCPServerHTTP(serverOptions, httpOptions) {
730
1127
  }
731
1128
  else if (sessionId) {
732
1129
  res.status(404).json({
733
- jsonrpc: '2.0',
734
- error: { code: -32001, message: 'Session not found' },
1130
+ jsonrpc: "2.0",
1131
+ error: { code: -32001, message: "Session not found" },
735
1132
  id: null,
736
1133
  });
737
1134
  }
738
1135
  else {
739
1136
  res.status(400).json({
740
- jsonrpc: '2.0',
741
- 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
+ },
742
1142
  id: null,
743
1143
  });
744
1144
  }
745
1145
  }
746
1146
  catch (error) {
747
- console.error('Error handling MCP request:', error);
1147
+ console.error("Error handling MCP request:", error);
748
1148
  if (!res.headersSent) {
749
1149
  res.status(500).json({
750
- jsonrpc: '2.0',
751
- error: { code: -32603, message: 'Internal server error' },
1150
+ jsonrpc: "2.0",
1151
+ error: { code: -32603, message: "Internal server error" },
752
1152
  id: null,
753
1153
  });
754
1154
  }