fodda-mcp 1.32.0 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +4 -9
  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.map +1 -1
  19. package/dist/brandTemplate.js +65 -4
  20. package/dist/brandTemplate.js.map +1 -1
  21. package/dist/catalogCache.d.ts +3 -13
  22. package/dist/catalogCache.d.ts.map +1 -1
  23. package/dist/catalogCache.js +238 -59
  24. package/dist/catalogCache.js.map +1 -1
  25. package/dist/debug_tools.d.ts +2 -0
  26. package/dist/debug_tools.d.ts.map +1 -0
  27. package/dist/debug_tools.js +19 -0
  28. package/dist/debug_tools.js.map +1 -0
  29. package/dist/deepResearch.d.ts +43 -0
  30. package/dist/deepResearch.d.ts.map +1 -0
  31. package/dist/deepResearch.js +498 -0
  32. package/dist/deepResearch.js.map +1 -0
  33. package/dist/errorHandling.d.ts +1 -1
  34. package/dist/errorHandling.d.ts.map +1 -1
  35. package/dist/errorHandling.js +24 -4
  36. package/dist/errorHandling.js.map +1 -1
  37. package/dist/index.d.ts +9 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +449 -36
  40. package/dist/index.js.map +1 -1
  41. package/dist/inspect_list_graphs.d.ts +2 -0
  42. package/dist/inspect_list_graphs.d.ts.map +1 -0
  43. package/dist/inspect_list_graphs.js +25 -0
  44. package/dist/inspect_list_graphs.js.map +1 -0
  45. package/dist/jobs/jlens_quarterly_sweep.d.ts +62 -0
  46. package/dist/jobs/jlens_quarterly_sweep.d.ts.map +1 -0
  47. package/dist/jobs/jlens_quarterly_sweep.js +160 -0
  48. package/dist/jobs/jlens_quarterly_sweep.js.map +1 -0
  49. package/dist/linkedinEngine.d.ts +150 -0
  50. package/dist/linkedinEngine.d.ts.map +1 -0
  51. package/dist/linkedinEngine.js +696 -0
  52. package/dist/linkedinEngine.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 +55 -11
  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/scratch_test.d.ts.map +1 -0
  66. package/dist/services/notifications.d.ts +49 -0
  67. package/dist/services/notifications.d.ts.map +1 -0
  68. package/dist/services/notifications.js +206 -0
  69. package/dist/services/notifications.js.map +1 -0
  70. package/dist/sessionTracker.js +1 -1
  71. package/dist/sessionTracker.js.map +1 -1
  72. package/dist/systemPrompt.d.ts +3 -3
  73. package/dist/systemPrompt.d.ts.map +1 -1
  74. package/dist/systemPrompt.js +44 -54
  75. package/dist/systemPrompt.js.map +1 -1
  76. package/dist/telemetry.d.ts +43 -0
  77. package/dist/telemetry.d.ts.map +1 -0
  78. package/dist/telemetry.js +101 -0
  79. package/dist/telemetry.js.map +1 -0
  80. package/dist/test_analyst.js +2 -2
  81. package/dist/test_analyst.js.map +1 -1
  82. package/dist/test_brand_intelligence_endpoint.d.ts +2 -0
  83. package/dist/test_brand_intelligence_endpoint.d.ts.map +1 -0
  84. package/dist/test_brand_intelligence_endpoint.js +20 -0
  85. package/dist/test_brand_intelligence_endpoint.js.map +1 -0
  86. package/dist/test_coverage_r2.d.ts +2 -0
  87. package/dist/test_coverage_r2.d.ts.map +1 -0
  88. package/dist/test_coverage_scratch.d.ts +2 -0
  89. package/dist/test_coverage_scratch.d.ts.map +1 -0
  90. package/dist/test_list_graphs_topics.d.ts +2 -0
  91. package/dist/test_list_graphs_topics.d.ts.map +1 -0
  92. package/dist/test_list_graphs_topics.js +26 -0
  93. package/dist/test_list_graphs_topics.js.map +1 -0
  94. package/dist/test_live_endpoint_runner.d.ts +2 -0
  95. package/dist/test_live_endpoint_runner.d.ts.map +1 -0
  96. package/dist/test_live_endpoint_runner.js +65 -0
  97. package/dist/test_live_endpoint_runner.js.map +1 -0
  98. package/dist/test_local_list_graphs.d.ts +2 -0
  99. package/dist/test_local_list_graphs.d.ts.map +1 -0
  100. package/dist/test_local_list_graphs.js +53 -0
  101. package/dist/test_local_list_graphs.js.map +1 -0
  102. package/dist/test_nike_cmo_alias.d.ts +2 -0
  103. package/dist/test_nike_cmo_alias.d.ts.map +1 -0
  104. package/dist/test_nike_cmo_alias.js +76 -0
  105. package/dist/test_nike_cmo_alias.js.map +1 -0
  106. package/dist/test_token_resolution.d.ts +2 -0
  107. package/dist/test_token_resolution.d.ts.map +1 -0
  108. package/dist/test_token_resolution.js +82 -0
  109. package/dist/test_token_resolution.js.map +1 -0
  110. package/dist/toolHandlers.d.ts +7 -3
  111. package/dist/toolHandlers.d.ts.map +1 -1
  112. package/dist/toolHandlers.js +963 -310
  113. package/dist/toolHandlers.js.map +1 -1
  114. package/dist/tools.d.ts +6 -2
  115. package/dist/tools.d.ts.map +1 -1
  116. package/dist/tools.js +7 -2
  117. package/dist/tools.js.map +1 -1
  118. package/dist/types.d.ts +9 -46
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/types.js +6 -0
  121. package/dist/types.js.map +1 -1
  122. package/dist/verify_discoverability.d.ts +2 -0
  123. package/dist/verify_discoverability.d.ts.map +1 -0
  124. package/dist/verify_discoverability.js +53 -0
  125. package/dist/verify_discoverability.js.map +1 -0
  126. package/package.json +5 -2
  127. package/server.json +17 -4
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ 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';
18
20
  import * as dotenv from 'dotenv';
