fodda-mcp 1.32.1 → 1.46.31

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 (186) hide show
  1. package/README.md +14 -16
  2. package/dist/a2aHandler.d.ts +33 -3
  3. package/dist/a2aHandler.d.ts.map +1 -1
  4. package/dist/a2aHandler.js +189 -36
  5. package/dist/a2aHandler.js.map +1 -1
  6. package/dist/agentFacts.d.ts +21 -0
  7. package/dist/agentFacts.d.ts.map +1 -0
  8. package/dist/agentFacts.js +72 -0
  9. package/dist/agentFacts.js.map +1 -0
  10. package/dist/agents/fodda-researcher/index.d.ts +6 -0
  11. package/dist/agents/fodda-researcher/index.d.ts.map +1 -1
  12. package/dist/agents/fodda-researcher/index.js +18 -0
  13. package/dist/agents/fodda-researcher/index.js.map +1 -1
  14. package/dist/agents/fodda-researcher/skills.d.ts +1 -1
  15. package/dist/agents/fodda-researcher/skills.d.ts.map +1 -1
  16. package/dist/agents/fodda-researcher/skills.js +8 -3
  17. package/dist/agents/fodda-researcher/skills.js.map +1 -1
  18. package/dist/brandTemplate.d.ts +1 -1
  19. package/dist/brandTemplate.d.ts.map +1 -1
  20. package/dist/brandTemplate.js +179 -11
  21. package/dist/brandTemplate.js.map +1 -1
  22. package/dist/catalogCache.d.ts +14 -13
  23. package/dist/catalogCache.d.ts.map +1 -1
  24. package/dist/catalogCache.js +266 -67
  25. package/dist/catalogCache.js.map +1 -1
  26. package/dist/coverageRelevance.d.ts +156 -0
  27. package/dist/coverageRelevance.d.ts.map +1 -0
  28. package/dist/coverageRelevance.js +1062 -0
  29. package/dist/coverageRelevance.js.map +1 -0
  30. package/dist/deepResearch.d.ts +43 -0
  31. package/dist/deepResearch.d.ts.map +1 -0
  32. package/dist/deepResearch.js +498 -0
  33. package/dist/deepResearch.js.map +1 -0
  34. package/dist/enrichment.d.ts.map +1 -1
  35. package/dist/enrichment.js +51 -1
  36. package/dist/enrichment.js.map +1 -1
  37. package/dist/errorHandling.d.ts +1 -1
  38. package/dist/errorHandling.d.ts.map +1 -1
  39. package/dist/errorHandling.js +26 -6
  40. package/dist/errorHandling.js.map +1 -1
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +548 -38
  44. package/dist/index.js.map +1 -1
  45. package/dist/linkedinEngine.d.ts +150 -0
  46. package/dist/linkedinEngine.d.ts.map +1 -0
  47. package/dist/linkedinEngine.js +696 -0
  48. package/dist/linkedinEngine.js.map +1 -0
  49. package/dist/oauthRegisterShim.d.ts +13 -0
  50. package/dist/oauthRegisterShim.d.ts.map +1 -0
  51. package/dist/oauthRegisterShim.js +83 -0
  52. package/dist/oauthRegisterShim.js.map +1 -0
  53. package/dist/pricingCache.d.ts +6 -8
  54. package/dist/pricingCache.d.ts.map +1 -1
  55. package/dist/pricingCache.js +69 -13
  56. package/dist/pricingCache.js.map +1 -1
  57. package/dist/queryCache.d.ts +2 -0
  58. package/dist/queryCache.d.ts.map +1 -1
  59. package/dist/queryCache.js +11 -2
  60. package/dist/queryCache.js.map +1 -1
  61. package/dist/resources.d.ts +27 -0
  62. package/dist/resources.d.ts.map +1 -0
  63. package/dist/resources.js +126 -0
  64. package/dist/resources.js.map +1 -0
  65. package/dist/sessionTracker.d.ts +24 -2
  66. package/dist/sessionTracker.d.ts.map +1 -1
  67. package/dist/sessionTracker.js +198 -4
  68. package/dist/sessionTracker.js.map +1 -1
  69. package/dist/systemPrompt.d.ts +3 -3
  70. package/dist/systemPrompt.d.ts.map +1 -1
  71. package/dist/systemPrompt.js +81 -61
  72. package/dist/systemPrompt.js.map +1 -1
  73. package/dist/telemetry.d.ts +43 -0
  74. package/dist/telemetry.d.ts.map +1 -0
  75. package/dist/telemetry.js +101 -0
  76. package/dist/telemetry.js.map +1 -0
  77. package/dist/test_analyst.js +2 -2
  78. package/dist/test_analyst.js.map +1 -1
  79. package/dist/test_book_a_call_live.d.ts +2 -0
  80. package/dist/test_book_a_call_live.d.ts.map +1 -0
  81. package/dist/test_book_a_call_live.js +148 -0
  82. package/dist/test_book_a_call_live.js.map +1 -0
  83. package/dist/test_brand_intelligence_endpoint.d.ts +2 -0
  84. package/dist/test_brand_intelligence_endpoint.d.ts.map +1 -0
  85. package/dist/test_brand_intelligence_endpoint.js +20 -0
  86. package/dist/test_brand_intelligence_endpoint.js.map +1 -0
  87. package/dist/test_coverage_relevance.d.ts +12 -0
  88. package/dist/test_coverage_relevance.d.ts.map +1 -0
  89. package/dist/test_coverage_relevance.js +121 -0
  90. package/dist/test_coverage_relevance.js.map +1 -0
  91. package/dist/test_dcr_and_legacy_deprecation.d.ts +2 -0
  92. package/dist/test_dcr_and_legacy_deprecation.d.ts.map +1 -0
  93. package/dist/test_dcr_and_legacy_deprecation.js +148 -0
  94. package/dist/test_dcr_and_legacy_deprecation.js.map +1 -0
  95. package/dist/test_financial_snapshot_render.d.ts +2 -0
  96. package/dist/test_financial_snapshot_render.d.ts.map +1 -0
  97. package/dist/test_financial_snapshot_render.js +167 -0
  98. package/dist/test_financial_snapshot_render.js.map +1 -0
  99. package/dist/test_gap_alert.d.ts +12 -0
  100. package/dist/test_gap_alert.d.ts.map +1 -0
  101. package/dist/test_gap_alert.js +43 -0
  102. package/dist/test_gap_alert.js.map +1 -0
  103. package/dist/test_list_graphs_topics.d.ts +2 -0
  104. package/dist/test_list_graphs_topics.d.ts.map +1 -0
  105. package/dist/test_list_graphs_topics.js +26 -0
  106. package/dist/test_list_graphs_topics.js.map +1 -0
  107. package/dist/test_live_endpoint_runner.d.ts +2 -0
  108. package/dist/test_live_endpoint_runner.d.ts.map +1 -0
  109. package/dist/test_live_endpoint_runner.js +65 -0
  110. package/dist/test_live_endpoint_runner.js.map +1 -0
  111. package/dist/test_local_list_graphs.d.ts +2 -0
  112. package/dist/test_local_list_graphs.d.ts.map +1 -0
  113. package/dist/test_local_list_graphs.js +53 -0
  114. package/dist/test_local_list_graphs.js.map +1 -0
  115. package/dist/test_next_moves.d.ts +2 -0
  116. package/dist/test_next_moves.d.ts.map +1 -0
  117. package/dist/test_next_moves.js +349 -0
  118. package/dist/test_next_moves.js.map +1 -0
  119. package/dist/test_next_moves_transcripts.d.ts +2 -0
  120. package/dist/test_next_moves_transcripts.d.ts.map +1 -0
  121. package/dist/test_next_moves_transcripts.js +413 -0
  122. package/dist/test_next_moves_transcripts.js.map +1 -0
  123. package/dist/test_nike_cmo_alias.d.ts +2 -0
  124. package/dist/test_nike_cmo_alias.d.ts.map +1 -0
  125. package/dist/test_nike_cmo_alias.js +76 -0
  126. package/dist/test_nike_cmo_alias.js.map +1 -0
  127. package/dist/test_session_next_moves_telemetry.d.ts +2 -0
  128. package/dist/test_session_next_moves_telemetry.d.ts.map +1 -0
  129. package/dist/test_session_next_moves_telemetry.js +100 -0
  130. package/dist/test_session_next_moves_telemetry.js.map +1 -0
  131. package/dist/test_token_resolution.d.ts +2 -0
  132. package/dist/test_token_resolution.d.ts.map +1 -0
  133. package/dist/test_token_resolution.js +82 -0
  134. package/dist/test_token_resolution.js.map +1 -0
  135. package/dist/toolHandlers.d.ts +7 -3
  136. package/dist/toolHandlers.d.ts.map +1 -1
  137. package/dist/toolHandlers.js +1678 -364
  138. package/dist/toolHandlers.js.map +1 -1
  139. package/dist/tools.d.ts +7 -2
  140. package/dist/tools.d.ts.map +1 -1
  141. package/dist/tools.js +12 -3
  142. package/dist/tools.js.map +1 -1
  143. package/dist/types.d.ts +9 -46
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js +6 -0
  146. package/dist/types.js.map +1 -1
  147. package/dist/verify_tools_endpoint.js +4 -3
  148. package/dist/verify_tools_endpoint.js.map +1 -1
  149. package/package.json +5 -2
  150. package/server.json +16 -3
  151. package/dist/scripts/simulate_gemini.d.ts +0 -2
  152. package/dist/scripts/simulate_gemini.d.ts.map +0 -1
  153. package/dist/scripts/simulate_gemini.js +0 -100
  154. package/dist/scripts/simulate_gemini.js.map +0 -1
  155. package/dist/scripts/verify_registry.d.ts +0 -2
  156. package/dist/scripts/verify_registry.d.ts.map +0 -1
  157. package/dist/scripts/verify_registry.js +0 -77
  158. package/dist/scripts/verify_registry.js.map +0 -1
  159. package/dist/src/index.d.ts +0 -2
  160. package/dist/src/index.d.ts.map +0 -1
  161. package/dist/src/index.js +0 -332
  162. package/dist/src/index.js.map +0 -1
  163. package/dist/src/tools.d.ts +0 -12
  164. package/dist/src/tools.d.ts.map +0 -1
  165. package/dist/src/tools.js +0 -104
  166. package/dist/src/tools.js.map +0 -1
  167. package/dist/src/types.d.ts +0 -44
  168. package/dist/src/types.d.ts.map +0 -1
  169. package/dist/src/types.js +0 -2
  170. package/dist/src/types.js.map +0 -1
  171. package/dist/src/verify.d.ts +0 -2
  172. package/dist/src/verify.d.ts.map +0 -1
  173. package/dist/src/verify.js +0 -47
  174. package/dist/src/verify.js.map +0 -1
  175. package/dist/src/verify_hmac.d.ts +0 -2
  176. package/dist/src/verify_hmac.d.ts.map +0 -1
  177. package/dist/src/verify_hmac.js +0 -106
  178. package/dist/src/verify_hmac.js.map +0 -1
  179. package/dist/src/verify_tools_endpoint.d.ts +0 -2
  180. package/dist/src/verify_tools_endpoint.d.ts.map +0 -1
  181. package/dist/src/verify_tools_endpoint.js +0 -40
  182. package/dist/src/verify_tools_endpoint.js.map +0 -1
  183. package/dist/test-waverunner-response.d.ts +0 -2
  184. package/dist/test-waverunner-response.d.ts.map +0 -1
  185. package/dist/test-waverunner-response.js +0 -20
  186. package/dist/test-waverunner-response.js.map +0 -1
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ import { cacheGet, cacheSet, getCacheStats } from './queryCache.js';
15
15
  import { MCP_SERVER_VERSION } from './tools.js';
