@hailer/mcp 1.1.12 → 1.1.14

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 (269) hide show
  1. package/CHANGELOG.md +0 -7
  2. package/{.claude → dist}/CLAUDE.md +2 -2
  3. package/dist/app.js +18 -5
  4. package/dist/bot/bot-config.d.ts +10 -1
  5. package/dist/bot/bot-config.js +64 -3
  6. package/dist/bot/bot-manager.d.ts +2 -0
  7. package/dist/bot/bot-manager.js +9 -2
  8. package/dist/bot/bot.d.ts +33 -0
  9. package/dist/bot/bot.js +461 -160
  10. package/dist/bot/services/message-classifier.js +17 -0
  11. package/dist/bot/services/permission-guard.d.ts +52 -0
  12. package/dist/bot/services/permission-guard.js +149 -0
  13. package/dist/bot/services/types.d.ts +5 -0
  14. package/dist/bot/services/typing-indicator.d.ts +6 -1
  15. package/dist/bot/services/typing-indicator.js +19 -3
  16. package/dist/cli.js +0 -0
  17. package/dist/config.d.ts +6 -1
  18. package/dist/config.js +43 -0
  19. package/dist/core.js +3 -6
  20. package/dist/lib/discussion-lock.d.ts +42 -0
  21. package/dist/lib/discussion-lock.js +110 -0
  22. package/dist/mcp/UserContextCache.d.ts +5 -0
  23. package/dist/mcp/UserContextCache.js +51 -19
  24. package/dist/mcp/hailer-clients.d.ts +19 -1
  25. package/dist/mcp/hailer-clients.js +158 -24
  26. package/dist/mcp/session-store.d.ts +68 -0
  27. package/dist/mcp/session-store.js +169 -0
  28. package/dist/mcp/signal-handler.js +2 -0
  29. package/dist/mcp/tool-registry.d.ts +17 -4
  30. package/dist/mcp/tool-registry.js +37 -7
  31. package/dist/mcp/tools/activity.js +99 -7
  32. package/dist/mcp/tools/app-scaffold.js +304 -336
  33. package/dist/mcp/tools/bot-config/constants.d.ts +23 -0
  34. package/dist/mcp/tools/bot-config/constants.js +94 -0
  35. package/dist/mcp/tools/bot-config/core.d.ts +253 -0
  36. package/dist/mcp/tools/bot-config/core.js +2456 -0
  37. package/dist/mcp/tools/bot-config/index.d.ts +10 -0
  38. package/dist/mcp/tools/bot-config/index.js +59 -0
  39. package/dist/mcp/tools/bot-config/tools.d.ts +7 -0
  40. package/dist/mcp/tools/bot-config/tools.js +15 -0
  41. package/dist/mcp/tools/bot-config/types.d.ts +50 -0
  42. package/dist/mcp/tools/bot-config/types.js +6 -0
  43. package/dist/mcp/tools/bug-fixer-tools.d.ts +45 -0
  44. package/dist/mcp/tools/bug-fixer-tools.js +1096 -0
  45. package/dist/mcp/tools/company.d.ts +9 -0
  46. package/dist/mcp/tools/company.js +88 -0
  47. package/dist/mcp/tools/discussion.js +68 -0
  48. package/dist/mcp/tools/document.d.ts +11 -0
  49. package/dist/mcp/tools/document.js +741 -0
  50. package/dist/mcp/tools/investigate.d.ts +9 -0
  51. package/dist/mcp/tools/investigate.js +254 -0
  52. package/dist/mcp/tools/workflow-permissions.d.ts +15 -0
  53. package/dist/mcp/tools/workflow-permissions.js +204 -0
  54. package/dist/mcp/tools/workflow.js +57 -18
  55. package/dist/mcp/utils/index.d.ts +2 -0
  56. package/dist/mcp/utils/index.js +12 -1
  57. package/dist/mcp/utils/role-utils.d.ts +74 -0
  58. package/dist/mcp/utils/role-utils.js +151 -0
  59. package/dist/mcp/utils/types.d.ts +43 -1
  60. package/dist/mcp/utils/types.js +14 -0
  61. package/dist/mcp/webhook-handler.d.ts +4 -0
  62. package/dist/mcp/webhook-handler.js +8 -0
  63. package/dist/mcp-server.d.ts +23 -2
  64. package/dist/mcp-server.js +639 -127
  65. package/dist/plugins/vipunen/client.d.ts +150 -0
  66. package/dist/plugins/vipunen/client.js +535 -0
  67. package/dist/plugins/vipunen/config/schema-config.json +19 -0
  68. package/dist/plugins/vipunen/config/schema-doc.json +22 -0
  69. package/dist/plugins/vipunen/index.d.ts +41 -0
  70. package/dist/plugins/vipunen/index.js +88 -0
  71. package/dist/plugins/vipunen/tools.d.ts +26 -0
  72. package/dist/plugins/vipunen/tools.js +501 -0
  73. package/dist/stdio-server.d.ts +14 -0
  74. package/dist/stdio-server.js +101 -0
  75. package/package.json +2 -1
  76. package/.claude/agents/agent-ada-skill-builder.md +0 -94
  77. package/.claude/agents/agent-alejandro-function-fields.md +0 -342
  78. package/.claude/agents/agent-bjorn-config-audit.md +0 -103
  79. package/.claude/agents/agent-builder-agent-creator.md +0 -130
  80. package/.claude/agents/agent-code-simplifier.md +0 -53
  81. package/.claude/agents/agent-dmitri-activity-crud.md +0 -159
  82. package/.claude/agents/agent-giuseppe-app-builder.md +0 -247
  83. package/.claude/agents/agent-gunther-mcp-tools.md +0 -39
  84. package/.claude/agents/agent-helga-workflow-config.md +0 -204
  85. package/.claude/agents/agent-igor-activity-mover-automation.md +0 -125
  86. package/.claude/agents/agent-ingrid-doc-templates.md +0 -261
  87. package/.claude/agents/agent-ivan-monolith.md +0 -154
  88. package/.claude/agents/agent-kenji-data-reader.md +0 -86
  89. package/.claude/agents/agent-lars-code-inspector.md +0 -102
  90. package/.claude/agents/agent-marco-mockup-builder.md +0 -110
  91. package/.claude/agents/agent-marcus-api-documenter.md +0 -323
  92. package/.claude/agents/agent-marketplace-publisher.md +0 -280
  93. package/.claude/agents/agent-marketplace-reviewer.md +0 -309
  94. package/.claude/agents/agent-permissions-handler.md +0 -208
  95. package/.claude/agents/agent-simple-writer.md +0 -48
  96. package/.claude/agents/agent-svetlana-code-review.md +0 -171
  97. package/.claude/agents/agent-tanya-test-runner.md +0 -333
  98. package/.claude/agents/agent-ui-designer.md +0 -100
  99. package/.claude/agents/agent-viktor-sql-insights.md +0 -212
  100. package/.claude/agents/agent-web-search.md +0 -55
  101. package/.claude/agents/agent-yevgeni-discussions.md +0 -45
  102. package/.claude/agents/agent-zara-zapier.md +0 -159
  103. package/.claude/commands/app-squad.md +0 -135
  104. package/.claude/commands/audit-squad.md +0 -158
  105. package/.claude/commands/autoplan.md +0 -563
  106. package/.claude/commands/cleanup-squad.md +0 -98
  107. package/.claude/commands/config-squad.md +0 -106
  108. package/.claude/commands/crud-squad.md +0 -87
  109. package/.claude/commands/data-squad.md +0 -97
  110. package/.claude/commands/debug-squad.md +0 -303
  111. package/.claude/commands/doc-squad.md +0 -65
  112. package/.claude/commands/handoff.md +0 -137
  113. package/.claude/commands/health.md +0 -49
  114. package/.claude/commands/help.md +0 -29
  115. package/.claude/commands/help:agents.md +0 -151
  116. package/.claude/commands/help:commands.md +0 -78
  117. package/.claude/commands/help:faq.md +0 -79
  118. package/.claude/commands/help:plugins.md +0 -50
  119. package/.claude/commands/help:skills.md +0 -93
  120. package/.claude/commands/help:tools.md +0 -75
  121. package/.claude/commands/hotfix-squad.md +0 -112
  122. package/.claude/commands/integration-squad.md +0 -82
  123. package/.claude/commands/janitor-squad.md +0 -167
  124. package/.claude/commands/learn-auto.md +0 -120
  125. package/.claude/commands/learn.md +0 -120
  126. package/.claude/commands/mcp-list.md +0 -27
  127. package/.claude/commands/onboard-squad.md +0 -140
  128. package/.claude/commands/plan-workspace.md +0 -732
  129. package/.claude/commands/prd.md +0 -130
  130. package/.claude/commands/project-status.md +0 -82
  131. package/.claude/commands/publish.md +0 -138
  132. package/.claude/commands/recap.md +0 -69
  133. package/.claude/commands/restore.md +0 -64
  134. package/.claude/commands/review-squad.md +0 -152
  135. package/.claude/commands/save.md +0 -24
  136. package/.claude/commands/stats.md +0 -19
  137. package/.claude/commands/swarm.md +0 -210
  138. package/.claude/commands/tool-builder.md +0 -39
  139. package/.claude/commands/ws-pull.md +0 -44
  140. package/.claude/hooks/_shared-memory.cjs +0 -305
  141. package/.claude/hooks/_utils.cjs +0 -108
  142. package/.claude/hooks/agent-failure-detector.cjs +0 -383
  143. package/.claude/hooks/agent-usage-logger.cjs +0 -204
  144. package/.claude/hooks/app-edit-guard.cjs +0 -494
  145. package/.claude/hooks/auto-learn.cjs +0 -304
  146. package/.claude/hooks/bash-guard.cjs +0 -272
  147. package/.claude/hooks/builder-mode-manager.cjs +0 -354
  148. package/.claude/hooks/bulk-activity-guard.cjs +0 -271
  149. package/.claude/hooks/context-watchdog.cjs +0 -230
  150. package/.claude/hooks/delegation-reminder.cjs +0 -465
  151. package/.claude/hooks/design-system-lint.cjs +0 -271
  152. package/.claude/hooks/post-scaffold-hook.cjs +0 -181
  153. package/.claude/hooks/prompt-guard.cjs +0 -354
  154. package/.claude/hooks/publish-template-guard.cjs +0 -147
  155. package/.claude/hooks/session-start.cjs +0 -35
  156. package/.claude/hooks/shared-memory-writer.cjs +0 -147
  157. package/.claude/hooks/skill-injector.cjs +0 -140
  158. package/.claude/hooks/skill-usage-logger.cjs +0 -258
  159. package/.claude/hooks/src-edit-guard.cjs +0 -240
  160. package/.claude/hooks/sync-marketplace-agents.cjs +0 -346
  161. package/.claude/settings.json +0 -257
  162. package/.claude/skills/SDK-activity-patterns/SKILL.md +0 -428
  163. package/.claude/skills/SDK-document-templates/SKILL.md +0 -1033
  164. package/.claude/skills/SDK-function-fields/SKILL.md +0 -542
  165. package/.claude/skills/SDK-generate-skill/SKILL.md +0 -92
  166. package/.claude/skills/SDK-init-skill/SKILL.md +0 -127
  167. package/.claude/skills/SDK-insight-queries/SKILL.md +0 -787
  168. package/.claude/skills/SDK-ws-config-skill/SKILL.md +0 -1139
  169. package/.claude/skills/agent-structure/SKILL.md +0 -98
  170. package/.claude/skills/api-documentation-patterns/SKILL.md +0 -474
  171. package/.claude/skills/chrome-mcp-reference/SKILL.md +0 -370
  172. package/.claude/skills/delegation-routing/SKILL.md +0 -202
  173. package/.claude/skills/frontend-design/SKILL.md +0 -254
  174. package/.claude/skills/hailer-activity-mover/SKILL.md +0 -213
  175. package/.claude/skills/hailer-api-client/SKILL.md +0 -518
  176. package/.claude/skills/hailer-app-builder/SKILL.md +0 -1434
  177. package/.claude/skills/hailer-apps-pictures/SKILL.md +0 -269
  178. package/.claude/skills/hailer-design-system/SKILL.md +0 -235
  179. package/.claude/skills/hailer-monolith-automations/SKILL.md +0 -686
  180. package/.claude/skills/hailer-permissions-system/SKILL.md +0 -121
  181. package/.claude/skills/hailer-project-protocol/SKILL.md +0 -488
  182. package/.claude/skills/hailer-rest-api/SKILL.md +0 -61
  183. package/.claude/skills/hailer-rest-api/hailer-activities.md +0 -184
  184. package/.claude/skills/hailer-rest-api/hailer-admin.md +0 -473
  185. package/.claude/skills/hailer-rest-api/hailer-calendar.md +0 -256
  186. package/.claude/skills/hailer-rest-api/hailer-feed.md +0 -249
  187. package/.claude/skills/hailer-rest-api/hailer-insights.md +0 -195
  188. package/.claude/skills/hailer-rest-api/hailer-messaging.md +0 -276
  189. package/.claude/skills/hailer-rest-api/hailer-workflows.md +0 -283
  190. package/.claude/skills/insight-join-patterns/SKILL.md +0 -174
  191. package/.claude/skills/integration-patterns/SKILL.md +0 -421
  192. package/.claude/skills/json-only-output/SKILL.md +0 -72
  193. package/.claude/skills/lsp-setup/SKILL.md +0 -160
  194. package/.claude/skills/mcp-direct-tools/SKILL.md +0 -153
  195. package/.claude/skills/optional-parameters/SKILL.md +0 -72
  196. package/.claude/skills/publish-hailer-app/SKILL.md +0 -244
  197. package/.claude/skills/testing-patterns/SKILL.md +0 -630
  198. package/.claude/skills/tool-builder/SKILL.md +0 -250
  199. package/.claude/skills/tool-parameter-usage/SKILL.md +0 -126
  200. package/.claude/skills/tool-response-verification/SKILL.md +0 -92
  201. package/.claude/skills/zapier-hailer-patterns/SKILL.md +0 -581
  202. package/.mcp.json +0 -13
  203. package/.opencode/agent/agent-ada-skill-builder.md +0 -35
  204. package/.opencode/agent/agent-alejandro-function-fields.md +0 -39
  205. package/.opencode/agent/agent-bjorn-config-audit.md +0 -36
  206. package/.opencode/agent/agent-builder-agent-creator.md +0 -39
  207. package/.opencode/agent/agent-code-simplifier.md +0 -31
  208. package/.opencode/agent/agent-dmitri-activity-crud.md +0 -40
  209. package/.opencode/agent/agent-giuseppe-app-builder.md +0 -37
  210. package/.opencode/agent/agent-gunther-mcp-tools.md +0 -39
  211. package/.opencode/agent/agent-helga-workflow-config.md +0 -203
  212. package/.opencode/agent/agent-igor-activity-mover-automation.md +0 -46
  213. package/.opencode/agent/agent-ingrid-doc-templates.md +0 -39
  214. package/.opencode/agent/agent-ivan-monolith.md +0 -46
  215. package/.opencode/agent/agent-kenji-data-reader.md +0 -53
  216. package/.opencode/agent/agent-lars-code-inspector.md +0 -28
  217. package/.opencode/agent/agent-marco-mockup-builder.md +0 -42
  218. package/.opencode/agent/agent-marcus-api-documenter.md +0 -53
  219. package/.opencode/agent/agent-marketplace-publisher.md +0 -44
  220. package/.opencode/agent/agent-marketplace-reviewer.md +0 -42
  221. package/.opencode/agent/agent-permissions-handler.md +0 -50
  222. package/.opencode/agent/agent-simple-writer.md +0 -45
  223. package/.opencode/agent/agent-svetlana-code-review.md +0 -39
  224. package/.opencode/agent/agent-tanya-test-runner.md +0 -57
  225. package/.opencode/agent/agent-ui-designer.md +0 -56
  226. package/.opencode/agent/agent-viktor-sql-insights.md +0 -34
  227. package/.opencode/agent/agent-web-search.md +0 -42
  228. package/.opencode/agent/agent-yevgeni-discussions.md +0 -37
  229. package/.opencode/agent/agent-zara-zapier.md +0 -53
  230. package/.opencode/commands/app-squad.md +0 -135
  231. package/.opencode/commands/audit-squad.md +0 -158
  232. package/.opencode/commands/autoplan.md +0 -563
  233. package/.opencode/commands/cleanup-squad.md +0 -98
  234. package/.opencode/commands/config-squad.md +0 -106
  235. package/.opencode/commands/crud-squad.md +0 -87
  236. package/.opencode/commands/data-squad.md +0 -97
  237. package/.opencode/commands/debug-squad.md +0 -303
  238. package/.opencode/commands/doc-squad.md +0 -65
  239. package/.opencode/commands/handoff.md +0 -137
  240. package/.opencode/commands/health.md +0 -49
  241. package/.opencode/commands/help-agents.md +0 -151
  242. package/.opencode/commands/help-commands.md +0 -32
  243. package/.opencode/commands/help-faq.md +0 -29
  244. package/.opencode/commands/help-plugins.md +0 -28
  245. package/.opencode/commands/help-skills.md +0 -7
  246. package/.opencode/commands/help-tools.md +0 -40
  247. package/.opencode/commands/help.md +0 -28
  248. package/.opencode/commands/hotfix-squad.md +0 -112
  249. package/.opencode/commands/integration-squad.md +0 -82
  250. package/.opencode/commands/janitor-squad.md +0 -167
  251. package/.opencode/commands/learn-auto.md +0 -120
  252. package/.opencode/commands/learn.md +0 -120
  253. package/.opencode/commands/mcp-list.md +0 -27
  254. package/.opencode/commands/onboard-squad.md +0 -140
  255. package/.opencode/commands/plan-workspace.md +0 -732
  256. package/.opencode/commands/prd.md +0 -131
  257. package/.opencode/commands/project-status.md +0 -82
  258. package/.opencode/commands/publish.md +0 -138
  259. package/.opencode/commands/recap.md +0 -69
  260. package/.opencode/commands/restore.md +0 -64
  261. package/.opencode/commands/review-squad.md +0 -152
  262. package/.opencode/commands/save.md +0 -24
  263. package/.opencode/commands/stats.md +0 -19
  264. package/.opencode/commands/swarm.md +0 -210
  265. package/.opencode/commands/tool-builder.md +0 -39
  266. package/.opencode/commands/ws-pull.md +0 -44
  267. package/.opencode/opencode.json +0 -28
  268. package/SESSION-HANDOFF.md +0 -68
  269. package/inbox/2026-03-04-bot-config-patterns.md +0 -24
