fodda-mcp 1.3.2 → 1.30.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 (109) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +192 -20
  3. package/dist/a2aHandler.d.ts +20 -0
  4. package/dist/a2aHandler.d.ts.map +1 -0
  5. package/dist/a2aHandler.js +247 -0
  6. package/dist/a2aHandler.js.map +1 -0
  7. package/dist/agents/fodda-researcher/index.d.ts +35 -0
  8. package/dist/agents/fodda-researcher/index.d.ts.map +1 -0
  9. package/dist/agents/fodda-researcher/index.js +62 -0
  10. package/dist/agents/fodda-researcher/index.js.map +1 -0
  11. package/dist/agents/fodda-researcher/skills.d.ts +16 -0
  12. package/dist/agents/fodda-researcher/skills.d.ts.map +1 -0
  13. package/dist/agents/fodda-researcher/skills.js +163 -0
  14. package/dist/agents/fodda-researcher/skills.js.map +1 -0
  15. package/dist/brandTemplate.d.ts +14 -0
  16. package/dist/brandTemplate.d.ts.map +1 -0
  17. package/dist/brandTemplate.js +742 -0
  18. package/dist/brandTemplate.js.map +1 -0
  19. package/dist/catalogCache.d.ts +177 -0
  20. package/dist/catalogCache.d.ts.map +1 -0
  21. package/dist/catalogCache.js +708 -0
  22. package/dist/catalogCache.js.map +1 -0
  23. package/dist/editorialFill.d.ts +44 -0
  24. package/dist/editorialFill.d.ts.map +1 -0
  25. package/dist/editorialFill.js +377 -0
  26. package/dist/editorialFill.js.map +1 -0
  27. package/dist/enrichment.d.ts +74 -0
  28. package/dist/enrichment.d.ts.map +1 -0
  29. package/dist/enrichment.js +211 -0
  30. package/dist/enrichment.js.map +1 -0
  31. package/dist/errorHandling.d.ts +52 -0
  32. package/dist/errorHandling.d.ts.map +1 -0
  33. package/dist/errorHandling.js +327 -0
  34. package/dist/errorHandling.js.map +1 -0
  35. package/dist/index.d.ts +0 -1
  36. package/dist/index.js +595 -410
  37. package/dist/index.js.map +1 -1
  38. package/dist/pricingCache.d.ts +99 -0
  39. package/dist/pricingCache.d.ts.map +1 -0
  40. package/dist/pricingCache.js +414 -0
  41. package/dist/pricingCache.js.map +1 -0
  42. package/dist/queryCache.d.ts +25 -0
  43. package/dist/queryCache.d.ts.map +1 -0
  44. package/dist/queryCache.js +194 -0
  45. package/dist/queryCache.js.map +1 -0
  46. package/dist/searchTemplate.d.ts +16 -0
  47. package/dist/searchTemplate.d.ts.map +1 -0
  48. package/dist/searchTemplate.js +318 -0
  49. package/dist/searchTemplate.js.map +1 -0
  50. package/dist/sessionTracker.d.ts +39 -0
  51. package/dist/sessionTracker.d.ts.map +1 -0
  52. package/dist/sessionTracker.js +187 -0
  53. package/dist/sessionTracker.js.map +1 -0
  54. package/dist/skillClient.d.ts +142 -0
  55. package/dist/skillClient.d.ts.map +1 -0
  56. package/dist/skillClient.js +256 -0
  57. package/dist/skillClient.js.map +1 -0
  58. package/dist/svgVisuals.d.ts +77 -0
  59. package/dist/svgVisuals.d.ts.map +1 -0
  60. package/dist/svgVisuals.js +323 -0
  61. package/dist/svgVisuals.js.map +1 -0
  62. package/dist/systemPrompt.d.ts +26 -0
  63. package/dist/systemPrompt.d.ts.map +1 -0
  64. package/dist/systemPrompt.js +546 -0
  65. package/dist/systemPrompt.js.map +1 -0
  66. package/dist/test-waverunner-response.d.ts +2 -0
  67. package/dist/test-waverunner-response.d.ts.map +1 -0
  68. package/dist/test-waverunner-response.js +20 -0
  69. package/dist/test-waverunner-response.js.map +1 -0
  70. package/dist/test_analyst.d.ts +2 -0
  71. package/dist/test_analyst.d.ts.map +1 -0
  72. package/dist/test_analyst.js +39 -0
  73. package/dist/test_analyst.js.map +1 -0
  74. package/dist/test_analyst_local.d.ts +2 -0
  75. package/dist/test_analyst_local.d.ts.map +1 -0
  76. package/dist/test_analyst_local.js +51 -0
  77. package/dist/test_analyst_local.js.map +1 -0
  78. package/dist/test_live_mcp.d.ts +2 -0
  79. package/dist/test_live_mcp.d.ts.map +1 -0
  80. package/dist/test_live_mcp.js +49 -0
  81. package/dist/test_live_mcp.js.map +1 -0
  82. package/dist/test_skills.d.ts +9 -0
  83. package/dist/test_skills.d.ts.map +1 -0
  84. package/dist/test_skills.js +267 -0
  85. package/dist/test_skills.js.map +1 -0
  86. package/dist/toolHandlers.d.ts +11 -0
  87. package/dist/toolHandlers.d.ts.map +1 -0
  88. package/dist/toolHandlers.js +2829 -0
  89. package/dist/toolHandlers.js.map +1 -0
  90. package/dist/tools.d.ts +80 -3
  91. package/dist/tools.d.ts.map +1 -1
  92. package/dist/tools.js +89 -186
  93. package/dist/tools.js.map +1 -1
  94. package/dist/trialTracker.d.ts +58 -0
  95. package/dist/trialTracker.d.ts.map +1 -0
  96. package/dist/trialTracker.js +179 -0
  97. package/dist/trialTracker.js.map +1 -0
  98. package/dist/types.d.ts +3 -0
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/verify_hmac.js +1 -1
  101. package/dist/verify_hmac.js.map +1 -1
  102. package/dist/verify_tools_endpoint.js +5 -5
  103. package/dist/verify_tools_endpoint.js.map +1 -1
  104. package/dist/widgetShell.d.ts +15 -0
  105. package/dist/widgetShell.d.ts.map +1 -0
  106. package/dist/widgetShell.js +151 -0
  107. package/dist/widgetShell.js.map +1 -0
  108. package/package.json +10 -6
  109. package/server.json +12 -17