16
16
  import { createServer } from './toolHandlers.js';
17
17
  import { registerA2ARoute } from './a2aHandler.js';
18
+ import { registerAgentFactsRoute } from './agentFacts.js';
19
+ import { getTelemetryStats, recordFeedbackEntry } from './telemetry.js';
20
+ import { handleOauthRegister } from './oauthRegisterShim.js';
18
21
  import * as dotenv from 'dotenv';
19
22
  dotenv.config();
20
23
  // ── Global crash guards ──
@@ -29,8 +32,16 @@ process.on('uncaughtException', (err) => {
29
32
  console.error('[uncaughtException] kept process alive:', err?.stack || err?.message || err);
30
33
  });
31
34
  const API_BASE_URL = process.env.FODDA_API_URL || 'https://api.fodda.ai';
35
+ const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'https://www.fodda.ai';
32
36
  const app = express();
33
37
  app.use(express.json({ limit: '512kb' }));
38
+ // Strip api_key from request logging globally before anything hits Cloud Run logs
39
+ app.use((req, _res, next) => {
40
+ if (req.url && req.url.includes('api_key=')) {
41
+ req.url = req.url.replace(/api_key=[^&]+/g, 'api_key=REDACTED');
42
+ }
43
+ next();
44
+ });
34
45
  // CORS — minimal, matching test server