@@ -45,20 +45,48 @@ Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.MCPServerService = void 0;
46
46
  const express_1 = __importDefault(require("express"));
47
47
  const cors_1 = __importDefault(require("cors"));
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
48
50
  const logger_1 = require("./lib/logger");
49
51
  const config_1 = require("./config");
50
52
  const UserContextCache_1 = require("./mcp/UserContextCache");
51
53
  const tool_registry_1 = require("./mcp/tool-registry");
54
+ const session_store_1 = require("./mcp/session-store");
52
55
  const webhook_handler_1 = require("./mcp/webhook-handler");
56
+ const vipunen_1 = require("./plugins/vipunen");
57
+ // Load MCP instructions for Claude App
58
+ // Try dist/mcp/ first (production), then src/mcp/ (development)
59
+ let mcpInstructions;
60
+ const instructionsPaths = [
61
+ path.join(__dirname, 'mcp', 'instructions.md'), // dist/mcp/instructions.md
62
+ path.join(__dirname, '..', 'src', 'mcp', 'instructions.md'), // src/mcp/instructions.md (from dist/)
63
+ path.join(process.cwd(), 'src', 'mcp', 'instructions.md') // src/mcp/instructions.md (from cwd)
64
+ ];
65
+ for (const instructionsPath of instructionsPaths) {
66
+ try {
67
+ mcpInstructions = fs.readFileSync(instructionsPath, 'utf-8');
68
+ break;
69
+ }
70
+ catch {
71
+ // Try next path
72
+ }
73
+ }
53
74
  class MCPServerService {
75
+ static ENDPOINTS = {
76
+ hailer: '/api/mcp',
77
+ cowork: '/api/cowork/mcp',
78
+ vipunen: '/api/vipunen'
79
+ };
54
80
  app;
55
81
  server;
56
82
  logger;
57
83
  config;
58
- toolRegistry; // ← Injected
84
+ toolRegistry;
85
+ appConfig;
59
86
  constructor(config) {
60
87
  this.config = config;
61
88
  this.toolRegistry = config.toolRegistry;
89
+ this.appConfig = (0, config_1.createApplicationConfig)();
62
90
  this.logger = (0, logger_1.createLogger)({
63
91
  component: 'mcp-server',
64
92
  service: 'hailer-mcp-server'
@@ -66,7 +94,7 @@ class MCPServerService {
66
94
  this.app = (0, express_1.default)();
67
95
  this.setupMiddleware();
68
96
  this.setupRoutes();
69
- this.logger.debug('MCP Server initialized', {
97
+ this.logger.info('MCP Server initialized', {
70
98
  port: config.port,
71
99
  corsOrigins: config.corsOrigins
72
100
  });
@@ -114,19 +142,278 @@ class MCPServerService {
114
142
  next();
115
143
  });
116
144
  }
145
+ escapeHtml(s) {
146
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
147
+ }
148
+ getBaseUrl(req) {
149
+ const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
150
+ const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3030';
151
+ return `${protocol}://${host}`;
152
+ }
117
153
  setupRoutes() {
118
- const appConfig = (0, config_1.createApplicationConfig)();
119
154
  // Health check endpoint (no logging - too noisy)
120
155
  this.app.get('/health', (_, res) => {
121
156
  const health = {
122
157
  status: 'ok',
123
158
  timestamp: new Date().toISOString(),
124
159
  service: 'hailer-mcp-server',
125
- version: config_1.APP_VERSION
160
+ version: config_1.APP_VERSION,
161
+ endpoints: MCPServerService.ENDPOINTS
126
162
  };
127
163
  res.json(health);
128
164
  });
129
- // Daemon status endpoint - monitor LLM context
165
+ // API prefix for Claude App / cowork endpoints
166
+ const API_PREFIX = '/api/cowork';
167
+ // OAuth 2.0 Authorization Server Metadata (RFC 8414)
168
+ // This tells mcp-remote where to send users for authorization
169
+ this.app.get('/.well-known/oauth-authorization-server', (req, res) => {
170
+ const baseUrl = this.getBaseUrl(req);
171
+ res.json({
172
+ issuer: baseUrl,
173
+ authorization_endpoint: `${baseUrl}${API_PREFIX}/oauth/authorize`,
174
+ token_endpoint: `${baseUrl}${API_PREFIX}/oauth/token`,
175
+ registration_endpoint: `${baseUrl}${API_PREFIX}/oauth/register`,
176
+ response_types_supported: ['code'],
177
+ grant_types_supported: ['authorization_code'],
178
+ code_challenge_methods_supported: ['S256'],
179
+ token_endpoint_auth_methods_supported: ['none']
180
+ });
181
+ });
182
+ // Dynamic Client Registration (RFC 7591)
183
+ // mcp-remote calls this before starting OAuth flow
184
+ this.app.post(`${API_PREFIX}/oauth/register`, (req, res) => {
185
+ const { client_name, redirect_uris } = req.body;
186
+ req.logger.info('OAuth client registration', { client_name, redirect_uris });
187
+ // Generate a client_id for this registration
188
+ const clientId = `mcp_client_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
189
+ // Return client credentials (RFC 7591 response)
190
+ res.status(201).json({
191
+ client_id: clientId,
192
+ client_name: client_name || 'MCP Client',
193
+ redirect_uris: redirect_uris || [],
194
+ grant_types: ['authorization_code'],
195
+ response_types: ['code'],
196
+ token_endpoint_auth_method: 'none'
197
+ });
198
+ });
199
+ // OAuth Protected Resource Metadata (for MCP endpoint)
200
+ // Note: mcp-remote requests both /.well-known/oauth-protected-resource AND
201
+ // /.well-known/oauth-protected-resource{API_PREFIX}/mcp - we handle both
202
+ const protectedResourceHandler = (req, res) => {
203
+ const baseUrl = this.getBaseUrl(req);
204
+ res.json({
205
+ resource: `${baseUrl}${API_PREFIX}/mcp`,
206
+ authorization_servers: [baseUrl]
207
+ });
208
+ };
209
+ this.app.get('/.well-known/oauth-protected-resource', protectedResourceHandler);
210
+ this.app.get(`/.well-known/oauth-protected-resource${API_PREFIX}/mcp`, protectedResourceHandler);
211
+ // OAuth Authorize endpoint - redirects to Hailer login
212
+ // Supports both Claude Desktop native connector (redirect_uri=claude.ai) and mcp-remote (localhost)
213
+ this.app.get(`${API_PREFIX}/oauth/authorize`, (req, res) => {
214
+ const { redirect_uri, state, code_challenge, code_challenge_method, client_id } = req.query;
215
+ req.logger.info('OAuth authorize request', {
216
+ redirect_uri,
217
+ client_id,
218
+ state: state ? 'present' : 'missing',
219
+ code_challenge: code_challenge ? 'present' : 'missing'
220
+ });
221
+ // Store PKCE challenge AND original redirect_uri for token exchange
222
+ if (state) {
223
+ const pkceKey = `pkce_${state}`;
224
+ session_store_1.sessionStore.set(pkceKey, code_challenge || '', redirect_uri || '');
225
+ }
226
+ // Determine the callback URL for Hailer
227
+ const baseUrl = this.getBaseUrl(req);
228
+ const ourCallback = `${baseUrl}${API_PREFIX}/oauth/callback`;
229
+ // Redirect to Hailer authorization page (hash routing)
230
+ // The Hailer frontend will create API key and redirect back with code+state
231
+ const params = new URLSearchParams();
232
+ params.set('redirectUri', ourCallback);
233
+ params.set('authorizeAppName', 'Claude');
234
+ params.set('state', state || '');
235
+ // Use HAILER_APP_URL env var, default to local dev
236
+ const hailerAppUrl = process.env.HAILER_APP_URL || 'https://app.hailer.com';
237
+ const hailerAuthUrl = `${hailerAppUrl}/#/authorize/user-api-key?${params.toString()}`;
238
+ res.redirect(hailerAuthUrl);
239
+ });
240
+ // OAuth Token endpoint - exchanges code for access token
241
+ // In our flow, the "code" IS the key_id (already registered by frontend)
242
+ this.app.post(`${API_PREFIX}/oauth/token`, express_1.default.urlencoded({ extended: true }), (req, res) => {
243
+ const { grant_type, code, code_verifier, redirect_uri } = req.body;
244
+ req.logger.info('OAuth token request', { grant_type, hasCode: !!code });
245
+ if (grant_type !== 'authorization_code') {
246
+ return res.status(400).json({ error: 'unsupported_grant_type' });
247
+ }
248
+ if (!code) {
249
+ return res.status(400).json({ error: 'invalid_request', error_description: 'code is required' });
250
+ }
251
+ // The "code" from Hailer frontend is actually the key_id
252
+ // It was already registered via /auth/register by the frontend
253
+ const session = session_store_1.sessionStore.get(code);
254
+ if (!session) {
255
+ req.logger.warn('OAuth token exchange failed - session not found', { code: code.substring(0, 8) + '...' });
256
+ return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid or expired code' });
257
+ }
258
+ // Return the key_id as access_token (real apiKey stays in session store)
259
+ req.logger.info('OAuth token issued', { keyId: code.substring(0, 8) + '...' });
260
+ res.json({
261
+ access_token: code,
262
+ token_type: 'Bearer',
263
+ expires_in: 86400 // 24 hours (matches session TTL)
264
+ });
265
+ });
266
+ // OAuth Callback endpoint - receives code+state from Hailer frontend via GET redirect
267
+ // Then redirects to the original redirect_uri (Claude Desktop or mcp-remote)
268
+ this.app.get(`${API_PREFIX}/oauth/callback`, (req, res) => {
269
+ const { code, state, error } = req.query;
270
+ req.logger.info('OAuth callback GET', {
271
+ hasCode: !!code,
272
+ hasState: !!state,
273
+ error
274
+ });
275
+ if (error) {
276
+ req.logger.warn('OAuth callback received error', { error });
277
+ return res.status(400).send(`
278
+ <html><body>
279
+ <h1>Authorization Failed</h1>
280
+ <p>Error: ${this.escapeHtml(String(error))}</p>
281
+ <p>You can close this window.</p>
282
+ </body></html>
283
+ `);
284
+ }
285
+ if (!code || !state) {
286
+ req.logger.warn('OAuth callback missing code or state');
287
+ return res.status(400).send(`
288
+ <html><body>
289
+ <h1>Authorization Failed</h1>
290
+ <p>Missing code or state parameter</p>
291
+ </body></html>
292
+ `);
293
+ }
294
+ // Look up the original redirect_uri from PKCE storage
295
+ const pkceKey = `pkce_${state}`;
296
+ const pkceSession = session_store_1.sessionStore.get(pkceKey);
297
+ if (!pkceSession) {
298
+ req.logger.warn('OAuth callback - PKCE session not found', { state });
299
+ return res.status(400).send(`
300
+ <html><body>
301
+ <h1>Authorization Failed</h1>
302
+ <p>Session expired or invalid state</p>
303
+ </body></html>
304
+ `);
305
+ }
306
+ const originalRedirectUri = pkceSession.workspaceId; // We stored redirect_uri in workspaceId field
307
+ req.logger.info('OAuth callback redirecting', {
308
+ keyId: code.substring(0, 8) + '...',
309
+ redirectUri: originalRedirectUri
310
+ });
311
+ // Redirect to original redirect_uri with code and state
312
+ let finalUrl;
313
+ try {
314
+ finalUrl = new URL(originalRedirectUri);
315
+ }
316
+ catch {
317
+ req.logger.error('OAuth callback - invalid redirect_uri', { originalRedirectUri });
318
+ return res.status(400).send('<html><body><h1>Authorization Failed</h1><p>Invalid redirect URI.</p></body></html>');
319
+ }
320
+ finalUrl.searchParams.set('code', code);
321
+ finalUrl.searchParams.set('state', state);
322
+ res.redirect(finalUrl.toString());
323
+ });
324
+ // Legacy POST callback for backwards compatibility
325
+ this.app.post(`${API_PREFIX}/oauth/callback`, express_1.default.urlencoded({ extended: true }), (req, res) => {
326
+ req.logger.debug('OAuth callback POST body', { body: req.body });
327
+ const { access_token, error } = req.body;
328
+ if (error) {
329
+ return res.status(400).send(`<html><body><h1>Authorization Failed</h1><p>Error: ${this.escapeHtml(String(error))}</p></body></html>`);
330
+ }
331
+ if (!access_token) {
332
+ return res.status(400).send(`<html><body><h1>Authorization Failed</h1><p>Missing access token</p></body></html>`);
333
+ }
334
+ req.logger.info('OAuth callback POST received', { keyId: access_token.substring(0, 8) + '...' });
335
+ res.send(`
336
+ <html><body>
337
+ <h1>Authorization Successful!</h1>
338
+ <p>You can close this window and return to Claude.</p>
339
+ </body></html>
340
+ `);
341
+ });
342
+ // Auth register endpoint - receives key_id + api_key from Hailer frontend
343
+ // Called after user login to register the session mapping
344
+ this.app.post(`${API_PREFIX}/auth/register`, (req, res) => {
345
+ const { key_id, api_key, workspace_id } = req.body;
346
+ if (!key_id || !api_key) {
347
+ req.logger.warn('Auth register missing required fields', { hasKeyId: !!key_id, hasApiKey: !!api_key });
348
+ return res.status(400).json({ error: 'key_id and api_key are required' });
349
+ }
350
+ session_store_1.sessionStore.set(key_id, api_key, workspace_id || '');
351
+ req.logger.info('Auth session registered', { keyId: key_id.substring(0, 8) + '...' });
352
+ res.json({ status: 'ok' });
353
+ });
354
+ // Session stats endpoint (for debugging)
355
+ this.app.get(`${API_PREFIX}/auth/stats`, (req, res) => {
356
+ const adminToken = req.headers['x-admin-token'];
357
+ if (!adminToken || adminToken !== process.env.WEBHOOK_TOKEN) {
358
+ return res.status(403).json({ error: 'forbidden' });
359
+ }
360
+ res.json(session_store_1.sessionStore.getStats());
361
+ });
362
+ // ===== Bot Config Webhook API =====
363
+ // Get secure webhook path (auto-generated token) - null if disabled
364
+ const webhookPath = (0, webhook_handler_1.getWebhookPath)();
365
+ if (webhookPath) {
366
+ // POST /webhook/{token} - Receives updates from Hailer workflow webhooks
367
+ this.app.post(webhookPath, (req, res) => {
368
+ req.logger.debug('Bot config webhook received', {
369
+ activityId: req.body?._id,
370
+ activityName: req.body?.name,
371
+ workspaceId: req.body?.cid,
372
+ });
373
+ try {
374
+ const result = (0, webhook_handler_1.handleBotConfigWebhook)(req.body);
375
+ if (result.success) {
376
+ req.logger.debug('Bot config updated via webhook', {
377
+ action: result.action,
378
+ workspaceId: result.workspaceId,
379
+ botType: result.botType,
380
+ });
381
+ res.status(200).json(result);
382
+ }
383
+ else {
384
+ req.logger.warn('Bot config webhook failed', { error: result.error });
385
+ res.status(400).json(result);
386
+ }
387
+ }
388
+ catch (error) {
389
+ req.logger.error('Bot config webhook error', { error });
390
+ res.status(500).json({
391
+ success: false,
392
+ error: error instanceof Error ? error.message : 'Internal error',
393
+ });
394
+ }
395
+ });
396
+ // GET /webhook/{token}/status - Status endpoint to see all workspace configs
397
+ this.app.get(`${webhookPath}/status`, (_req, res) => {
398
+ const configs = (0, webhook_handler_1.listWorkspaceConfigs)();
399
+ res.json({
400
+ timestamp: new Date().toISOString(),
401
+ workspaceCount: configs.length,
402
+ workspaces: configs.map((c) => ({
403
+ workspaceId: c.workspaceId,
404
+ workspaceName: c.workspaceName,
405
+ hasOrchestrator: !!c.orchestrator,
406
+ specialistCount: c.specialists.length,
407
+ enabledSpecialists: c.specialists.filter((s) => s.enabled).length,
408
+ lastSynced: c.lastSynced,
409
+ })),
410
+ });
411
+ });
412
+ }
413
+ else {
414
+ this.logger.debug('Webhook endpoint disabled (no WEBHOOK_TOKEN)');
415
+ }
416
+ // ===== Daemon status endpoint =====
130
417
  this.app.get('/daemon/status', (_, res) => {
131
418
  if (!this.config.getDaemonStatus) {
132
419
  res.status(404).json({ error: 'Daemon mode not enabled' });
@@ -137,25 +424,25 @@ class MCPServerService {
137
424
  res.status(503).json({ error: 'Daemon not running' });
138
425
  return;
139
426
  }
140
- res.json({
141
- timestamp: new Date().toISOString(),
142
- daemons: status
143
- });
427
+ res.json({ timestamp: new Date().toISOString(), daemons: status });
144
428
  });
145
- // MCP Protocol handler - shared by both routes
429
+ // ===== Hailer MCP endpoint Direct API key auth (for Claude Code, mcp-remote, SDK) =====
430
+ // Restored original mcpHandler from pre-Cowork era with BOT_INTERNAL filtering and strict access control
146
431
  const mcpHandler = async (req, res, apiKeyOverride) => {
147
432
  const apiKey = apiKeyOverride || req.query.apiKey;
148
433
  req.logger.debug('MCP request received', { method: req.body?.method, apiKey: apiKey?.slice(0, 8) + '...' });
149
434
  try {
150
435
  const mcpRequest = req.body;
436
+ if (!mcpRequest.params) {
437
+ mcpRequest.params = {};
438
+ }
151
439
  let result;
152
440
  if (mcpRequest.method === 'tools/list') {
153
441
  req.logger.debug('Handling tools/list request');
154
- // Get agent configuration for filtering
155
442
  let filterConfig;
156
443
  if (apiKey) {
157
444
  try {
158
- const agentConfig = appConfig.getClientConfig(apiKey);
445
+ const agentConfig = this.appConfig.getClientConfig(apiKey);
159
446
  if (agentConfig.allowedTools || agentConfig.allowedGroups) {
160
447
  filterConfig = {
161
448
  allowedTools: agentConfig.allowedTools,
@@ -167,26 +454,17 @@ class MCPServerService {
167
454
  req.logger.debug('No config found for apiKey, returning all tools');
168
455
  }
169
456
  }
170
- // Apply default tool group filtering
171
- // - NUCLEAR: Only if ENABLE_NUCLEAR_TOOLS=true
172
- // - BOT_INTERNAL: Only if explicitly requested via params.includeBotInternal (for daemons)
457
+ // BOT_INTERNAL filtering: only include if explicitly requested (for daemons)
173
458
  const includeBotInternal = mcpRequest.params?.includeBotInternal === true;
174
459
  if (!filterConfig) {
175
- // No filter yet - create default excluding NUCLEAR and BOT_INTERNAL
176
460
  filterConfig = {
177
461
  allowedGroups: [tool_registry_1.ToolGroup.READ, tool_registry_1.ToolGroup.WRITE, tool_registry_1.ToolGroup.PLAYGROUND]
178
462
  };
179
463
  req.logger.debug('Using default tool filter (excludes NUCLEAR and BOT_INTERNAL)');
180
464
  }
181
465
  else if (filterConfig.allowedGroups) {
182
- // Filter groups - remove BOT_INTERNAL unless explicitly requested, remove NUCLEAR unless enabled
183
466
  filterConfig.allowedGroups = filterConfig.allowedGroups.filter(g => (includeBotInternal || g !== tool_registry_1.ToolGroup.BOT_INTERNAL) &&
184
467
  (config_1.environment.ENABLE_NUCLEAR_TOOLS || g !== tool_registry_1.ToolGroup.NUCLEAR));
185
- req.logger.debug('Filtered tool groups', {
186
- allowedGroups: filterConfig.allowedGroups,
187
- nuclearEnabled: config_1.environment.ENABLE_NUCLEAR_TOOLS,
188
- includeBotInternal
189
- });
190
468
  }
191
469
  result = {
192
470
  tools: this.toolRegistry.getToolDefinitions(filterConfig)
@@ -208,10 +486,13 @@ class MCPServerService {
208
486
  if (!apiKey) {
209
487
  return this.sendMcpError(res, mcpRequest.id, -32602, 'API key required for tools/call', 400);
210
488
  }
489
+ if (!mcpRequest.params?.name) {
490
+ return this.sendMcpError(res, mcpRequest.id, -32602, 'params.name is required', 400);
491
+ }
211
492
  const { name, arguments: args = {} } = mcpRequest.params;
212
493
  req.logger.info('Tool call', { tool: name });
213
- // Check access control
214
- if (!this.canAccessTool(name, apiKey, appConfig)) {
494
+ // Strict access control — returns false on catch (no config = no access)
495
+ if (!this.canAccessToolStrict(name, apiKey)) {
215
496
  return this.sendMcpError(res, mcpRequest.id, -32603, `Access denied to tool: ${name}`, 403);
216
497
  }
217
498
  const userContext = await UserContextCache_1.UserContextCache.getContext(apiKey);
@@ -253,12 +534,9 @@ class MCPServerService {
253
534
  }
254
535
  catch (error) {
255
536
  const errorMessage = error instanceof Error ? error.message : String(error);
256
- // Check for multi-workspace credentials error - return user-friendly error
257
- // Use HTTP 200 so the JSON-RPC error reaches the client properly
258
537
  if (errorMessage.includes('Multi-workspace credentials detected')) {
259
538
  req.logger.warn('Multi-workspace credentials blocked', { apiKey: req.query.apiKey });
260
- this.sendMcpError(res, req.body?.id || null, -32001, errorMessage, 200 // JSON-RPC errors should use HTTP 200 for proper client handling
261
- );
539
+ this.sendMcpError(res, req.body?.id || null, -32001, errorMessage, 200);
262
540
  }
263
541
  else {
264
542
  req.logger.error('MCP handler error', error, { apiKey: req.query.apiKey });
@@ -266,25 +544,20 @@ class MCPServerService {
266
544
  }
267
545
  }
268
546
  };
269
- // MCP Protocol endpoint - JSON-RPC 2.0 over SSE
270
547
  // Route 1: /api/mcp?apiKey=xxx (standard format)
271
548
  this.app.post('/api/mcp', (req, res) => mcpHandler(req, res));
272
549
  // Route 2: /:apiKey (simplified format - API key as path)
273
550
  // Matches 16-64 char alphanumeric keys, but ONLY for MCP requests (has jsonrpc field)
274
- // Non-MCP requests (webhooks) pass through to later routes
275
551
  this.app.post('/:apiKey([a-zA-Z0-9_-]{16,64})', (req, res, next) => {
276
552
  if (req.body?.jsonrpc) {
277
- // MCP request - handle it
278
553
  mcpHandler(req, res, req.params.apiKey);
279
554
  }
280
555
  else {
281
- // Not MCP (likely webhook) - pass to next route
282
556
  next();
283
557
  }
284
558
  });
285
559
  // ===== Bot Configuration API (only when MCP_CLIENT_ENABLED=true) =====
286
560
  if (config_1.environment.MCP_CLIENT_ENABLED) {
287
- // GET /api/bots - List all bots and their status
288
561
  this.app.get('/api/bots', async (req, res) => {
289
562
  req.logger.debug('List bots requested');
290
563
  try {
@@ -301,7 +574,6 @@ class MCPServerService {
301
574
  res.status(500).json({ error: 'Failed to list bots' });
302
575
  }
303
576
  });
304
- // POST /api/bots/:id/enable - Enable a bot
305
577
  this.app.post('/api/bots/:id/enable', async (req, res) => {
306
578
  const { id } = req.params;
307
579
  req.logger.debug('Enable bot requested', { botId: id });
@@ -319,7 +591,6 @@ class MCPServerService {
319
591
  res.status(500).json({ error: 'Failed to enable bot' });
320
592
  }
321
593
  });
322
- // POST /api/bots/:id/disable - Disable a bot
323
594
  this.app.post('/api/bots/:id/disable', async (req, res) => {
324
595
  const { id } = req.params;
325
596
  req.logger.debug('Disable bot requested', { botId: id });
@@ -337,7 +608,6 @@ class MCPServerService {
337
608
  res.status(500).json({ error: 'Failed to disable bot' });
338
609
  }
339
610
  });
340
- // POST /api/bots/:id/toggle - Toggle a bot
341
611
  this.app.post('/api/bots/:id/toggle', async (req, res) => {
342
612
  const { id } = req.params;
343
613
  req.logger.debug('Toggle bot requested', { botId: id });
@@ -357,101 +627,358 @@ class MCPServerService {
357
627
  res.status(500).json({ error: 'Failed to toggle bot' });
358
628
  }
359
629
  });
360
- // ===== Bot Config Webhook API =====
361
- // Get secure webhook path (auto-generated token) - null if disabled
362
- const webhookPath = (0, webhook_handler_1.getWebhookPath)();
363
- if (webhookPath) {
364
- // POST /webhook/{token} - Receives updates from Hailer workflow webhooks
365
- this.app.post(webhookPath, (req, res) => {
366
- req.logger.debug('Bot config webhook received', {
367
- activityId: req.body?._id,
368
- activityName: req.body?.name,
369
- workspaceId: req.body?.cid,
370
- });
371
- try {
372
- const result = (0, webhook_handler_1.handleBotConfigWebhook)(req.body);
373
- if (result.success) {
374
- req.logger.debug('Bot config updated via webhook', {
375
- action: result.action,
376
- workspaceId: result.workspaceId,
377
- botType: result.botType,
378
- });
379
- res.status(200).json(result);
380
- }
381
- else {
382
- req.logger.warn('Bot config webhook failed', { error: result.error });
383
- res.status(400).json(result);
630
+ }
631
+ // ===== Cowork MCP endpoint OAuth multi-user (for Claude.ai native connector) =====
632
+ // MCP session termination endpoint (required by MCP spec)
633
+ // NOTE: This only closes the MCP transport, NOT the OAuth session
634
+ // OAuth sessions have their own TTL (24h) and should persist across MCP reconnects
635
+ this.app.delete(`${API_PREFIX}/mcp`, (req, res) => {
636
+ req.logger.debug('Cowork MCP session close request');
637
+ // Don't delete OAuth session - it should persist for reconnects
638
+ res.status(200).json({ success: true });
639
+ });
640
+ // ===== Vipunen endpoint — API key auth only, no OAuth, no session store =====
641
+ // DELETE /api/vipunen - session close
642
+ this.app.delete('/api/vipunen', (req, res) => {
643
+ req.logger.debug('Vipunen MCP session close request');
644
+ res.status(200).json({ success: true });
645
+ });
646
+ // GET /api/vipunen - SSE stream
647
+ this.app.get('/api/vipunen', (req, res) => {
648
+ req.logger.debug('Vipunen SSE connection opened');
649
+ this.setupSseStream(req, res, 'Vipunen ');
650
+ });
651
+ // POST /api/vipunen - JSON-RPC 2.0, Vipunen tools only
652
+ this.app.post('/api/vipunen', async (req, res) => {
653
+ req.logger.debug('Vipunen MCP request received', { method: req.body?.method });
654
+ try {
655
+ const mcpRequest = req.body;
656
+ if (mcpRequest.method === 'initialize') {
657
+ req.logger.info('Vipunen MCP initialize request received');
658
+ res.setHeader('Mcp-Session-Id', this.generateSessionId('vipunen-session'));
659
+ return this.sendMcpResult(res, mcpRequest.id, {
660
+ protocolVersion: '2024-11-05',
661
+ capabilities: { tools: {} },
662
+ serverInfo: {
663
+ name: 'vipunen-server',
664
+ version: '1.0.0'
384
665
  }
666
+ });
667
+ }
668
+ else if (mcpRequest.method === 'notifications/initialized') {
669
+ req.logger.info('Vipunen MCP handshake completed');
670
+ res.status(204).end();
671
+ return;
672
+ }
673
+ else if (mcpRequest.method === 'tools/list') {
674
+ const vipunenDefs = this.toolRegistry.getToolDefinitionsByContextType('none');
675
+ req.logger.debug('Vipunen tools/list', { count: vipunenDefs.length });
676
+ return this.sendMcpResult(res, mcpRequest.id, { tools: vipunenDefs });
677
+ }
678
+ else if (mcpRequest.method === 'tools/call') {
679
+ if (!mcpRequest.params?.name) {
680
+ return this.sendMcpError(res, mcpRequest.id, -32602, 'params.name is required', 400);
385
681
  }
386
- catch (error) {
387
- req.logger.error('Bot config webhook error', { error });
388
- res.status(500).json({
389
- success: false,
390
- error: error instanceof Error ? error.message : 'Internal error',
682
+ const bearerToken = this.extractBearerToken(req);
683
+ // Require valid Vipunen API key
684
+ if (!bearerToken || !(0, vipunen_1.isValidVipunenKey)(bearerToken)) {
685
+ req.logger.warn('Vipunen tools/call - invalid or missing API key');
686
+ res.setHeader('WWW-Authenticate', 'Bearer');
687
+ return res.status(401).json({
688
+ error: 'unauthorized',
689
+ error_description: 'Valid Vipunen API key required.'
391
690
  });
392
691
  }
393
- });
394
- // GET /webhook/{token}/status - Status endpoint to see all workspace configs
395
- this.app.get(`${webhookPath}/status`, (_req, res) => {
396
- const configs = (0, webhook_handler_1.listWorkspaceConfigs)();
397
- res.json({
398
- timestamp: new Date().toISOString(),
399
- workspaceCount: configs.length,
400
- workspaces: configs.map((c) => ({
401
- workspaceId: c.workspaceId,
402
- workspaceName: c.workspaceName,
403
- hasOrchestrator: !!c.orchestrator,
404
- specialistCount: c.specialists.length,
405
- enabledSpecialists: c.specialists.filter((s) => s.enabled).length,
406
- lastSynced: c.lastSynced,
407
- })),
408
- });
409
- });
692
+ const { name, arguments: args = {} } = mcpRequest.params;
693
+ req.logger.debug('Vipunen tools/call', { toolName: name });
694
+ const tool = this.toolRegistry.getTool(name);
695
+ if (!tool) {
696
+ return this.sendMcpError(res, mcpRequest.id, -32601, `Unknown tool: ${name}`, 404);
697
+ }
698
+ if (tool.contextType !== 'none') {
699
+ return this.sendMcpError(res, mcpRequest.id, -32603, `Tool not accessible via Vipunen endpoint: ${name}`, 403);
700
+ }
701
+ const vipunenCtx = (0, vipunen_1.resolveVipunenContext)(bearerToken);
702
+ const result = await this.toolRegistry.executeTool(name, args, vipunenCtx);
703
+ return this.sendMcpResult(res, mcpRequest.id, result);
704
+ }
705
+ else {
706
+ return this.sendMcpError(res, mcpRequest.id, -32601, `Method not found: ${mcpRequest.method}`, 400);
707
+ }
410
708
  }
411
- else {
412
- this.logger.debug('Webhook endpoint disabled (no WEBHOOK_TOKEN)');
413
- }
414
- this.logger.debug('Routes configured', {
415
- routes: [
416
- '/health',
417
- '/daemon/status',
418
- '/api/mcp',
419
- '/api/bots'
420
- ]
709
+ catch (error) {
710
+ const errorMessage = error instanceof Error ? error.message : String(error);
711
+ req.logger.error('Vipunen MCP handler error', error);
712
+ this.sendMcpError(res, req.body?.id || null, -32000, `Server error: ${errorMessage}`, 500);
713
+ }
714
+ });
715
+ // Cowork MCP Protocol endpoint - GET for SSE stream
716
+ this.app.get(`${API_PREFIX}/mcp`, (req, res) => {
717
+ req.logger.debug('Cowork SSE connection opened');
718
+ this.setupSseStream(req, res, 'Cowork ');
719
+ });
720
+ // Cowork MCP Protocol endpoint - JSON-RPC 2.0 over SSE (OAuth auth)
721
+ this.app.post(`${API_PREFIX}/mcp`, (req, res) => {
722
+ this.handleCoworkMcp(req, res, {
723
+ resolveApiKey: (r) => {
724
+ const bearer = this.extractBearerToken(r);
725
+ let apiKey;
726
+ let keyId;
727
+ if (bearer) {
728
+ keyId = bearer;
729
+ const session = session_store_1.sessionStore.get(keyId);
730
+ if (session) {
731
+ apiKey = session.apiKey;
732
+ }
733
+ }
734
+ // Fallback to query param (legacy support)
735
+ if (!apiKey) {
736
+ apiKey = r.query.apiKey;
737
+ }
738
+ return { apiKey, keyId };
739
+ },
740
+ onUnauthorized: (r, rs, _id, toolName) => {
741
+ const baseUrl = this.getBaseUrl(r);
742
+ r.logger.info('No auth for tools/call - returning 401 to trigger OAuth', { tool: toolName });
743
+ rs.setHeader('WWW-Authenticate', `Bearer resource="${baseUrl}${API_PREFIX}/mcp"`);
744
+ rs.status(401).json({
745
+ error: 'unauthorized',
746
+ error_description: 'Authentication required. Please authorize via OAuth to use tools.'
747
+ });
748
+ },
749
+ label: 'Cowork'
421
750
  });
751
+ });
752
+ this.logger.debug('Routes configured', {
753
+ routes: [
754
+ '/health',
755
+ '/daemon/status',
756
+ '/api/mcp (Hailer — direct API key)',
757
+ '/:apiKey (Hailer — API key as path)',
758
+ '/api/bots (Bot management)',
759
+ `${API_PREFIX}/mcp (Cowork — OAuth)`,
760
+ '/api/vipunen (Vipunen — Bearer key)',
761
+ '/.well-known/oauth-authorization-server',
762
+ '/.well-known/oauth-protected-resource',
763
+ `${API_PREFIX}/oauth/register`,
764
+ `${API_PREFIX}/oauth/authorize`,
765
+ `${API_PREFIX}/oauth/token`,
766
+ `${API_PREFIX}/oauth/callback`,
767
+ `${API_PREFIX}/auth/register`,
768
+ `${API_PREFIX}/auth/stats`
769
+ ]
770
+ });
771
+ }
772
+ extractBearerToken(req) {
773
+ const auth = req.headers.authorization;
774
+ return auth?.startsWith('Bearer ') ? auth.substring(7) : undefined;
775
+ }
776
+ setupSseStream(req, res, label) {
777
+ res.setHeader('Content-Type', 'text/event-stream');
778
+ res.setHeader('Cache-Control', 'no-cache');
779
+ res.setHeader('Connection', 'keep-alive');
780
+ res.setHeader('X-Accel-Buffering', 'no');
781
+ res.write(': connected\n\n');
782
+ const heartbeat = setInterval(() => {
783
+ if (!res.write(': heartbeat\n\n')) {
784
+ clearInterval(heartbeat);
785
+ }
786
+ }, 30000);
787
+ req.on('close', () => {
788
+ clearInterval(heartbeat);
789
+ req.logger.debug(`${label}SSE connection closed`);
790
+ });
791
+ }
792
+ generateSessionId(prefix) {
793
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
794
+ }
795
+ /**
796
+ * Strict access control for /api/mcp — returns false on catch (no config = no access)
797
+ */
798
+ canAccessToolStrict(toolName, apiKey) {
799
+ // User API Keys aren't in CLIENT_CONFIGS — apply permissive access (non-NUCLEAR)
800
+ if (apiKey.startsWith('userapikey_')) {
801
+ const tool = this.toolRegistry.getTool(toolName);
802
+ if (!tool)
803
+ return false;
804
+ if (tool.group === tool_registry_1.ToolGroup.NUCLEAR && !config_1.environment.ENABLE_NUCLEAR_TOOLS) {
805
+ return false;
806
+ }
807
+ return true;
422
808
  }
423
- else {
424
- this.logger.debug('Routes configured', {
425
- routes: [
426
- '/health',
427
- '/daemon/status',
428
- '/api/mcp'
429
- ]
430
- });
809
+ try {
810
+ const agentConfig = this.appConfig.getClientConfig(apiKey);
811
+ if (agentConfig.allowedTools) {
812
+ return agentConfig.allowedTools.includes(toolName);
813
+ }
814
+ if (agentConfig.allowedGroups) {
815
+ const tool = this.toolRegistry.getTool(toolName);
816
+ return tool ? agentConfig.allowedGroups.includes(tool.group) : false;
817
+ }
818
+ return true;
819
+ }
820
+ catch {
821
+ return false;
431
822
  }
432
823
  }
433
824
  /**
434
- * Check if agent has access to a specific tool
825
+ * Permissive access control for Cowork allows non-NUCLEAR tools on catch (OAuth sessions)
435
826
  */
436
- canAccessTool(toolName, apiKey, appConfig) {
827
+ canAccessToolPermissive(toolName, apiKey) {
437
828
  try {
438
- const agentConfig = appConfig.getClientConfig(apiKey);
439
- // Explicit tool whitelist (takes precedence)
829
+ const agentConfig = this.appConfig.getClientConfig(apiKey);
440
830
  if (agentConfig.allowedTools) {
441
831
  return agentConfig.allowedTools.includes(toolName);
442
832
  }
443
- // Group-based access control
444
833
  if (agentConfig.allowedGroups) {
445
834
  const tool = this.toolRegistry.getTool(toolName);
446
835
  return tool ? agentConfig.allowedGroups.includes(tool.group) : false;
447
836
  }
448
- // Default: allow all (backward compatible)
449
837
  return true;
450
838
  }
451
839
  catch {
452
- return false;
840
+ const tool = this.toolRegistry.getTool(toolName);
841
+ if (!tool)
842
+ return false;
843
+ if (tool.group === tool_registry_1.ToolGroup.NUCLEAR && !config_1.environment.ENABLE_NUCLEAR_TOOLS) {
844
+ return false;
845
+ }
846
+ return true;
453
847
  }
454
848
  }
849
+ /**
850
+ * Cowork MCP JSON-RPC handler for /api/cowork/mcp (OAuth multi-user).
851
+ * Permissive access control, contextType filter, OAuth 401 flow.
852
+ */
853
+ async handleCoworkMcp(req, res, options) {
854
+ req.logger.debug(`${options.label} MCP request received`, { method: req.body?.method });
855
+ try {
856
+ const { apiKey, keyId } = options.resolveApiKey(req);
857
+ if (apiKey) {
858
+ req.logger.debug(`${options.label} API key resolved`, { keyId: keyId?.substring(0, 8) || 'direct' });
859
+ }
860
+ const mcpRequest = req.body;
861
+ let result;
862
+ // tools/list is intentionally unauthenticated — MCP clients need the list
863
+ // to construct the OAuth prompt before they have a token.
864
+ // tools/call is the enforcement gate.
865
+ if (mcpRequest.method === 'tools/list') {
866
+ req.logger.debug(`${options.label} Handling tools/list`);
867
+ let filterConfig;
868
+ if (apiKey) {
869
+ try {
870
+ const agentConfig = this.appConfig.getClientConfig(apiKey);
871
+ if (agentConfig.allowedTools || agentConfig.allowedGroups) {
872
+ filterConfig = {
873
+ allowedTools: agentConfig.allowedTools,
874
+ allowedGroups: agentConfig.allowedGroups
875
+ };
876
+ }
877
+ }
878
+ catch {
879
+ req.logger.debug('No config found for apiKey, returning all tools');
880
+ }
881
+ }
882
+ if (!filterConfig) {
883
+ filterConfig = {
884
+ allowedGroups: [tool_registry_1.ToolGroup.READ, tool_registry_1.ToolGroup.WRITE, tool_registry_1.ToolGroup.PLAYGROUND]
885
+ };
886
+ req.logger.debug('Using default tool filter (excludes NUCLEAR)');
887
+ }
888
+ else if (filterConfig.allowedGroups) {
889
+ filterConfig.allowedGroups = filterConfig.allowedGroups.filter(g => (config_1.environment.ENABLE_NUCLEAR_TOOLS || g !== tool_registry_1.ToolGroup.NUCLEAR));
890
+ }
891
+ result = {
892
+ tools: this.toolRegistry.getToolDefinitions(filterConfig)
893
+ .filter((def) => {
894
+ const tool = this.toolRegistry.getTool(def.name);
895
+ return (tool?.contextType ?? 'hailer') !== 'none';
896
+ })
897
+ };
898
+ }
899
+ else if (mcpRequest.method === 'tools/get_schema') {
900
+ const toolName = mcpRequest.params?.name;
901
+ req.logger.debug(`${options.label} Handling tools/get_schema`, { toolName });
902
+ if (!toolName) {
903
+ return this.sendMcpError(res, mcpRequest.id, -32602, 'Tool name required in params.name', 400);
904
+ }
905
+ const schema = this.toolRegistry.getToolSchema(toolName);
906
+ if (!schema) {
907
+ return this.sendMcpError(res, mcpRequest.id, -32602, `Tool not found: ${toolName}`, 404);
908
+ }
909
+ result = schema;
910
+ }
911
+ else if (mcpRequest.method === 'tools/call') {
912
+ if (!apiKey) {
913
+ return options.onUnauthorized(req, res, mcpRequest.id, mcpRequest.params?.name);
914
+ }
915
+ if (!mcpRequest.params?.name) {
916
+ return this.sendMcpError(res, mcpRequest.id, -32602, 'params.name is required', 400);
917
+ }
918
+ const { name, arguments: args = {} } = mcpRequest.params;
919
+ req.logger.debug(`${options.label} Handling tools/call`, { toolName: name });
920
+ const tool = this.toolRegistry.getTool(name);
921
+ const contextType = tool?.contextType ?? 'hailer';
922
+ if (!this.canAccessToolPermissive(name, apiKey)) {
923
+ return this.sendMcpError(res, mcpRequest.id, -32603, `Access denied to tool: ${name}`, 403);
924
+ }
925
+ if (contextType === 'none') {
926
+ return this.sendMcpError(res, mcpRequest.id, -32602, `Tool not found: ${name}`, 404);
927
+ }
928
+ const userContext = await UserContextCache_1.UserContextCache.getContext(apiKey);
929
+ result = await this.toolRegistry.executeTool(name, args, userContext);
930
+ }
931
+ else if (mcpRequest.method === 'initialize') {
932
+ req.logger.info(`${options.label} MCP initialize request received`);
933
+ res.setHeader('Mcp-Session-Id', this.generateSessionId('session'));
934
+ result = {
935
+ protocolVersion: '2024-11-05',
936
+ capabilities: { tools: {} },
937
+ serverInfo: {
938
+ name: 'hailer-mcp-server',
939
+ version: '1.0.0'
940
+ },
941
+ // Instructions help Claude App understand how to use Hailer tools
942
+ ...(mcpInstructions && { instructions: mcpInstructions })
943
+ };
944
+ }
945
+ else if (mcpRequest.method === 'notifications/initialized') {
946
+ req.logger.info(`${options.label} MCP handshake completed`);
947
+ res.status(204).end();
948
+ return;
949
+ }
950
+ else {
951
+ return this.sendMcpError(res, mcpRequest.id, -32601, `Method not found: ${mcpRequest.method}`, 400);
952
+ }
953
+ this.sendMcpResult(res, mcpRequest.id, result);
954
+ }
955
+ catch (error) {
956
+ const errorMessage = error instanceof Error ? error.message : String(error);
957
+ if (errorMessage.includes('Multi-workspace credentials detected')) {
958
+ req.logger.warn('Multi-workspace credentials blocked');
959
+ this.sendMcpError(res, req.body?.id || null, -32001, errorMessage, 200);
960
+ }
961
+ else if (errorMessage.includes('No Hailer account found')) {
962
+ req.logger.warn('Invalid API key - no Hailer account found');
963
+ this.sendMcpError(res, req.body?.id || null, -32002, 'Invalid or expired API key. Please disconnect and reconnect the Hailer MCP connector to re-authorize.', 200);
964
+ }
965
+ else {
966
+ req.logger.error(`${options.label} MCP handler error`, error);
967
+ this.sendMcpError(res, req.body?.id || null, -32000, `Server error: ${errorMessage}`, 500);
968
+ }
969
+ }
970
+ }
971
+ /**
972
+ * Send MCP success response via SSE
973
+ */
974
+ sendMcpResult(res, id, result) {
975
+ const response = { jsonrpc: '2.0', result, id };
976
+ res.setHeader('Content-Type', 'text/event-stream');
977
+ res.setHeader('Cache-Control', 'no-cache');
978
+ res.setHeader('Connection', 'keep-alive');
979
+ res.write(`data: ${JSON.stringify(response)}\n\n`);
980
+ res.end();
981
+ }
455
982
  /**
456
983
  * Send MCP error response
457
984
  */
@@ -472,11 +999,12 @@ class MCPServerService {
472
999
  this.logger.info('MCP Server started', {
473
1000
  port: port,
474
1001
  healthCheck: `http://localhost:${port}/health`,
475
- mcpEndpoint: `http://localhost:${port}/api/mcp`
1002
+ daemonStatus: `http://localhost:${port}/daemon/status`,
1003
+ hailerEndpoint: `http://localhost:${port}/api/mcp`,
1004
+ coworkEndpoint: `http://localhost:${port}/api/cowork/mcp`,
1005
+ vipunenEndpoint: `http://localhost:${port}/api/vipunen`
476
1006
  });
477
1007
  // Update .mcp.json with the actual port so Claude Code connects to the right server
478
- const fs = require('fs');
479
- const path = require('path');
480
1008
  const mcpJsonPath = path.join(process.cwd(), '.mcp.json');
481
1009
  try {
482
1010
  if (fs.existsSync(mcpJsonPath)) {
@@ -495,22 +1023,6 @@ class MCPServerService {
495
1023
  catch (err) {
496
1024
  this.logger.debug('Could not update .mcp.json', { error: err.message });
497
1025
  }
498
- // Update .opencode/opencode.json with the actual port so OpenCode connects to the right server
499
- const opencodeJsonPath = path.join(process.cwd(), '.opencode', 'opencode.json');
500
- try {
501
- if (fs.existsSync(opencodeJsonPath)) {
502
- const opencodeJson = JSON.parse(fs.readFileSync(opencodeJsonPath, 'utf-8'));
503
- const hailerMcp = opencodeJson?.mcp?.hailer;
504
- if (hailerMcp?.url && typeof hailerMcp.url === 'string' && hailerMcp.url.includes('localhost:')) {
505
- hailerMcp.url = hailerMcp.url.replace(/localhost:\d+/, `localhost:${port}`);
506
- fs.writeFileSync(opencodeJsonPath, JSON.stringify(opencodeJson, null, 2) + '\n');
507
- this.logger.debug('.opencode/opencode.json updated with port', { port });
508
- }
509
- }
510
- }
511
- catch (err) {
512
- this.logger.debug('Could not update .opencode/opencode.json', { error: err.message });
513
- }
514
1026
  resolve();
515
1027
  });
516
1028
  this.server.on('error', (error) => {
@@ -524,7 +1036,7 @@ class MCPServerService {
524
1036
  const server = this.server;
525
1037
  return new Promise((resolve) => {
526
1038
  server.close(() => {
527
- this.logger.debug('MCP Server stopped gracefully');
1039
+ this.logger.info('MCP Server stopped gracefully');
528
1040
  resolve();
529
1041
  });
530
1042
  });