fodda-mcp 1.46.31 → 1.46.97

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 (289) hide show
  1. package/README.md +14 -94
  2. package/dist/agents/fodda-researcher/skills.d.ts +1 -1
  3. package/dist/agents/fodda-researcher/skills.d.ts.map +1 -1
  4. package/dist/agents/fodda-researcher/skills.js +1 -1
  5. package/dist/brandTemplate.d.ts +1 -1
  6. package/dist/brandTemplate.d.ts.map +1 -1
  7. package/dist/brandTemplate.js +162 -74
  8. package/dist/brandTemplate.js.map +1 -1
  9. package/dist/catalogCache.d.ts +29 -1
  10. package/dist/catalogCache.d.ts.map +1 -1
  11. package/dist/catalogCache.js +267 -16
  12. package/dist/catalogCache.js.map +1 -1
  13. package/dist/coverageRelevance.d.ts +97 -2
  14. package/dist/coverageRelevance.d.ts.map +1 -1
  15. package/dist/coverageRelevance.js +959 -164
  16. package/dist/coverageRelevance.js.map +1 -1
  17. package/dist/editorialFill.d.ts +5 -0
  18. package/dist/editorialFill.d.ts.map +1 -1
  19. package/dist/editorialFill.js +1 -1
  20. package/dist/editorialFill.js.map +1 -1
  21. package/dist/enrichment.d.ts +26 -0
  22. package/dist/enrichment.d.ts.map +1 -1
  23. package/dist/enrichment.js +394 -31
  24. package/dist/enrichment.js.map +1 -1
  25. package/dist/errorHandling.d.ts +35 -7
  26. package/dist/errorHandling.d.ts.map +1 -1
  27. package/dist/errorHandling.js +225 -155
  28. package/dist/errorHandling.js.map +1 -1
  29. package/dist/index.d.ts +25 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +351 -143
  32. package/dist/index.js.map +1 -1
  33. package/dist/jobs/jlens_quarterly_sweep.d.ts +62 -0
  34. package/dist/jobs/jlens_quarterly_sweep.d.ts.map +1 -0
  35. package/dist/jobs/jlens_quarterly_sweep.js +160 -0
  36. package/dist/jobs/jlens_quarterly_sweep.js.map +1 -0
  37. package/dist/pricingCache.d.ts.map +1 -1
  38. package/dist/pricingCache.js +41 -1
  39. package/dist/pricingCache.js.map +1 -1
  40. package/dist/queryCache.d.ts.map +1 -1
  41. package/dist/queryCache.js +2 -0
  42. package/dist/queryCache.js.map +1 -1
  43. package/dist/reportBriefing.d.ts +89 -0
  44. package/dist/reportBriefing.d.ts.map +1 -0
  45. package/dist/reportBriefing.js +662 -0
  46. package/dist/reportBriefing.js.map +1 -0
  47. package/dist/searchTemplate.d.ts.map +1 -1
  48. package/dist/searchTemplate.js +65 -53
  49. package/dist/searchTemplate.js.map +1 -1
  50. package/dist/services/graphRouter.d.ts +7 -0
  51. package/dist/services/graphRouter.d.ts.map +1 -0
  52. package/dist/services/graphRouter.js +7 -0
  53. package/dist/services/graphRouter.js.map +1 -0
  54. package/dist/services/notifications.d.ts +49 -0
  55. package/dist/services/notifications.d.ts.map +1 -0
  56. package/dist/services/notifications.js +206 -0
  57. package/dist/services/notifications.js.map +1 -0
  58. package/dist/sessionTracker.d.ts +19 -2
  59. package/dist/sessionTracker.d.ts.map +1 -1
  60. package/dist/sessionTracker.js +69 -16
  61. package/dist/sessionTracker.js.map +1 -1
  62. package/dist/skillClient.d.ts.map +1 -1
  63. package/dist/skillClient.js +11 -6
  64. package/dist/skillClient.js.map +1 -1
  65. package/dist/svgVisuals.d.ts +17 -19
  66. package/dist/svgVisuals.d.ts.map +1 -1
  67. package/dist/svgVisuals.js +189 -233
  68. package/dist/svgVisuals.js.map +1 -1
  69. package/dist/systemPrompt.d.ts +7 -2
  70. package/dist/systemPrompt.d.ts.map +1 -1
  71. package/dist/systemPrompt.js +110 -68
  72. package/dist/systemPrompt.js.map +1 -1
  73. package/dist/telemetry.d.ts.map +1 -1
  74. package/dist/telemetry.js +15 -0
  75. package/dist/telemetry.js.map +1 -1
  76. package/dist/test_adjacent_trends_polymorphic.d.ts +2 -0
  77. package/dist/test_adjacent_trends_polymorphic.d.ts.map +1 -0
  78. package/dist/test_adjacent_trends_polymorphic.js +154 -0
  79. package/dist/test_adjacent_trends_polymorphic.js.map +1 -0
  80. package/dist/test_book_a_call_live.js +18 -5
  81. package/dist/test_book_a_call_live.js.map +1 -1
  82. package/dist/test_byo_mcp_submission_clarity.d.ts +2 -0
  83. package/dist/test_byo_mcp_submission_clarity.d.ts.map +1 -0
  84. package/dist/test_byo_mcp_submission_clarity.js +142 -0
  85. package/dist/test_byo_mcp_submission_clarity.js.map +1 -0
  86. package/dist/test_capabilities_guidance.d.ts +2 -0
  87. package/dist/test_capabilities_guidance.d.ts.map +1 -0
  88. package/dist/test_capabilities_guidance.js +372 -0
  89. package/dist/test_capabilities_guidance.js.map +1 -0
  90. package/dist/test_client_provenance.d.ts +2 -0
  91. package/dist/test_client_provenance.d.ts.map +1 -0
  92. package/dist/test_client_provenance.js +77 -0
  93. package/dist/test_client_provenance.js.map +1 -0
  94. package/dist/test_connection_surface_cleanup.d.ts +2 -0
  95. package/dist/test_connection_surface_cleanup.d.ts.map +1 -0
  96. package/dist/test_connection_surface_cleanup.js +203 -0
  97. package/dist/test_connection_surface_cleanup.js.map +1 -0
  98. package/dist/test_consult_routing.d.ts +2 -0
  99. package/dist/test_consult_routing.d.ts.map +1 -0
  100. package/dist/test_consult_routing.js +86 -0
  101. package/dist/test_consult_routing.js.map +1 -0
  102. package/dist/test_consult_split.d.ts +2 -0
  103. package/dist/test_consult_split.d.ts.map +1 -0
  104. package/dist/test_consult_split.js +30 -0
  105. package/dist/test_consult_split.js.map +1 -0
  106. package/dist/test_coverage_relevance.js +111 -70
  107. package/dist/test_coverage_relevance.js.map +1 -1
  108. package/dist/test_coverage_replay.d.ts +2 -0
  109. package/dist/test_coverage_replay.d.ts.map +1 -0
  110. package/dist/test_coverage_replay.js +170 -0
  111. package/dist/test_coverage_replay.js.map +1 -0
  112. package/dist/test_dcr_and_legacy_deprecation.js +45 -50
  113. package/dist/test_dcr_and_legacy_deprecation.js.map +1 -1
  114. package/dist/test_demand_signal_logging.d.ts +2 -0
  115. package/dist/test_demand_signal_logging.d.ts.map +1 -0
  116. package/dist/test_demand_signal_logging.js +92 -0
  117. package/dist/test_demand_signal_logging.js.map +1 -0
  118. package/dist/test_earnings_activity_scope.d.ts +5 -0
  119. package/dist/test_earnings_activity_scope.d.ts.map +1 -0
  120. package/dist/test_earnings_activity_scope.js +101 -0
  121. package/dist/test_earnings_activity_scope.js.map +1 -0
  122. package/dist/test_earnings_divergence_live.d.ts +5 -0
  123. package/dist/test_earnings_divergence_live.d.ts.map +1 -0
  124. package/dist/test_earnings_divergence_live.js +135 -0
  125. package/dist/test_earnings_divergence_live.js.map +1 -0
  126. package/dist/test_earnings_hygiene_and_binding.d.ts +5 -0
  127. package/dist/test_earnings_hygiene_and_binding.d.ts.map +1 -0
  128. package/dist/test_earnings_hygiene_and_binding.js +104 -0
  129. package/dist/test_earnings_hygiene_and_binding.js.map +1 -0
  130. package/dist/test_exec_quotes_render.d.ts +2 -0
  131. package/dist/test_exec_quotes_render.d.ts.map +1 -0
  132. package/dist/test_exec_quotes_render.js +86 -0
  133. package/dist/test_exec_quotes_render.js.map +1 -0
  134. package/dist/test_expert_intro.d.ts +2 -0
  135. package/dist/test_expert_intro.d.ts.map +1 -0
  136. package/dist/test_expert_intro.js +57 -0
  137. package/dist/test_expert_intro.js.map +1 -0
  138. package/dist/test_expert_layer.d.ts +11 -0
  139. package/dist/test_expert_layer.d.ts.map +1 -0
  140. package/dist/test_expert_layer.js +356 -0
  141. package/dist/test_expert_layer.js.map +1 -0
  142. package/dist/test_gap_alert.js +8 -0
  143. package/dist/test_gap_alert.js.map +1 -1
  144. package/dist/test_grok_offering_surfaces.d.ts +2 -0
  145. package/dist/test_grok_offering_surfaces.d.ts.map +1 -0
  146. package/dist/test_grok_offering_surfaces.js +155 -0
  147. package/dist/test_grok_offering_surfaces.js.map +1 -0
  148. package/dist/test_human_agent_live.d.ts +2 -0
  149. package/dist/test_human_agent_live.d.ts.map +1 -0
  150. package/dist/test_human_agent_live.js +48 -0
  151. package/dist/test_human_agent_live.js.map +1 -0
  152. package/dist/test_identity_gap.d.ts +2 -0
  153. package/dist/test_identity_gap.d.ts.map +1 -0
  154. package/dist/test_identity_gap.js +171 -0
  155. package/dist/test_identity_gap.js.map +1 -0
  156. package/dist/test_intelligence_and_verifier.d.ts +17 -0
  157. package/dist/test_intelligence_and_verifier.d.ts.map +1 -0
  158. package/dist/test_intelligence_and_verifier.js +162 -0
  159. package/dist/test_intelligence_and_verifier.js.map +1 -0
  160. package/dist/test_latency_mcp_brief5.d.ts +12 -0
  161. package/dist/test_latency_mcp_brief5.d.ts.map +1 -0
  162. package/dist/test_latency_mcp_brief5.js +200 -0
  163. package/dist/test_latency_mcp_brief5.js.map +1 -0
  164. package/dist/test_limit_and_credit_card_capture.d.ts +21 -0
  165. package/dist/test_limit_and_credit_card_capture.d.ts.map +1 -0
  166. package/dist/test_limit_and_credit_card_capture.js +216 -0
  167. package/dist/test_limit_and_credit_card_capture.js.map +1 -0
  168. package/dist/test_list_graphs_topics.js +1 -1
  169. package/dist/test_list_graphs_topics.js.map +1 -1
  170. package/dist/test_live_deployed_mcp_search.d.ts +2 -0
  171. package/dist/test_live_deployed_mcp_search.d.ts.map +1 -0
  172. package/dist/test_live_deployed_mcp_search.js +85 -0
  173. package/dist/test_live_deployed_mcp_search.js.map +1 -0
  174. package/dist/test_live_deployed_split.d.ts +2 -0
  175. package/dist/test_live_deployed_split.d.ts.map +1 -0
  176. package/dist/test_live_deployed_split.js +72 -0
  177. package/dist/test_live_deployed_split.js.map +1 -0
  178. package/dist/test_live_endpoint_runner.js +2 -2
  179. package/dist/test_live_endpoint_runner.js.map +1 -1
  180. package/dist/test_live_mcp.js +1 -1
  181. package/dist/test_live_mcp.js.map +1 -1
  182. package/dist/test_live_neil_widget.d.ts +2 -0
  183. package/dist/test_live_neil_widget.d.ts.map +1 -0
  184. package/dist/test_live_neil_widget.js +84 -0
  185. package/dist/test_live_neil_widget.js.map +1 -0
  186. package/dist/test_longpoll_status.d.ts +2 -0
  187. package/dist/test_longpoll_status.d.ts.map +1 -0
  188. package/dist/test_longpoll_status.js +117 -0
  189. package/dist/test_longpoll_status.js.map +1 -0
  190. package/dist/test_mcp_find_expert_and_vocabulary_routing.d.ts +2 -0
  191. package/dist/test_mcp_find_expert_and_vocabulary_routing.d.ts.map +1 -0
  192. package/dist/test_mcp_find_expert_and_vocabulary_routing.js +130 -0
  193. package/dist/test_mcp_find_expert_and_vocabulary_routing.js.map +1 -0
  194. package/dist/test_next_moves.js +644 -279
  195. package/dist/test_next_moves.js.map +1 -1
  196. package/dist/test_next_moves_transcripts.js +122 -40
  197. package/dist/test_next_moves_transcripts.js.map +1 -1
  198. package/dist/test_on_request_caller_identity.d.ts +13 -0
  199. package/dist/test_on_request_caller_identity.d.ts.map +1 -0
  200. package/dist/test_on_request_caller_identity.js +131 -0
  201. package/dist/test_on_request_caller_identity.js.map +1 -0
  202. package/dist/test_on_request_intercept.d.ts +10 -0
  203. package/dist/test_on_request_intercept.d.ts.map +1 -0
  204. package/dist/test_on_request_intercept.js +95 -0
  205. package/dist/test_on_request_intercept.js.map +1 -0
  206. package/dist/test_onboarding_consent_and_errors.d.ts +2 -0
  207. package/dist/test_onboarding_consent_and_errors.d.ts.map +1 -0
  208. package/dist/test_onboarding_consent_and_errors.js +110 -0
  209. package/dist/test_onboarding_consent_and_errors.js.map +1 -0
  210. package/dist/test_onboarding_live_scenarios.d.ts +2 -0
  211. package/dist/test_onboarding_live_scenarios.d.ts.map +1 -0
  212. package/dist/test_onboarding_live_scenarios.js +189 -0
  213. package/dist/test_onboarding_live_scenarios.js.map +1 -0
  214. package/dist/test_referral_live.d.ts +2 -0
  215. package/dist/test_referral_live.d.ts.map +1 -0
  216. package/dist/test_referral_live.js +33 -0
  217. package/dist/test_referral_live.js.map +1 -0
  218. package/dist/test_referral_piers.d.ts +2 -0
  219. package/dist/test_referral_piers.d.ts.map +1 -0
  220. package/dist/test_referral_piers.js +33 -0
  221. package/dist/test_referral_piers.js.map +1 -0
  222. package/dist/test_report_editorial_briefing.d.ts +21 -0
  223. package/dist/test_report_editorial_briefing.d.ts.map +1 -0
  224. package/dist/test_report_editorial_briefing.js +356 -0
  225. package/dist/test_report_editorial_briefing.js.map +1 -0
  226. package/dist/test_search_graph_quality.d.ts +2 -0
  227. package/dist/test_search_graph_quality.d.ts.map +1 -0
  228. package/dist/test_search_graph_quality.js +493 -0
  229. package/dist/test_search_graph_quality.js.map +1 -0
  230. package/dist/test_service_to_service_auth.d.ts +2 -0
  231. package/dist/test_service_to_service_auth.d.ts.map +1 -0
  232. package/dist/test_service_to_service_auth.js +199 -0
  233. package/dist/test_service_to_service_auth.js.map +1 -0
  234. package/dist/test_sources_live.d.ts +2 -0
  235. package/dist/test_sources_live.d.ts.map +1 -0
  236. package/dist/test_sources_live.js +30 -0
  237. package/dist/test_sources_live.js.map +1 -0
  238. package/dist/test_specialist_and_analyst_tiers.d.ts +2 -0
  239. package/dist/test_specialist_and_analyst_tiers.d.ts.map +1 -0
  240. package/dist/test_specialist_and_analyst_tiers.js +155 -0
  241. package/dist/test_specialist_and_analyst_tiers.js.map +1 -0
  242. package/dist/test_v2_2_visual_hardening.d.ts +5 -0
  243. package/dist/test_v2_2_visual_hardening.d.ts.map +1 -0
  244. package/dist/test_v2_2_visual_hardening.js +333 -0
  245. package/dist/test_v2_2_visual_hardening.js.map +1 -0
  246. package/dist/test_verify_deprecate_render_spec.d.ts +2 -0
  247. package/dist/test_verify_deprecate_render_spec.d.ts.map +1 -0
  248. package/dist/test_verify_deprecate_render_spec.js +217 -0
  249. package/dist/test_verify_deprecate_render_spec.js.map +1 -0
  250. package/dist/test_verify_neil_demo.d.ts +2 -0
  251. package/dist/test_verify_neil_demo.d.ts.map +1 -0
  252. package/dist/test_verify_neil_demo.js +149 -0
  253. package/dist/test_verify_neil_demo.js.map +1 -0
  254. package/dist/test_waitable_research_and_grounded_links.d.ts +2 -0
  255. package/dist/test_waitable_research_and_grounded_links.d.ts.map +1 -0
  256. package/dist/test_waitable_research_and_grounded_links.js +129 -0
  257. package/dist/test_waitable_research_and_grounded_links.js.map +1 -0
  258. package/dist/test_widget_guards.d.ts +2 -0
  259. package/dist/test_widget_guards.d.ts.map +1 -0
  260. package/dist/test_widget_guards.js +229 -0
  261. package/dist/test_widget_guards.js.map +1 -0
  262. package/dist/test_zero_retention_alerts.d.ts +14 -0
  263. package/dist/test_zero_retention_alerts.d.ts.map +1 -0
  264. package/dist/test_zero_retention_alerts.js +76 -0
  265. package/dist/test_zero_retention_alerts.js.map +1 -0
  266. package/dist/toolHandlers.d.ts +38 -1
  267. package/dist/toolHandlers.d.ts.map +1 -1
  268. package/dist/toolHandlers.js +3856 -938
  269. package/dist/toolHandlers.js.map +1 -1
  270. package/dist/tools.d.ts +20 -0
  271. package/dist/tools.d.ts.map +1 -1
  272. package/dist/tools.js +25 -4
  273. package/dist/tools.js.map +1 -1
  274. package/dist/verify_discoverability.d.ts +2 -0
  275. package/dist/verify_discoverability.d.ts.map +1 -0
  276. package/dist/verify_discoverability.js +53 -0
  277. package/dist/verify_discoverability.js.map +1 -0
  278. package/dist/verify_tools_endpoint.js +16 -3
  279. package/dist/verify_tools_endpoint.js.map +1 -1
  280. package/dist/widgetShell.d.ts +2 -2
  281. package/dist/widgetShell.d.ts.map +1 -1
  282. package/dist/widgetShell.js +1 -1
  283. package/dist/widgetShell.js.map +1 -1
  284. package/package.json +1 -1
  285. package/server.json +4 -32
  286. package/dist/oauthRegisterShim.d.ts +0 -13
  287. package/dist/oauthRegisterShim.d.ts.map +0 -1
  288. package/dist/oauthRegisterShim.js +0 -83
  289. package/dist/oauthRegisterShim.js.map +0 -1
