-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcp-server.cjs
More file actions
322 lines (292 loc) · 11.8 KB
/
Copy pathmcp-server.cjs
File metadata and controls
322 lines (292 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
#!/usr/bin/env node
/**
* Soulfield Lens MCP Server
*
* Exposes Soulfield Lens validation as MCP tools for Claude Code,
* Cursor, and other MCP-compatible agents.
*
* Tools:
* - validate_content: Multi-dimensional validation of AI-generated content
* - scrub_pii: PII-only scan using regex (instant, no LLM)
* - lens_health: Check if Lens API service is running
*
* Wraps HTTP calls to http://localhost:8002/v1/*
*/
// CRITICAL: Redirect all console.log to stderr BEFORE any imports
// MCP stdio protocol requires stdout to be protocol-only (no logging)
const _origLog = console.log;
console.log = (...args) => console.error('[soulfield-lens]', ...args);
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
// Use Zod v3 compatibility path for MCP SDK compatibility
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/906
const { z } = require("zod/v3");
const API_BASE = process.env.SOULFIELD_API_BASE || "http://localhost:8002";
const API_KEY = process.env.SOULFIELD_API_KEY || "";
const VALIDATE_TIMEOUT_MS = parseInt(process.env.SOULFIELD_VALIDATE_TIMEOUT_MS || "180000", 10);
// Long-input async submit+poll (2026-06-19): inputs >= ASYNC_MIN_CHARS are
// submitted as a pro-async job and polled to completion, so a single request
// never hits VALIDATE_TIMEOUT_MS (the 180s ceiling). Short inputs keep the
// fast sync path. VALIDATE_BUDGET_MS bounds total wall-clock for the poll loop.
const VALIDATE_BUDGET_MS = parseInt(process.env.SOULFIELD_VALIDATE_BUDGET_MS || "600000", 10);
const POLL_TIMEOUT_MS = parseInt(process.env.SOULFIELD_POLL_TIMEOUT_MS || "30000", 10);
const ASYNC_MIN_CHARS = parseInt(process.env.SOULFIELD_ASYNC_MIN_CHARS || "4000", 10);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Create MCP Server
const server = new McpServer({
name: "soulfield-lens",
version: "1.0.0"
});
// --- Helpers ---
function jsonResponse(payload) {
const text = JSON.stringify(payload, null, 2);
return { content: [{ type: "text", text }] };
}
function jsonError(toolName, error) {
console.error(`[${toolName}] error`, error);
return {
...jsonResponse({ error: error.message || String(error) }),
isError: true
};
}
/**
* Make an HTTP request to the Lens API.
* Uses native Node.js http/https — no external deps needed.
*/
function apiRequest(method, path, body, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, API_BASE);
const isHttps = url.protocol === "https:";
const http = require(isHttps ? "https" : "http");
const headers = {
"Accept": "application/json",
"User-Agent": "soulfield-lens-mcp/1.0.0"
};
if (body) {
headers["Content-Type"] = "application/json";
}
if (options.apiKey) {
headers["X-API-Key"] = options.apiKey;
}
const reqOptions = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search,
method: method,
headers: headers,
timeout: options.timeout || VALIDATE_TIMEOUT_MS
};
const req = http.request(reqOptions, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode >= 400) {
const err = new Error(parsed.detail || parsed.error || `HTTP ${res.statusCode}`);
err.statusCode = res.statusCode;
err.body = parsed;
reject(err);
} else {
resolve(parsed);
}
} catch (e) {
if (res.statusCode >= 400) {
const err = new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`);
err.statusCode = res.statusCode;
reject(err);
} else {
reject(new Error(`Invalid JSON response: ${data.slice(0, 200)}`));
}
}
});
});
req.on("error", (err) => reject(err));
req.on("timeout", () => {
req.destroy();
reject(new Error(`Request to ${path} timed out after ${reqOptions.timeout}ms`));
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
/**
* Poll a pro/premium async validation job to completion.
* Returns the completed ValidateResponse, or throws on failed/expired/budget.
* Each GET is short (POLL_TIMEOUT_MS); the loop is bounded by budgetMs total.
*/
async function pollJob(jobId, budgetMs) {
const start = Date.now();
let delay = 2000;
while (Date.now() - start < budgetMs) {
await sleep(delay);
let job;
try {
job = await apiRequest("GET", `/v1/validate/${jobId}`, null, {
apiKey: API_KEY,
timeout: POLL_TIMEOUT_MS
});
} catch (e) {
// 404 = job evicted/expired → terminal; otherwise transient (slow
// poll, 5xx) → keep trying within the budget.
if (e.statusCode === 404) {
throw new Error(`Validation job ${jobId} not found (expired)`);
}
delay = Math.min(delay * 1.5, 15000);
continue;
}
if (job.status === "complete") return job;
if (job.status === "failed") {
throw new Error(`Validation job ${jobId} failed${job.error ? ": " + job.error : ""}`);
}
delay = Math.min(delay * 1.5, 15000); // pending → backoff
}
throw new Error(`Validation exceeded total budget of ${budgetMs}ms (job ${jobId})`);
}
// --- Tools ---
/**
* Tool: validate_content
* Multi-dimensional validation of AI-generated content.
* Calls POST /v1/validate with API key auth.
*/
server.registerTool(
"validate_content",
{
title: "Validate Content",
description: "Run multi-dimensional validation on AI-generated content. Returns pass/fail, score (0-100), per-dimension results, and violation details. Requires SOULFIELD_API_KEY. Supports domains: general, finance, marketing, legal, seo, agency.",
inputSchema: {
text: z.string().describe("The AI-generated content to validate"),
domain: z.enum(["general", "finance", "marketing", "legal", "seo", "agency"]).optional().describe("Domain context for validation (default: general)"),
context: z.string().optional().describe("Audience/purpose context for Relevance lens (Lens 9). Omit to skip Relevance silently.")
}
},
async ({ text, domain, context }) => {
try {
if (!API_KEY) {
return jsonError("validate_content", new Error(
"SOULFIELD_API_KEY not set. Set it as an environment variable in your MCP server config."
));
}
const payload = { text };
if (domain) payload.domain = domain;
if (context) payload.context = context;
// Short inputs: fast single sync request (unchanged behavior).
if (text.length < ASYNC_MIN_CHARS) {
const result = await apiRequest("POST", "/v1/validate", payload, {
apiKey: API_KEY,
timeout: VALIDATE_TIMEOUT_MS
});
return jsonResponse(result);
}
// Long inputs: submit a pro-async job + poll to completion so a
// single request never hits VALIDATE_TIMEOUT_MS (the 180s ceiling).
let submit;
try {
submit = await apiRequest("POST", "/v1/validate", { ...payload, options: { async: true } }, {
apiKey: API_KEY,
timeout: VALIDATE_TIMEOUT_MS
});
} catch (e) {
// Older server without the async flag (400/422/404) → one sync
// attempt with the full budget as the per-request timeout.
if (e.statusCode === 400 || e.statusCode === 422 || e.statusCode === 404) {
const result = await apiRequest("POST", "/v1/validate", payload, {
apiKey: API_KEY,
timeout: VALIDATE_BUDGET_MS
});
return jsonResponse(result);
}
throw e; // 401 / 429 / other → outer catch
}
// Old server may ignore the flag and return a sync result.
if (submit && submit.status === "complete") {
return jsonResponse(submit);
}
if (submit && submit.status === "pending" && submit.id) {
const result = await pollJob(submit.id, VALIDATE_BUDGET_MS);
return jsonResponse(result);
}
// Unexpected shape — surface it rather than silently pass.
return jsonResponse(submit);
} catch (error) {
// Provide actionable error messages for common failures
if (error.statusCode === 401) {
return jsonError("validate_content", new Error(
"Authentication failed (401). Check that SOULFIELD_API_KEY is valid."
));
}
if (error.statusCode === 429) {
return jsonError("validate_content", new Error(
"Rate limited (429). Wait a moment and try again, or upgrade your API tier."
));
}
return jsonError("validate_content", error);
}
}
);
/**
* Tool: scrub_pii
* PII-only scan using regex — instant, no LLM. Requires API key.
* Calls POST /v1/scrub.
*/
server.registerTool(
"scrub_pii",
{
title: "Scrub PII",
description: "Scan text for PII (emails, phone numbers, SSNs, credit cards, etc.) using regex. Returns scrubbed text with PII replaced by type markers, plus a list of findings. Instant — no LLM call. Requires SOULFIELD_API_KEY.",
inputSchema: {
text: z.string().describe("The text to scan for PII")
}
},
async ({ text }) => {
try {
const result = await apiRequest("POST", "/v1/scrub", { text }, {
apiKey: API_KEY,
timeout: 10000 // Regex-only, should be fast
});
return jsonResponse(result);
} catch (error) {
return jsonError("scrub_pii", error);
}
}
);
/**
* Tool: lens_health
* Check if the Lens API service is running.
* Calls GET /v1/health — no auth needed.
*/
server.registerTool(
"lens_health",
{
title: "Lens API Health Check",
description: "Check if the Soulfield Lens API service is running and responsive. Returns status and version. No auth required.",
inputSchema: {}
},
async () => {
try {
const result = await apiRequest("GET", "/v1/health", null, {
timeout: 5000
});
return jsonResponse(result);
} catch (error) {
return jsonError("lens_health", new Error(
`Lens API unreachable at ${API_BASE}: ${error.message}. Is the service running?`
));
}
}
);
// --- Main ---
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Soulfield Lens MCP Server running on stdio (3 tools available)");
if (!API_KEY) {
console.error("WARNING: SOULFIELD_API_KEY not set — validate_content will fail");
}
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});