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.
- package/README.md +4 -9
- package/dist/a2aHandler.d.ts +33 -3
- package/dist/a2aHandler.d.ts.map +1 -1
- package/dist/a2aHandler.js +189 -36
- package/dist/a2aHandler.js.map +1 -1
- package/dist/agentFacts.d.ts +21 -0
- package/dist/agentFacts.d.ts.map +1 -0
- package/dist/agentFacts.js +72 -0
- package/dist/agentFacts.js.map +1 -0
- package/dist/agents/fodda-researcher/index.d.ts +6 -0
- package/dist/agents/fodda-researcher/index.d.ts.map +1 -1
- package/dist/agents/fodda-researcher/index.js +18 -0
- package/dist/agents/fodda-researcher/index.js.map +1 -1
- package/dist/agents/fodda-researcher/skills.d.ts +1 -1
- package/dist/agents/fodda-researcher/skills.d.ts.map +1 -1
- package/dist/agents/fodda-researcher/skills.js +8 -3
- package/dist/agents/fodda-researcher/skills.js.map +1 -1
- package/dist/brandTemplate.d.ts.map +1 -1
- package/dist/brandTemplate.js +65 -4
- package/dist/brandTemplate.js.map +1 -1
- package/dist/catalogCache.d.ts +3 -13
- package/dist/catalogCache.d.ts.map +1 -1
- package/dist/catalogCache.js +238 -59
- package/dist/catalogCache.js.map +1 -1
- package/dist/debug_tools.d.ts +2 -0
- package/dist/debug_tools.d.ts.map +1 -0
- package/dist/debug_tools.js +19 -0
- package/dist/debug_tools.js.map +1 -0
- package/dist/deepResearch.d.ts +43 -0
- package/dist/deepResearch.d.ts.map +1 -0
- package/dist/deepResearch.js +498 -0
- package/dist/deepResearch.js.map +1 -0
- package/dist/errorHandling.d.ts +1 -1
- package/dist/errorHandling.d.ts.map +1 -1
- package/dist/errorHandling.js +24 -4
- package/dist/errorHandling.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +449 -36
- package/dist/index.js.map +1 -1
- package/dist/inspect_list_graphs.d.ts +2 -0
- package/dist/inspect_list_graphs.d.ts.map +1 -0
- package/dist/inspect_list_graphs.js +25 -0
- package/dist/inspect_list_graphs.js.map +1 -0
- package/dist/jobs/jlens_quarterly_sweep.d.ts +62 -0
- package/dist/jobs/jlens_quarterly_sweep.d.ts.map +1 -0
- package/dist/jobs/jlens_quarterly_sweep.js +160 -0
- package/dist/jobs/jlens_quarterly_sweep.js.map +1 -0
- package/dist/linkedinEngine.d.ts +150 -0
- package/dist/linkedinEngine.d.ts.map +1 -0
- package/dist/linkedinEngine.js +696 -0
- package/dist/linkedinEngine.js.map +1 -0
- package/dist/pricingCache.d.ts +6 -8
- package/dist/pricingCache.d.ts.map +1 -1
- package/dist/pricingCache.js +55 -11
- package/dist/pricingCache.js.map +1 -1
- package/dist/queryCache.d.ts +2 -0
- package/dist/queryCache.d.ts.map +1 -1
- package/dist/queryCache.js +11 -2
- package/dist/queryCache.js.map +1 -1
- package/dist/resources.d.ts +27 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +126 -0
- package/dist/resources.js.map +1 -0
- package/dist/scratch_test.d.ts.map +1 -0
- package/dist/services/notifications.d.ts +49 -0
- package/dist/services/notifications.d.ts.map +1 -0
- package/dist/services/notifications.js +206 -0
- package/dist/services/notifications.js.map +1 -0
- package/dist/sessionTracker.js +1 -1
- package/dist/sessionTracker.js.map +1 -1
- package/dist/systemPrompt.d.ts +3 -3
- package/dist/systemPrompt.d.ts.map +1 -1
- package/dist/systemPrompt.js +44 -54
- package/dist/systemPrompt.js.map +1 -1
- package/dist/telemetry.d.ts +43 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +101 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/test_analyst.js +2 -2
- package/dist/test_analyst.js.map +1 -1
- package/dist/test_brand_intelligence_endpoint.d.ts +2 -0
- package/dist/test_brand_intelligence_endpoint.d.ts.map +1 -0
- package/dist/test_brand_intelligence_endpoint.js +20 -0
- package/dist/test_brand_intelligence_endpoint.js.map +1 -0
- package/dist/test_coverage_r2.d.ts +2 -0
- package/dist/test_coverage_r2.d.ts.map +1 -0
- package/dist/test_coverage_scratch.d.ts +2 -0
- package/dist/test_coverage_scratch.d.ts.map +1 -0
- package/dist/test_list_graphs_topics.d.ts +2 -0
- package/dist/test_list_graphs_topics.d.ts.map +1 -0
- package/dist/test_list_graphs_topics.js +26 -0
- package/dist/test_list_graphs_topics.js.map +1 -0
- package/dist/test_live_endpoint_runner.d.ts +2 -0
- package/dist/test_live_endpoint_runner.d.ts.map +1 -0
- package/dist/test_live_endpoint_runner.js +65 -0
- package/dist/test_live_endpoint_runner.js.map +1 -0
- package/dist/test_local_list_graphs.d.ts +2 -0
- package/dist/test_local_list_graphs.d.ts.map +1 -0
- package/dist/test_local_list_graphs.js +53 -0
- package/dist/test_local_list_graphs.js.map +1 -0
- package/dist/test_nike_cmo_alias.d.ts +2 -0
- package/dist/test_nike_cmo_alias.d.ts.map +1 -0
- package/dist/test_nike_cmo_alias.js +76 -0
- package/dist/test_nike_cmo_alias.js.map +1 -0
- package/dist/test_token_resolution.d.ts +2 -0
- package/dist/test_token_resolution.d.ts.map +1 -0
- package/dist/test_token_resolution.js +82 -0
- package/dist/test_token_resolution.js.map +1 -0
- package/dist/toolHandlers.d.ts +7 -3
- package/dist/toolHandlers.d.ts.map +1 -1
- package/dist/toolHandlers.js +963 -310
- package/dist/toolHandlers.js.map +1 -1
- package/dist/tools.d.ts +6 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +7 -2
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +9 -46
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/dist/verify_discoverability.d.ts +2 -0
- package/dist/verify_discoverability.d.ts.map +1 -0
- package/dist/verify_discoverability.js +53 -0
- package/dist/verify_discoverability.js.map +1 -0
- package/package.json +5 -2
- 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 —
|
|
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(
|
|
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
|
|
49
|
-
res.status(
|
|
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.
|
|
65
|
+
res.redirect(302, `${CLERK_ISSUER}/.well-known/oauth-authorization-server`);
|
|
53
66
|
});
|
|
54
67
|
app.post('/register', (_req, res) => {
|
|
55
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
|
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
|
|
177
|
-
const AXIOS_TIMEOUT_MS = /\/analysts\/consult/.test(path)
|
|
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
|
|
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
|
|
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
|
-
|
|
537
|
-
//
|
|
538
|
-
|
|
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
|
-
|
|
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
|
|
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([
|