35
46
  app.use((req, res, next) => {
36
47
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -41,18 +52,315 @@ app.use((req, res, next) => {
41
52
  return res.status(204).end();
42
53
  next();
43
54
  });
44
- // OAuth discovery endpoints — return 404 to indicate no OAuth support
55
+ // Deprecation middleware for legacy URL parameters (?api_key=... / ?user_id=...)
56
+ const LEGACY_DEPRECATION_PATHS = [
57
+ '/sse', '/mcp', '/messages', '/copilot',
58
+ '/brand-intelligence', '/topic-research', '/deep-research',
59
+ '/earnings-intelligence', '/expert-consult'
60
+ ];
61
+ app.use(LEGACY_DEPRECATION_PATHS, (req, res, next) => {
62
+ 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.';
64
+ const accept = req.headers['accept'] || '';
65
+ const contentType = req.headers['content-type'] || '';
66
+ const isJson = accept.includes('application/json') || contentType.includes('application/json') || (req.method === 'POST' && req.body && typeof req.body === 'object');
67
+ if (isJson && req.method === 'POST') {
68
+ const bodyId = (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null;
69
+ return res.status(401).json({
70
+ jsonrpc: '2.0',
71
+ error: {
72
+ code: -32001,
73
+ message,
74
+ data: { docs: 'https://fodda.ai/platform-integration-anthropic-claude' }
75
+ },
76
+ id: bodyId,
77
+ });
78
+ }
79
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
80
+ return res.status(401).send(`${message}\n`);
81
+ }
82
+ next();
83
+ });
84
+ // 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.
87
+ const CLERK_ISSUER = process.env.CLERK_ISSUER_URL || 'https://clerk.fodda.ai';
45
88
  app.get('/.well-known/oauth-protected-resource', (_req, res) => {
46
- res.status(404).json({ error: 'OAuth not supported. Use API key auth via URL: ?api_key=YOUR_KEY' });
89
+ res.status(200).json({
90
+ resource: getServiceUrl(),
91
+ authorization_servers: [getServiceUrl()],
92
+ });
93
+ });
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
+ });
99
+ });
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);
47
139
  });