19
21
  dotenv.config();
20
22
  // ── Global crash guards ──
@@ -29,6 +31,7 @@ process.on('uncaughtException', (err) => {
29
31
  console.error('[uncaughtException] kept process alive:', err?.stack || err?.message || err);
30
32
  });
31
33
  const API_BASE_URL = process.env.FODDA_API_URL || 'https://api.fodda.ai';
34
+ const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'https://www.fodda.ai';
32
35
  const app = express();
33
36
  app.use(express.json({ limit: '512kb' }));
34
37
  // CORS — minimal, matching test server
@@ -41,18 +44,226 @@ app.use((req, res, next) => {
41
44
  return res.status(204).end();
42
45
  next();
43
46
  });
44
- // OAuth discovery endpoints — return 404 to indicate no OAuth support
47
+ // OAuth discovery endpoints — Clerk-backed OAuth for the MCP Connectors Directory.
48
+ // The authorization server is Clerk (configured as CLERK_ISSUER_URL).
49
+ // These endpoints follow RFC 8414 / draft-ietf-oauth-security-topics so MCP clients
50
+ // can auto-discover the auth server and initiate PKCE flows.
51
+ const CLERK_ISSUER = process.env.CLERK_ISSUER_URL || 'https://clerk.fodda.ai';
45
52
  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' });
53
+ res.status(200).json({
54
+ resource: 'https://mcp.fodda.ai',
55
+ authorization_servers: [CLERK_ISSUER],
56
+ });
47
57
  });