package/dist/index.js CHANGED
@@ -6,7 +6,6 @@
6
6
  */
7
7
  import express from 'express';
8
8
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
- import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
10
9
  import crypto from 'crypto';
11
10
  import axios from 'axios';
12
11
  import { initCatalogCache } from './catalogCache.js';
@@ -17,7 +16,6 @@ import { createServer } from './toolHandlers.js';
17
16
  import { registerA2ARoute } from './a2aHandler.js';
18
17
  import { registerAgentFactsRoute } from './agentFacts.js';
19
18
  import { getTelemetryStats, recordFeedbackEntry } from './telemetry.js';
20
- import { handleOauthRegister } from './oauthRegisterShim.js';
21
19
  import * as dotenv from 'dotenv';
22
20
  dotenv.config();
23
21
  // ── Global crash guards ──
@@ -33,7 +31,7 @@ process.on('uncaughtException', (err) => {
33
31
  });
34
32
  const API_BASE_URL = process.env.FODDA_API_URL || 'https://api.fodda.ai';
35
33
  const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'https://www.fodda.ai';
36
- const app = express();
34
+ export const app = express();
37
35
  app.use(express.json({ limit: '512kb' }));
38
36
  // Strip api_key from request logging globally before anything hits Cloud Run logs
39
37
  app.use((req, _res, next) => {
@@ -46,24 +44,137 @@ app.use((req, _res, next) => {
46
44
  app.use((req, res, next) => {
47
45
  res.setHeader('Access-Control-Allow-Origin', '*');
48
46
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
49
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-User-Id, X-Stripe-SPT, Mcp-Session-Id, Accept');
47
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-User-Id, X-User-Email, X-Internal-Key, X-Fodda-Signature, X-Fodda-Timestamp, X-Stripe-SPT, Mcp-Session-Id, Accept, X-Fodda-Session-Kind, X-Fodda-Source, X-Fodda-Client');
50
48
  res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
51
49
  if (req.method === 'OPTIONS')
52
50
  return res.status(204).end();
53
51
  next();
54
52
  });
53
+ // Helper to verify incoming HMAC signature from internal services
54
+ export function isIncomingHmacValid(req) {
55
+ const secret = process.env.FODDA_MCP_SECRET;
56
+ if (!secret)
57
+ return false;
58
+ const signature = req.headers['x-fodda-signature'] || '';
59
+ if (!signature)
60
+ return false;
61
+ const timestamp = req.headers['x-fodda-timestamp'] || '';
62
+ // If timestamp is supplied, enforce a 5-minute replay window (300,000 ms)
63
+ if (timestamp) {
64
+ const ts = parseInt(timestamp, 10);
65
+ if (isNaN(ts) || Math.abs(Date.now() - ts) > 300000) {
66
+ return false;
67
+ }
68
+ }
69
+ // Try payload with timestamp: `${timestamp}.${JSON.stringify(body)}` or `${timestamp}.${path}`
70
+ const payloadWithTs = (req.method === 'POST' || req.method === 'PATCH')
71
+ ? (timestamp ? `${timestamp}.${JSON.stringify(req.body ?? {})}` : JSON.stringify(req.body ?? {}))
72
+ : (timestamp ? `${timestamp}.${req.originalUrl || req.url}` : (req.originalUrl || req.url));
73
+ const expectedWithTs = crypto.createHmac('sha256', secret).update(payloadWithTs).digest('hex');
74
+ if (signature.length === expectedWithTs.length) {
75
+ try {
76
+ if (crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedWithTs, 'hex'))) {
77
+ return true;
78
+ }
79
+ }
80
+ catch { /* ignore */ }
81
+ }
82
+ // Also support payload without timestamp fallback
83
+ if (timestamp) {
84
+ const fallbackPayload = (req.method === 'POST' || req.method === 'PATCH')
85
+ ? JSON.stringify(req.body ?? {})
86
+ : (req.originalUrl || req.url);
87
+ const fallbackExpected = crypto.createHmac('sha256', secret).update(fallbackPayload).digest('hex');
88
+ if (signature.length === fallbackExpected.length) {
89
+ try {
90
+ if (crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(fallbackExpected, 'hex'))) {
91
+ return true;
92
+ }
93
+ }
94
+ catch { /* ignore */ }
95
+ }
96
+ }
97
+ return false;
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Service URL & RFC 9728 401 Helper
101
+ // ---------------------------------------------------------------------------
102
+ function getServiceUrl() {
103
+ if (process.env.FODDA_SERVICE_URL)
104
+ return process.env.FODDA_SERVICE_URL;
105
+ if (process.env.NODE_ENV === 'production' || process.env.K_SERVICE) {
106
+ return 'https://mcp.fodda.ai';
107
+ }
108
+ return `http://localhost:${process.env.PORT || 8080}`;
109
+ }
110
+ export function sendAuth401(res, options) {
111
+ const rawSlug = options.offeringSlug;
112
+ const metadataSlug = (!rawSlug || rawSlug === 'c') ? 'mcp' : rawSlug;
113
+ const resourceMetadata = `${getServiceUrl()}/.well-known/oauth-protected-resource/${metadataSlug}`;
114
+ if (options.invalidToken) {
115
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadata}", error="invalid_token"`);
116
+ }
117
+ else {
118
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadata}"`);
119
+ }
120
+ const errorPayload = {
121
+ code: -32000,
122
+ message: options.message,
123
+ };
124
+ if (options.invalidToken) {
125
+ errorPayload.error = 'invalid_token';
126
+ errorPayload.data = { error: 'invalid_token', ...(options.data || {}) };
127
+ }
128
+ else if (options.data) {
129
+ errorPayload.data = options.data;
130
+ }
131
+ return res.status(401).json({
132
+ jsonrpc: '2.0',
133
+ error: errorPayload,
134
+ id: options.id ?? null,
135
+ });
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Retired SSE transport (v1.46.85) — explicitly return 410 Gone
139
+ // ---------------------------------------------------------------------------
140
+ app.all(['/sse', '/messages'], (_req, res) => {
141
+ res.status(410).json({
142
+ error: 'sse_retired',
143
+ message: 'Use Streamable HTTP at https://mcp.fodda.ai/mcp',
144
+ source: 'fodda-mcp'
145
+ });
146
+ });
55
147
  // Deprecation middleware for legacy URL parameters (?api_key=... / ?user_id=...)
56
148
  const LEGACY_DEPRECATION_PATHS = [
57
- '/sse', '/mcp', '/messages', '/copilot',
149
+ '/mcp', '/copilot', '/chatgpt',
58
150
  '/brand-intelligence', '/topic-research', '/deep-research',
59
- '/earnings-intelligence', '/expert-consult'
151
+ '/earnings-intelligence', '/expert-consult',
152
+ '/grok-brand-context', '/c/:token', '/c'
60
153
  ];
61
154
  app.use(LEGACY_DEPRECATION_PATHS, (req, res, next) => {
62
155
  if (req.query.api_key !== undefined || req.query.user_id !== undefined) {
63
- const message = 'Fodda: this connection URL is outdated. Get your new MCP URL at https://app.fodda.ai (Account → MCP Integration) and update your connector.';
156
+ const authHeader = (req.headers['authorization'] || '').toString().trim();
157
+ const rawBearer = authHeader.replace(/^Bearer\s+/i, '').trim();
158
+ const internalKey = process.env.FODDA_INTERNAL_API_KEY || '';
159
+ const internalKeyHeader = req.headers['x-internal-key'] || '';
160
+ const apiKeyHeader = req.headers['x-api-key'] || '';
161
+ // Check if request carries modern credentials or internal service authorization
162
+ const hasBearerAuth = rawBearer.length > 0;
163
+ const hasInternalKey = !!(internalKey && (internalKeyHeader === internalKey ||
164
+ rawBearer === internalKey ||
165
+ apiKeyHeader === internalKey ||
166
+ req.query.api_key === internalKey));
167
+ const hasValidHmac = isIncomingHmacValid(req);
168
+ const hasHeaderApiKey = apiKeyHeader.startsWith('sk_live_') || apiKeyHeader.startsWith('sk_trial_');
169
+ // Service-to-service calls or requests supplying valid header credentials bypass deprecation
170
+ if (hasBearerAuth || hasInternalKey || hasValidHmac || hasHeaderApiKey) {
171
+ return next();
172
+ }
173
+ const message = 'Fodda: this connection URL is outdated. Get your new MCP URL at https://app.fodda.ai (Connections) and update your connector.';
64
174
  const accept = req.headers['accept'] || '';
65
175
  const contentType = req.headers['content-type'] || '';
66
176
  const isJson = accept.includes('application/json') || contentType.includes('application/json') || (req.method === 'POST' && req.body && typeof req.body === 'object');
177
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${getServiceUrl()}/.well-known/oauth-protected-resource/mcp"`);
67
178
  if (isJson && req.method === 'POST') {
68
179
  const bodyId = (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null;
69
180
  return res.status(401).json({
@@ -71,7 +182,7 @@ app.use(LEGACY_DEPRECATION_PATHS, (req, res, next) => {
71
182
  error: {
72
183
  code: -32001,
73
184
  message,
74
- data: { docs: 'https://fodda.ai/platform-integration-anthropic-claude' }
185
+ data: { docs: 'https://www.fodda.ai/connect' }
75
186
  },
76
187
  id: bodyId,
77
188
  });
@@ -82,62 +193,22 @@ app.use(LEGACY_DEPRECATION_PATHS, (req, res, next) => {
82
193
  next();
83
194
  });
84
195
  // OAuth discovery endpoints — Clerk-backed OAuth for the MCP Connectors Directory.
85
- // Discovery advertises mcp.fodda.ai as authorization server metadata host and points
86
- // registration_endpoint to /oauth/register shim for DCR openid scope injection.
196
+ // Advertises Clerk (CLERK_ISSUER) as the authorization server for RFC 9728 discovery,
197
+ // ensuring RFC 8414 issuer matching and RFC 9207 callback iss validation succeed end-to-end.
87
198
  const CLERK_ISSUER = process.env.CLERK_ISSUER_URL || 'https://clerk.fodda.ai';
88
- app.get('/.well-known/oauth-protected-resource', (_req, res) => {
199
+ app.get(['/.well-known/oauth-protected-resource', '/.well-known/oauth-protected-resource/:slug'], (req, res) => {
200
+ const slug = req.params.slug;
201
+ const resourceUrl = slug ? `${getServiceUrl()}/${slug}` : getServiceUrl();
89
202
  res.status(200).json({
90
- resource: getServiceUrl(),
91
- authorization_servers: [getServiceUrl()],
203
+ resource: resourceUrl,
204
+ authorization_servers: [CLERK_ISSUER],
92
205
  });
93
206
  });
94
- app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => {
95
- res.status(200).json({
96
- resource: `${getServiceUrl()}/mcp`,
97
- authorization_servers: [getServiceUrl()],
98
- });
207
+ app.get('/.well-known/openai-apps-challenge', (_req, res) => {
208
+ const challenge = process.env.OPENAI_APPS_CHALLENGE || '';
209
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
210
+ res.status(200).send(challenge);
99
211
  });
100
- let cachedClerkMetadata = null;
101
- let lastClerkFetch = 0;
102
- app.get('/.well-known/oauth-authorization-server', async (_req, res) => {
103
- const now = Date.now();
104
- if (!cachedClerkMetadata || now - lastClerkFetch > 5 * 60 * 1000) {
105
- try {
106
- const resp = await axios.get(`${CLERK_ISSUER}/.well-known/oauth-authorization-server`, { timeout: 5000 });
107
- if (resp.status === 200 && resp.data) {
108
- cachedClerkMetadata = resp.data;
109
- lastClerkFetch = now;
110
- }
111
- }
112
- catch (err) {
113
- console.error('[oauth-discovery] Unable to refresh Clerk OAuth metadata:', err.message);
114
- }
115
- }
116
- const serviceUrl = getServiceUrl();
117
- const metadata = {
118
- authorization_endpoint: `${CLERK_ISSUER}/oauth/authorize`,
119
- token_endpoint: `${CLERK_ISSUER}/oauth/token`,
120
- revocation_endpoint: `${CLERK_ISSUER}/oauth/token/revoke`,
121
- jwks_uri: `${CLERK_ISSUER}/.well-known/jwks.json`,
122
- response_types_supported: ['code'],
123
- grant_types_supported: ['authorization_code', 'refresh_token'],
124
- token_endpoint_auth_methods_supported: ['client_secret_basic', 'none', 'client_secret_post'],
125
- scopes_supported: ['openid', 'profile', 'email', 'public_metadata', 'private_metadata', 'offline_access', 'user:org:read'],
126
- subject_types_supported: ['public'],
127
- id_token_signing_alg_values_supported: ['RS256'],
128
- claims_supported: ['sub', 'iss', 'aud', 'exp', 'iat', 'email', 'name', 'org_id'],
129
- service_documentation: 'https://clerk.com/docs/oauth/scoped-access',
130
- ui_locales_supported: ['en'],
131
- op_tos_uri: 'https://clerk.com/legal/standard-terms',
132
- code_challenge_methods_supported: ['S256'],
133
- authorization_response_iss_parameter_supported: true,
134
- ...(cachedClerkMetadata || {}),
135
- issuer: CLERK_ISSUER,
136
- registration_endpoint: `${serviceUrl}/oauth/register`,
137
- };
138
- res.status(200).json(metadata);
139
- });
140
- app.post(['/oauth/register', '/register'], handleOauthRegister);
141
212
  // ---------------------------------------------------------------------------
142
213
  // .well-known MCP Server Discovery Card & Pricing Metadata Tier
143
214
  // ---------------------------------------------------------------------------
@@ -207,8 +278,11 @@ export const OFFERING_SCOPED_TOOLS = {
207
278
  ],
208
279
  'expert-consult': [
209
280
  'get_capabilities',
281
+ 'find_expert',
282
+ 'request_expert_intro',
210
283
  'consult_analyst',
211
284
  'consult_human_agent',
285
+ 'verify_claim',
212
286
  'list_analysts',
213
287
  'request_deliverable',
214
288
  'check_deliverable_status',
@@ -231,15 +305,53 @@ export const OFFERING_SCOPED_TOOLS = {
231
305
  'get_company_earnings',
232
306
  'deep_research_topic',
233
307
  'check_research_status',
308
+ 'find_expert',
309
+ 'request_expert_intro',
310
+ 'consult_analyst',
311
+ 'consult_human_agent',
312
+ 'list_analysts',
313
+ 'get_evidence',
314
+ 'list_graphs',
315
+ 'get_my_account',
316
+ 'read_url',
317
+ 'get_supplemental_context',
318
+ 'check_supplemental_status',
319
+ ],
320
+ 'chatgpt': [
321
+ 'get_capabilities',
322
+ 'search_graph',
323
+ 'search_statistics',
324
+ 'search_insights',
325
+ 'get_validated_trends',
326
+ 'brand_tracker',
327
+ 'get_company_earnings',
328
+ 'get_earnings_intelligence',
329
+ 'get_earnings_divergence',
330
+ 'deep_research_topic',
331
+ 'check_research_status',
234
332
  'consult_analyst',
235
333
  'consult_human_agent',
236
334
  'list_analysts',
237
335
  'get_evidence',
336
+ 'get_node',
337
+ 'get_neighbors',
338
+ 'get_label_values',
238
339
  'list_graphs',
239
340
  'get_my_account',
240
341
  'read_url',
241
342
  'get_supplemental_context',
242
343
  'check_supplemental_status',
344
+ 'generate_visual',
345
+ ],
346
+ 'grok-brand-context': [
347
+ 'get_capabilities',
348
+ 'get_domain_intelligence',
349
+ 'brand_tracker',
350
+ 'discover_adjacent_trends',
351
+ 'get_earnings_intelligence',
352
+ 'get_evidence',
353
+ 'find_expert',
354
+ 'verify_claim',
243
355
  ],
244
356
  };
245
357
  const OFFERING_CARD_METADATA = {
@@ -273,6 +385,16 @@ const OFFERING_CARD_METADATA = {
273
385
  title: 'Fodda Copilot Market Intelligence',
274
386
  description: 'Curated market intelligence, trend insights & expert evidence for Microsoft Copilot Studio agents.',
275
387
  },
388
+ 'chatgpt': {
389
+ name: 'ai.fodda/chatgpt',
390
+ title: 'Fodda Market Intelligence for ChatGPT',
391
+ description: 'Expert-curated knowledge, brand, research & earnings intelligence across PSFK expert graphs.',
392
+ },
393
+ 'grok-brand-context': {
394
+ name: 'ai.fodda/grok-brand-context',
395
+ title: 'Fodda Brand & Market Context',
396
+ description: 'Verified brand, market & earnings context across PSFK expert graphs with citable evidence, not web summaries.',
397
+ },
276
398
  };
277
399
  app.get([
278
400
  '/.well-known/mcp-server.json', '/.well-known/mcp',
@@ -282,6 +404,8 @@ app.get([
282
404
  '/.well-known/earnings-intelligence', '/.well-known/earnings-intelligence.json',
283
405
  '/.well-known/expert-consult', '/.well-known/expert-consult.json',
284
406
  '/.well-known/copilot', '/.well-known/copilot.json',
407
+ '/.well-known/chatgpt', '/.well-known/chatgpt.json',
408
+ '/.well-known/grok-brand-context', '/.well-known/grok-brand-context.json',
285
409
  ], (req, res) => {
286
410
  res.setHeader('Content-Type', 'application/json');
287
411
  const matchedOffering = Object.keys(OFFERING_CARD_METADATA).find(slug => req.path.includes(slug));
@@ -302,7 +426,7 @@ app.get([
302
426
  pricingTier: {
303
427
  structure: 'free',
304
428
  substance: 'metered',
305
- tokenModel: 'Canonical token costs defined in API metering.ts ($0.50 via SPT / API credits)',
429
+ tokenModel: 'Metered per API call — see https://fodda.ai/pricing',
306
430
  },
307
431
  resourceSchemes: [
308
432
  'fodda://expert/{slug}/insight/{id}',
@@ -310,7 +434,6 @@ app.get([
310
434
  ],
311
435
  endpoints: {
312
436
  mcpStreamableHttp: `${getServiceUrl()}/${matchedOffering}`,
313
- mcpSse: `${getServiceUrl()}/sse`,
314
437
  telemetry: `${getServiceUrl()}/telemetry`,
315
438
  feedback: `${getServiceUrl()}/v1/feedback`,
316
439
  },
@@ -331,7 +454,7 @@ app.get([
331
454
  pricingTier: {
332
455
  structure: 'free',
333
456
  substance: 'metered',
334
- tokenModel: 'Canonical token costs defined in API metering.ts ($0.50 via SPT / API credits)',
457
+ tokenModel: 'Metered per API call — see https://fodda.ai/pricing',
335
458
  },
336
459
  resourceSchemes: [
337
460
  'fodda://expert/{slug}/insight/{id}',
@@ -339,7 +462,6 @@ app.get([
339
462
  ],
340
463
  endpoints: {
341
464
  mcpStreamableHttp: `${getServiceUrl()}/mcp`,
342
- mcpSse: `${getServiceUrl()}/sse`,
343
465
  telemetry: `${getServiceUrl()}/telemetry`,
344
466
  feedback: `${getServiceUrl()}/v1/feedback`,
345
467
  },
@@ -389,12 +511,56 @@ const transports = new Map();
389
511
  const sessionApiKeys = new Map();
390
512
  const sessionUserIds = new Map();
391
513
  const sessionSources = new Map();
514
+ export const sessionClients = new Map();
392
515
  // SPT-paying anonymous sessions (no API key). Holds the token + the connect-time
393
516
  // validate result (cap + price map) so per-task coverage can be checked locally.
394
517
  const sessionSpts = new Map();
395
518
  // Track session creation time in our own Map (transport._createdAt is a private
396
519
  // field that is never set by the SDK — reading it always yields undefined).
397
520
  const sessionCreatedAt = new Map();
521
+ /**
522
+ * Normalize clientInfo from MCP initialize handshake to a short, canonical slug.
523
+ * E.g. Claude Desktop -> claude-desktop, Cursor -> cursor, VS Code -> vscode.
524
+ */
525
+ export function normalizeClientSlug(name, version) {
526
+ if (!name || typeof name !== 'string')
527
+ return '';
528
+ const lower = name.toLowerCase().trim();
529
+ if (lower.includes('claude desktop') || lower.includes('claude-desktop') || lower.includes('claude for desktop')) {
530
+ return 'claude-desktop';
531
+ }
532
+ if (lower.includes('claude code') || lower.includes('claude-code')) {
533
+ return 'claude-code';
534
+ }
535
+ if (lower === 'claude' || lower.includes('claude.ai') || lower.startsWith('claude')) {
536
+ return 'claude';
537
+ }
538
+ if (lower.includes('cursor')) {
539
+ return 'cursor';
540
+ }
541
+ if (lower.includes('visual studio code') || lower.includes('vscode')) {
542
+ return 'vscode';
543
+ }
544
+ if (lower.includes('windsurf')) {
545
+ return 'windsurf';
546
+ }
547
+ if (lower.includes('chatgpt') || lower.includes('openai')) {
548
+ return 'chatgpt';
549
+ }
550
+ if (lower.includes('zed')) {
551
+ return 'zed';
552
+ }
553
+ if (lower.includes('librechat')) {
554
+ return 'librechat';
555
+ }
556
+ if (lower.includes('roo-code') || lower.includes('roo code') || lower.includes('roocode')) {
557
+ return 'roo-code';
558
+ }
559
+ if (lower.includes('cline')) {
560
+ return 'cline';
561
+ }
562
+ return lower.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
563
+ }
398
564
  // ---------------------------------------------------------------------------
399
565
  // Periodic cleanup — prevent unbounded memory growth
400
566
  // ---------------------------------------------------------------------------
@@ -415,44 +581,40 @@ setInterval(() => {
415
581
  sessionApiKeys.delete(sid);
416
582
  sessionUserIds.delete(sid);
417
583
  sessionSources.delete(sid);
584
+ sessionClients.delete(sid);
418
585
  sessionSpts.delete(sid);
419
586
  sessionCreatedAt.delete(sid);
420
587
  }
421
588
  }
422
589
  }, CLEANUP_INTERVAL_MS).unref();
423
590
  // ---------------------------------------------------------------------------
424
- // Service URL helper
425
- // ---------------------------------------------------------------------------
426
- // M2: FODDA_SERVICE_URL must be set in Cloud Run env vars.
427
- // Fallback to localhost for local dev only. The hardcoded project hash
428
- // was removed — if it was being used, the URL was silently wrong.
429
- function getServiceUrl() {
430
- if (process.env.FODDA_SERVICE_URL)
431
- return process.env.FODDA_SERVICE_URL;
432
- if (process.env.NODE_ENV === 'production' || process.env.K_SERVICE) {
433
- return 'https://mcp.fodda.ai';
434
- }
435
- return `http://localhost:${process.env.PORT || 8080}`;
436
- }
437
- // ---------------------------------------------------------------------------
438
591
  // Authenticated API caller — checks query cache first
439
592
  // ---------------------------------------------------------------------------
440
593
  /**
441
594
  * Make an authenticated request to the Fodda API.
442
595
  * Checks the query cache first; stores responses on cache miss.
443
596
  */
444
- async function foddaRequest(method, path, apiKey, userId, body, requestId, source, spt) {
597
+ const PLACEHOLDER_USER_IDS = new Set(['', 'anonymous', 'undefined', 'null', 'oauth_user']);
598
+ export function isPlaceholderUserId(id) {
599
+ if (!id)
600
+ return true;
601
+ return PLACEHOLDER_USER_IDS.has(id.trim().toLowerCase());
602
+ }
603
+ export async function foddaRequest(method, path, apiKey, userId, body, requestId, source, spt, client) {
445
604
  // ── Cache check ──
446
605
  const cached = cacheGet(method, path, body);
447
606
  if (cached !== null)
448
607
  return cached;
449
608
  const timestamp = Date.now().toString();
450
609
  const headers = {
451
- 'X-User-Id': userId,
452
610
  'X-Fodda-Timestamp': timestamp,
453
611
  'X-Fodda-Billing': 'mcp-orchestrated', // Tells API to skip per-call billing — MCP charges lump sum via meter
454
612
  'Content-Type': 'application/json',
455
613
  };
614
+ // Never send placeholder user IDs upstream — let the API's account-label fallback apply
615
+ if (userId && !isPlaceholderUserId(userId)) {
616
+ headers['X-User-Id'] = userId;
617
+ }
456
618
  // SPT settlement: the Shared Payment Token is the payer (Authorization Bearer), no X-API-Key.
457
619
  if (spt) {
458
620
  headers['Authorization'] = `Bearer ${spt}`;
@@ -464,6 +626,8 @@ async function foddaRequest(method, path, apiKey, userId, body, requestId, sourc
464
626
  headers['X-Request-Id'] = requestId;
465
627
  if (source)
466
628
  headers['X-Fodda-Source'] = source;
629
+ if (client)
630
+ headers['X-Fodda-Client'] = client;
467
631
  // HMAC sign the request
468
632
  const secret = process.env.FODDA_MCP_SECRET;
469
633
  if (secret) {
@@ -473,11 +637,11 @@ async function foddaRequest(method, path, apiKey, userId, body, requestId, sourc
473
637
  const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
474
638
  headers['X-Fodda-Signature'] = signature;
475
639
  }
476
- const baseUrl = path.startsWith('/api/') ? WEBSITE_BASE_URL : API_BASE_URL;
640
+ const baseUrl = path.startsWith('/api/') ? (process.env.WEBSITE_BASE_URL || WEBSITE_BASE_URL) : (process.env.FODDA_API_URL || API_BASE_URL);
477
641
  const url = `${baseUrl}${path}`;
478
642
  // Base timeout: 30s aligns with MCP client expectations.
479
- // Extended to 90s for analyst and human agent consults, and 35s for supplemental.
480
- const AXIOS_TIMEOUT_MS = /(\/analysts\/consult|\/human-agents\/consult)/.test(path)
643
+ // Extended to 90s for analyst and human agent consults, verify/claim, and intelligence routes, and 35s for supplemental.
644
+ const AXIOS_TIMEOUT_MS = /(\/analysts\/consult|\/human-agents\/consult|\/verify\/|\/intelligence\/)/.test(path)
481
645
  ? 90000
482
646
  : /\/supplemental\//.test(path)
483
647
  ? 35000
@@ -503,6 +667,12 @@ async function foddaRequest(method, path, apiKey, userId, body, requestId, sourc
503
667
  const overageTokens = response.headers['x-usage-overage-tokens'];
504
668
  if (overageTokens)
505
669
  response.data._upstream_usage.overage_tokens = parseInt(overageTokens, 10);
670
+ const dailyRemaining = response.headers['x-usage-daily-remaining'];
671
+ if (dailyRemaining)
672
+ response.data._upstream_usage.daily_remaining = parseInt(dailyRemaining, 10);
673
+ const dailyCalls = response.headers['x-usage-daily-calls'];
674
+ if (dailyCalls)
675
+ response.data._upstream_usage.daily_calls = parseInt(dailyCalls, 10);
506
676
  }
507
677
  // ── Billing-mode trust check (Option C) ──
508
678
  // Every MCP request claims mcp-orchestrated; the API echoes the EFFECTIVE mode.
@@ -939,7 +1109,7 @@ export async function resolveMcpToken(token, websiteBaseUrl) {
939
1109
  // ---------------------------------------------------------------------------
940
1110
  // MCP Transport Handler
941
1111
  // ---------------------------------------------------------------------------
942
- app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/earnings-intelligence', '/expert-consult', '/copilot', '/c/:token'], async (req, res) => {
1112
+ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/earnings-intelligence', '/expert-consult', '/copilot', '/chatgpt', '/grok-brand-context', '/c/:token'], async (req, res) => {
943
1113
  try {
944
1114
  const sessionId = req.headers['mcp-session-id'];
945
1115
  let transport;
@@ -956,9 +1126,17 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
956
1126
  || '';
957
1127
  const isSpt = !!spt;
958
1128
  const rawBearer = !isSpt ? rawAuth.replace(/^Bearer\s+/i, '').trim() : '';
1129
+ // Internal service auth detection
1130
+ const internalKey = process.env.FODDA_INTERNAL_API_KEY || '';
1131
+ const internalKeyHeader = req.headers['x-internal-key'] || '';
1132
+ const hasValidHmac = isIncomingHmacValid(req);
1133
+ const isInternalAuth = hasValidHmac || !!(internalKey && (internalKeyHeader === internalKey ||
1134
+ rawBearer === internalKey ||
1135
+ req.headers['x-api-key'] === internalKey ||
1136
+ req.query.api_key === internalKey));
959
1137
  // Clerk OAuth tokens may be JWTs (eyJ...), opaque tokens (oat_...), session tokens (sess_...), or custom OAuth tokens.
960
- // If it's not a standard sk_live_ / sk_trial_ key, send it to clerk-resolve.
961
- const isClerkJwt = !isSpt && rawBearer.length > 0 && !rawBearer.startsWith('sk_live_') && !rawBearer.startsWith('sk_trial_');
1138
+ // If it's not a standard sk_live_ / sk_trial_ key and not internal auth, send it to clerk-resolve.
1139
+ const isClerkJwt = !isSpt && !isInternalAuth && rawBearer.length > 0 && !rawBearer.startsWith('sk_live_') && !rawBearer.startsWith('sk_trial_');
962
1140
  if (isClerkJwt) {
963
1141
  // Attempt Clerk JWT → Fodda API key resolution
964
1142
  try {
@@ -972,9 +1150,10 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
972
1150
  req.__resolvedUserId = resolveResp.data.user_id || 'oauth_user';
973
1151
  }
974
1152
  else {
975
- return res.status(401).json({
976
- jsonrpc: '2.0',
977
- error: { code: -32000, message: 'OAuth token could not be resolved to a Fodda account. Visit https://app.fodda.ai to reconnect.' },
1153
+ return sendAuth401(res, {
1154
+ offeringSlug,
1155
+ message: 'OAuth token could not be resolved to a Fodda account. Visit https://app.fodda.ai to reconnect.',
1156
+ invalidToken: true,
978
1157
  id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
979
1158
  });
980
1159
  }
@@ -988,9 +1167,10 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
988
1167
  id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
989
1168
  });
990
1169
  }
991
- return res.status(401).json({
992
- jsonrpc: '2.0',
993
- error: { code: -32000, message: 'OAuth token validation failed. Please reconnect at https://app.fodda.ai.' },
1170
+ return sendAuth401(res, {
1171
+ offeringSlug,
1172
+ message: 'OAuth token validation failed. Please reconnect at https://app.fodda.ai.',
1173
+ invalidToken: true,
994
1174
  id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
995
1175
  });
996
1176
  }
@@ -1008,45 +1188,61 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
1008
1188
  }
1009
1189
  catch (err) {
1010
1190
  console.error(`[token-resolver] Failed to resolve connection token:`, err.message);
1011
- return res.status(401).json({ error: 'Invalid or expired connection token' });
1191
+ return sendAuth401(res, {
1192
+ offeringSlug,
1193
+ message: 'Invalid or expired connection token. Visit https://app.fodda.ai to reconnect.',
1194
+ invalidToken: true,
1195
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
1196
+ });
1012
1197
  }
1013
1198
  }
1014
1199
  // Extract API key, userId, and entry ID from URL or headers.
1015
- // Priority: resolved OAuth key → X-API-Key header → Authorization Bearer (if non-Clerk sk_live_/sk_trial_) → resolved token key → query string (fallback).
1200
+ // Priority: resolved OAuth key → X-API-Key header → Authorization Bearer (if non-Clerk sk_live_/sk_trial_) → internal service key → resolved token key → query string (fallback).
1016
1201
  const resolvedOAuthKey = req.__resolvedApiKey || '';
1017
1202
  const resolvedOAuthUser = req.__resolvedUserId || '';
1018
1203
  const apiKey = isSpt ? '' : (resolvedOAuthKey
1019
1204
  || req.headers['x-api-key']
1020
- || (!isClerkJwt ? (req.headers['authorization']?.toString().replace(/^Bearer\s+/i, '')) : '')
1205
+ || (!isClerkJwt ? rawBearer : '')
1206
+ || (isInternalAuth ? (internalKey || 'sk_internal_service') : '')
1021
1207
  || resolvedApiKey
1022
1208
  || req.query.api_key
1023
1209
  || '');
1024
1210
  const entryId = resolvedEntryId || req.query.id || '';
1025
1211
  // If id looks like an email and no explicit user_id, use it as userId for tracking + signup
1026
1212
  const isEmailId = entryId.includes('@') && entryId.includes('.');
1027
- // Priority for userId: resolved OAuth user → resolved token email → query user_id → header X-User-Id → query id / anonymous
1213
+ const headerUserId = req.headers['x-user-id'] || req.headers['x-user-email'] || '';
1214
+ // Priority for userId: resolved OAuth user → resolved token email → header X-User-Id / X-User-Email → query user_id → query id / anonymous
1028
1215
  const userId = isSpt ? 'spt_agent' : (resolvedOAuthUser
1029
1216
  || resolvedEmail
1217
+ || headerUserId
1030
1218
  || req.query.user_id
1031
- || req.headers['x-user-id']
1032
1219
  || (isEmailId ? entryId : 'anonymous'));
1033
- const defaultSource = (offeringSlug !== 'mcp' && allowedTools !== undefined) ? offeringSlug : (isSpt ? 'spt' : '');
1034
- const source = req.headers['x-fodda-source'] || req.query.source || defaultSource;
1220
+ const defaultSessionKind = isInternalAuth ? 'internal-test' : 'customer';
1221
+ const sessionKind = req.headers['x-fodda-session-kind'] || req.query.session_kind || defaultSessionKind;
1222
+ const isInternalTest = sessionKind === 'internal-test';
1223
+ const defaultSource = isInternalTest ? 'mcp-internal-test' : ((offeringSlug !== 'mcp' && allowedTools !== undefined) ? offeringSlug : (isSpt ? 'spt' : ''));
1224
+ const rawSource = req.headers['x-fodda-source'] || req.query.source || '';
1225
+ const userAgent = (req.headers['user-agent'] || '').toString().toLowerCase();
1226
+ const isChatGptUa = userAgent.includes('chatgpt') || userAgent.includes('chatgpt-user') || userAgent.includes('openai');
1227
+ const source = isInternalTest ? 'mcp-internal-test' : (rawSource || (isChatGptUa ? 'chatgpt' : defaultSource));
1035
1228
  if (sessionId && transports.has(sessionId)) {
1036
1229
  transport = transports.get(sessionId);
1037
1230
  }
1038
1231
  else if (!sessionId && req.method === 'POST') {
1039
1232
  const body = req.body;
1233
+ const clientInfo = body?.params?.clientInfo;
1234
+ const declaredClientSlug = normalizeClientSlug(clientInfo?.name, clientInfo?.version);
1235
+ const clientSlug = declaredClientSlug || (isChatGptUa ? 'chatgpt' : '');
1040
1236
  if (body?.method === 'initialize') {
1041
- // Directory connector policy: /mcp requires authentication — Clerk OAuth,
1237
+ // Directory connector policy: All offering routes require authentication — Clerk OAuth,
1042
1238
  // an API key, or a /c/<token> connection URL. An anonymous handshake gets
1043
1239
  // 401 + WWW-Authenticate (RFC 9728) so MCP clients auto-start the OAuth
1044
1240
  // flow. Set MCP_ALLOW_ANONYMOUS=true to re-open the anonymous trial lane.
1045
- if ((offeringSlug === 'mcp' || offeringSlug === 'copilot') && !isSpt && !apiKey && process.env.MCP_ALLOW_ANONYMOUS !== 'true') {
1046
- res.setHeader('WWW-Authenticate', 'Bearer resource_metadata="https://mcp.fodda.ai/.well-known/oauth-protected-resource/mcp"');
1047
- return res.status(401).json({
1048
- jsonrpc: '2.0',
1049
- error: { code: -32000, message: 'Authentication required. Connect via OAuth, or use your personal connection URL or API key from https://app.fodda.ai.' },
1241
+ if (!isSpt && !apiKey && process.env.MCP_ALLOW_ANONYMOUS !== 'true') {
1242
+ return sendAuth401(res, {
1243
+ offeringSlug,
1244
+ message: 'Authentication required. Connect via OAuth, or use your personal connection URL or API key from https://app.fodda.ai.',
1245
+ invalidToken: false,
1050
1246
  id: body?.id ?? null,
1051
1247
  });
1052
1248
  }
@@ -1078,23 +1274,22 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
1078
1274
  // Wrap foddaRequest. SPT sessions authenticate the internal fan-out with the
1079
1275
  // internal service key (the SPT is spent ONCE at settlement, never on fan-out);
1080
1276
  // otherwise bake in source attribution.
1081
- const internalKey = process.env.FODDA_INTERNAL_API_KEY || '';
1082
1277
  const boundFoddaRequest = isSpt
1083
- ? ((m, p, _k, u, b, r, _s, sptArg) => foddaRequest(m, p, sptArg ? '' : internalKey, u, b, r, 'spt', sptArg))
1084
- : (source
1085
- ? ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source))
1086
- : foddaRequest);
1087
- const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId, sptInfo ?? undefined, allowedTools);
1278
+ ? ((m, p, _k, u, b, r, _s, sptArg) => foddaRequest(m, p, sptArg ? '' : internalKey, u, b, r, isInternalTest ? 'mcp-internal-test' : 'spt', sptArg, clientSlug || undefined))
1279
+ : ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source || undefined, undefined, clientSlug || undefined));
1280
+ const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId, sptInfo ?? undefined, allowedTools, source || undefined);
1088
1281
  transport = new StreamableHTTPServerTransport({
1089
1282
  sessionIdGenerator: () => crypto.randomUUID(),
1090
1283
  onsessioninitialized: (sid) => {
1091
- console.error(`Session created: ${sid}`);
1284
+ console.error(`Session created: ${sid} (source: ${source || 'none'}, client: ${clientSlug || 'unknown'})`);
1092
1285
  transports.set(sid, transport);
1093
1286
  sessionApiKeys.set(sid, apiKey);
1094
1287
  sessionUserIds.set(sid, userId);
1095
1288
  sessionCreatedAt.set(sid, Date.now()); // C2: track creation time
1096
1289
  if (source)
1097
1290
  sessionSources.set(sid, source);
1291
+ if (clientSlug)
1292
+ sessionClients.set(sid, clientSlug);
1098
1293
  if (sptInfo)
1099
1294
  sessionSpts.set(sid, sptInfo);
1100
1295
  }
@@ -1106,6 +1301,7 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
1106
1301
  sessionApiKeys.delete(sid);
1107
1302
  sessionUserIds.delete(sid);
1108
1303
  sessionSources.delete(sid);
1304
+ sessionClients.delete(sid);
1109
1305
  sessionSpts.delete(sid);
1110
1306
  sessionCreatedAt.delete(sid); // C2: clean up creation time
1111
1307
  }
@@ -1113,11 +1309,41 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
1113
1309
  await server.connect(transport);
1114
1310
  }
1115
1311
  else {
1116
- return res.status(400).json({
1117
- jsonrpc: '2.0',
1118
- error: { code: -32000, message: 'Bad Request: Session required' },
1119
- id: null
1120
- });
1312
+ // Non-initialize POST without an existing sessionId:
1313
+ // Support stateless single-shot JSON-RPC execution (e.g. direct tools/list via curl or script)
1314
+ if (!isSpt && !apiKey && process.env.MCP_ALLOW_ANONYMOUS !== 'true') {
1315
+ return sendAuth401(res, {
1316
+ offeringSlug,
1317
+ message: 'Authentication required. Connect via OAuth, or use your personal connection URL or API key from https://app.fodda.ai.',
1318
+ invalidToken: false,
1319
+ id: body?.id ?? null,
1320
+ });
1321
+ }
1322
+ let sptInfo = null;
1323
+ if (isSpt) {
1324
+ if (process.env.ENABLE_SPT !== 'true') {
1325
+ return res.status(401).json({
1326
+ jsonrpc: '2.0',
1327
+ error: { code: -32000, message: 'SPT payment tokens are not accepted on this endpoint. Connect via OAuth at https://app.fodda.ai.' },
1328
+ id: body?.id ?? null,
1329
+ });
1330
+ }
1331
+ const v = await validateSpt(spt);
1332
+ if (!v.valid) {
1333
+ return res.status(402).json({
1334
+ jsonrpc: '2.0',
1335
+ error: { code: -32002, message: `SPT validation failed: ${v.error || 'invalid token'}` },
1336
+ id: body?.id ?? null,
1337
+ });
1338
+ }
1339
+ sptInfo = { token: spt, maxAmountCents: v.max_amount_cents, prices: v.prices };
1340
+ }
1341
+ const boundFoddaRequest = isSpt
1342
+ ? ((m, p, _k, u, b, r, _s, sptArg) => foddaRequest(m, p, sptArg ? '' : internalKey, u, b, r, isInternalTest ? 'mcp-internal-test' : 'spt', sptArg, clientSlug || undefined))
1343
+ : ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source || undefined, undefined, clientSlug || undefined));
1344
+ const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId, sptInfo ?? undefined, allowedTools, source || undefined);
1345
+ transport = new StreamableHTTPServerTransport();
1346
+ await server.connect(transport);
1121
1347
  }
1122
1348
  }
1123
1349
  else {
@@ -1127,10 +1353,15 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
1127
1353
  id: null
1128
1354
  });
1129
1355
  }
1130
- // Inject Accept: text/event-stream if missing (prevents SDK 406)
1131
- const accept = req.headers['accept'] || '';
1132
- if (!accept.includes('text/event-stream')) {
1133
- req.headers['accept'] = accept ? `${accept}, text/event-stream` : 'application/json, text/event-stream';
1356
+ // Ensure Accept includes both application/json and text/event-stream (required by MCP SDK Streamable HTTP transport)
1357
+ const currentAccept = (req.headers['accept'] || '').toString();
1358
+ const needed = [];
1359
+ if (!currentAccept.includes('application/json'))
1360
+ needed.push('application/json');
1361
+ if (!currentAccept.includes('text/event-stream'))
1362
+ needed.push('text/event-stream');
1363
+ if (needed.length > 0) {
1364
+ req.headers['accept'] = currentAccept ? `${currentAccept}, ${needed.join(', ')}` : 'application/json, text/event-stream';
1134
1365
  const idx = req.rawHeaders?.findIndex((h) => h.toLowerCase() === 'accept');
1135
1366
  if (idx !== undefined && idx >= 0 && req.rawHeaders) {
1136
1367
  req.rawHeaders[idx + 1] = req.headers['accept'];
@@ -1147,29 +1378,6 @@ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/e
1147
1378
  res.status(500).json({ error: 'Internal server error' });
1148
1379
  }
1149
1380
  });
1150
- // Legacy SSE transport
1151
- app.get('/sse', async (req, res) => {
1152
- const apiKey = req.query.api_key || '';
1153
- const entryId = req.query.id || '';
1154
- const isEmailId = entryId.includes('@') && entryId.includes('.');
1155
- const userId = req.query.user_id
1156
- || req.headers['x-user-id']
1157
- || (isEmailId ? entryId : 'anonymous');
1158
- const source = req.query.source || '';
1159
- const sessionId = crypto.randomUUID();
1160
- const transport = new SSEServerTransport('/messages', res);
1161
- // Bind source parameter to foddaRequest to forward it upstream as X-Fodda-Source
1162
- const boundFoddaRequest = source
1163
- ? ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source))
1164
- : foddaRequest;
1165
- const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl);
1166
- await server.connect(transport);
1167
- console.error(`SSE session: ${sessionId}`);
1168
- });
1169
- app.post('/messages', async (req, res) => {
1170
- // SSE message handler would go here
1171
- res.status(404).json({ error: 'Use /mcp endpoint' });
1172
- });
1173
1381
  // Favicon — required for Anthropic Connectors Directory (Google favicon API)
1174
1382
  const FAVICON_SVG = 'https://ucarecdn.com/e3ce77f2-661e-48a1-a294-c7c01039aed4/foddaminilogo.svg';
1175
1383
  const FAVICON_PNG = 'https://ucarecdn.com/6e7893d7-6b14-426b-83bc-574a3f72d6bc/foddafavicon.png';