48
- app.get('/.well-known/oauth-protected-resource/:path', (_req, res) => {
49
- res.status(404).json({ error: 'OAuth not supported.' });
140
+ app.post(['/oauth/register', '/register'], handleOauthRegister);
141
+ // ---------------------------------------------------------------------------
142
+ // .well-known MCP Server Discovery Card & Pricing Metadata Tier
143
+ // ---------------------------------------------------------------------------
144
+ export const OFFERING_SCOPED_TOOLS = {
145
+ 'brand-intelligence': [
146
+ 'get_capabilities',
147
+ 'brand_tracker',
148
+ 'search_graph',
149
+ 'read_url',
150
+ 'get_supplemental_context',
151
+ 'check_supplemental_status',
152
+ 'get_evidence',
153
+ 'get_node',
154
+ 'get_neighbors',
155
+ 'get_label_values',
156
+ 'list_graphs',
157
+ 'get_my_account',
158
+ 'generate_visual',
159
+ ],
160
+ 'topic-research': [
161
+ 'get_capabilities',
162
+ 'search_graph',
163
+ 'search_statistics',
164
+ 'search_insights',
165
+ 'get_validated_trends',
166
+ 'get_evidence',
167
+ 'get_node',
168
+ 'get_neighbors',
169
+ 'get_label_values',
170
+ 'list_graphs',
171
+ 'get_my_account',
172
+ 'generate_visual',
173
+ 'read_url',
174
+ 'get_supplemental_context',
175
+ 'check_supplemental_status',
176
+ ],
177
+ 'deep-research': [
178
+ 'get_capabilities',
179
+ 'deep_research_topic',
180
+ 'check_research_status',
181
+ 'search_graph',
182
+ 'get_evidence',
183
+ 'get_node',
184
+ 'get_neighbors',
185
+ 'get_label_values',
186
+ 'list_graphs',
187
+ 'get_my_account',
188
+ 'generate_visual',
189
+ 'read_url',
190
+ 'get_supplemental_context',
191
+ 'check_supplemental_status',
192
+ ],
193
+ 'earnings-intelligence': [
194
+ 'get_capabilities',
195
+ 'get_earnings_intelligence',
196
+ 'get_earnings_divergence',
197
+ 'get_company_earnings',
198
+ 'get_validated_trends',
199
+ 'search_graph',
200
+ 'get_evidence',
201
+ 'get_node',
202
+ 'get_neighbors',
203
+ 'get_label_values',
204
+ 'list_graphs',
205
+ 'get_my_account',
206
+ 'generate_visual',
207
+ ],
208
+ 'expert-consult': [
209
+ 'get_capabilities',
210
+ 'consult_analyst',
211
+ 'consult_human_agent',
212
+ 'list_analysts',
213
+ 'request_deliverable',
214
+ 'check_deliverable_status',
215
+ 'search_graph',
216
+ 'get_evidence',
217
+ 'get_node',
218
+ 'get_neighbors',
219
+ 'get_label_values',
220
+ 'list_graphs',
221
+ 'get_my_account',
222
+ 'generate_visual',
223
+ ],
224
+ 'copilot': [
225
+ 'get_capabilities',
226
+ 'search_graph',
227
+ 'search_statistics',
228
+ 'search_insights',
229
+ 'get_validated_trends',
230
+ 'brand_tracker',
231
+ 'get_company_earnings',
232
+ 'deep_research_topic',
233
+ 'check_research_status',
234
+ 'consult_analyst',
235
+ 'consult_human_agent',
236
+ 'list_analysts',
237
+ 'get_evidence',
238
+ 'list_graphs',
239
+ 'get_my_account',
240
+ 'read_url',
241
+ 'get_supplemental_context',
242
+ 'check_supplemental_status',
243
+ ],
244
+ };
245
+ const OFFERING_CARD_METADATA = {
246
+ 'brand-intelligence': {
247
+ name: 'ai.fodda/brand-intelligence',
248
+ title: 'Fodda Brand Intelligence',
249
+ description: 'Brand health & trend footprint across PSFK expert graphs with citable sources, not web summaries.',
250
+ },
251
+ 'topic-research': {
252
+ name: 'ai.fodda/topic-research',
253
+ title: 'Fodda Topic & Trend Research',
254
+ description: 'Trend, statistics & insight search across PSFK expert graphs with citable sources, not web summaries.',
255
+ },
256
+ 'deep-research': {
257
+ name: 'ai.fodda/deep-research',
258
+ title: 'Fodda Deep Research',
259
+ description: 'Autonomous deep research reports merging PSFK knowledge graphs & web validation with citable sources.',
260
+ },
261
+ 'earnings-intelligence': {
262
+ name: 'ai.fodda/earnings-intelligence',
263
+ title: 'Fodda Earnings Intelligence',
264
+ description: 'Cross-company earnings trends, executive divergence & ticker analysis with citable sources.',
265
+ },
266
+ 'expert-consult': {
267
+ name: 'ai.fodda/expert-consult',
268
+ title: 'Fodda Synthetic Expert Consult',
269
+ description: 'Consult synthetic industry experts grounded in PSFK trend graphs with citable analyst perspectives.',
270
+ },
271
+ 'copilot': {
272
+ name: 'ai.fodda/copilot',
273
+ title: 'Fodda Copilot Market Intelligence',
274
+ description: 'Curated market intelligence, trend insights & expert evidence for Microsoft Copilot Studio agents.',
275
+ },
276
+ };
277
+ app.get([
278
+ '/.well-known/mcp-server.json', '/.well-known/mcp',
279
+ '/.well-known/brand-intelligence', '/.well-known/brand-intelligence.json',
280
+ '/.well-known/topic-research', '/.well-known/topic-research.json',
281
+ '/.well-known/deep-research', '/.well-known/deep-research.json',
282
+ '/.well-known/earnings-intelligence', '/.well-known/earnings-intelligence.json',
283
+ '/.well-known/expert-consult', '/.well-known/expert-consult.json',
284
+ '/.well-known/copilot', '/.well-known/copilot.json',
285
+ ], (req, res) => {
286
+ res.setHeader('Content-Type', 'application/json');
287
+ const matchedOffering = Object.keys(OFFERING_CARD_METADATA).find(slug => req.path.includes(slug));
288
+ if (matchedOffering && OFFERING_CARD_METADATA[matchedOffering]) {
289
+ const meta = OFFERING_CARD_METADATA[matchedOffering];
290
+ return res.json({
291
+ $schema: 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json',
292
+ name: meta.name,
293
+ title: meta.title,
294
+ description: meta.description,
295
+ websiteUrl: 'https://www.fodda.ai',
296
+ version: MCP_SERVER_VERSION,
297
+ capabilities: {
298
+ tools: { listChanged: false },
299
+ resources: { listChanged: false, subscribe: false },
300
+ prompts: { listChanged: false },
301
+ },
302
+ pricingTier: {
303
+ structure: 'free',
304
+ substance: 'metered',
305
+ tokenModel: 'Canonical token costs defined in API metering.ts ($0.50 via SPT / API credits)',
306
+ },
307
+ resourceSchemes: [
308
+ 'fodda://expert/{slug}/insight/{id}',
309
+ 'fodda://graph/{vertical}/trend/{slug}',
310
+ ],
311
+ endpoints: {
312
+ mcpStreamableHttp: `${getServiceUrl()}/${matchedOffering}`,
313
+ mcpSse: `${getServiceUrl()}/sse`,
314
+ telemetry: `${getServiceUrl()}/telemetry`,
315
+ feedback: `${getServiceUrl()}/v1/feedback`,
316
+ },
317
+ });
318
+ }
319
+ res.json({
320
+ $schema: 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json',
321
+ name: 'ai.fodda/mcp-server',
322
+ title: 'Fodda Knowledge Graphs',
323
+ description: 'Expert-curated knowledge, brand, research & earnings intelligence — 46 tools, 220+ graphs.',
324
+ websiteUrl: 'https://www.fodda.ai',
325
+ version: MCP_SERVER_VERSION,
326
+ capabilities: {
327
+ tools: { listChanged: false },
328
+ resources: { listChanged: false, subscribe: false },
329
+ prompts: { listChanged: false },
330
+ },
331
+ pricingTier: {
332
+ structure: 'free',
333
+ substance: 'metered',
334
+ tokenModel: 'Canonical token costs defined in API metering.ts ($0.50 via SPT / API credits)',
335
+ },
336
+ resourceSchemes: [
337
+ 'fodda://expert/{slug}/insight/{id}',
338
+ 'fodda://graph/{vertical}/trend/{slug}',
339
+ ],
340
+ endpoints: {
341
+ mcpStreamableHttp: `${getServiceUrl()}/mcp`,
342
+ mcpSse: `${getServiceUrl()}/sse`,
343
+ telemetry: `${getServiceUrl()}/telemetry`,
344
+ feedback: `${getServiceUrl()}/v1/feedback`,
345
+ },
346
+ });
50
347
  });
51
- app.get('/.well-known/oauth-authorization-server', (_req, res) => {
52
- res.status(404).json({ error: 'OAuth not supported.' });
348
+ // ---------------------------------------------------------------------------
349
+ // Telemetry & Error-Rate Instrumentation Endpoints
350
+ // ---------------------------------------------------------------------------
351
+ app.get(['/telemetry', '/v1/telemetry'], (_req, res) => {
352
+ res.setHeader('Content-Type', 'application/json');
353
+ res.json(getTelemetryStats());
53
354
  });
54
- app.post('/register', (_req, res) => {
55
- res.status(404).json({ error: 'OAuth not supported.' });
355
+ app.post('/v1/feedback', (req, res) => {
356
+ const { category, feedback, user_email, user } = req.body || {};
357
+ if (!feedback) {
358
+ return res.status(400).json({ error: 'Missing required field: feedback' });
359
+ }
360
+ const userLabel = user_email || user || 'anonymous HTTP user';
361
+ const catLabel = category || 'general';
362
+ recordFeedbackEntry(catLabel, feedback, userLabel);
363
+ res.json({ status: 'LOGGED', message: 'Feedback recorded successfully.' });
56
364
  });
57
365
  // ---------------------------------------------------------------------------
58
366
  // Widget cache — stores pre-rendered HTML for browser access
@@ -121,14 +429,8 @@ setInterval(() => {
121
429
  function getServiceUrl() {
122
430
  if (process.env.FODDA_SERVICE_URL)
123
431
  return process.env.FODDA_SERVICE_URL;
124
- if (process.env.K_SERVICE) {
125
- // Derive from K_SERVICE + K_REVISION if FODDA_SERVICE_URL not set.
126
- // Requires CLOUD_RUN_REGION env var (e.g. 'uk') to be set.
127
- const region = process.env.CLOUD_RUN_REGION || 'uk';
128
- const revHash = process.env.K_REVISION?.split('-').pop();
129
- if (!revHash)
130
- console.error('[getServiceUrl] WARNING: K_REVISION not set — widget URLs may be wrong');
131
- return `https://${process.env.K_SERVICE}-${revHash || 'unknown'}-${region}.a.run.app`;
432
+ if (process.env.NODE_ENV === 'production' || process.env.K_SERVICE) {
433
+ return 'https://mcp.fodda.ai';
132
434
  }
133
435
  return `http://localhost:${process.env.PORT || 8080}`;
134
436
  }
@@ -165,21 +467,26 @@ async function foddaRequest(method, path, apiKey, userId, body, requestId, sourc
165
467
  // HMAC sign the request
166
468
  const secret = process.env.FODDA_MCP_SECRET;
167
469
  if (secret) {
168
- const payload = (method === 'POST' || method === 'PATCH') && body
169
- ? timestamp + '.' + JSON.stringify(body)
470
+ const payload = (method === 'POST' || method === 'PATCH')
471
+ ? timestamp + '.' + JSON.stringify(body ?? {})
170
472
  : timestamp + '.' + path;
171
473
  const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
172
474
  headers['X-Fodda-Signature'] = signature;
173
475
  }
174
- const url = `${API_BASE_URL}${path}`;
476
+ const baseUrl = path.startsWith('/api/') ? WEBSITE_BASE_URL : API_BASE_URL;
477
+ const url = `${baseUrl}${path}`;
175
478
  // Base timeout: 30s aligns with MCP client expectations.
176
- // Extended to 60s for analyst consult — multi-turn LLM + tool calls can legitimately exceed 30s.
177
- const AXIOS_TIMEOUT_MS = /\/analysts\/consult/.test(path) ? 60000 : 30000;
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)
481
+ ? 90000
482
+ : /\/supplemental\//.test(path)
483
+ ? 35000
484
+ : 30000;
178
485
  const response = method === 'GET'
179
486
  ? await axios.get(url, { headers, timeout: AXIOS_TIMEOUT_MS })
180
487
  : method === 'PATCH'
181
- ? await axios.patch(url, body, { headers, timeout: AXIOS_TIMEOUT_MS })
182
- : await axios.post(url, body, { headers, timeout: AXIOS_TIMEOUT_MS });
488
+ ? await axios.patch(url, body ?? {}, { headers, timeout: AXIOS_TIMEOUT_MS })
489
+ : await axios.post(url, body ?? {}, { headers, timeout: AXIOS_TIMEOUT_MS });
183
490
  // ── Cache store ──
184
491
  cacheSet(method, path, body, response.data);
185
492
  // ── Inject upstream usage warning headers into response data ──
@@ -250,25 +557,95 @@ async function validateSpt(spt) {
250
557
  async function waverunnerRequest(interactionType, tokenCost, userApiKey, userId, waverunnerPayload) {
251
558
  // ── Pre-check credits ──
252
559
  // (sk_trial_ pre-check removed — new trial accounts are metered server-side by the API)
253
- // ── Call Waverunner via Gemini SDK ──
254
- const geminiKey = process.env.GEMINI_API_KEY;
560
+ // ── Call Waverunner via Gemini SDK (generateContent) ──
561
+ const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY;
255
562
  if (!geminiKey) {
256
563
  throw new Error('GEMINI_API_KEY environment variable is required for Waverunner calls.');
257
564
  }
258
565
  const { GoogleGenAI } = await import('@google/genai');
259
566
  const ai = new GoogleGenAI({ apiKey: geminiKey });
260
- const result = await ai.interactions.create(waverunnerPayload);
567
+ const model = waverunnerPayload.model || 'gemini-2.5-flash';
568
+ const systemInstruction = waverunnerPayload.system_instruction;
569
+ const inputParts = Array.isArray(waverunnerPayload.input)
570
+ ? waverunnerPayload.input.map((i) => i.text || '').filter(Boolean).join('\n\n')
571
+ : (typeof waverunnerPayload.input === 'string' ? waverunnerPayload.input : (waverunnerPayload.prompt || ''));
572
+ const config = {};
573
+ if (systemInstruction) {
574
+ config.systemInstruction = systemInstruction;
575
+ }
576
+ if (waverunnerPayload.response_mime_type || waverunnerPayload.responseMimeType) {
577
+ config.responseMimeType = waverunnerPayload.response_mime_type || waverunnerPayload.responseMimeType;
578
+ }
579
+ if (Array.isArray(waverunnerPayload.tools) && waverunnerPayload.tools.length > 0) {
580
+ const toolsMapped = [];
581
+ let googleSearchAdded = false;
582
+ let urlContextAdded = false;
583
+ for (const t of waverunnerPayload.tools) {
584
+ if (t.type === 'url_context') {
585
+ // urlContext retrieves the page itself. It must NOT collapse into
586
+ // googleSearch — search cannot fetch a specific URL, which left
587
+ // read_url asking the model to extract a page it had no way to
588
+ // read, so it answered with a refusal instead of content.
589
+ if (!urlContextAdded) {
590
+ toolsMapped.push({ urlContext: {} });
591
+ urlContextAdded = true;
592
+ }
593
+ }
594
+ else if (t.type === 'google_search') {
595
+ if (!googleSearchAdded) {
596
+ toolsMapped.push({ googleSearch: {} });
597
+ googleSearchAdded = true;
598
+ }
599
+ }
600
+ else {
601
+ toolsMapped.push(t);
602
+ }
603
+ }
604
+ if (toolsMapped.length > 0) {
605
+ config.tools = toolsMapped;
606
+ }
607
+ }
608
+ const generatePromise = ai.models.generateContent({
609
+ model,
610
+ contents: [
611
+ {
612
+ role: 'user',
613
+ parts: [{ text: inputParts }]
614
+ }
615
+ ],
616
+ config
617
+ });
618
+ let timeoutTimer;
619
+ const timeoutPromise = new Promise((_, reject) => {
620
+ timeoutTimer = setTimeout(() => reject(new Error('Gemini model generation timed out after 180 seconds.')), 180000);
621
+ });
622
+ let response;
623
+ try {
624
+ response = await Promise.race([generatePromise, timeoutPromise]);
625
+ }
626
+ finally {
627
+ clearTimeout(timeoutTimer);
628
+ }
629
+ const reportText = response.text || '';
630
+ const groundingChunks = response.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
631
+ // Surfaced so URL-reading callers can distinguish "page fetched" from a
632
+ // model refusal — a refusal is non-empty text and otherwise looks like success.
633
+ const urlContextMetadata = response.candidates?.[0]?.urlContextMetadata || null;
261
634
  // ── Post-decrement (paid accounts) ──
262
635
  foddaRequest('POST', '/v1/research/meter', userApiKey, userId, {
263
636
  type: interactionType,
264
637
  billable_units: tokenCost,
265
638
  }).catch(err => console.error('[waverunnerRequest] Metering failed:', err.message));
266
- return result;
639
+ return {
640
+ outputs: [{ type: 'text', text: reportText }],
641
+ groundingMetadata: { groundingChunks },
642
+ urlContextMetadata
643
+ };
267
644
  }
268
645
  // ---------------------------------------------------------------------------
269
646
  // Browser Landing Page — catch humans who paste the MCP URL into a browser
270
647
  // ---------------------------------------------------------------------------
271
- app.get('/mcp', (req, res, next) => {
648
+ app.get(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/earnings-intelligence', '/expert-consult', '/copilot'], (req, res, next) => {
272
649
  // Only intercept browser requests (Accept: text/html).
273
650
  // MCP SDK clients send application/json or text/event-stream, so they
274
651
  // fall through to the app.all('/mcp') transport handler below.
@@ -518,13 +895,58 @@ app.get('/mcp', (req, res, next) => {
518
895
  </body>
519
896
  </html>`);
520
897
  });
898
+ const tokenCache = new Map();
899
+ const TOKEN_CACHE_TTL_MS = 60 * 1000; // 1 minute TTL
900
+ export async function resolveMcpToken(token, websiteBaseUrl) {
901
+ const cached = tokenCache.get(token);
902
+ if (cached && (Date.now() - cached.fetchedAt < TOKEN_CACHE_TTL_MS)) {
903
+ return cached;
904
+ }
905
+ const secret = process.env.FODDA_MCP_SECRET || process.env.ONBOARD_SECRET || '';
906
+ if (!secret) {
907
+ console.warn('[token-resolver] Neither FODDA_MCP_SECRET nor ONBOARD_SECRET is set in environment; sending token resolution request without X-Fodda-Mcp-Secret header.');
908
+ }
909
+ const headers = {};
910
+ if (secret) {
911
+ headers['X-Fodda-Mcp-Secret'] = secret;
912
+ }
913
+ try {
914
+ const response = await axios.get(`${websiteBaseUrl}/api/mcp-tokens/${token}`, {
915
+ headers,
916
+ timeout: 5000,
917
+ });
918
+ const data = response.data;
919
+ if (!data.apiKey || !data.email) {
920
+ throw new Error('Invalid token resolution payload');
921
+ }
922
+ const val = {
923
+ apiKey: data.apiKey,
924
+ email: data.email,
925
+ name: data.name || '',
926
+ analystId: data.analystId || '',
927
+ fetchedAt: Date.now()
928
+ };
929
+ tokenCache.set(token, val);
930
+ return val;
931
+ }
932
+ catch (err) {
933
+ if (axios.isAxiosError(err) && err.response && (err.response.status === 401 || err.response.status === 403)) {
934
+ throw new Error(`Token resolution request unauthorized (HTTP ${err.response.status}). Check FODDA_MCP_SECRET / ONBOARD_SECRET configuration.`);
935
+ }
936
+ throw err;
937
+ }
938
+ }
521
939
  // ---------------------------------------------------------------------------
522
940
  // MCP Transport Handler
523
941
  // ---------------------------------------------------------------------------
524
- app.all('/mcp', async (req, res) => {
942
+ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/earnings-intelligence', '/expert-consult', '/copilot', '/c/:token'], async (req, res) => {
525
943
  try {
526
944
  const sessionId = req.headers['mcp-session-id'];
527
945
  let transport;
946
+ // Determine offering slug and tool filtering from URL path
947
+ const rawPath = req.path || '';
948
+ const offeringSlug = (rawPath.replace(/^\//, '').split('/')[0] || '');
949
+ const allowedTools = OFFERING_SCOPED_TOOLS[offeringSlug];
528
950
  // SPT (anonymous Shared Payment Token) detection — MUST precede api-key
529
951
  // extraction so an `spt_xxx` Bearer is never treated as an API key.
530
952
  const rawAuth = req.headers['authorization']?.toString() || '';
@@ -533,30 +955,116 @@ app.all('/mcp', async (req, res) => {
533
955
  || (/^Bearer\s+spt_/i.test(rawAuth) ? rawAuth.replace(/^Bearer\s+/i, '') : '')
534
956
  || '';
535
957
  const isSpt = !!spt;
536
- // Extract API key, userId, and entry ID from URL or headers
537
- // Priority: query string (existing MCP clients) → X-API-Key header → Authorization Bearer (Remote MCP)
538
- const apiKey = isSpt ? '' : (req.query.api_key
958
+ const rawBearer = !isSpt ? rawAuth.replace(/^Bearer\s+/i, '').trim() : '';
959
+ // 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_');
962
+ if (isClerkJwt) {
963
+ // Attempt Clerk JWT → Fodda API key resolution
964
+ try {
965
+ const resolveUrl = `${API_BASE_URL}/v1/auth/clerk-resolve`;
966
+ const resolveResp = await axios.post(resolveUrl, { clerk_jwt: rawBearer }, {
967
+ headers: { 'X-Internal-Key': process.env.FODDA_INTERNAL_API_KEY || '' },
968
+ timeout: 5000,
969
+ });
970
+ if (resolveResp.data?.api_key) {
971
+ req.__resolvedApiKey = resolveResp.data.api_key;
972
+ req.__resolvedUserId = resolveResp.data.user_id || 'oauth_user';
973
+ }
974
+ 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.' },
978
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
979
+ });
980
+ }
981
+ }
982
+ catch (clerkErr) {
983
+ const status = clerkErr.response?.status;
984
+ if (status === 404 || status === 501) {
985
+ return res.status(501).json({
986
+ jsonrpc: '2.0',
987
+ error: { code: -32000, message: 'OAuth token resolution is not yet available on this server. Please use header API key auth (Authorization: Bearer sk_live_...) from https://app.fodda.ai.' },
988
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
989
+ });
990
+ }
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.' },
994
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
995
+ });
996
+ }
997
+ }
998
+ const token = req.params.token;
999
+ let resolvedApiKey = '';
1000
+ let resolvedEmail = '';
1001
+ let resolvedEntryId = '';
1002
+ if (token) {
1003
+ try {
1004
+ const resolved = await resolveMcpToken(token, WEBSITE_BASE_URL);
1005
+ resolvedApiKey = resolved.apiKey;
1006
+ resolvedEmail = resolved.email;
1007
+ resolvedEntryId = resolved.analystId;
1008
+ }
1009
+ catch (err) {
1010
+ console.error(`[token-resolver] Failed to resolve connection token:`, err.message);
1011
+ return res.status(401).json({ error: 'Invalid or expired connection token' });
1012
+ }
1013
+ }
1014
+ // 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).
1016
+ const resolvedOAuthKey = req.__resolvedApiKey || '';
1017
+ const resolvedOAuthUser = req.__resolvedUserId || '';
1018
+ const apiKey = isSpt ? '' : (resolvedOAuthKey
539
1019
  || req.headers['x-api-key']
540
- || (req.headers['authorization']?.toString().replace(/^Bearer\s+/i, ''))
1020
+ || (!isClerkJwt ? (req.headers['authorization']?.toString().replace(/^Bearer\s+/i, '')) : '')
1021
+ || resolvedApiKey
1022
+ || req.query.api_key
541
1023
  || '');
542
- const entryId = req.query.id || '';
1024
+ const entryId = resolvedEntryId || req.query.id || '';
543
1025
  // If id looks like an email and no explicit user_id, use it as userId for tracking + signup
544
1026
  const isEmailId = entryId.includes('@') && entryId.includes('.');
545
- const userId = isSpt ? 'spt_agent' : (req.query.user_id
1027
+ // Priority for userId: resolved OAuth user → resolved token email → query user_id → header X-User-Id → query id / anonymous
1028
+ const userId = isSpt ? 'spt_agent' : (resolvedOAuthUser
1029
+ || resolvedEmail
1030
+ || req.query.user_id
546
1031
  || req.headers['x-user-id']
547
1032
  || (isEmailId ? entryId : 'anonymous'));
548
- const source = req.query.source || (isSpt ? 'spt' : '');
1033
+ const defaultSource = (offeringSlug !== 'mcp' && allowedTools !== undefined) ? offeringSlug : (isSpt ? 'spt' : '');
1034
+ const source = req.headers['x-fodda-source'] || req.query.source || defaultSource;
549
1035
  if (sessionId && transports.has(sessionId)) {
550
1036
  transport = transports.get(sessionId);
551
1037
  }
552
1038
  else if (!sessionId && req.method === 'POST') {
553
1039
  const body = req.body;
554
1040
  if (body?.method === 'initialize') {
1041
+ // Directory connector policy: /mcp requires authentication — Clerk OAuth,
1042
+ // an API key, or a /c/<token> connection URL. An anonymous handshake gets
1043
+ // 401 + WWW-Authenticate (RFC 9728) so MCP clients auto-start the OAuth
1044
+ // 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.' },
1050
+ id: body?.id ?? null,
1051
+ });
1052
+ }
555
1053
  // For an SPT (anonymous) session: validate the token ONCE now (no charge)
556
1054
  // so we can refuse a task the SPT can't cover before running it. Reject the
557
1055
  // connection if the token is bad/unfunded.
1056
+ //
1057
+ // Directory connector policy: SPT lane is disabled on this endpoint when
1058
+ // ENABLE_SPT !== 'true'. The listed connector is OAuth + account credits only.
558
1059
  let sptInfo = null;
559
1060
  if (isSpt) {
1061
+ if (process.env.ENABLE_SPT !== 'true') {
1062
+ return res.status(401).json({
1063
+ jsonrpc: '2.0',
1064
+ error: { code: -32000, message: 'SPT payment tokens are not accepted on this endpoint. Connect via OAuth at https://app.fodda.ai.' },
1065
+ id: body?.id ?? null,
1066
+ });
1067
+ }
560
1068
  const v = await validateSpt(spt);
561
1069
  if (!v.valid) {
562
1070
  return res.status(402).json({
@@ -576,7 +1084,7 @@ app.all('/mcp', async (req, res) => {
576
1084
  : (source
577
1085
  ? ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source))
578
1086
  : foddaRequest);
579
- const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId, sptInfo ?? undefined);
1087
+ const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId, sptInfo ?? undefined, allowedTools);
580
1088
  transport = new StreamableHTTPServerTransport({
581
1089
  sessionIdGenerator: () => crypto.randomUUID(),
582
1090
  onsessioninitialized: (sid) => {
@@ -685,7 +1193,9 @@ app.get('/health', (_req, res) => res.json({ status: 'ok', version: MCP_SERVER_V
685
1193
  // ---------------------------------------------------------------------------
686
1194
  // A2A Protocol Endpoint — agent-to-agent task delegation
687
1195
  // ---------------------------------------------------------------------------
688
- registerA2ARoute(app, foddaRequest);
1196
+ registerA2ARoute(app, foddaRequest, waverunnerRequest);
1197
+ // AgentFacts (NANDA) identity document — projection of the A2A card + version
1198
+ registerAgentFactsRoute(app, getServiceUrl);
689
1199
  const PORT = parseInt(process.env.PORT || '8080');
690
1200
  // Initialize catalog and pricing caches before accepting connections
691
1201
  Promise.all([