48
- app.get('/.well-known/oauth-protected-resource/:path', (_req, res) => {
49
- res.status(404).json({ error: 'OAuth not supported.' });
58
+ app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => {
59
+ res.status(200).json({
60
+ resource: 'https://mcp.fodda.ai/mcp',
61
+ authorization_servers: [CLERK_ISSUER],
62
+ });
50
63
  });
51
64
  app.get('/.well-known/oauth-authorization-server', (_req, res) => {
52
- res.status(404).json({ error: 'OAuth not supported.' });
65
+ res.redirect(302, `${CLERK_ISSUER}/.well-known/oauth-authorization-server`);
53
66
  });
54
67
  app.post('/register', (_req, res) => {
55
- res.status(404).json({ error: 'OAuth not supported.' });
68
+ // DCR not handled here — managed by Clerk dashboard
69
+ res.status(501).json({ error: 'Dynamic client registration is managed via the Clerk dashboard.' });
70
+ });
71
+ // ---------------------------------------------------------------------------
72
+ // .well-known MCP Server Discovery Card & Pricing Metadata Tier
73
+ // ---------------------------------------------------------------------------
74
+ export const OFFERING_SCOPED_TOOLS = {
75
+ 'brand-intelligence': [
76
+ 'get_capabilities',
77
+ 'brand_tracker',
78
+ 'search_graph',
79
+ 'read_url',
80
+ 'get_supplemental_context',
81
+ 'check_supplemental_status',
82
+ 'get_evidence',
83
+ 'get_node',
84
+ 'get_neighbors',
85
+ 'get_label_values',
86
+ 'list_graphs',
87
+ 'get_my_account',
88
+ 'generate_visual',
89
+ ],
90
+ 'topic-research': [
91
+ 'get_capabilities',
92
+ 'search_graph',
93
+ 'search_statistics',
94
+ 'search_insights',
95
+ 'get_validated_trends',
96
+ 'get_evidence',
97
+ 'get_node',
98
+ 'get_neighbors',
99
+ 'get_label_values',
100
+ 'list_graphs',
101
+ 'get_my_account',
102
+ 'generate_visual',
103
+ 'read_url',
104
+ 'get_supplemental_context',
105
+ 'check_supplemental_status',
106
+ ],
107
+ 'deep-research': [
108
+ 'get_capabilities',
109
+ 'deep_research_topic',
110
+ 'check_research_status',
111
+ 'search_graph',
112
+ 'get_evidence',
113
+ 'get_node',
114
+ 'get_neighbors',
115
+ 'get_label_values',
116
+ 'list_graphs',
117
+ 'get_my_account',
118
+ 'generate_visual',
119
+ 'read_url',
120
+ 'get_supplemental_context',
121
+ 'check_supplemental_status',
122
+ ],
123
+ 'earnings-intelligence': [
124
+ 'get_capabilities',
125
+ 'get_earnings_intelligence',
126
+ 'get_earnings_divergence',
127
+ 'get_company_earnings',
128
+ 'get_validated_trends',
129
+ 'search_graph',
130
+ 'get_evidence',
131
+ 'get_node',
132
+ 'get_neighbors',
133
+ 'get_label_values',
134
+ 'list_graphs',
135
+ 'get_my_account',
136
+ 'generate_visual',
137
+ ],
138
+ 'expert-consult': [
139
+ 'get_capabilities',
140
+ 'consult_analyst',
141
+ 'list_analysts',
142
+ 'request_deliverable',
143
+ 'check_deliverable_status',
144
+ 'search_graph',
145
+ 'get_evidence',
146
+ 'get_node',
147
+ 'get_neighbors',
148
+ 'get_label_values',
149
+ 'list_graphs',
150
+ 'get_my_account',
151
+ 'generate_visual',
152
+ ],
153
+ };
154
+ const OFFERING_CARD_METADATA = {
155
+ 'brand-intelligence': {
156
+ name: 'ai.fodda/brand-intelligence',
157
+ title: 'Fodda Brand Intelligence',
158
+ description: 'Brand health & trend footprint across PSFK expert graphs with citable sources, not web summaries.',
159
+ },
160
+ 'topic-research': {
161
+ name: 'ai.fodda/topic-research',
162
+ title: 'Fodda Topic & Trend Research',
163
+ description: 'Trend, statistics & insight search across PSFK expert graphs with citable sources, not web summaries.',
164
+ },
165
+ 'deep-research': {
166
+ name: 'ai.fodda/deep-research',
167
+ title: 'Fodda Deep Research',
168
+ description: 'Autonomous deep research reports merging PSFK knowledge graphs & web validation with citable sources.',
169
+ },
170
+ 'earnings-intelligence': {
171
+ name: 'ai.fodda/earnings-intelligence',
172
+ title: 'Fodda Earnings Intelligence',
173
+ description: 'Cross-company earnings trends, executive divergence & ticker analysis with citable sources.',
174
+ },
175
+ 'expert-consult': {
176
+ name: 'ai.fodda/expert-consult',
177
+ title: 'Fodda Synthetic Expert Consult',
178
+ description: 'Consult synthetic industry experts grounded in PSFK trend graphs with citable analyst perspectives.',
179
+ },
180
+ };
181
+ app.get([
182
+ '/.well-known/mcp-server.json', '/.well-known/mcp',
183
+ '/.well-known/brand-intelligence', '/.well-known/brand-intelligence.json',
184
+ '/.well-known/topic-research', '/.well-known/topic-research.json',
185
+ '/.well-known/deep-research', '/.well-known/deep-research.json',
186
+ '/.well-known/earnings-intelligence', '/.well-known/earnings-intelligence.json',
187
+ '/.well-known/expert-consult', '/.well-known/expert-consult.json',
188
+ ], (req, res) => {
189
+ res.setHeader('Content-Type', 'application/json');
190
+ const matchedOffering = Object.keys(OFFERING_CARD_METADATA).find(slug => req.path.includes(slug));
191
+ if (matchedOffering && OFFERING_CARD_METADATA[matchedOffering]) {
192
+ const meta = OFFERING_CARD_METADATA[matchedOffering];
193
+ return res.json({
194
+ $schema: 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json',
195
+ name: meta.name,
196
+ title: meta.title,
197
+ description: meta.description,
198
+ websiteUrl: 'https://www.fodda.ai',
199
+ version: MCP_SERVER_VERSION,
200
+ capabilities: {
201
+ tools: { listChanged: false },
202
+ resources: { listChanged: false, subscribe: false },
203
+ prompts: { listChanged: false },
204
+ },
205
+ pricingTier: {
206
+ structure: 'free',
207
+ substance: 'metered',
208
+ tokenModel: 'Canonical token costs defined in API metering.ts ($0.50 via SPT / API credits)',
209
+ },
210
+ resourceSchemes: [
211
+ 'fodda://expert/{slug}/insight/{id}',
212
+ 'fodda://graph/{vertical}/trend/{slug}',
213
+ ],
214
+ endpoints: {
215
+ mcpStreamableHttp: `${getServiceUrl()}/${matchedOffering}`,
216
+ mcpSse: `${getServiceUrl()}/sse`,
217
+ telemetry: `${getServiceUrl()}/telemetry`,
218
+ feedback: `${getServiceUrl()}/v1/feedback`,
219
+ },
220
+ });
221
+ }
222
+ res.json({
223
+ $schema: 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json',
224
+ name: 'ai.fodda/mcp-server',
225
+ title: 'Fodda Knowledge Graphs',
226
+ description: 'Expert-curated knowledge, brand, research & earnings intelligence — 46 tools, 220+ graphs.',
227
+ websiteUrl: 'https://www.fodda.ai',
228
+ version: MCP_SERVER_VERSION,
229
+ capabilities: {
230
+ tools: { listChanged: false },
231
+ resources: { listChanged: false, subscribe: false },
232
+ prompts: { listChanged: false },
233
+ },
234
+ pricingTier: {
235
+ structure: 'free',
236
+ substance: 'metered',
237
+ tokenModel: 'Canonical token costs defined in API metering.ts ($0.50 via SPT / API credits)',
238
+ },
239
+ resourceSchemes: [
240
+ 'fodda://expert/{slug}/insight/{id}',
241
+ 'fodda://graph/{vertical}/trend/{slug}',
242
+ ],
243
+ endpoints: {
244
+ mcpStreamableHttp: `${getServiceUrl()}/mcp`,
245
+ mcpSse: `${getServiceUrl()}/sse`,
246
+ telemetry: `${getServiceUrl()}/telemetry`,
247
+ feedback: `${getServiceUrl()}/v1/feedback`,
248
+ },
249
+ });
250
+ });
251
+ // ---------------------------------------------------------------------------
252
+ // Telemetry & Error-Rate Instrumentation Endpoints
253
+ // ---------------------------------------------------------------------------
254
+ app.get(['/telemetry', '/v1/telemetry'], (_req, res) => {
255
+ res.setHeader('Content-Type', 'application/json');
256
+ res.json(getTelemetryStats());
257
+ });
258
+ app.post('/v1/feedback', (req, res) => {
259
+ const { category, feedback, user_email, user } = req.body || {};
260
+ if (!feedback) {
261
+ return res.status(400).json({ error: 'Missing required field: feedback' });
262
+ }
263
+ const userLabel = user_email || user || 'anonymous HTTP user';
264
+ const catLabel = category || 'general';
265
+ recordFeedbackEntry(catLabel, feedback, userLabel);
266
+ res.json({ status: 'LOGGED', message: 'Feedback recorded successfully.' });
56
267
  });
57
268
  // ---------------------------------------------------------------------------
58
269
  // Widget cache — stores pre-rendered HTML for browser access
@@ -121,14 +332,8 @@ setInterval(() => {
121
332
  function getServiceUrl() {
122
333
  if (process.env.FODDA_SERVICE_URL)
123
334
  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`;
335
+ if (process.env.NODE_ENV === 'production' || process.env.K_SERVICE) {
336
+ return 'https://mcp.fodda.ai';
132
337
  }
133
338
  return `http://localhost:${process.env.PORT || 8080}`;
134
339
  }
@@ -165,21 +370,26 @@ async function foddaRequest(method, path, apiKey, userId, body, requestId, sourc
165
370
  // HMAC sign the request
166
371
  const secret = process.env.FODDA_MCP_SECRET;
167
372
  if (secret) {
168
- const payload = (method === 'POST' || method === 'PATCH') && body
169
- ? timestamp + '.' + JSON.stringify(body)
373
+ const payload = (method === 'POST' || method === 'PATCH')
374
+ ? timestamp + '.' + JSON.stringify(body ?? {})
170
375
  : timestamp + '.' + path;
171
376
  const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
172
377
  headers['X-Fodda-Signature'] = signature;
173
378
  }
174
- const url = `${API_BASE_URL}${path}`;
379
+ const baseUrl = path.startsWith('/api/') ? WEBSITE_BASE_URL : API_BASE_URL;
380
+ const url = `${baseUrl}${path}`;
175
381
  // 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;
382
+ // Extended to 60s for analyst consult, and 35s for supplemental.
383
+ const AXIOS_TIMEOUT_MS = /\/analysts\/consult/.test(path)
384
+ ? 60000
385
+ : /\/supplemental\//.test(path)
386
+ ? 35000
387
+ : 30000;
178
388
  const response = method === 'GET'
179
389
  ? await axios.get(url, { headers, timeout: AXIOS_TIMEOUT_MS })
180
390
  : method === 'PATCH'
181
- ? await axios.patch(url, body, { headers, timeout: AXIOS_TIMEOUT_MS })
182
- : await axios.post(url, body, { headers, timeout: AXIOS_TIMEOUT_MS });
391
+ ? await axios.patch(url, body ?? {}, { headers, timeout: AXIOS_TIMEOUT_MS })
392
+ : await axios.post(url, body ?? {}, { headers, timeout: AXIOS_TIMEOUT_MS });
183
393
  // ── Cache store ──
184
394
  cacheSet(method, path, body, response.data);
185
395
  // ── Inject upstream usage warning headers into response data ──
@@ -250,25 +460,95 @@ async function validateSpt(spt) {
250
460
  async function waverunnerRequest(interactionType, tokenCost, userApiKey, userId, waverunnerPayload) {
251
461
  // ── Pre-check credits ──
252
462
  // (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;
463
+ // ── Call Waverunner via Gemini SDK (generateContent) ──
464
+ const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY;
255
465
  if (!geminiKey) {
256
466
  throw new Error('GEMINI_API_KEY environment variable is required for Waverunner calls.');
257
467
  }
258
468
  const { GoogleGenAI } = await import('@google/genai');
259
469
  const ai = new GoogleGenAI({ apiKey: geminiKey });
260
- const result = await ai.interactions.create(waverunnerPayload);
470
+ const model = waverunnerPayload.model || 'gemini-2.5-flash';
471
+ const systemInstruction = waverunnerPayload.system_instruction;
472
+ const inputParts = Array.isArray(waverunnerPayload.input)
473
+ ? waverunnerPayload.input.map((i) => i.text || '').filter(Boolean).join('\n\n')
474
+ : (typeof waverunnerPayload.input === 'string' ? waverunnerPayload.input : (waverunnerPayload.prompt || ''));
475
+ const config = {};
476
+ if (systemInstruction) {
477
+ config.systemInstruction = systemInstruction;
478
+ }
479
+ if (waverunnerPayload.response_mime_type || waverunnerPayload.responseMimeType) {
480
+ config.responseMimeType = waverunnerPayload.response_mime_type || waverunnerPayload.responseMimeType;
481
+ }
482
+ if (Array.isArray(waverunnerPayload.tools) && waverunnerPayload.tools.length > 0) {
483
+ const toolsMapped = [];
484
+ let googleSearchAdded = false;
485
+ let urlContextAdded = false;
486
+ for (const t of waverunnerPayload.tools) {
487
+ if (t.type === 'url_context') {
488
+ // urlContext retrieves the page itself. It must NOT collapse into
489
+ // googleSearch — search cannot fetch a specific URL, which left
490
+ // read_url asking the model to extract a page it had no way to
491
+ // read, so it answered with a refusal instead of content.
492
+ if (!urlContextAdded) {
493
+ toolsMapped.push({ urlContext: {} });
494
+ urlContextAdded = true;
495
+ }
496
+ }
497
+ else if (t.type === 'google_search') {
498
+ if (!googleSearchAdded) {
499
+ toolsMapped.push({ googleSearch: {} });
500
+ googleSearchAdded = true;
501
+ }
502
+ }
503
+ else {
504
+ toolsMapped.push(t);
505
+ }
506
+ }
507
+ if (toolsMapped.length > 0) {
508
+ config.tools = toolsMapped;
509
+ }
510
+ }
511
+ const generatePromise = ai.models.generateContent({
512
+ model,
513
+ contents: [
514
+ {
515
+ role: 'user',
516
+ parts: [{ text: inputParts }]
517
+ }
518
+ ],
519
+ config
520
+ });
521
+ let timeoutTimer;
522
+ const timeoutPromise = new Promise((_, reject) => {
523
+ timeoutTimer = setTimeout(() => reject(new Error('Gemini model generation timed out after 180 seconds.')), 180000);
524
+ });
525
+ let response;
526
+ try {
527
+ response = await Promise.race([generatePromise, timeoutPromise]);
528
+ }
529
+ finally {
530
+ clearTimeout(timeoutTimer);
531
+ }
532
+ const reportText = response.text || '';
533
+ const groundingChunks = response.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
534
+ // Surfaced so URL-reading callers can distinguish "page fetched" from a
535
+ // model refusal — a refusal is non-empty text and otherwise looks like success.
536
+ const urlContextMetadata = response.candidates?.[0]?.urlContextMetadata || null;
261
537
  // ── Post-decrement (paid accounts) ──
262
538
  foddaRequest('POST', '/v1/research/meter', userApiKey, userId, {
263
539
  type: interactionType,
264
540
  billable_units: tokenCost,
265
541
  }).catch(err => console.error('[waverunnerRequest] Metering failed:', err.message));
266
- return result;
542
+ return {
543
+ outputs: [{ type: 'text', text: reportText }],
544
+ groundingMetadata: { groundingChunks },
545
+ urlContextMetadata
546
+ };
267
547
  }
268
548
  // ---------------------------------------------------------------------------
269
549
  // Browser Landing Page — catch humans who paste the MCP URL into a browser
270
550
  // ---------------------------------------------------------------------------
271
- app.get('/mcp', (req, res, next) => {
551
+ app.get(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/earnings-intelligence', '/expert-consult'], (req, res, next) => {
272
552
  // Only intercept browser requests (Accept: text/html).
273
553
  // MCP SDK clients send application/json or text/event-stream, so they
274
554
  // fall through to the app.all('/mcp') transport handler below.
@@ -518,13 +798,58 @@ app.get('/mcp', (req, res, next) => {
518
798
  </body>
519
799
  </html>`);
520
800
  });
801
+ const tokenCache = new Map();
802
+ const TOKEN_CACHE_TTL_MS = 60 * 1000; // 1 minute TTL
803
+ export async function resolveMcpToken(token, websiteBaseUrl) {
804
+ const cached = tokenCache.get(token);
805
+ if (cached && (Date.now() - cached.fetchedAt < TOKEN_CACHE_TTL_MS)) {
806
+ return cached;
807
+ }
808
+ const secret = process.env.FODDA_MCP_SECRET || process.env.ONBOARD_SECRET || '';
809
+ if (!secret) {
810
+ 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.');
811
+ }
812
+ const headers = {};
813
+ if (secret) {
814
+ headers['X-Fodda-Mcp-Secret'] = secret;
815
+ }
816
+ try {
817
+ const response = await axios.get(`${websiteBaseUrl}/api/mcp-tokens/${token}`, {
818
+ headers,
819
+ timeout: 5000,
820
+ });
821
+ const data = response.data;
822
+ if (!data.apiKey || !data.email) {
823
+ throw new Error('Invalid token resolution payload');
824
+ }
825
+ const val = {
826
+ apiKey: data.apiKey,
827
+ email: data.email,
828
+ name: data.name || '',
829
+ analystId: data.analystId || '',
830
+ fetchedAt: Date.now()
831
+ };
832
+ tokenCache.set(token, val);
833
+ return val;
834
+ }
835
+ catch (err) {
836
+ if (axios.isAxiosError(err) && err.response && (err.response.status === 401 || err.response.status === 403)) {
837
+ throw new Error(`Token resolution request unauthorized (HTTP ${err.response.status}). Check FODDA_MCP_SECRET / ONBOARD_SECRET configuration.`);
838
+ }
839
+ throw err;
840
+ }
841
+ }
521
842
  // ---------------------------------------------------------------------------
522
843
  // MCP Transport Handler
523
844
  // ---------------------------------------------------------------------------
524
- app.all('/mcp', async (req, res) => {
845
+ app.all(['/mcp', '/brand-intelligence', '/topic-research', '/deep-research', '/earnings-intelligence', '/expert-consult', '/c/:token'], async (req, res) => {
525
846
  try {
526
847
  const sessionId = req.headers['mcp-session-id'];
527
848
  let transport;
849
+ // Determine offering slug and tool filtering from URL path
850
+ const rawPath = req.path || '';
851
+ const offeringSlug = (rawPath.replace(/^\//, '').split('/')[0] || '');
852
+ const allowedTools = OFFERING_SCOPED_TOOLS[offeringSlug];
528
853
  // SPT (anonymous Shared Payment Token) detection — MUST precede api-key
529
854
  // extraction so an `spt_xxx` Bearer is never treated as an API key.
530
855
  const rawAuth = req.headers['authorization']?.toString() || '';
@@ -533,30 +858,116 @@ app.all('/mcp', async (req, res) => {
533
858
  || (/^Bearer\s+spt_/i.test(rawAuth) ? rawAuth.replace(/^Bearer\s+/i, '') : '')
534
859
  || '';
535
860
  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
861
+ const rawBearer = !isSpt ? rawAuth.replace(/^Bearer\s+/i, '').trim() : '';
862
+ // Clerk OAuth tokens may be JWTs (eyJ...), opaque tokens (oat_...), session tokens (sess_...), or custom OAuth tokens.
863
+ // If it's not a standard sk_live_ / sk_trial_ key, send it to clerk-resolve.
864
+ const isClerkJwt = !isSpt && rawBearer.length > 0 && !rawBearer.startsWith('sk_live_') && !rawBearer.startsWith('sk_trial_');
865
+ if (isClerkJwt) {
866
+ // Attempt Clerk JWT → Fodda API key resolution
867
+ try {
868
+ const resolveUrl = `${API_BASE_URL}/v1/auth/clerk-resolve`;
869
+ const resolveResp = await axios.post(resolveUrl, { clerk_jwt: rawBearer }, {
870
+ headers: { 'X-Internal-Key': process.env.FODDA_INTERNAL_API_KEY || '' },
871
+ timeout: 5000,
872
+ });
873
+ if (resolveResp.data?.api_key) {
874
+ req.__resolvedApiKey = resolveResp.data.api_key;
875
+ req.__resolvedUserId = resolveResp.data.user_id || 'oauth_user';
876
+ }
877
+ else {
878
+ return res.status(401).json({
879
+ jsonrpc: '2.0',
880
+ error: { code: -32000, message: 'OAuth token could not be resolved to a Fodda account. Visit https://app.fodda.ai to reconnect.' },
881
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
882
+ });
883
+ }
884
+ }
885
+ catch (clerkErr) {
886
+ const status = clerkErr.response?.status;
887
+ if (status === 404 || status === 501) {
888
+ return res.status(501).json({
889
+ jsonrpc: '2.0',
890
+ 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.' },
891
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
892
+ });
893
+ }
894
+ return res.status(401).json({
895
+ jsonrpc: '2.0',
896
+ error: { code: -32000, message: 'OAuth token validation failed. Please reconnect at https://app.fodda.ai.' },
897
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body) ? req.body.id : null,
898
+ });
899
+ }
900
+ }
901
+ const token = req.params.token;
902
+ let resolvedApiKey = '';
903
+ let resolvedEmail = '';
904
+ let resolvedEntryId = '';
905
+ if (token) {
906
+ try {
907
+ const resolved = await resolveMcpToken(token, WEBSITE_BASE_URL);
908
+ resolvedApiKey = resolved.apiKey;
909
+ resolvedEmail = resolved.email;
910
+ resolvedEntryId = resolved.analystId;
911
+ }
912
+ catch (err) {
913
+ console.error(`[token-resolver] Failed to resolve connection token:`, err.message);
914
+ return res.status(401).json({ error: 'Invalid or expired connection token' });
915
+ }
916
+ }
917
+ // Extract API key, userId, and entry ID from URL or headers.
918
+ // Priority: resolved OAuth key → X-API-Key header → Authorization Bearer (if non-Clerk) → resolved token key → query string (fallback).
919
+ const resolvedOAuthKey = req.__resolvedApiKey || '';
920
+ const resolvedOAuthUser = req.__resolvedUserId || '';
921
+ const apiKey = isSpt ? '' : (resolvedOAuthKey
539
922
  || req.headers['x-api-key']
540
- || (req.headers['authorization']?.toString().replace(/^Bearer\s+/i, ''))
923
+ || (!isClerkJwt ? (req.headers['authorization']?.toString().replace(/^Bearer\s+/i, '')) : '')
924
+ || resolvedApiKey
925
+ || req.query.api_key
541
926
  || '');
542
- const entryId = req.query.id || '';
927
+ const entryId = resolvedEntryId || req.query.id || '';
543
928
  // If id looks like an email and no explicit user_id, use it as userId for tracking + signup
544
929
  const isEmailId = entryId.includes('@') && entryId.includes('.');
545
- const userId = isSpt ? 'spt_agent' : (req.query.user_id
930
+ // Priority for userId: resolved OAuth user → resolved token email → query user_id → header X-User-Id → query id / anonymous
931
+ const userId = isSpt ? 'spt_agent' : (resolvedOAuthUser
932
+ || resolvedEmail
933
+ || req.query.user_id
546
934
  || req.headers['x-user-id']
547
935
  || (isEmailId ? entryId : 'anonymous'));
548
- const source = req.query.source || (isSpt ? 'spt' : '');
936
+ const defaultSource = (offeringSlug !== 'mcp' && allowedTools !== undefined) ? offeringSlug : (isSpt ? 'spt' : '');
937
+ const source = req.query.source || defaultSource;
549
938
  if (sessionId && transports.has(sessionId)) {
550
939
  transport = transports.get(sessionId);
551
940
  }
552
941
  else if (!sessionId && req.method === 'POST') {
553
942
  const body = req.body;
554
943
  if (body?.method === 'initialize') {
944
+ // Directory connector policy: /mcp requires authentication — Clerk OAuth,
945
+ // an API key, or a /c/<token> connection URL. An anonymous handshake gets
946
+ // 401 + WWW-Authenticate (RFC 9728) so MCP clients auto-start the OAuth
947
+ // flow. Set MCP_ALLOW_ANONYMOUS=true to re-open the anonymous trial lane.
948
+ if (offeringSlug === 'mcp' && !isSpt && !apiKey && process.env.MCP_ALLOW_ANONYMOUS !== 'true') {
949
+ res.setHeader('WWW-Authenticate', 'Bearer resource_metadata="https://mcp.fodda.ai/.well-known/oauth-protected-resource/mcp"');
950
+ return res.status(401).json({
951
+ jsonrpc: '2.0',
952
+ error: { code: -32000, message: 'Authentication required. Connect via OAuth, or use your personal connection URL or API key from https://app.fodda.ai.' },
953
+ id: body?.id ?? null,
954
+ });
955
+ }
555
956
  // For an SPT (anonymous) session: validate the token ONCE now (no charge)
556
957
  // so we can refuse a task the SPT can't cover before running it. Reject the
557
958
  // connection if the token is bad/unfunded.
959
+ //
960
+ // Directory connector policy: SPT lane is disabled on this endpoint when
961
+ // ENABLE_SPT !== 'true'. The listed connector is OAuth + account credits only.
558
962
  let sptInfo = null;
559
963
  if (isSpt) {
964
+ if (process.env.ENABLE_SPT !== 'true') {
965
+ return res.status(401).json({
966
+ jsonrpc: '2.0',
967
+ error: { code: -32000, message: 'SPT payment tokens are not accepted on this endpoint. Connect via OAuth at https://app.fodda.ai.' },
968
+ id: body?.id ?? null,
969
+ });
970
+ }
560
971
  const v = await validateSpt(spt);
561
972
  if (!v.valid) {
562
973
  return res.status(402).json({
@@ -576,7 +987,7 @@ app.all('/mcp', async (req, res) => {
576
987
  : (source
577
988
  ? ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source))
578
989
  : foddaRequest);
579
- const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId, sptInfo ?? undefined);
990
+ const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId, sptInfo ?? undefined, allowedTools);
580
991
  transport = new StreamableHTTPServerTransport({
581
992
  sessionIdGenerator: () => crypto.randomUUID(),
582
993
  onsessioninitialized: (sid) => {
@@ -685,7 +1096,9 @@ app.get('/health', (_req, res) => res.json({ status: 'ok', version: MCP_SERVER_V
685
1096
  // ---------------------------------------------------------------------------
686
1097
  // A2A Protocol Endpoint — agent-to-agent task delegation
687
1098
  // ---------------------------------------------------------------------------
688
- registerA2ARoute(app, foddaRequest);
1099
+ registerA2ARoute(app, foddaRequest, waverunnerRequest);
1100
+ // AgentFacts (NANDA) identity document — projection of the A2A card + version
1101
+ registerAgentFactsRoute(app, getServiceUrl);
689
1102
  const PORT = parseInt(process.env.PORT || '8080');
690
1103
  // Initialize catalog and pricing caches before accepting connections
691
1104
  Promise.all([