fodda-mcp 1.7.0 → 1.31.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/LICENSE +31 -0
- package/README.md +184 -24
- package/dist/a2aHandler.d.ts +20 -0
- package/dist/a2aHandler.d.ts.map +1 -0
- package/dist/a2aHandler.js +343 -0
- package/dist/a2aHandler.js.map +1 -0
- package/dist/agents/fodda-researcher/index.d.ts +38 -0
- package/dist/agents/fodda-researcher/index.d.ts.map +1 -0
- package/dist/agents/fodda-researcher/index.js +81 -0
- package/dist/agents/fodda-researcher/index.js.map +1 -0
- package/dist/agents/fodda-researcher/skills.d.ts +16 -0
- package/dist/agents/fodda-researcher/skills.d.ts.map +1 -0
- package/dist/agents/fodda-researcher/skills.js +163 -0
- package/dist/agents/fodda-researcher/skills.js.map +1 -0
- package/dist/brandTemplate.d.ts +14 -0
- package/dist/brandTemplate.d.ts.map +1 -0
- package/dist/brandTemplate.js +742 -0
- package/dist/brandTemplate.js.map +1 -0
- package/dist/catalogCache.d.ts +207 -0
- package/dist/catalogCache.d.ts.map +1 -0
- package/dist/catalogCache.js +837 -0
- package/dist/catalogCache.js.map +1 -0
- package/dist/editorialFill.d.ts +44 -0
- package/dist/editorialFill.d.ts.map +1 -0
- package/dist/editorialFill.js +377 -0
- package/dist/editorialFill.js.map +1 -0
- package/dist/enrichment.d.ts +74 -0
- package/dist/enrichment.d.ts.map +1 -0
- package/dist/enrichment.js +211 -0
- package/dist/enrichment.js.map +1 -0
- package/dist/errorHandling.d.ts +52 -0
- package/dist/errorHandling.d.ts.map +1 -0
- package/dist/errorHandling.js +338 -0
- package/dist/errorHandling.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +672 -577
- package/dist/index.js.map +1 -1
- package/dist/pricingCache.d.ts +111 -0
- package/dist/pricingCache.d.ts.map +1 -0
- package/dist/pricingCache.js +454 -0
- package/dist/pricingCache.js.map +1 -0
- package/dist/queryCache.d.ts +25 -0
- package/dist/queryCache.d.ts.map +1 -0
- package/dist/queryCache.js +195 -0
- package/dist/queryCache.js.map +1 -0
- package/dist/searchTemplate.d.ts +16 -0
- package/dist/searchTemplate.d.ts.map +1 -0
- package/dist/searchTemplate.js +318 -0
- package/dist/searchTemplate.js.map +1 -0
- package/dist/sessionTracker.d.ts +39 -0
- package/dist/sessionTracker.d.ts.map +1 -0
- package/dist/sessionTracker.js +187 -0
- package/dist/sessionTracker.js.map +1 -0
- package/dist/skillClient.d.ts +142 -0
- package/dist/skillClient.d.ts.map +1 -0
- package/dist/skillClient.js +256 -0
- package/dist/skillClient.js.map +1 -0
- package/dist/svgVisuals.d.ts +77 -0
- package/dist/svgVisuals.d.ts.map +1 -0
- package/dist/svgVisuals.js +323 -0
- package/dist/svgVisuals.js.map +1 -0
- package/dist/systemPrompt.d.ts +26 -0
- package/dist/systemPrompt.d.ts.map +1 -0
- package/dist/systemPrompt.js +574 -0
- package/dist/systemPrompt.js.map +1 -0
- package/dist/test-waverunner-response.d.ts +2 -0
- package/dist/test-waverunner-response.d.ts.map +1 -0
- package/dist/test-waverunner-response.js +20 -0
- package/dist/test-waverunner-response.js.map +1 -0
- package/dist/test_analyst.d.ts +2 -0
- package/dist/test_analyst.d.ts.map +1 -0
- package/dist/test_analyst.js +39 -0
- package/dist/test_analyst.js.map +1 -0
- package/dist/test_analyst_local.d.ts +2 -0
- package/dist/test_analyst_local.d.ts.map +1 -0
- package/dist/test_analyst_local.js +51 -0
- package/dist/test_analyst_local.js.map +1 -0
- package/dist/test_live_mcp.js +1 -1
- package/dist/test_skills.d.ts +9 -0
- package/dist/test_skills.d.ts.map +1 -0
- package/dist/test_skills.js +267 -0
- package/dist/test_skills.js.map +1 -0
- package/dist/toolHandlers.d.ts +15 -0
- package/dist/toolHandlers.d.ts.map +1 -0
- package/dist/toolHandlers.js +2986 -0
- package/dist/toolHandlers.js.map +1 -0
- package/dist/tools.d.ts +78 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +85 -292
- package/dist/tools.js.map +1 -1
- package/dist/trialTracker.d.ts +58 -0
- package/dist/trialTracker.d.ts.map +1 -0
- package/dist/trialTracker.js +179 -0
- package/dist/trialTracker.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/verify_hmac.js +1 -1
- package/dist/verify_hmac.js.map +1 -1
- package/dist/verify_tools_endpoint.js +5 -5
- package/dist/verify_tools_endpoint.js.map +1 -1
- package/dist/widgetShell.d.ts +15 -0
- package/dist/widgetShell.d.ts.map +1 -0
- package/dist/widgetShell.js +151 -0
- package/dist/widgetShell.js.map +1 -0
- package/package.json +8 -4
- package/server.json +13 -4
package/dist/index.js
CHANGED
|
@@ -1,606 +1,701 @@
|
|
|
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, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
-
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
7
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
|
-
import express from "express";
|
|
10
|
-
import axios from "axios";
|
|
11
|
-
import * as dotenv from "dotenv";
|
|
12
|
-
import { TOOLS, MCP_SERVER_VERSION } from "./tools.js";
|
|
13
|
-
const requestContext = new AsyncLocalStorage();
|
|
14
|
-
dotenv.config();
|
|
15
|
-
const API_BASE_URL = process.env.FODDA_API_URL || "https://api.fodda.ai";
|
|
16
|
-
const IS_DEV = process.env.NODE_ENV === "development";
|
|
17
|
-
const DUMMY_API_KEY = "dummy-test-key";
|
|
18
|
-
const DUMMY_USER_ID = "dummy-test-user";
|
|
19
|
-
const server = new Server({
|
|
20
|
-
name: "fodda-mcp",
|
|
21
|
-
version: MCP_SERVER_VERSION,
|
|
22
|
-
}, {
|
|
23
|
-
capabilities: {
|
|
24
|
-
tools: {},
|
|
25
|
-
},
|
|
26
|
-
});
|
|
27
1
|
/**
|
|
28
|
-
*
|
|
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.
|
|
29
6
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 { registerA2ARoute } from './a2aHandler.js';
|
|
18
|
+
import * as dotenv from 'dotenv';
|
|
19
|
+
dotenv.config();
|
|
20
|
+
// ── Global crash guards ──
|
|
21
|
+
// This is a long-lived server holding in-memory session state (transports, sessionSpts,
|
|
22
|
+
// activeResearchJobs) on a single instance (session affinity). A single un-caught promise
|
|
23
|
+
// rejection from any request would otherwise call exit(1) and drop EVERY active session on
|
|
24
|
+
// the instance. Log loudly and stay alive — a degraded request beats a dead server.
|
|
25
|
+
process.on('unhandledRejection', (reason) => {
|
|
26
|
+
console.error('[unhandledRejection] kept process alive:', reason?.stack || reason?.message || reason);
|
|
34
27
|
});
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
28
|
+
process.on('uncaughtException', (err) => {
|
|
29
|
+
console.error('[uncaughtException] kept process alive:', err?.stack || err?.message || err);
|
|
30
|
+
});
|
|
31
|
+
const API_BASE_URL = process.env.FODDA_API_URL || 'https://api.fodda.ai';
|
|
32
|
+
const app = express();
|
|
33
|
+
app.use(express.json({ limit: '512kb' }));
|
|
34
|
+
// CORS — minimal, matching test server
|
|
35
|
+
app.use((req, res, next) => {
|
|
36
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
37
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
38
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-User-Id, X-Stripe-SPT, Mcp-Session-Id, Accept');
|
|
39
|
+
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
|
40
|
+
if (req.method === 'OPTIONS')
|
|
41
|
+
return res.status(204).end();
|
|
42
|
+
next();
|
|
43
|
+
});
|
|
44
|
+
// OAuth discovery endpoints — return 404 to indicate no OAuth support
|
|
45
|
+
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' });
|
|
47
|
+
});
|
|
48
|
+
app.get('/.well-known/oauth-protected-resource/:path', (_req, res) => {
|
|
49
|
+
res.status(404).json({ error: 'OAuth not supported.' });
|
|
50
|
+
});
|
|
51
|
+
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
|
|
52
|
+
res.status(404).json({ error: 'OAuth not supported.' });
|
|
53
|
+
});
|
|
54
|
+
app.post('/register', (_req, res) => {
|
|
55
|
+
res.status(404).json({ error: 'OAuth not supported.' });
|
|
56
|
+
});
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Widget cache — stores pre-rendered HTML for browser access
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
const widgetCache = new Map();
|
|
61
|
+
const WIDGET_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
62
|
+
function storeWidget(html) {
|
|
63
|
+
const id = crypto.randomUUID();
|
|
64
|
+
widgetCache.set(id, { html, createdAt: Date.now() });
|
|
65
|
+
return id;
|
|
66
|
+
}
|
|
67
|
+
// Widget serving endpoint — returns cached pre-rendered HTML
|
|
68
|
+
app.get('/widget/:id', (req, res) => {
|
|
69
|
+
const entry = widgetCache.get(req.params.id);
|
|
70
|
+
if (!entry)
|
|
71
|
+
return res.status(404).send('<html><body><p>Widget expired or not found.</p></body></html>');
|
|
72
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
73
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
74
|
+
// Wrap in full HTML document for iframe rendering
|
|
75
|
+
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>`);
|
|
76
|
+
});
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Session management
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
const transports = new Map();
|
|
81
|
+
const sessionApiKeys = new Map();
|
|
82
|
+
const sessionUserIds = new Map();
|
|
83
|
+
const sessionSources = new Map();
|
|
84
|
+
// SPT-paying anonymous sessions (no API key). Holds the token + the connect-time
|
|
85
|
+
// validate result (cap + price map) so per-task coverage can be checked locally.
|
|
86
|
+
const sessionSpts = new Map();
|
|
87
|
+
// Track session creation time in our own Map (transport._createdAt is a private
|
|
88
|
+
// field that is never set by the SDK — reading it always yields undefined).
|
|
89
|
+
const sessionCreatedAt = new Map();
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Periodic cleanup — prevent unbounded memory growth
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
94
|
+
const SESSION_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
95
|
+
setInterval(() => {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
// Sweep expired widgets
|
|
98
|
+
for (const [id, entry] of widgetCache) {
|
|
99
|
+
if (now - entry.createdAt > WIDGET_TTL_MS)
|
|
100
|
+
widgetCache.delete(id);
|
|
81
101
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
// Sweep stale sessions using our own creation-time Map
|
|
103
|
+
for (const [sid] of transports) {
|
|
104
|
+
const created = sessionCreatedAt.get(sid);
|
|
105
|
+
if (created && now - created > SESSION_MAX_AGE_MS) {
|
|
106
|
+
transports.delete(sid);
|
|
107
|
+
sessionApiKeys.delete(sid);
|
|
108
|
+
sessionUserIds.delete(sid);
|
|
109
|
+
sessionSources.delete(sid);
|
|
110
|
+
sessionSpts.delete(sid);
|
|
111
|
+
sessionCreatedAt.delete(sid);
|
|
92
112
|
}
|
|
93
113
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// "Reject unknown test modes" -> Throwing error is safer to signal invalid usage
|
|
113
|
-
return {
|
|
114
|
-
isError: true,
|
|
115
|
-
content: [{ type: "text", text: JSON.stringify({ error: { code: "INVALID_PARAMS", message: `Error: Invalid test_mode '${testMode}'. Only 'gemini_echo' is supported.`, requestId } }) }]
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
// 2. Production Guardrails
|
|
119
|
-
// Simulation is ignored (treated as normal unauthorized/error if it falls through, or just explicitly blocked here)
|
|
120
|
-
// unless ENV != production OR API key has role internal_testing.
|
|
121
|
-
// We assume 'apiKey' variable holds the extracted key.
|
|
122
|
-
// Since we can't check roles dynamically without a DB call, we'll use a placeholder logic or ENV var for now.
|
|
123
|
-
const isInternalKey = process.env.INTERNAL_TEST_KEYS?.split(',').includes(apiKey || "");
|
|
124
|
-
const isProduction = process.env.NODE_ENV === "production";
|
|
125
|
-
if (isProduction && !isInternalKey) {
|
|
126
|
-
// In production, matching keys are required to use simulation.
|
|
127
|
-
// If not internal key, we pretend simulation doesn't exist or return error?
|
|
128
|
-
// "Be ignored unless..." -> Proceed to normal execution path (which handles auth/tool call normally)
|
|
129
|
-
// But wait, if we proceed, it tries to execute the tool for real.
|
|
130
|
-
// If the intention is to SAFEGUARD against accidental real execution, we should probably BLOCK if test_mode is present but unauthorized.
|
|
131
|
-
// However, "Be ignored" implies falling back to normal behavior OR just doing nothing.
|
|
132
|
-
// 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.
|
|
133
|
-
return {
|
|
134
|
-
isError: true,
|
|
135
|
-
content: [{ type: "text", text: JSON.stringify({ error: { code: "FORBIDDEN", message: "Error: Simulation mode not permitted in production without internal privileges.", requestId } }) }]
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
// 3. Structured Logging
|
|
139
|
-
console.error(JSON.stringify({
|
|
140
|
-
event: "mcp.tool_call.simulation",
|
|
141
|
-
simulation_mode: true,
|
|
142
|
-
simulation_type: "gemini_echo",
|
|
143
|
-
tool: name,
|
|
144
|
-
graphId,
|
|
145
|
-
userId
|
|
146
|
-
}));
|
|
147
|
-
// 4. Gemini Echo Response
|
|
148
|
-
return {
|
|
149
|
-
content: [{
|
|
150
|
-
type: "text",
|
|
151
|
-
text: JSON.stringify({
|
|
152
|
-
tool_calls: [{
|
|
153
|
-
name: name,
|
|
154
|
-
arguments: args
|
|
155
|
-
}]
|
|
156
|
-
}, null, 2)
|
|
157
|
-
}]
|
|
158
|
-
};
|
|
114
|
+
}, CLEANUP_INTERVAL_MS).unref();
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Service URL helper
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// M2: FODDA_SERVICE_URL must be set in Cloud Run env vars.
|
|
119
|
+
// Fallback to localhost for local dev only. The hardcoded project hash
|
|
120
|
+
// was removed — if it was being used, the URL was silently wrong.
|
|
121
|
+
function getServiceUrl() {
|
|
122
|
+
if (process.env.FODDA_SERVICE_URL)
|
|
123
|
+
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`;
|
|
159
132
|
}
|
|
133
|
+
return `http://localhost:${process.env.PORT || 8080}`;
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Authenticated API caller — checks query cache first
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
/**
|
|
139
|
+
* Make an authenticated request to the Fodda API.
|
|
140
|
+
* Checks the query cache first; stores responses on cache miss.
|
|
141
|
+
*/
|
|
142
|
+
async function foddaRequest(method, path, apiKey, userId, body, requestId, source, spt) {
|
|
143
|
+
// ── Cache check ──
|
|
144
|
+
const cached = cacheGet(method, path, body);
|
|
145
|
+
if (cached !== null)
|
|
146
|
+
return cached;
|
|
160
147
|
const timestamp = Date.now().toString();
|
|
161
148
|
const headers = {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"X-Request-Id": requestId,
|
|
149
|
+
'X-User-Id': userId,
|
|
150
|
+
'X-Fodda-Timestamp': timestamp,
|
|
151
|
+
'X-Fodda-Billing': 'mcp-orchestrated', // Tells API to skip per-call billing — MCP charges lump sum via meter
|
|
152
|
+
'Content-Type': 'application/json',
|
|
167
153
|
};
|
|
168
|
-
|
|
169
|
-
|
|
154
|
+
// SPT settlement: the Shared Payment Token is the payer (Authorization Bearer), no X-API-Key.
|
|
155
|
+
if (spt) {
|
|
156
|
+
headers['Authorization'] = `Bearer ${spt}`;
|
|
170
157
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (internalServiceKey && apiKey === internalServiceKey) {
|
|
174
|
-
headers["fodda-internal-service-key"] = internalServiceKey;
|
|
158
|
+
else {
|
|
159
|
+
headers['X-API-Key'] = apiKey;
|
|
175
160
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
switch (name) {
|
|
189
|
-
case "search_graph": {
|
|
190
|
-
const limit = Math.min(Number(args?.limit) || 25, 50); // Defense in Depth: Cap results
|
|
191
|
-
const body = {
|
|
192
|
-
query: args?.query,
|
|
193
|
-
limit: limit,
|
|
194
|
-
use_semantic: args?.use_semantic !== false,
|
|
195
|
-
};
|
|
196
|
-
if (args?.filters)
|
|
197
|
-
body.filters = args.filters;
|
|
198
|
-
if (args?.include_evidence !== undefined) {
|
|
199
|
-
body.include_evidence = args.include_evidence;
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
body.include_evidence = true; // Default to true — most agent use cases benefit from inline evidence
|
|
203
|
-
}
|
|
204
|
-
signRequest(body);
|
|
205
|
-
response = await axios.post(`${API_BASE_URL}/v1/graphs/${graphId}/search`, body, { headers });
|
|
206
|
-
break;
|
|
207
|
-
}
|
|
208
|
-
case "get_neighbors": {
|
|
209
|
-
const depth = Math.min(Number(args?.depth) || 1, 2); // Defense in Depth: Cap traversal depth
|
|
210
|
-
const limit = Math.min(Number(args?.limit) || 50, 50); // Defense in Depth: Cap results
|
|
211
|
-
const body = {
|
|
212
|
-
seed_node_ids: args?.seed_node_ids,
|
|
213
|
-
relationship_types: args?.relationship_types,
|
|
214
|
-
depth: depth,
|
|
215
|
-
limit: limit,
|
|
216
|
-
};
|
|
217
|
-
if (args?.direction)
|
|
218
|
-
body.direction = args.direction;
|
|
219
|
-
signRequest(body);
|
|
220
|
-
response = await axios.post(`${API_BASE_URL}/v1/graphs/${graphId}/neighbors`, body, { headers });
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
case "get_evidence": {
|
|
224
|
-
const top_k = Math.min(Number(args?.top_k) || 5, 10); // Defense in Depth: Cap evidence sources
|
|
225
|
-
const body = {
|
|
226
|
-
for_node_id: args?.for_node_id,
|
|
227
|
-
top_k: top_k,
|
|
228
|
-
};
|
|
229
|
-
signRequest(body);
|
|
230
|
-
response = await axios.post(`${API_BASE_URL}/v1/graphs/${graphId}/evidence`, body, { headers });
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
case "get_node": {
|
|
234
|
-
const getNodePath = `/v1/graphs/${graphId}/nodes/${args?.nodeId}`;
|
|
235
|
-
const getNodeSecret = process.env.FODDA_MCP_SECRET;
|
|
236
|
-
if (getNodeSecret) {
|
|
237
|
-
const payload = timestamp + "." + getNodePath;
|
|
238
|
-
const signature = crypto.createHmac("sha256", getNodeSecret).update(payload).digest("hex");
|
|
239
|
-
headers["X-Fodda-Signature"] = signature;
|
|
240
|
-
}
|
|
241
|
-
response = await axios.get(`${API_BASE_URL}${getNodePath}`, { headers });
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
244
|
-
case "get_label_values": {
|
|
245
|
-
const propertyParam = args?.property ? `?property=${encodeURIComponent(String(args.property))}` : '';
|
|
246
|
-
const getLabelPath = `/v1/graphs/${graphId}/labels/${args?.label}/values${propertyParam}`;
|
|
247
|
-
const getLabelSecret = process.env.FODDA_MCP_SECRET;
|
|
248
|
-
if (getLabelSecret) {
|
|
249
|
-
const payload = timestamp + "." + getLabelPath;
|
|
250
|
-
const signature = crypto.createHmac("sha256", getLabelSecret).update(payload).digest("hex");
|
|
251
|
-
headers["X-Fodda-Signature"] = signature;
|
|
252
|
-
}
|
|
253
|
-
response = await axios.get(`${API_BASE_URL}${getLabelPath}`, { headers });
|
|
254
|
-
break;
|
|
255
|
-
}
|
|
256
|
-
case "psfk_overview": {
|
|
257
|
-
const industry = args?.industry;
|
|
258
|
-
const sector = args?.sector;
|
|
259
|
-
const body = {
|
|
260
|
-
industry,
|
|
261
|
-
sector,
|
|
262
|
-
region: args?.region,
|
|
263
|
-
timeframe: args?.timeframe,
|
|
264
|
-
};
|
|
265
|
-
signRequest(body);
|
|
266
|
-
response = await axios.post(`${API_BASE_URL}/v1/psfk/overview`, body, { headers });
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
case "discover_adjacent_trends": {
|
|
270
|
-
const adjParams = new URLSearchParams({ node_id: String(args?.trend_id) });
|
|
271
|
-
if (args?.min_score !== undefined)
|
|
272
|
-
adjParams.set("min_score", String(args.min_score));
|
|
273
|
-
const adjLimit = Math.min(Number(args?.limit) || 10, 20); // Defense in Depth: Cap adjacent results
|
|
274
|
-
adjParams.set("limit", String(adjLimit));
|
|
275
|
-
if (args?.include_editorial !== undefined)
|
|
276
|
-
adjParams.set("include_editorial", String(args.include_editorial));
|
|
277
|
-
const adjPath = `/v1/graphs/${graphId}/adjacent?${adjParams.toString()}`;
|
|
278
|
-
const adjSecret = process.env.FODDA_MCP_SECRET;
|
|
279
|
-
if (adjSecret) {
|
|
280
|
-
const payload = timestamp + "." + adjPath;
|
|
281
|
-
const signature = crypto.createHmac("sha256", adjSecret).update(payload).digest("hex");
|
|
282
|
-
headers["X-Fodda-Signature"] = signature;
|
|
283
|
-
}
|
|
284
|
-
response = await axios.get(`${API_BASE_URL}${adjPath}`, { headers });
|
|
285
|
-
break;
|
|
286
|
-
}
|
|
287
|
-
case "list_graphs": {
|
|
288
|
-
const listGraphsPath = `/v1/graphs`;
|
|
289
|
-
const listGraphsSecret = process.env.FODDA_MCP_SECRET;
|
|
290
|
-
if (listGraphsSecret) {
|
|
291
|
-
const payload = timestamp + "." + listGraphsPath;
|
|
292
|
-
const signature = crypto.createHmac("sha256", listGraphsSecret).update(payload).digest("hex");
|
|
293
|
-
headers["X-Fodda-Signature"] = signature;
|
|
294
|
-
}
|
|
295
|
-
response = await axios.get(`${API_BASE_URL}${listGraphsPath}`, { headers });
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
default:
|
|
299
|
-
throw new Error(`Unknown tool: ${name}`);
|
|
300
|
-
}
|
|
301
|
-
const durationMs = Date.now() - startTime;
|
|
302
|
-
const usage = response.data.usage || { total_billable_units: 0 };
|
|
303
|
-
// Structured Audit Log for Enterprise Traceability (Refined for Security Compliance)
|
|
304
|
-
console.error(JSON.stringify({
|
|
305
|
-
event: "mcp.tool_call",
|
|
306
|
-
tool: name,
|
|
307
|
-
graphId,
|
|
308
|
-
userId, // Corresponds to tenant/user identity
|
|
309
|
-
status: response.status,
|
|
310
|
-
durationMs,
|
|
311
|
-
billable_units: usage.total_billable_units,
|
|
312
|
-
deterministic: false,
|
|
313
|
-
layer: "mcp_proxy",
|
|
314
|
-
requestId,
|
|
315
|
-
traceId
|
|
316
|
-
}));
|
|
317
|
-
const resultData = { ...response.data, meta: { requestId } };
|
|
318
|
-
const resultString = JSON.stringify(resultData, null, 2);
|
|
319
|
-
const MAX_RESPONSE_BYTES = process.env.MAX_RESPONSE_BYTES ? parseInt(process.env.MAX_RESPONSE_BYTES, 10) : 2 * 1024 * 1024;
|
|
320
|
-
if (Buffer.byteLength(resultString, 'utf8') > MAX_RESPONSE_BYTES) {
|
|
321
|
-
console.error(JSON.stringify({
|
|
322
|
-
event: "mcp.tool_error",
|
|
323
|
-
tool: name,
|
|
324
|
-
graphId,
|
|
325
|
-
userId,
|
|
326
|
-
status: 413,
|
|
327
|
-
durationMs,
|
|
328
|
-
error: "Response exceeded maximum size limit",
|
|
329
|
-
requestId,
|
|
330
|
-
traceId
|
|
331
|
-
}));
|
|
332
|
-
return {
|
|
333
|
-
isError: true,
|
|
334
|
-
content: [{ type: "text", text: JSON.stringify({ error: { code: "PAYLOAD_TOO_LARGE", message: `Response exceeded maximum size of ${MAX_RESPONSE_BYTES} bytes.`, requestId } }) }]
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
return {
|
|
338
|
-
content: [{ type: "text", text: resultString }]
|
|
339
|
-
};
|
|
161
|
+
if (requestId)
|
|
162
|
+
headers['X-Request-Id'] = requestId;
|
|
163
|
+
if (source)
|
|
164
|
+
headers['X-Fodda-Source'] = source;
|
|
165
|
+
// HMAC sign the request
|
|
166
|
+
const secret = process.env.FODDA_MCP_SECRET;
|
|
167
|
+
if (secret) {
|
|
168
|
+
const payload = (method === 'POST' || method === 'PATCH') && body
|
|
169
|
+
? timestamp + '.' + JSON.stringify(body)
|
|
170
|
+
: timestamp + '.' + path;
|
|
171
|
+
const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
172
|
+
headers['X-Fodda-Signature'] = signature;
|
|
340
173
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
code = "INVALID_AUTH";
|
|
360
|
-
else if (status === 403)
|
|
361
|
-
code = "FORBIDDEN";
|
|
362
|
-
else if (status === 404)
|
|
363
|
-
code = "GRAPH_NOT_FOUND";
|
|
364
|
-
else if (status === 429)
|
|
365
|
-
code = "RATE_LIMITED";
|
|
366
|
-
else if (status === 504)
|
|
367
|
-
code = "BACKEND_TIMEOUT";
|
|
368
|
-
else if (error.message && error.message.includes("Unknown tool"))
|
|
369
|
-
code = "UNKNOWN_TOOL";
|
|
370
|
-
return {
|
|
371
|
-
isError: true,
|
|
372
|
-
content: [{ type: "text", text: JSON.stringify({ error: { code, message: error.response?.data?.message || error.message, requestId } }) }],
|
|
174
|
+
const url = `${API_BASE_URL}${path}`;
|
|
175
|
+
// 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;
|
|
178
|
+
const response = method === 'GET'
|
|
179
|
+
? await axios.get(url, { headers, timeout: AXIOS_TIMEOUT_MS })
|
|
180
|
+
: method === 'PATCH'
|
|
181
|
+
? await axios.patch(url, body, { headers, timeout: AXIOS_TIMEOUT_MS })
|
|
182
|
+
: await axios.post(url, body, { headers, timeout: AXIOS_TIMEOUT_MS });
|
|
183
|
+
// ── Cache store ──
|
|
184
|
+
cacheSet(method, path, body, response.data);
|
|
185
|
+
// ── Inject upstream usage warning headers into response data ──
|
|
186
|
+
// The app returns X-Usage-Warning / X-Usage-Percent / X-Usage-Overage-Tokens
|
|
187
|
+
// on successful responses to indicate approaching-limit or overage-active status.
|
|
188
|
+
const usageWarningHeader = response.headers?.['x-usage-warning'];
|
|
189
|
+
if (usageWarningHeader && response.data && typeof response.data === 'object') {
|
|
190
|
+
response.data._upstream_usage = {
|
|
191
|
+
warning: usageWarningHeader, // 'approaching-limit' or 'overage-active'
|
|
373
192
|
};
|
|
193
|
+
const pct = response.headers['x-usage-percent'];
|
|
194
|
+
if (pct)
|
|
195
|
+
response.data._upstream_usage.percent = parseInt(pct, 10);
|
|
196
|
+
const overageTokens = response.headers['x-usage-overage-tokens'];
|
|
197
|
+
if (overageTokens)
|
|
198
|
+
response.data._upstream_usage.overage_tokens = parseInt(overageTokens, 10);
|
|
374
199
|
}
|
|
375
|
-
|
|
200
|
+
// ── Billing-mode trust check (Option C) ──
|
|
201
|
+
// Every MCP request claims mcp-orchestrated; the API echoes the EFFECTIVE mode.
|
|
202
|
+
// If it returns 'per-call', the API did NOT trust our HMAC → the user is billed
|
|
203
|
+
// per-call AND will be metered = double-charge. Surface loudly (don't suppress
|
|
204
|
+
// silently — under correct Option C this should never fire, so it means a real
|
|
205
|
+
// regression: FODDA_MCP_SECRET parity or signing drift).
|
|
206
|
+
const effectiveBillingMode = response.headers?.['x-fodda-billing-mode']
|
|
207
|
+
|| (response.data && typeof response.data === 'object' ? response.data?.usage?.billing_mode : undefined);
|
|
208
|
+
if (effectiveBillingMode === 'per-call') {
|
|
209
|
+
console.error(`[foddaRequest] ⚠️ DOUBLE-CHARGE RISK: ${method} ${path} returned billing_mode='per-call' despite mcp-orchestrated — API did not trust the MCP HMAC. Check FODDA_MCP_SECRET parity.`);
|
|
210
|
+
}
|
|
211
|
+
return response.data;
|
|
212
|
+
}
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// SPT validation — connect-time, non-charging coverage check
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
376
216
|
/**
|
|
377
|
-
*
|
|
217
|
+
* Validate a Stripe Shared Payment Token at session connect (no charge).
|
|
218
|
+
* GET /v1/spt/validate → { valid, max_amount_cents, spt_prices_usd }.
|
|
219
|
+
* Lets the MCP refuse a task the SPT can't cover BEFORE running it.
|
|
378
220
|
*/
|
|
379
|
-
async function
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const transports = new Map();
|
|
385
|
-
app.use((req, res, next) => {
|
|
386
|
-
const requestId = req.headers["x-request-id"] || crypto.randomUUID();
|
|
387
|
-
const traceId = req.headers["traceparent"];
|
|
388
|
-
requestContext.run({ requestId, traceId }, () => next());
|
|
221
|
+
async function validateSpt(spt) {
|
|
222
|
+
try {
|
|
223
|
+
const resp = await axios.get(`${API_BASE_URL}/v1/spt/validate`, {
|
|
224
|
+
headers: { 'Authorization': `Bearer ${spt}` },
|
|
225
|
+
timeout: 15000,
|
|
389
226
|
});
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
227
|
+
const d = resp.data || {};
|
|
228
|
+
return { valid: d.valid === true, max_amount_cents: d.max_amount_cents ?? null, prices: d.spt_prices_usd || {} };
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return { valid: false, max_amount_cents: null, prices: {}, error: e.response?.data?.error || e.message };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Waverunner (Gemini Interactions API) caller — parallel to foddaRequest()
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
/**
|
|
238
|
+
* Make an authenticated Waverunner call (Gemini Interactions API).
|
|
239
|
+
* This is NOT a Fodda API call — it goes directly to Google.
|
|
240
|
+
*
|
|
241
|
+
* Handles:
|
|
242
|
+
* - Pre-check trial credits (split pools: search / deep_dive / expert_agent)
|
|
243
|
+
* - Call Gemini via @google/genai SDK
|
|
244
|
+
* - Post-decrement trial credits, or fire-and-forget metering for paid accounts
|
|
245
|
+
* - NO caching (Waverunner calls are non-deterministic)
|
|
246
|
+
*
|
|
247
|
+
* When the Waverunner agent internally calls Fodda graph tools, it should
|
|
248
|
+
* use FODDA_INTERNAL_API_KEY so those calls skip billing.
|
|
249
|
+
*/
|
|
250
|
+
async function waverunnerRequest(interactionType, tokenCost, userApiKey, userId, waverunnerPayload) {
|
|
251
|
+
// ── Pre-check credits ──
|
|
252
|
+
// (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;
|
|
255
|
+
if (!geminiKey) {
|
|
256
|
+
throw new Error('GEMINI_API_KEY environment variable is required for Waverunner calls.');
|
|
257
|
+
}
|
|
258
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
259
|
+
const ai = new GoogleGenAI({ apiKey: geminiKey });
|
|
260
|
+
const result = await ai.interactions.create(waverunnerPayload);
|
|
261
|
+
// ── Post-decrement (paid accounts) ──
|
|
262
|
+
foddaRequest('POST', '/v1/research/meter', userApiKey, userId, {
|
|
263
|
+
type: interactionType,
|
|
264
|
+
billable_units: tokenCost,
|
|
265
|
+
}).catch(err => console.error('[waverunnerRequest] Metering failed:', err.message));
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Browser Landing Page — catch humans who paste the MCP URL into a browser
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
app.get('/mcp', (req, res, next) => {
|
|
272
|
+
// Only intercept browser requests (Accept: text/html).
|
|
273
|
+
// MCP SDK clients send application/json or text/event-stream, so they
|
|
274
|
+
// fall through to the app.all('/mcp') transport handler below.
|
|
275
|
+
const accept = req.headers['accept'] || '';
|
|
276
|
+
if (!accept.includes('text/html'))
|
|
277
|
+
return next();
|
|
278
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
279
|
+
res.send(`<!DOCTYPE html>
|
|
280
|
+
<html lang="en">
|
|
281
|
+
<head>
|
|
282
|
+
<meta charset="utf-8">
|
|
283
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
284
|
+
<title>Fodda — This URL is for AI Systems</title>
|
|
285
|
+
<link rel="icon" type="image/png" href="${FAVICON_PNG}">
|
|
286
|
+
<link rel="icon" type="image/svg+xml" href="${FAVICON_SVG}">
|
|
287
|
+
<meta http-equiv="refresh" content="12;url=https://www.fodda.ai">
|
|
288
|
+
<style>
|
|
289
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
|
|
290
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
291
|
+
body {
|
|
292
|
+
min-height: 100vh;
|
|
293
|
+
display: flex;
|
|
294
|
+
align-items: center;
|
|
295
|
+
justify-content: center;
|
|
296
|
+
background: #0D0A14;
|
|
297
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
298
|
+
color: #E8E2F0;
|
|
299
|
+
overflow: hidden;
|
|
300
|
+
}
|
|
301
|
+
/* Ambient background glow */
|
|
302
|
+
body::before {
|
|
303
|
+
content: '';
|
|
304
|
+
position: fixed;
|
|
305
|
+
top: -40%; left: -20%;
|
|
306
|
+
width: 140%; height: 140%;
|
|
307
|
+
background: radial-gradient(ellipse at 30% 50%, rgba(102,51,153,0.15) 0%, transparent 60%),
|
|
308
|
+
radial-gradient(ellipse at 70% 60%, rgba(74,36,112,0.10) 0%, transparent 50%);
|
|
309
|
+
pointer-events: none;
|
|
310
|
+
z-index: 0;
|
|
311
|
+
}
|
|
312
|
+
.card {
|
|
313
|
+
position: relative;
|
|
314
|
+
z-index: 1;
|
|
315
|
+
max-width: 520px;
|
|
316
|
+
width: 90%;
|
|
317
|
+
padding: 2.5rem 2.5rem 2rem;
|
|
318
|
+
border-radius: 20px;
|
|
319
|
+
background: rgba(22, 17, 32, 0.85);
|
|
320
|
+
border: 1px solid rgba(155, 114, 204, 0.25);
|
|
321
|
+
backdrop-filter: blur(24px);
|
|
322
|
+
-webkit-backdrop-filter: blur(24px);
|
|
323
|
+
box-shadow: 0 0 80px rgba(102, 51, 153, 0.12), 0 4px 32px rgba(0,0,0,0.4);
|
|
324
|
+
text-align: center;
|
|
325
|
+
animation: cardIn 0.6s ease-out;
|
|
326
|
+
}
|
|
327
|
+
@keyframes cardIn {
|
|
328
|
+
from { opacity: 0; transform: translateY(24px) scale(0.97); }
|
|
329
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
330
|
+
}
|
|
331
|
+
.logo-row {
|
|
332
|
+
display: flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
justify-content: center;
|
|
335
|
+
gap: 10px;
|
|
336
|
+
margin-bottom: 2rem;
|
|
337
|
+
}
|
|
338
|
+
.logo-mark {
|
|
339
|
+
width: 32px; height: 32px;
|
|
340
|
+
border-radius: 8px;
|
|
341
|
+
background: #663399;
|
|
342
|
+
display: flex;
|
|
343
|
+
align-items: center;
|
|
344
|
+
justify-content: center;
|
|
345
|
+
}
|
|
346
|
+
.logo-mark span {
|
|
347
|
+
color: #fff;
|
|
348
|
+
font-size: 17px;
|
|
349
|
+
font-weight: 600;
|
|
350
|
+
font-family: monospace;
|
|
351
|
+
}
|
|
352
|
+
.logo-text {
|
|
353
|
+
font-size: 20px;
|
|
354
|
+
font-weight: 600;
|
|
355
|
+
color: #E8E2F0;
|
|
356
|
+
letter-spacing: -0.5px;
|
|
357
|
+
}
|
|
358
|
+
.icon-row {
|
|
359
|
+
margin-bottom: 1.5rem;
|
|
360
|
+
}
|
|
361
|
+
.icon-row svg {
|
|
362
|
+
width: 56px; height: 56px;
|
|
363
|
+
color: #9B72CC;
|
|
364
|
+
animation: pulse 2.5s ease-in-out infinite;
|
|
365
|
+
}
|
|
366
|
+
@keyframes pulse {
|
|
367
|
+
0%, 100% { opacity: 0.7; transform: scale(1); }
|
|
368
|
+
50% { opacity: 1; transform: scale(1.06); }
|
|
369
|
+
}
|
|
370
|
+
h1 {
|
|
371
|
+
font-size: 1.35rem;
|
|
372
|
+
font-weight: 600;
|
|
373
|
+
color: #fff;
|
|
374
|
+
margin-bottom: 0.75rem;
|
|
375
|
+
line-height: 1.3;
|
|
376
|
+
}
|
|
377
|
+
.subtitle {
|
|
378
|
+
font-size: 0.95rem;
|
|
379
|
+
color: #A89BBE;
|
|
380
|
+
line-height: 1.6;
|
|
381
|
+
margin-bottom: 1.75rem;
|
|
382
|
+
}
|
|
383
|
+
.subtitle strong {
|
|
384
|
+
color: #C9B5E0;
|
|
385
|
+
font-weight: 500;
|
|
386
|
+
}
|
|
387
|
+
.url-preview {
|
|
388
|
+
display: inline-block;
|
|
389
|
+
padding: 0.45rem 1rem;
|
|
390
|
+
border-radius: 8px;
|
|
391
|
+
background: rgba(102, 51, 153, 0.12);
|
|
392
|
+
border: 1px solid rgba(155, 114, 204, 0.2);
|
|
393
|
+
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
|
394
|
+
font-size: 0.72rem;
|
|
395
|
+
color: #9B72CC;
|
|
396
|
+
word-break: break-all;
|
|
397
|
+
margin-bottom: 1.75rem;
|
|
398
|
+
max-width: 100%;
|
|
399
|
+
}
|
|
400
|
+
.cta-btn {
|
|
401
|
+
display: inline-flex;
|
|
402
|
+
align-items: center;
|
|
403
|
+
gap: 8px;
|
|
404
|
+
padding: 0.75rem 1.75rem;
|
|
405
|
+
border-radius: 12px;
|
|
406
|
+
background: #663399;
|
|
407
|
+
color: #fff;
|
|
408
|
+
font-size: 0.95rem;
|
|
409
|
+
font-weight: 500;
|
|
410
|
+
text-decoration: none;
|
|
411
|
+
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
|
|
412
|
+
box-shadow: 0 0 0 0 rgba(102,51,153,0);
|
|
413
|
+
margin-bottom: 1rem;
|
|
414
|
+
}
|
|
415
|
+
.cta-btn:hover {
|
|
416
|
+
background: #7840B0;
|
|
417
|
+
transform: translateY(-1px);
|
|
418
|
+
box-shadow: 0 4px 20px rgba(102,51,153,0.35);
|
|
419
|
+
}
|
|
420
|
+
.cta-btn svg { width: 16px; height: 16px; }
|
|
421
|
+
.redirect-note {
|
|
422
|
+
font-size: 0.78rem;
|
|
423
|
+
color: #6B5A82;
|
|
424
|
+
margin-top: 0.5rem;
|
|
425
|
+
}
|
|
426
|
+
.steps {
|
|
427
|
+
margin-top: 1.5rem;
|
|
428
|
+
padding-top: 1.25rem;
|
|
429
|
+
border-top: 1px solid rgba(155, 114, 204, 0.12);
|
|
430
|
+
text-align: left;
|
|
431
|
+
}
|
|
432
|
+
.steps-title {
|
|
433
|
+
font-size: 0.75rem;
|
|
434
|
+
text-transform: uppercase;
|
|
435
|
+
letter-spacing: 1.2px;
|
|
436
|
+
color: #6B5A82;
|
|
437
|
+
margin-bottom: 0.85rem;
|
|
438
|
+
text-align: center;
|
|
439
|
+
}
|
|
440
|
+
.step {
|
|
441
|
+
display: flex;
|
|
442
|
+
align-items: flex-start;
|
|
443
|
+
gap: 12px;
|
|
444
|
+
margin-bottom: 0.65rem;
|
|
445
|
+
}
|
|
446
|
+
.step-num {
|
|
447
|
+
width: 22px; height: 22px;
|
|
448
|
+
border-radius: 50%;
|
|
449
|
+
background: rgba(102, 51, 153, 0.2);
|
|
450
|
+
color: #9B72CC;
|
|
451
|
+
font-size: 0.7rem;
|
|
452
|
+
font-weight: 600;
|
|
453
|
+
display: flex;
|
|
454
|
+
align-items: center;
|
|
455
|
+
justify-content: center;
|
|
456
|
+
flex-shrink: 0;
|
|
457
|
+
margin-top: 1px;
|
|
458
|
+
}
|
|
459
|
+
.step-text {
|
|
460
|
+
font-size: 0.82rem;
|
|
461
|
+
color: #A89BBE;
|
|
462
|
+
line-height: 1.5;
|
|
463
|
+
}
|
|
464
|
+
.step-text a {
|
|
465
|
+
color: #C9B5E0;
|
|
466
|
+
text-decoration: underline;
|
|
467
|
+
text-underline-offset: 2px;
|
|
468
|
+
}
|
|
469
|
+
.step-text a:hover { color: #fff; }
|
|
470
|
+
</style>
|
|
471
|
+
</head>
|
|
472
|
+
<body>
|
|
473
|
+
<div class="card">
|
|
474
|
+
<div class="logo-row">
|
|
475
|
+
<div class="logo-mark"><span>F</span></div>
|
|
476
|
+
<div class="logo-text">Fodda</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<div class="icon-row">
|
|
480
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
481
|
+
<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" />
|
|
482
|
+
</svg>
|
|
483
|
+
</div>
|
|
484
|
+
|
|
485
|
+
<h1>This URL is for AI systems,<br>not your browser</h1>
|
|
486
|
+
|
|
487
|
+
<p class="subtitle">
|
|
488
|
+
You've opened a <strong>Model Context Protocol (MCP)</strong> endpoint.
|
|
489
|
+
It's designed to be used by AI assistants like Claude, ChatGPT, Cursor, or the OpenAI Responses API —
|
|
490
|
+
not visited directly in a web browser.
|
|
491
|
+
</p>
|
|
492
|
+
|
|
493
|
+
<a href="https://www.fodda.ai" class="cta-btn">
|
|
494
|
+
Learn how to connect
|
|
495
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
496
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
|
|
497
|
+
</svg>
|
|
498
|
+
</a>
|
|
499
|
+
|
|
500
|
+
<div class="redirect-note">Redirecting to fodda.ai in a few seconds…</div>
|
|
501
|
+
|
|
502
|
+
<div class="steps">
|
|
503
|
+
<div class="steps-title">How to use this URL</div>
|
|
504
|
+
<div class="step">
|
|
505
|
+
<div class="step-num">1</div>
|
|
506
|
+
<div class="step-text">Copy this MCP URL from your <a href="https://app.fodda.ai/settings" target="_blank">Fodda settings</a></div>
|
|
507
|
+
</div>
|
|
508
|
+
<div class="step">
|
|
509
|
+
<div class="step-num">2</div>
|
|
510
|
+
<div class="step-text">Paste it into the MCP settings of your AI tool (Claude, Cursor, Windsurf, etc.)</div>
|
|
511
|
+
</div>
|
|
512
|
+
<div class="step">
|
|
513
|
+
<div class="step-num">3</div>
|
|
514
|
+
<div class="step-text">Start asking questions — Fodda's knowledge graphs power your AI</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
</body>
|
|
519
|
+
</html>`);
|
|
520
|
+
});
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// MCP Transport Handler
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
app.all('/mcp', async (req, res) => {
|
|
525
|
+
try {
|
|
526
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
527
|
+
let transport;
|
|
528
|
+
// SPT (anonymous Shared Payment Token) detection — MUST precede api-key
|
|
529
|
+
// extraction so an `spt_xxx` Bearer is never treated as an API key.
|
|
530
|
+
const rawAuth = req.headers['authorization']?.toString() || '';
|
|
531
|
+
const spt = req.query.spt
|
|
532
|
+
|| req.headers['x-stripe-spt']
|
|
533
|
+
|| (/^Bearer\s+spt_/i.test(rawAuth) ? rawAuth.replace(/^Bearer\s+/i, '') : '')
|
|
534
|
+
|| '';
|
|
535
|
+
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
|
|
539
|
+
|| req.headers['x-api-key']
|
|
540
|
+
|| (req.headers['authorization']?.toString().replace(/^Bearer\s+/i, ''))
|
|
541
|
+
|| '');
|
|
542
|
+
const entryId = req.query.id || '';
|
|
543
|
+
// If id looks like an email and no explicit user_id, use it as userId for tracking + signup
|
|
544
|
+
const isEmailId = entryId.includes('@') && entryId.includes('.');
|
|
545
|
+
const userId = isSpt ? 'spt_agent' : (req.query.user_id
|
|
546
|
+
|| req.headers['x-user-id']
|
|
547
|
+
|| (isEmailId ? entryId : 'anonymous'));
|
|
548
|
+
const source = req.query.source || (isSpt ? 'spt' : '');
|
|
549
|
+
if (sessionId && transports.has(sessionId)) {
|
|
550
|
+
transport = transports.get(sessionId);
|
|
551
|
+
}
|
|
552
|
+
else if (!sessionId && req.method === 'POST') {
|
|
553
|
+
const body = req.body;
|
|
554
|
+
if (body?.method === 'initialize') {
|
|
555
|
+
// For an SPT (anonymous) session: validate the token ONCE now (no charge)
|
|
556
|
+
// so we can refuse a task the SPT can't cover before running it. Reject the
|
|
557
|
+
// connection if the token is bad/unfunded.
|
|
558
|
+
let sptInfo = null;
|
|
559
|
+
if (isSpt) {
|
|
560
|
+
const v = await validateSpt(spt);
|
|
561
|
+
if (!v.valid) {
|
|
562
|
+
return res.status(402).json({
|
|
563
|
+
jsonrpc: '2.0',
|
|
564
|
+
error: { code: -32002, message: `SPT validation failed: ${v.error || 'invalid token'}` },
|
|
565
|
+
id: body?.id ?? null,
|
|
566
|
+
});
|
|
488
567
|
}
|
|
568
|
+
sptInfo = { token: spt, maxAmountCents: v.max_amount_cents, prices: v.prices };
|
|
489
569
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
570
|
+
// Wrap foddaRequest. SPT sessions authenticate the internal fan-out with the
|
|
571
|
+
// internal service key (the SPT is spent ONCE at settlement, never on fan-out);
|
|
572
|
+
// otherwise bake in source attribution.
|
|
573
|
+
const internalKey = process.env.FODDA_INTERNAL_API_KEY || '';
|
|
574
|
+
const boundFoddaRequest = isSpt
|
|
575
|
+
? ((m, p, _k, u, b, r, _s, sptArg) => foddaRequest(m, p, sptArg ? '' : internalKey, u, b, r, 'spt', sptArg))
|
|
576
|
+
: (source
|
|
577
|
+
? ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source))
|
|
578
|
+
: foddaRequest);
|
|
579
|
+
const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId, sptInfo ?? undefined);
|
|
580
|
+
transport = new StreamableHTTPServerTransport({
|
|
581
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
582
|
+
onsessioninitialized: (sid) => {
|
|
583
|
+
console.error(`Session created: ${sid}`);
|
|
584
|
+
transports.set(sid, transport);
|
|
585
|
+
sessionApiKeys.set(sid, apiKey);
|
|
586
|
+
sessionUserIds.set(sid, userId);
|
|
587
|
+
sessionCreatedAt.set(sid, Date.now()); // C2: track creation time
|
|
588
|
+
if (source)
|
|
589
|
+
sessionSources.set(sid, source);
|
|
590
|
+
if (sptInfo)
|
|
591
|
+
sessionSpts.set(sid, sptInfo);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
transport.onclose = () => {
|
|
595
|
+
const sid = transport.sessionId;
|
|
596
|
+
if (sid) {
|
|
597
|
+
transports.delete(sid);
|
|
598
|
+
sessionApiKeys.delete(sid);
|
|
599
|
+
sessionUserIds.delete(sid);
|
|
600
|
+
sessionSources.delete(sid);
|
|
601
|
+
sessionSpts.delete(sid);
|
|
602
|
+
sessionCreatedAt.delete(sid); // C2: clean up creation time
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
await server.connect(transport);
|
|
511
606
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
607
|
+
else {
|
|
608
|
+
return res.status(400).json({
|
|
609
|
+
jsonrpc: '2.0',
|
|
610
|
+
error: { code: -32000, message: 'Bad Request: Session required' },
|
|
611
|
+
id: null
|
|
612
|
+
});
|
|
516
613
|
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
// Clean up on disconnect
|
|
524
|
-
res.on("close", () => {
|
|
525
|
-
transports.delete(sessionId);
|
|
526
|
-
console.error(`SSE session ${sessionId} disconnected (${transports.size} active)`);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
return res.status(404).json({
|
|
617
|
+
jsonrpc: '2.0',
|
|
618
|
+
error: { code: -32001, message: 'Session not found' },
|
|
619
|
+
id: null
|
|
527
620
|
});
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
else if (transports.size === 1) {
|
|
537
|
-
// Fallback: single-client mode
|
|
538
|
-
let transport;
|
|
539
|
-
transports.forEach((v) => { transport = v; });
|
|
540
|
-
await transport.handlePostMessage(req, res);
|
|
541
|
-
}
|
|
542
|
-
else if (transports.size === 0) {
|
|
543
|
-
res.status(400).send("No SSE connections established");
|
|
621
|
+
}
|
|
622
|
+
// Inject Accept: text/event-stream if missing (prevents SDK 406)
|
|
623
|
+
const accept = req.headers['accept'] || '';
|
|
624
|
+
if (!accept.includes('text/event-stream')) {
|
|
625
|
+
req.headers['accept'] = accept ? `${accept}, text/event-stream` : 'application/json, text/event-stream';
|
|
626
|
+
const idx = req.rawHeaders?.findIndex((h) => h.toLowerCase() === 'accept');
|
|
627
|
+
if (idx !== undefined && idx >= 0 && req.rawHeaders) {
|
|
628
|
+
req.rawHeaders[idx + 1] = req.headers['accept'];
|
|
544
629
|
}
|
|
545
|
-
else {
|
|
546
|
-
|
|
630
|
+
else if (req.rawHeaders) {
|
|
631
|
+
req.rawHeaders.push('Accept', req.headers['accept']);
|
|
547
632
|
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
app.get("/health", (req, res) => {
|
|
551
|
-
res.json({ status: "ok", version: MCP_SERVER_VERSION });
|
|
552
|
-
});
|
|
553
|
-
// Enterprise Transparency: Tool Capability Registry
|
|
554
|
-
app.get("/mcp/tools", (req, res) => {
|
|
555
|
-
res.json({
|
|
556
|
-
tools: TOOLS,
|
|
557
|
-
count: TOOLS.length,
|
|
558
|
-
version: MCP_SERVER_VERSION
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
// MCP Server Discovery (emerging .well-known standard)
|
|
562
|
-
app.get("/.well-known/mcp.json", (req, res) => {
|
|
563
|
-
res.json({
|
|
564
|
-
name: "ai.fodda/mcp-server",
|
|
565
|
-
title: "Fodda Knowledge Graphs",
|
|
566
|
-
description: "Fodda MCP — Expert-curated trend intelligence graphs exposed as structured context for AI agents.",
|
|
567
|
-
version: MCP_SERVER_VERSION,
|
|
568
|
-
transport: {
|
|
569
|
-
type: "streamable-http",
|
|
570
|
-
url: `${req.protocol}://${req.get("host")}/mcp`,
|
|
571
|
-
fallback: {
|
|
572
|
-
type: "sse",
|
|
573
|
-
url: `${req.protocol}://${req.get("host")}/sse`
|
|
574
|
-
}
|
|
575
|
-
},
|
|
576
|
-
tools_endpoint: `${req.protocol}://${req.get("host")}/mcp/tools`,
|
|
577
|
-
health_endpoint: `${req.protocol}://${req.get("host")}/health`
|
|
578
|
-
});
|
|
579
|
-
});
|
|
580
|
-
const httpServer = app.listen(port, () => {
|
|
581
|
-
console.error(`Fodda MCP server running on SSE at http://localhost:${port}/sse`);
|
|
582
|
-
});
|
|
583
|
-
// Graceful shutdown for Cloud Run
|
|
584
|
-
const shutdown = () => {
|
|
585
|
-
console.error("Shutting down gracefully...");
|
|
586
|
-
httpServer.close(() => {
|
|
587
|
-
console.error(`Closed ${transports.size} active SSE connections.`);
|
|
588
|
-
process.exit(0);
|
|
589
|
-
});
|
|
590
|
-
// Force exit after 10s if connections don't close
|
|
591
|
-
setTimeout(() => process.exit(1), 10000);
|
|
592
|
-
};
|
|
593
|
-
process.on("SIGTERM", shutdown);
|
|
594
|
-
process.on("SIGINT", shutdown);
|
|
633
|
+
}
|
|
634
|
+
await transport.handleRequest(req, res, req.body);
|
|
595
635
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
636
|
+
catch (error) {
|
|
637
|
+
console.error('Error:', error);
|
|
638
|
+
if (!res.headersSent)
|
|
639
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
600
640
|
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
641
|
+
});
|
|
642
|
+
// Legacy SSE transport
|
|
643
|
+
app.get('/sse', async (req, res) => {
|
|
644
|
+
const apiKey = req.query.api_key || '';
|
|
645
|
+
const entryId = req.query.id || '';
|
|
646
|
+
const isEmailId = entryId.includes('@') && entryId.includes('.');
|
|
647
|
+
const userId = req.query.user_id
|
|
648
|
+
|| req.headers['x-user-id']
|
|
649
|
+
|| (isEmailId ? entryId : 'anonymous');
|
|
650
|
+
const source = req.query.source || '';
|
|
651
|
+
const sessionId = crypto.randomUUID();
|
|
652
|
+
const transport = new SSEServerTransport('/messages', res);
|
|
653
|
+
// Bind source parameter to foddaRequest to forward it upstream as X-Fodda-Source
|
|
654
|
+
const boundFoddaRequest = source
|
|
655
|
+
? ((m, p, k, u, b, r) => foddaRequest(m, p, k, u, b, r, source))
|
|
656
|
+
: foddaRequest;
|
|
657
|
+
const server = await createServer(apiKey, userId, boundFoddaRequest, waverunnerRequest, storeWidget, getServiceUrl);
|
|
658
|
+
await server.connect(transport);
|
|
659
|
+
console.error(`SSE session: ${sessionId}`);
|
|
660
|
+
});
|
|
661
|
+
app.post('/messages', async (req, res) => {
|
|
662
|
+
// SSE message handler would go here
|
|
663
|
+
res.status(404).json({ error: 'Use /mcp endpoint' });
|
|
664
|
+
});
|
|
665
|
+
// Favicon — required for Anthropic Connectors Directory (Google favicon API)
|
|
666
|
+
const FAVICON_SVG = 'https://ucarecdn.com/e3ce77f2-661e-48a1-a294-c7c01039aed4/foddaminilogo.svg';
|
|
667
|
+
const FAVICON_PNG = 'https://ucarecdn.com/6e7893d7-6b14-426b-83bc-574a3f72d6bc/foddafavicon.png';
|
|
668
|
+
app.get('/favicon.ico', (_req, res) => { res.redirect(301, FAVICON_PNG); });
|
|
669
|
+
app.get('/favicon.svg', (_req, res) => { res.redirect(301, FAVICON_SVG); });
|
|
670
|
+
// Root — minimal HTML so Google's favicon crawler can find the icon
|
|
671
|
+
app.get('/', (_req, res) => {
|
|
672
|
+
res.setHeader('Content-Type', 'text/html');
|
|
673
|
+
res.send(`<!DOCTYPE html>
|
|
674
|
+
<html><head>
|
|
675
|
+
<title>Fodda MCP Server</title>
|
|
676
|
+
<link rel="icon" type="image/png" href="${FAVICON_PNG}">
|
|
677
|
+
<link rel="icon" type="image/svg+xml" href="${FAVICON_SVG}">
|
|
678
|
+
</head><body>
|
|
679
|
+
<h1>Fodda MCP Server v${MCP_SERVER_VERSION}</h1>
|
|
680
|
+
<p>Expert-curated knowledge graphs for AI agents.</p>
|
|
681
|
+
<p>Connect via <a href="https://app.fodda.ai">app.fodda.ai</a></p>
|
|
682
|
+
</body></html>`);
|
|
683
|
+
});
|
|
684
|
+
app.get('/health', (_req, res) => res.json({ status: 'ok', version: MCP_SERVER_VERSION, queryCache: getCacheStats() }));
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// A2A Protocol Endpoint — agent-to-agent task delegation
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
registerA2ARoute(app, foddaRequest);
|
|
689
|
+
const PORT = parseInt(process.env.PORT || '8080');
|
|
690
|
+
// Initialize catalog and pricing caches before accepting connections
|
|
691
|
+
Promise.all([
|
|
692
|
+
initCatalogCache(),
|
|
693
|
+
initPricingCache(),
|
|
694
|
+
]).then(() => {
|
|
695
|
+
app.listen(PORT, () => console.error(`Fodda MCP server v${MCP_SERVER_VERSION} on port ${PORT}`));
|
|
696
|
+
}).catch(() => {
|
|
697
|
+
// Start anyway with hardcoded fallbacks if cache init fails
|
|
698
|
+
console.error('[startup] Cache init failed — using hardcoded fallbacks');
|
|
699
|
+
app.listen(PORT, () => console.error(`Fodda MCP server v${MCP_SERVER_VERSION} on port ${PORT} (fallback mode)`));
|
|
605
700
|
});
|
|
606
701
|
//# sourceMappingURL=index.js.map
|