package/dist/index.js CHANGED
@@ -1,435 +1,620 @@
1
- #!/usr/bin/env node
2
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import crypto from "crypto";
5
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
6
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
7
- import express from "express";
8
- import axios from "axios";
9
- import dotenv from "dotenv";
10
- import { TOOLS, MCP_SERVER_VERSION } from "./tools.js";
11
- dotenv.config();
12
- const API_BASE_URL = process.env.FODDA_API_URL || "https://api.fodda.ai";
13
- const IS_DEV = process.env.NODE_ENV === "development";
14
- const DUMMY_API_KEY = "dummy-test-key";
15
- const DUMMY_USER_ID = "dummy-test-user";
16
- const server = new Server({
17
- name: "fodda-mcp",
18
- version: MCP_SERVER_VERSION,
19
- }, {
20
- capabilities: {
21
- tools: {},
22
- },
23
- });
24
1
  /**
25
- * List available tools.
2
+ * Fodda MCP Server — Clean Architecture
3
+ * Uses McpServer (high-level API) which is proven to work with Claude.
4
+ * NO middleware, NO AsyncLocalStorage, NO response interceptors.
5
+ * API key is extracted from URL query params and passed to tool handlers.
26
6
  */
27
- server.setRequestHandler(ListToolsRequestSchema, async () => {
28
- return {
29
- tools: TOOLS,
30
- };
7
+ import express from 'express';
8
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
10
+ import crypto from 'crypto';
11
+ import axios from 'axios';
12
+ import { initCatalogCache } from './catalogCache.js';
13
+ import { initPricingCache } from './pricingCache.js';
14
+ import { cacheGet, cacheSet, getCacheStats } from './queryCache.js';
15
+ import { MCP_SERVER_VERSION } from './tools.js';
16
+ import { createServer } from './toolHandlers.js';
17
+ import { checkTrialLimit, incrementTrialUsage } from './trialTracker.js';
18
+ import { registerA2ARoute } from './a2aHandler.js';
19
+ import * as dotenv from 'dotenv';
20
+ dotenv.config();
21
+ const API_BASE_URL = process.env.FODDA_API_URL || 'https://api.fodda.ai';
22
+ const app = express();
23
+ app.use(express.json({ limit: '512kb' }));
24
+ // CORS — minimal, matching test server
25
+ app.use((req, res, next) => {
26
+ res.setHeader('Access-Control-Allow-Origin', '*');
27
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
28
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-User-Id, Mcp-Session-Id, Accept');
29
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
30
+ if (req.method === 'OPTIONS')
31
+ return res.status(204).end();
32
+ next();
31
33
  });
32
- /**
33
- * Handle tool calls.
34
- */
35
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
36
- const { name, arguments: args } = request.params;
37
- const graphId = args?.graphId;
38
- let userId = args?.userId;
39
- // Validate graphId for tools that require it
40
- if (name !== "psfk_overview" && !graphId) {
41
- throw new Error("graphId is required for all Fodda tools except psfk_overview.");
34
+ // OAuth discovery endpoints — return 404 to indicate no OAuth support
35
+ app.get('/.well-known/oauth-protected-resource', (_req, res) => {
36
+ res.status(404).json({ error: 'OAuth not supported. Use API key auth via URL: ?api_key=YOUR_KEY' });
37
+ });
38
+ app.get('/.well-known/oauth-protected-resource/:path', (_req, res) => {
39
+ res.status(404).json({ error: 'OAuth not supported.' });
40
+ });
41
+ app.get('/.well-known/oauth-authorization-server', (_req, res) => {
42
+ res.status(404).json({ error: 'OAuth not supported.' });
43
+ });
44
+ app.post('/register', (_req, res) => {
45
+ res.status(404).json({ error: 'OAuth not supported.' });
46
+ });
47
+ // ---------------------------------------------------------------------------
48
+ // Widget cache — stores pre-rendered HTML for browser access
49
+ // ---------------------------------------------------------------------------
50
+ const widgetCache = new Map();
51
+ const WIDGET_TTL_MS = 30 * 60 * 1000; // 30 minutes
52
+ function storeWidget(html) {
53
+ const id = crypto.randomUUID();
54
+ widgetCache.set(id, { html, createdAt: Date.now() });
55
+ return id;
56
+ }
57
+ // Widget serving endpoint — returns cached pre-rendered HTML
58
+ app.get('/widget/:id', (req, res) => {
59
+ const entry = widgetCache.get(req.params.id);
60
+ if (!entry)
61
+ return res.status(404).send('<html><body><p>Widget expired or not found.</p></body></html>');
62
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
63
+ res.setHeader('Cache-Control', 'no-store');
64
+ // Wrap in full HTML document for iframe rendering
65
+ res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;}</style></head><body>${entry.html}</body></html>`);
66
+ });
67
+ // ---------------------------------------------------------------------------
68
+ // Session management
69
+ // ---------------------------------------------------------------------------
70
+ const transports = new Map();
71
+ const sessionApiKeys = new Map();
72
+ const sessionUserIds = new Map();
73
+ const sessionSources = new Map();
74
+ // Track session creation time in our own Map (transport._createdAt is a private
75
+ // field that is never set by the SDK — reading it always yields undefined).
76
+ const sessionCreatedAt = new Map();
77
+ // ---------------------------------------------------------------------------
78
+ // Periodic cleanup — prevent unbounded memory growth
79
+ // ---------------------------------------------------------------------------
80
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
81
+ const SESSION_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
82
+ setInterval(() => {
83
+ const now = Date.now();
84
+ // Sweep expired widgets
85
+ for (const [id, entry] of widgetCache) {
86
+ if (now - entry.createdAt > WIDGET_TTL_MS)
87
+ widgetCache.delete(id);
42
88
  }
43
- // Extract Bearer Token from _meta
44
- const authHeader = request.params._meta?.authorization || "";
45
- let apiKey = typeof authHeader === 'string' && authHeader.startsWith("Bearer ") ? authHeader.substring(7) : null;
46
- // Strict Enforcement / Dev Mode Fallback
47
- if (!apiKey) {
48
- if (IS_DEV) {
49
- apiKey = DUMMY_API_KEY;
50
- }
51
- else {
52
- return {
53
- isError: true,
54
- content: [{ type: "text", text: "Error: No API Key provided. Please include a Bearer token in the 'authorization' field of the MCP request _meta." }],
55
- };
89
+ // Sweep stale sessions using our own creation-time Map
90
+ for (const [sid] of transports) {
91
+ const created = sessionCreatedAt.get(sid);
92
+ if (created && now - created > SESSION_MAX_AGE_MS) {
93
+ transports.delete(sid);
94
+ sessionApiKeys.delete(sid);
95
+ sessionUserIds.delete(sid);
96
+ sessionSources.delete(sid);
97
+ sessionCreatedAt.delete(sid);
56
98
  }
57
99
  }
58
- if (!userId) {
59
- if (IS_DEV) {
60
- userId = DUMMY_USER_ID;
61
- }
62
- else {
63
- return {
64
- isError: true,
65
- content: [{ type: "text", text: "Error: No userId provided. This field is required for tracking and billing." }],
66
- };
67
- }
68
- }
69
- // Enterprise Readiness: Simulated Gemini Tool Invocation Mode
70
- // strictly enforced production constraints
71
- const meta = request.params._meta || {};
72
- const testMode = meta.test_mode;
73
- if (testMode) {
74
- // 1. Strict Value Check
75
- if (testMode !== "gemini_echo") {
76
- // Silently ignore unknown test modes or throw error?
77
- // "Reject unknown test modes" -> Throwing error is safer to signal invalid usage
78
- return {
79
- isError: true,
80
- content: [{ type: "text", text: `Error: Invalid test_mode '${testMode}'. Only 'gemini_echo' is supported.` }]
81
- };
82
- }
83
- // 2. Production Guardrails
84
- // Simulation is ignored (treated as normal unauthorized/error if it falls through, or just explicitly blocked here)
85
- // unless ENV != production OR API key has role internal_testing.
86
- // We assume 'apiKey' variable holds the extracted key.
87
- // Since we can't check roles dynamically without a DB call, we'll use a placeholder logic or ENV var for now.
88
- const isInternalKey = process.env.INTERNAL_TEST_KEYS?.split(',').includes(apiKey || "");
89
- const isProduction = process.env.NODE_ENV === "production";
90
- if (isProduction && !isInternalKey) {
91
- // In production, matching keys are required to use simulation.
92
- // If not internal key, we pretend simulation doesn't exist or return error?
93
- // "Be ignored unless..." -> Proceed to normal execution path (which handles auth/tool call normally)
94
- // But wait, if we proceed, it tries to execute the tool for real.
95
- // If the intention is to SAFEGUARD against accidental real execution, we should probably BLOCK if test_mode is present but unauthorized.
96
- // However, "Be ignored" implies falling back to normal behavior OR just doing nothing.
97
- // Safest Enterprise approach: If you ASK for test_mode but aren't allowed, FAIL. Don't fall back to real execution which might cost money.
98
- return {
99
- isError: true,
100
- content: [{ type: "text", text: "Error: Simulation mode not permitted in production without internal privileges." }]
101
- };
102
- }
103
- // 3. Structured Logging
104
- console.error(JSON.stringify({
105
- event: "mcp.tool_call.simulation",
106
- simulation_mode: true,
107
- simulation_type: "gemini_echo",
108
- tool: name,
109
- graphId,
110
- userId
111
- }));
112
- // 4. Gemini Echo Response
113
- return {
114
- content: [{
115
- type: "text",
116
- text: JSON.stringify({
117
- tool_calls: [{
118
- name: name,
119
- arguments: args
120
- }]
121
- }, null, 2)
122
- }]
123
- };
100
+ }, CLEANUP_INTERVAL_MS).unref();
101
+ // ---------------------------------------------------------------------------
102
+ // Service URL helper
103
+ // ---------------------------------------------------------------------------
104
+ // M2: FODDA_SERVICE_URL must be set in Cloud Run env vars.
105
+ // Fallback to localhost for local dev only. The hardcoded project hash
106
+ // was removed — if it was being used, the URL was silently wrong.
107
+ function getServiceUrl() {
108
+ if (process.env.FODDA_SERVICE_URL)
109
+ return process.env.FODDA_SERVICE_URL;
110
+ if (process.env.K_SERVICE) {
111
+ // Derive from K_SERVICE + K_REVISION if FODDA_SERVICE_URL not set.
112
+ // Requires CLOUD_RUN_REGION env var (e.g. 'uk') to be set.
113
+ const region = process.env.CLOUD_RUN_REGION || 'uk';
114
+ const revHash = process.env.K_REVISION?.split('-').pop();
115
+ if (!revHash)
116
+ console.error('[getServiceUrl] WARNING: K_REVISION not set — widget URLs may be wrong');
117
+ return `https://${process.env.K_SERVICE}-${revHash || 'unknown'}-${region}.a.run.app`;
124
118
  }
119
+ return `http://localhost:${process.env.PORT || 8080}`;
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Authenticated API caller — checks query cache first
123
+ // ---------------------------------------------------------------------------
124
+ /**
125
+ * Make an authenticated request to the Fodda API.
126
+ * Checks the query cache first; stores responses on cache miss.
127
+ */
128
+ async function foddaRequest(method, path, apiKey, userId, body, requestId, source) {
129
+ // ── Cache check ──
130
+ const cached = cacheGet(method, path, body);
131
+ if (cached !== null)
132
+ return cached;
125
133
  const timestamp = Date.now().toString();
126
134
  const headers = {
127
- "X-API-Key": apiKey,
128
- "X-User-Id": userId,
129
- "X-Fodda-Timestamp": timestamp,
130
- "Content-Type": "application/json",
135
+ 'X-API-Key': apiKey,
136
+ 'X-User-Id': userId,
137
+ 'X-Fodda-Timestamp': timestamp,
138
+ 'X-Fodda-Billing': 'mcp-orchestrated', // Tells API to skip per-call billing — MCP charges lump sum via meter
139
+ 'Content-Type': 'application/json',
131
140
  };
132
- // Helper to sign payload
133
- const signRequest = (body) => {
134
- const secret = process.env.FODDA_MCP_SECRET;
135
- if (secret) {
136
- const payload = timestamp + "." + JSON.stringify(body);
137
- const signature = crypto.createHmac("sha256", secret).update(payload).digest("hex");
138
- headers["X-Fodda-Signature"] = signature;
139
- }
140
- };
141
- const startTime = Date.now();
142
- try {
143
- let response;
144
- switch (name) {
145
- case "search_graph": {
146
- const limit = Math.min(Number(args?.limit) || 25, 50); // Defense in Depth: Cap results
147
- const body = {
148
- query: args?.query,
149
- limit: limit,
150
- use_semantic: args?.use_semantic !== false,
151
- };
152
- signRequest(body);
153
- response = await axios.post(`${API_BASE_URL}/v1/graphs/${graphId}/search`, body, { headers });
154
- break;
155
- }
156
- case "get_neighbors": {
157
- const depth = Math.min(Number(args?.depth) || 1, 2); // Defense in Depth: Cap traversal depth
158
- const limit = Math.min(Number(args?.limit) || 50, 50); // Defense in Depth: Cap results
159
- const body = {
160
- seed_node_ids: args?.seed_node_ids,
161
- relationship_types: args?.relationship_types,
162
- depth: depth,
163
- limit: limit,
164
- };
165
- signRequest(body);
166
- response = await axios.post(`${API_BASE_URL}/v1/graphs/${graphId}/neighbors`, body, { headers });
167
- break;
168
- }
169
- case "get_evidence": {
170
- const top_k = Math.min(Number(args?.top_k) || 5, 10); // Defense in Depth: Cap evidence sources
171
- const body = {
172
- for_node_id: args?.for_node_id,
173
- top_k: top_k,
174
- };
175
- signRequest(body);
176
- response = await axios.post(`${API_BASE_URL}/v1/graphs/${graphId}/evidence`, body, { headers });
177
- break;
178
- }
179
- case "get_node": {
180
- const getNodePath = `/v1/graphs/${graphId}/nodes/${args?.nodeId}`;
181
- const getNodeSecret = process.env.FODDA_MCP_SECRET;
182
- if (getNodeSecret) {
183
- const payload = timestamp + "." + getNodePath;
184
- const signature = crypto.createHmac("sha256", getNodeSecret).update(payload).digest("hex");
185
- headers["X-Fodda-Signature"] = signature;
186
- }
187
- response = await axios.get(`${API_BASE_URL}${getNodePath}`, { headers });
188
- break;
189
- }
190
- case "get_label_values": {
191
- const getLabelPath = `/v1/graphs/${graphId}/labels/${args?.label}/values`;
192
- const getLabelSecret = process.env.FODDA_MCP_SECRET;
193
- if (getLabelSecret) {
194
- const payload = timestamp + "." + getLabelPath;
195
- const signature = crypto.createHmac("sha256", getLabelSecret).update(payload).digest("hex");
196
- headers["X-Fodda-Signature"] = signature;
197
- }
198
- response = await axios.get(`${API_BASE_URL}${getLabelPath}`, { headers });
199
- break;
200
- }
201
- case "psfk_overview": {
202
- // Logic: At least one of industry or sector is required
203
- const industry = args?.industry;
204
- const sector = args?.sector;
205
- if (!industry && !sector) {
206
- throw new Error("At least one of 'industry' or 'sector' must be provided for psfk_overview.");
207
- }
208
- const body = {
209
- industry,
210
- sector,
211
- region: args?.region,
212
- timeframe: args?.timeframe,
213
- };
214
- signRequest(body);
215
- response = await axios.post(`${API_BASE_URL}/v1/psfk/overview`, body, { headers });
216
- break;
217
- }
218
- default:
219
- throw new Error(`Unknown tool: ${name}`);
220
- }
221
- const durationMs = Date.now() - startTime;
222
- const usage = response.data.usage || { total_billable_units: 0 };
223
- // Structured Audit Log for Enterprise Traceability (Refined for Security Compliance)
224
- console.error(JSON.stringify({
225
- event: "mcp.tool_call",
226
- tool: name,
227
- graphId,
228
- userId, // Corresponds to tenant/user identity
229
- status: response.status,
230
- durationMs,
231
- billable_units: usage.total_billable_units,
232
- deterministic: false,
233
- layer: "mcp_proxy"
234
- }));
235
- return {
236
- content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }]
237
- };
141
+ if (requestId)
142
+ headers['X-Request-Id'] = requestId;
143
+ if (source)
144
+ headers['X-Fodda-Source'] = source;
145
+ // HMAC sign the request
146
+ const secret = process.env.FODDA_MCP_SECRET;
147
+ if (secret) {
148
+ const payload = (method === 'POST' || method === 'PATCH') && body
149
+ ? timestamp + '.' + JSON.stringify(body)
150
+ : timestamp + '.' + path;
151
+ const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
152
+ headers['X-Fodda-Signature'] = signature;
238
153
  }
239
- catch (error) {
240
- const durationMs = Date.now() - startTime;
241
- console.error(JSON.stringify({
242
- event: "mcp.tool_error",
243
- tool: name,
244
- graphId,
245
- userId,
246
- status: error.response?.status || 500,
247
- durationMs,
248
- error: error.response?.data?.message || error.message
249
- }));
250
- return {
251
- isError: true,
252
- content: [{ type: "text", text: `Error calling Fodda API: ${error.response?.data?.message || error.message}` }],
154
+ const url = `${API_BASE_URL}${path}`;
155
+ // H3: 30s timeout — aligns with MCP client expectations.
156
+ // The previous 120s timeout held sessions open for 2 minutes on slow upstreams.
157
+ const AXIOS_TIMEOUT_MS = 30000;
158
+ const response = method === 'GET'
159
+ ? await axios.get(url, { headers, timeout: AXIOS_TIMEOUT_MS })
160
+ : method === 'PATCH'
161
+ ? await axios.patch(url, body, { headers, timeout: AXIOS_TIMEOUT_MS })
162
+ : await axios.post(url, body, { headers, timeout: AXIOS_TIMEOUT_MS });
163
+ // ── Cache store ──
164
+ cacheSet(method, path, body, response.data);
165
+ // ── Inject upstream usage warning headers into response data ──
166
+ // The app returns X-Usage-Warning / X-Usage-Percent / X-Usage-Overage-Tokens
167
+ // on successful responses to indicate approaching-limit or overage-active status.
168
+ const usageWarningHeader = response.headers?.['x-usage-warning'];
169
+ if (usageWarningHeader && response.data && typeof response.data === 'object') {
170
+ response.data._upstream_usage = {
171
+ warning: usageWarningHeader, // 'approaching-limit' or 'overage-active'
253
172
  };
173
+ const pct = response.headers['x-usage-percent'];
174
+ if (pct)
175
+ response.data._upstream_usage.percent = parseInt(pct, 10);
176
+ const overageTokens = response.headers['x-usage-overage-tokens'];
177
+ if (overageTokens)
178
+ response.data._upstream_usage.overage_tokens = parseInt(overageTokens, 10);
254
179
  }
255
- });
180
+ return response.data;
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // Waverunner (Gemini Interactions API) caller — parallel to foddaRequest()
184
+ // ---------------------------------------------------------------------------
256
185
  /**
257
- * Start the server.
186
+ * Make an authenticated Waverunner call (Gemini Interactions API).
187
+ * This is NOT a Fodda API call — it goes directly to Google.
188
+ *
189
+ * Handles:
190
+ * - Pre-check trial credits (split pools: search / deep_dive / expert_agent)
191
+ * - Call Gemini via @google/genai SDK
192
+ * - Post-decrement trial credits, or fire-and-forget metering for paid accounts
193
+ * - NO caching (Waverunner calls are non-deterministic)
194
+ *
195
+ * When the Waverunner agent internally calls Fodda graph tools, it should
196
+ * use FODDA_INTERNAL_API_KEY so those calls skip billing.
258
197
  */
259
- async function main() {
260
- if (process.env.PORT) {
261
- const app = express();
262
- app.set("trust proxy", true);
263
- const port = parseInt(process.env.PORT) || 8080;
264
- const transports = new Map();
265
- // Security: HMAC Signature Verification Middleware
266
- const verifySignature = (req, res, next) => {
267
- // Skip verification for public/health endpoints
268
- if ((req.path === "/mcp/tools" || req.path === "/health" || req.path === "/.well-known/mcp.json") && req.method === "GET") {
269
- return next();
270
- }
271
- const signature = req.headers["x-fodda-signature"];
272
- const secret = process.env.FODDA_MCP_SECRET;
273
- if (!secret) {
274
- console.error("CRITICAL: FODDA_MCP_SECRET not set in environment.");
275
- return res.status(500).json({ error: "Server misconfiguration" });
276
- }
277
- if (!signature || typeof signature !== 'string') {
278
- return res.status(401).json({ error: "Missing or invalid signature" });
279
- }
280
- const payload = JSON.stringify(req.body);
281
- const expectedSignature = crypto
282
- .createHmac("sha256", secret)
283
- .update(payload)
284
- .digest("hex");
285
- // Timing-safe comparison
286
- const trusted = Buffer.from(expectedSignature, 'ascii');
287
- const untrusted = Buffer.from(signature, 'ascii');
288
- if (trusted.length !== untrusted.length || !crypto.timingSafeEqual(trusted, untrusted)) {
289
- console.error("❌ Invalid Signature:", signature, "Expected:", expectedSignature);
290
- return res.status(401).json({ error: "Invalid signature" });
291
- }
292
- next();
293
- };
294
- // --- Per-Key Rate Limiting ---
295
- const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
296
- const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_RPM || "60", 10);
297
- const rateLimitMap = new Map();
298
- // Clean up expired entries every 5 minutes
299
- setInterval(() => {
300
- const now = Date.now();
301
- for (const [key, entry] of rateLimitMap) {
302
- if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
303
- rateLimitMap.delete(key);
304
- }
305
- }
306
- }, 5 * 60_000);
307
- const rateLimiter = (req, res, next) => {
308
- // Skip rate limiting for public/health endpoints
309
- if ((req.path === "/mcp/tools" || req.path === "/health" || req.path === "/.well-known/mcp.json") && req.method === "GET") {
310
- return next();
311
- }
312
- // Extract key from API key header or fallback to IP
313
- const apiKey = req.headers["x-api-key"] || req.ip || "unknown";
314
- const now = Date.now();
315
- const entry = rateLimitMap.get(apiKey);
316
- if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
317
- // New window
318
- rateLimitMap.set(apiKey, { count: 1, windowStart: now });
198
+ async function waverunnerRequest(interactionType, tokenCost, userApiKey, userId, waverunnerPayload) {
199
+ const isTrial = false; // sk_trial_ keys retired — waverunner only used by paid/new-trial accounts
200
+ // ── Pre-check credits ──
201
+ // (sk_trial_ pre-check removed — new trial accounts are metered server-side by the API)
202
+ // ── Call Waverunner via Gemini SDK ──
203
+ const geminiKey = process.env.GEMINI_API_KEY;
204
+ if (!geminiKey) {
205
+ throw new Error('GEMINI_API_KEY environment variable is required for Waverunner calls.');
206
+ }
207
+ const { GoogleGenAI } = await import('@google/genai');
208
+ const ai = new GoogleGenAI({ apiKey: geminiKey });
209
+ const result = await ai.interactions.create(waverunnerPayload);
210
+ // ── Post-decrement (paid accounts) ──
211
+ foddaRequest('POST', '/v1/research/meter', userApiKey, userId, {
212
+ type: interactionType,
213
+ billable_units: tokenCost,
214
+ }).catch(err => console.error('[waverunnerRequest] Metering failed:', err.message));
215
+ return result;
216
+ }
217
+ // ---------------------------------------------------------------------------
218
+ // Browser Landing Page — catch humans who paste the MCP URL into a browser
219
+ // ---------------------------------------------------------------------------
220
+ app.get('/mcp', (req, res, next) => {
221
+ // Only intercept browser requests (Accept: text/html).
222
+ // MCP SDK clients send application/json or text/event-stream, so they
223
+ // fall through to the app.all('/mcp') transport handler below.
224
+ const accept = req.headers['accept'] || '';
225
+ if (!accept.includes('text/html'))
226
+ return next();
227
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
228
+ res.send(`<!DOCTYPE html>
229
+ <html lang="en">
230
+ <head>
231
+ <meta charset="utf-8">
232
+ <meta name="viewport" content="width=device-width, initial-scale=1">
233
+ <title>Fodda — This URL is for AI Systems</title>
234
+ <link rel="icon" type="image/png" href="${FAVICON_PNG}">
235
+ <link rel="icon" type="image/svg+xml" href="${FAVICON_SVG}">
236
+ <meta http-equiv="refresh" content="12;url=https://www.fodda.ai">
237
+ <style>
238
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
239
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
240
+ body {
241
+ min-height: 100vh;
242
+ display: flex;
243
+ align-items: center;
244
+ justify-content: center;
245
+ background: #0D0A14;
246
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
247
+ color: #E8E2F0;
248
+ overflow: hidden;
249
+ }
250
+ /* Ambient background glow */
251
+ body::before {
252
+ content: '';
253
+ position: fixed;
254
+ top: -40%; left: -20%;
255
+ width: 140%; height: 140%;
256
+ background: radial-gradient(ellipse at 30% 50%, rgba(102,51,153,0.15) 0%, transparent 60%),
257
+ radial-gradient(ellipse at 70% 60%, rgba(74,36,112,0.10) 0%, transparent 50%);
258
+ pointer-events: none;
259
+ z-index: 0;
260
+ }
261
+ .card {
262
+ position: relative;
263
+ z-index: 1;
264
+ max-width: 520px;
265
+ width: 90%;
266
+ padding: 2.5rem 2.5rem 2rem;
267
+ border-radius: 20px;
268
+ background: rgba(22, 17, 32, 0.85);
269
+ border: 1px solid rgba(155, 114, 204, 0.25);
270
+ backdrop-filter: blur(24px);
271
+ -webkit-backdrop-filter: blur(24px);
272
+ box-shadow: 0 0 80px rgba(102, 51, 153, 0.12), 0 4px 32px rgba(0,0,0,0.4);
273
+ text-align: center;
274
+ animation: cardIn 0.6s ease-out;
275
+ }
276
+ @keyframes cardIn {
277
+ from { opacity: 0; transform: translateY(24px) scale(0.97); }
278
+ to { opacity: 1; transform: translateY(0) scale(1); }
279
+ }
280
+ .logo-row {
281
+ display: flex;
282
+ align-items: center;
283
+ justify-content: center;
284
+ gap: 10px;
285
+ margin-bottom: 2rem;
286
+ }
287
+ .logo-mark {
288
+ width: 32px; height: 32px;
289
+ border-radius: 8px;
290
+ background: #663399;
291
+ display: flex;
292
+ align-items: center;
293
+ justify-content: center;
294
+ }
295
+ .logo-mark span {
296
+ color: #fff;
297
+ font-size: 17px;
298
+ font-weight: 600;
299
+ font-family: monospace;
300
+ }
301
+ .logo-text {
302
+ font-size: 20px;
303
+ font-weight: 600;
304
+ color: #E8E2F0;
305
+ letter-spacing: -0.5px;
306
+ }
307
+ .icon-row {
308
+ margin-bottom: 1.5rem;
309
+ }
310
+ .icon-row svg {
311
+ width: 56px; height: 56px;
312
+ color: #9B72CC;
313
+ animation: pulse 2.5s ease-in-out infinite;
314
+ }
315
+ @keyframes pulse {
316
+ 0%, 100% { opacity: 0.7; transform: scale(1); }
317
+ 50% { opacity: 1; transform: scale(1.06); }
318
+ }
319
+ h1 {
320
+ font-size: 1.35rem;
321
+ font-weight: 600;
322
+ color: #fff;
323
+ margin-bottom: 0.75rem;
324
+ line-height: 1.3;
325
+ }
326
+ .subtitle {
327
+ font-size: 0.95rem;
328
+ color: #A89BBE;
329
+ line-height: 1.6;
330
+ margin-bottom: 1.75rem;
331
+ }
332
+ .subtitle strong {
333
+ color: #C9B5E0;
334
+ font-weight: 500;
335
+ }
336
+ .url-preview {
337
+ display: inline-block;
338
+ padding: 0.45rem 1rem;
339
+ border-radius: 8px;
340
+ background: rgba(102, 51, 153, 0.12);
341
+ border: 1px solid rgba(155, 114, 204, 0.2);
342
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
343
+ font-size: 0.72rem;
344
+ color: #9B72CC;
345
+ word-break: break-all;
346
+ margin-bottom: 1.75rem;
347
+ max-width: 100%;
348
+ }
349
+ .cta-btn {
350
+ display: inline-flex;
351
+ align-items: center;
352
+ gap: 8px;
353
+ padding: 0.75rem 1.75rem;
354
+ border-radius: 12px;
355
+ background: #663399;
356
+ color: #fff;
357
+ font-size: 0.95rem;
358
+ font-weight: 500;
359
+ text-decoration: none;
360
+ transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
361
+ box-shadow: 0 0 0 0 rgba(102,51,153,0);
362
+ margin-bottom: 1rem;
363
+ }
364
+ .cta-btn:hover {
365
+ background: #7840B0;
366
+ transform: translateY(-1px);
367
+ box-shadow: 0 4px 20px rgba(102,51,153,0.35);
368
+ }
369
+ .cta-btn svg { width: 16px; height: 16px; }
370
+ .redirect-note {
371
+ font-size: 0.78rem;
372
+ color: #6B5A82;
373
+ margin-top: 0.5rem;
374
+ }
375
+ .steps {
376
+ margin-top: 1.5rem;
377
+ padding-top: 1.25rem;
378
+ border-top: 1px solid rgba(155, 114, 204, 0.12);
379
+ text-align: left;
380
+ }
381
+ .steps-title {
382
+ font-size: 0.75rem;
383
+ text-transform: uppercase;
384
+ letter-spacing: 1.2px;
385
+ color: #6B5A82;
386
+ margin-bottom: 0.85rem;
387
+ text-align: center;
388
+ }
389
+ .step {
390
+ display: flex;
391
+ align-items: flex-start;
392
+ gap: 12px;
393
+ margin-bottom: 0.65rem;
394
+ }
395
+ .step-num {
396
+ width: 22px; height: 22px;
397
+ border-radius: 50%;
398
+ background: rgba(102, 51, 153, 0.2);
399
+ color: #9B72CC;
400
+ font-size: 0.7rem;
401
+ font-weight: 600;
402
+ display: flex;
403
+ align-items: center;
404
+ justify-content: center;
405
+ flex-shrink: 0;
406
+ margin-top: 1px;
407
+ }
408
+ .step-text {
409
+ font-size: 0.82rem;
410
+ color: #A89BBE;
411
+ line-height: 1.5;
412
+ }
413
+ .step-text a {
414
+ color: #C9B5E0;
415
+ text-decoration: underline;
416
+ text-underline-offset: 2px;
417
+ }
418
+ .step-text a:hover { color: #fff; }
419
+ </style>
420
+ </head>
421
+ <body>
422
+ <div class="card">
423
+ <div class="logo-row">
424
+ <div class="logo-mark"><span>F</span></div>
425
+ <div class="logo-text">Fodda</div>
426
+ </div>
427
+
428
+ <div class="icon-row">
429
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
430
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714a2.25 2.25 0 0 0 .659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-1.482 4.17a2.25 2.25 0 0 1-2.133 1.58H8.615a2.25 2.25 0 0 1-2.133-1.58L5 14.5m14 0H5" />
431
+ </svg>
432
+ </div>
433
+
434
+ <h1>This URL is for AI systems,<br>not your browser</h1>
435
+
436
+ <p class="subtitle">
437
+ You've opened a <strong>Model Context Protocol (MCP)</strong> endpoint.
438
+ It's designed to be used by AI assistants like Claude, ChatGPT, Cursor, or the OpenAI Responses API &mdash;
439
+ not visited directly in a web browser.
440
+ </p>
441
+
442
+ <a href="https://www.fodda.ai" class="cta-btn">
443
+ Learn how to connect
444
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
445
+ <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
446
+ </svg>
447
+ </a>
448
+
449
+ <div class="redirect-note">Redirecting to fodda.ai in a few seconds…</div>
450
+
451
+ <div class="steps">
452
+ <div class="steps-title">How to use this URL</div>
453
+ <div class="step">
454
+ <div class="step-num">1</div>
455
+ <div class="step-text">Copy this MCP URL from your <a href="https://app.fodda.ai/settings" target="_blank">Fodda settings</a></div>
456
+ </div>
457
+ <div class="step">
458
+ <div class="step-num">2</div>
459
+ <div class="step-text">Paste it into the MCP settings of your AI tool (Claude, Cursor, Windsurf, etc.)</div>
460
+ </div>
461
+ <div class="step">
462
+ <div class="step-num">3</div>
463
+ <div class="step-text">Start asking questions &mdash; Fodda's knowledge graphs power your AI</div>
464
+ </div>
465
+ </div>
466
+ </div>
467
+ </body>
468
+ </html>`);
469
+ });
470
+ // ---------------------------------------------------------------------------
471
+ // MCP Transport Handler
472
+ // ---------------------------------------------------------------------------
473
+ app.all('/mcp', async (req, res) => {
474
+ try {
475
+ const sessionId = req.headers['mcp-session-id'];
476
+ let transport;
477
+ // Extract API key, userId, and entry ID from URL or headers
478
+ // Priority: query string (existing MCP clients) → X-API-Key header → Authorization Bearer (Remote MCP)
479
+ const apiKey = req.query.api_key
480
+ || req.headers['x-api-key']
481
+ || (req.headers['authorization']?.toString().replace(/^Bearer\s+/i, ''))
482
+ || '';
483
+ const entryId = req.query.id || '';
484
+ // If id looks like an email and no explicit user_id, use it as userId for tracking + signup
485
+ const isEmailId = entryId.includes('@') && entryId.includes('.');
486
+ const userId = req.query.user_id
487
+ || req.headers['x-user-id']
488
+ || (isEmailId ? entryId : 'anonymous');
489
+ const source = req.query.source || '';
490
+ if (sessionId && transports.has(sessionId)) {
491
+ transport = transports.get(sessionId);
492
+ }
493
+ else if (!sessionId && req.method === 'POST') {
494
+ const body = req.body;
495
+ if (body?.method === 'initialize') {
496
+ // Store API key/userId for this session
497
+ // Wrap foddaRequest to bake in source attribution
498
+ const boundFoddaRequest = source
499
+ ? ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source))
500
+ : foddaRequest;
501
+ const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId);
502
+ transport = new StreamableHTTPServerTransport({
503
+ sessionIdGenerator: () => crypto.randomUUID(),
504
+ onsessioninitialized: (sid) => {
505
+ console.error(`Session created: ${sid}`);
506
+ transports.set(sid, transport);
507
+ sessionApiKeys.set(sid, apiKey);
508
+ sessionUserIds.set(sid, userId);
509
+ sessionCreatedAt.set(sid, Date.now()); // C2: track creation time
510
+ if (source)
511
+ sessionSources.set(sid, source);
512
+ }
513
+ });
514
+ transport.onclose = () => {
515
+ const sid = transport.sessionId;
516
+ if (sid) {
517
+ transports.delete(sid);
518
+ sessionApiKeys.delete(sid);
519
+ sessionUserIds.delete(sid);
520
+ sessionSources.delete(sid);
521
+ sessionCreatedAt.delete(sid); // C2: clean up creation time
522
+ }
523
+ };
524
+ await server.connect(transport);
319
525
  }
320
526
  else {
321
- entry.count++;
322
- if (entry.count > RATE_LIMIT_MAX) {
323
- const retryAfter = Math.ceil((entry.windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000);
324
- console.error(JSON.stringify({
325
- event: "mcp.rate_limit",
326
- apiKey: apiKey.substring(0, 8) + "...",
327
- count: entry.count,
328
- limit: RATE_LIMIT_MAX,
329
- }));
330
- res.set("Retry-After", String(retryAfter));
331
- return res.status(429).json({
332
- error: "Rate limit exceeded",
333
- limit: RATE_LIMIT_MAX,
334
- window: "60s",
335
- retry_after: retryAfter,
336
- });
337
- }
527
+ return res.status(400).json({
528
+ jsonrpc: '2.0',
529
+ error: { code: -32000, message: 'Bad Request: Session required' },
530
+ id: null
531
+ });
338
532
  }
339
- // Set rate limit headers
340
- const current = rateLimitMap.get(apiKey);
341
- res.set("X-RateLimit-Limit", String(RATE_LIMIT_MAX));
342
- res.set("X-RateLimit-Remaining", String(Math.max(0, RATE_LIMIT_MAX - current.count)));
343
- res.set("X-RateLimit-Reset", String(Math.ceil((current.windowStart + RATE_LIMIT_WINDOW_MS) / 1000)));
344
- next();
345
- };
346
- // Parse JSON bodies with size limit (required for signature verification)
347
- app.use(express.json({ limit: '1mb' }));
348
- // Rate limiting (before HMAC to reject early)
349
- app.use(rateLimiter);
350
- // HMAC Signature Verification
351
- app.use(verifySignature);
352
- app.get("/sse", async (req, res) => {
353
- const sessionId = crypto.randomUUID();
354
- console.error(`New SSE connection established (session: ${sessionId})`);
355
- const transport = new SSEServerTransport("/messages", res);
356
- transports.set(sessionId, transport);
357
- // Clean up on disconnect
358
- res.on("close", () => {
359
- transports.delete(sessionId);
360
- console.error(`SSE session ${sessionId} disconnected (${transports.size} active)`);
533
+ }
534
+ else {
535
+ return res.status(404).json({
536
+ jsonrpc: '2.0',
537
+ error: { code: -32001, message: 'Session not found' },
538
+ id: null
361
539
  });
362
- await server.connect(transport);
363
- });
364
- app.post("/messages", async (req, res) => {
365
- // Find the appropriate transport by checking the sessionId query param
366
- const sessionId = req.query.sessionId;
367
- if (sessionId && transports.has(sessionId)) {
368
- await transports.get(sessionId).handlePostMessage(req, res);
369
- }
370
- else if (transports.size === 1) {
371
- // Fallback: single-client mode
372
- const [transport] = transports.values();
373
- await transport.handlePostMessage(req, res);
374
- }
375
- else if (transports.size === 0) {
376
- res.status(400).send("No SSE connections established");
540
+ }
541
+ // Inject Accept: text/event-stream if missing (prevents SDK 406)
542
+ const accept = req.headers['accept'] || '';
543
+ if (!accept.includes('text/event-stream')) {
544
+ req.headers['accept'] = accept ? `${accept}, text/event-stream` : 'application/json, text/event-stream';
545
+ const idx = req.rawHeaders?.findIndex((h) => h.toLowerCase() === 'accept');
546
+ if (idx !== undefined && idx >= 0 && req.rawHeaders) {
547
+ req.rawHeaders[idx + 1] = req.headers['accept'];
377
548
  }
378
- else {
379
- res.status(400).send("Multiple SSE sessions active. Specify sessionId query parameter.");
549
+ else if (req.rawHeaders) {
550
+ req.rawHeaders.push('Accept', req.headers['accept']);
380
551
  }
381
- });
382
- // Health check for Cloud Run liveness/readiness probes
383
- app.get("/health", (req, res) => {
384
- res.json({ status: "ok", version: MCP_SERVER_VERSION });
385
- });
386
- // Enterprise Transparency: Tool Capability Registry
387
- app.get("/mcp/tools", (req, res) => {
388
- res.json({
389
- tools: TOOLS,
390
- count: TOOLS.length,
391
- version: MCP_SERVER_VERSION
392
- });
393
- });
394
- // MCP Server Discovery (emerging .well-known standard)
395
- app.get("/.well-known/mcp.json", (req, res) => {
396
- res.json({
397
- name: "ai.fodda/mcp-server",
398
- title: "Fodda Knowledge Graphs",
399
- description: "Expert-curated knowledge graphs for AI agents — PSFK Retail, Beauty, Sports and more.",
400
- version: MCP_SERVER_VERSION,
401
- transport: {
402
- type: "sse",
403
- url: `${req.protocol}://${req.get("host")}/sse`
404
- },
405
- tools_endpoint: `${req.protocol}://${req.get("host")}/mcp/tools`,
406
- health_endpoint: `${req.protocol}://${req.get("host")}/health`
407
- });
408
- });
409
- const httpServer = app.listen(port, () => {
410
- console.error(`Fodda MCP server running on SSE at http://localhost:${port}/sse`);
411
- });
412
- // Graceful shutdown for Cloud Run
413
- const shutdown = () => {
414
- console.error("Shutting down gracefully...");
415
- httpServer.close(() => {
416
- console.error(`Closed ${transports.size} active SSE connections.`);
417
- process.exit(0);
418
- });
419
- // Force exit after 10s if connections don't close
420
- setTimeout(() => process.exit(1), 10000);
421
- };
422
- process.on("SIGTERM", shutdown);
423
- process.on("SIGINT", shutdown);
552
+ }
553
+ await transport.handleRequest(req, res, req.body);
424
554
  }
425
- else {
426
- const transport = new StdioServerTransport();
427
- await server.connect(transport);
428
- console.error("Fodda MCP server running on stdio");
555
+ catch (error) {
556
+ console.error('Error:', error);
557
+ if (!res.headersSent)
558
+ res.status(500).json({ error: 'Internal server error' });
429
559
  }
430
- }
431
- main().catch((error) => {
432
- console.error("Server error:", error);
433
- process.exit(1);
560
+ });
561
+ // Legacy SSE transport
562
+ app.get('/sse', async (req, res) => {
563
+ const apiKey = req.query.api_key || '';
564
+ const entryId = req.query.id || '';
565
+ const isEmailId = entryId.includes('@') && entryId.includes('.');
566
+ const userId = req.query.user_id
567
+ || req.headers['x-user-id']
568
+ || (isEmailId ? entryId : 'anonymous');
569
+ const source = req.query.source || '';
570
+ const sessionId = crypto.randomUUID();
571
+ const transport = new SSEServerTransport('/messages', res);
572
+ // Bind source parameter to foddaRequest to forward it upstream as X-Fodda-Source
573
+ const boundFoddaRequest = source
574
+ ? ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source))
575
+ : foddaRequest;
576
+ const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl);
577
+ await server.connect(transport);
578
+ console.error(`SSE session: ${sessionId}`);
579
+ });
580
+ app.post('/messages', async (req, res) => {
581
+ // SSE message handler would go here
582
+ res.status(404).json({ error: 'Use /mcp endpoint' });
583
+ });
584
+ // Favicon — required for Anthropic Connectors Directory (Google favicon API)
585
+ const FAVICON_SVG = 'https://ucarecdn.com/e3ce77f2-661e-48a1-a294-c7c01039aed4/foddaminilogo.svg';
586
+ const FAVICON_PNG = 'https://ucarecdn.com/6e7893d7-6b14-426b-83bc-574a3f72d6bc/foddafavicon.png';
587
+ app.get('/favicon.ico', (_req, res) => { res.redirect(301, FAVICON_PNG); });
588
+ app.get('/favicon.svg', (_req, res) => { res.redirect(301, FAVICON_SVG); });
589
+ // Root — minimal HTML so Google's favicon crawler can find the icon
590
+ app.get('/', (_req, res) => {
591
+ res.setHeader('Content-Type', 'text/html');
592
+ res.send(`<!DOCTYPE html>
593
+ <html><head>
594
+ <title>Fodda MCP Server</title>
595
+ <link rel="icon" type="image/png" href="${FAVICON_PNG}">
596
+ <link rel="icon" type="image/svg+xml" href="${FAVICON_SVG}">
597
+ </head><body>
598
+ <h1>Fodda MCP Server v${MCP_SERVER_VERSION}</h1>
599
+ <p>Expert-curated knowledge graphs for AI agents.</p>
600
+ <p>Connect via <a href="https://app.fodda.ai">app.fodda.ai</a></p>
601
+ </body></html>`);
602
+ });
603
+ app.get('/health', (_req, res) => res.json({ status: 'ok', version: MCP_SERVER_VERSION, queryCache: getCacheStats() }));
604
+ // ---------------------------------------------------------------------------
605
+ // A2A Protocol Endpoint — agent-to-agent task delegation
606
+ // ---------------------------------------------------------------------------
607
+ registerA2ARoute(app, foddaRequest);
608
+ const PORT = parseInt(process.env.PORT || '8080');
609
+ // Initialize catalog and pricing caches before accepting connections
610
+ Promise.all([
611
+ initCatalogCache(),
612
+ initPricingCache(),
613
+ ]).then(() => {
614
+ app.listen(PORT, () => console.error(`Fodda MCP server v${MCP_SERVER_VERSION} on port ${PORT}`));
615
+ }).catch(() => {
616
+ // Start anyway with hardcoded fallbacks if cache init fails
617
+ console.error('[startup] Cache init failed — using hardcoded fallbacks');
618
+ app.listen(PORT, () => console.error(`Fodda MCP server v${MCP_SERVER_VERSION} on port ${PORT} (fallback mode)`));
434
619
  });
435
620
  //# sourceMappingURL=index.js.map