@philau2512/fast-context-mcp 1.5.3

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/src/server.mjs ADDED
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Windsurf Fast Context MCP Server (Node.js)
4
+ *
5
+ * AI-driven semantic code search via reverse-engineered Windsurf protocol.
6
+ *
7
+ * Configuration (environment variables):
8
+ * WINDSURF_API_KEY — Windsurf API key (auto-discovered from local install if not set)
9
+ * FC_MAX_TURNS — Search rounds per query (default: 3)
10
+ * FC_MAX_COMMANDS — Max parallel commands per round (default: 8)
11
+ * FC_TIMEOUT_MS — Connect-Timeout-Ms for streaming requests (default: 30000)
12
+ * FC_HIDE_EXTRACT_WINDSURF_KEY_TOOL — Hide extract_windsurf_key from MCP tools (default: false)
13
+ *
14
+ * Start:
15
+ * node src/server.mjs
16
+ */
17
+
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+ import { readFileSync, realpathSync } from "node:fs";
21
+ import { fileURLToPath } from "node:url";
22
+ import { z } from "zod";
23
+
24
+ import { searchWithContent, extractKeyInfo } from "./core.mjs";
25
+ import { projectPathSchema, validateProjectPath } from "./project-path.mjs";
26
+
27
+ const PACKAGE_JSON = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
28
+ export const SERVER_VERSION = PACKAGE_JSON.version;
29
+
30
+ /**
31
+ * Parse an integer env var with optional clamping.
32
+ * @param {string} name
33
+ * @param {number} defaultValue
34
+ * @param {{ min?: number, max?: number }} [opts]
35
+ * @returns {number}
36
+ */
37
+ export function readIntEnv(name, defaultValue, opts = {}, env = process.env) {
38
+ const raw = env[name];
39
+ const parsed = Number.parseInt(raw ?? "", 10);
40
+ if (!Number.isFinite(parsed)) return defaultValue;
41
+ const min = typeof opts.min === "number" ? opts.min : null;
42
+ const max = typeof opts.max === "number" ? opts.max : null;
43
+ let value = parsed;
44
+ if (min !== null) value = Math.max(min, value);
45
+ if (max !== null) value = Math.min(max, value);
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * Parse a boolean env var.
51
+ * @param {string} name
52
+ * @param {boolean} defaultValue
53
+ * @returns {boolean}
54
+ */
55
+ export function readBoolEnv(name, defaultValue, env = process.env) {
56
+ const raw = env[name];
57
+ if (raw == null) return defaultValue;
58
+ const v = String(raw).trim().toLowerCase();
59
+ if (["1", "true", "yes", "on"].includes(v)) return true;
60
+ if (["0", "false", "no", "off"].includes(v)) return false;
61
+ return defaultValue;
62
+ }
63
+
64
+ export function readRuntimeConfig(env = process.env) {
65
+ return {
66
+ maxTurns: readIntEnv("FC_MAX_TURNS", 3, { min: 1, max: 5 }, env),
67
+ maxCommands: readIntEnv("FC_MAX_COMMANDS", 8, { min: 1, max: 20 }, env),
68
+ timeoutMs: readIntEnv("FC_TIMEOUT_MS", 30000, { min: 1000, max: 300000 }, env),
69
+ repoMapMode: env.FC_REPO_MAP_MODE === "classic" ? "classic" : "bootstrap_hotspot",
70
+ bootstrapTreeDepth: readIntEnv("FC_BOOTSTRAP_TREE_DEPTH", 1, { min: 1, max: 3 }, env),
71
+ hotspotTopK: readIntEnv("FC_HOTSPOT_TOP_K", 4, { min: 0, max: 8 }, env),
72
+ hotspotTreeDepth: readIntEnv("FC_HOTSPOT_TREE_DEPTH", 2, { min: 1, max: 4 }, env),
73
+ hotspotMaxBytes: readIntEnv("FC_HOTSPOT_MAX_BYTES", 122880, { min: 16384, max: 262144 }, env),
74
+ bootstrapEnabled: readBoolEnv("FC_BOOTSTRAP_ENABLED", true, env),
75
+ bootstrapMaxTurns: readIntEnv("FC_BOOTSTRAP_MAX_TURNS", 2, { min: 1, max: 3 }, env),
76
+ bootstrapMaxCommands: readIntEnv("FC_BOOTSTRAP_MAX_COMMANDS", 6, { min: 1, max: 8 }, env),
77
+ includeSnippets: readBoolEnv("FC_INCLUDE_SNIPPETS", false, env),
78
+ includeSnippetsExplicitlySet: env.FC_INCLUDE_SNIPPETS != null,
79
+ hideExtractWindsurfKeyTool: readBoolEnv(
80
+ "FC_HIDE_EXTRACT_WINDSURF_KEY_TOOL",
81
+ false,
82
+ env,
83
+ ),
84
+ };
85
+ }
86
+
87
+ export function buildFastContextSearchTool({
88
+ config = readRuntimeConfig(),
89
+ deps = {},
90
+ } = {}) {
91
+ const runSearchWithContent = deps.searchWithContent || searchWithContent;
92
+ const validatePath = deps.validateProjectPath || validateProjectPath;
93
+ const description =
94
+ "AI-driven semantic code search using Windsurf's Devstral model. " +
95
+ "Searches a codebase with natural language and returns relevant file paths with line ranges, " +
96
+ "plus suggested grep keywords for follow-up searches.\n" +
97
+ "For best semantic search quality, write the query primarily in English; add local-language business terms only when needed.\n" +
98
+ "Use tree_depth/max_turns/max_results for task-level tuning; use exclude_paths to reduce payload or noise.\n" +
99
+ (config.includeSnippetsExplicitlySet
100
+ ? `- include_code_snippets: Server-configured default is ${config.includeSnippets}. Do NOT override unless explicitly asked by the user.\n`
101
+ : "- include_code_snippets: Default false (lightweight mode, ~2-5KB output). " +
102
+ "Set to true to include full code snippets in the response (~45KB output).\n") +
103
+ "Response includes a [config] line showing actual parameters used \u2014 use this to decide adjustments on retry.";
104
+
105
+ const schema = {
106
+ query: z.string().describe(
107
+ 'Natural language search query. English is recommended for best semantic matching; add local-language business terms when useful (e.g. "where is user login authentication and JWT validation handled 登录鉴权", "database connection pool")'
108
+ ),
109
+ project_path: projectPathSchema.describe(
110
+ "Absolute path to project root directory (required). " +
111
+ "Example: /Users/username/projects/myproject or C:/Users/username/projects/myproject"
112
+ ),
113
+ tree_depth: z
114
+ .number()
115
+ .int()
116
+ .min(0)
117
+ .max(6)
118
+ .default(3)
119
+ .describe(
120
+ "Directory tree depth for the initial repo map sent to the remote AI. " +
121
+ "Use 0 for auto depth based on project size. " +
122
+ "Default 3. Use 1-2 for huge monorepos (>5000 files) or if you get payload size errors. " +
123
+ "Use 4-6 for small projects (<200 files) where you want the AI to see deeper structure. " +
124
+ "Auto falls back to a lower depth if tree output exceeds 250KB."
125
+ ),
126
+ max_turns: z
127
+ .number()
128
+ .int()
129
+ .min(1)
130
+ .max(5)
131
+ .default(config.maxTurns)
132
+ .describe(
133
+ "Number of search rounds. Each round: remote AI generates search commands → local execution → results sent back. " +
134
+ "Default 3. Use 1 for quick simple lookups. Use 4-5 for complex queries requiring deep tracing across many files. " +
135
+ "More rounds = better results but slower and uses more API quota."
136
+ ),
137
+ max_results: z
138
+ .number()
139
+ .int()
140
+ .min(1)
141
+ .max(30)
142
+ .default(10)
143
+ .describe(
144
+ "Maximum number of files to return. Default 10. " +
145
+ "Use a smaller value (3-5) for focused queries. " +
146
+ "Use a larger value (15-30) for broad exploration queries."
147
+ ),
148
+ exclude_paths: z
149
+ .array(z.string())
150
+ .default([])
151
+ .describe(
152
+ "Directory/file patterns to exclude from tree and search context. " +
153
+ "Useful for reducing payload size on large repos. " +
154
+ "Examples: ['node_modules', 'dist', '.git', 'build', 'coverage', '*.min.*']"
155
+ ),
156
+ include_code_snippets: z
157
+ .boolean()
158
+ .default(config.includeSnippets)
159
+ .describe(
160
+ config.includeSnippetsExplicitlySet
161
+ ? `Include full code snippets in the response. ` +
162
+ `Server default: ${config.includeSnippets} (configured via FC_INCLUDE_SNIPPETS env var). ` +
163
+ `Do NOT override this value unless the user explicitly asks for a different mode.`
164
+ : `Include full code snippets in the response. ` +
165
+ `Default false (lightweight mode: file paths + line ranges + grep keywords, ~2-5KB output). ` +
166
+ `Set to true for full code context (~45KB output).`
167
+ ),
168
+ };
169
+
170
+ const handler = async ({
171
+ query,
172
+ project_path,
173
+ tree_depth,
174
+ max_turns,
175
+ max_results,
176
+ exclude_paths,
177
+ include_code_snippets,
178
+ }) => {
179
+ const projectPath = project_path;
180
+ const validationError = validatePath(projectPath);
181
+ if (validationError) {
182
+ return { content: [{ type: "text", text: validationError }] };
183
+ }
184
+
185
+ try {
186
+ const result = await runSearchWithContent({
187
+ query,
188
+ projectRoot: projectPath,
189
+ maxTurns: max_turns,
190
+ maxCommands: config.maxCommands,
191
+ maxResults: max_results,
192
+ treeDepth: tree_depth,
193
+ timeoutMs: config.timeoutMs,
194
+ excludePaths: exclude_paths,
195
+ repoMapMode: config.repoMapMode,
196
+ bootstrapTreeDepth: config.bootstrapTreeDepth,
197
+ hotspotTopK: config.hotspotTopK,
198
+ hotspotTreeDepth: config.hotspotTreeDepth,
199
+ hotspotMaxBytes: config.hotspotMaxBytes,
200
+ bootstrapEnabled: config.bootstrapEnabled,
201
+ bootstrapMaxTurns: config.bootstrapMaxTurns,
202
+ bootstrapMaxCommands: config.bootstrapMaxCommands,
203
+ includeSnippets: include_code_snippets,
204
+ });
205
+ return { content: [{ type: "text", text: result }] };
206
+ } catch (e) {
207
+ const code = e.code || "UNKNOWN";
208
+ return {
209
+ content: [{
210
+ type: "text", text:
211
+ `Error [${code}]: ${e.message}\n\n` +
212
+ `[hint] Suggestions based on error type:\n` +
213
+ ` - Reduce tree_depth (current: ${tree_depth})\n` +
214
+ ` - Add exclude_paths to filter large directories (e.g. ['node_modules', 'dist'])\n` +
215
+ ` - Narrow project_path to a subdirectory\n` +
216
+ ` - Reduce max_turns (current: ${max_turns})`
217
+ }]
218
+ };
219
+ }
220
+ };
221
+
222
+ return {
223
+ name: "fast_context_search",
224
+ description,
225
+ schema,
226
+ handler,
227
+ };
228
+ }
229
+
230
+ export function buildExtractWindsurfKeyTool({ deps = {} } = {}) {
231
+ const getExtractKeyInfo = deps.extractKeyInfo || extractKeyInfo;
232
+
233
+ return {
234
+ name: "extract_windsurf_key",
235
+ description:
236
+ "Extract Windsurf / Devin API Key from local installation. " +
237
+ "Auto-detects OS (macOS/Windows/Linux) and reads the API key from " +
238
+ "Devin CLI credentials.toml (Linux/WSL) or Windsurf/Devin local SQLite. " +
239
+ "Set the result as WINDSURF_API_KEY env var.",
240
+ schema: {},
241
+ handler: async () => {
242
+ const result = await getExtractKeyInfo();
243
+
244
+ if (result.error) {
245
+ const tried = Array.isArray(result.tried_paths) && result.tried_paths.length
246
+ ? `\nTried paths:\n${result.tried_paths.map((path) => ` - ${path}`).join("\n")}`
247
+ : "";
248
+ const text =
249
+ `Error: ${result.error}\n${result.hint || ""}\n` +
250
+ `Source path: ${result.db_path || "N/A"}${tried}`;
251
+ return { content: [{ type: "text", text }] };
252
+ }
253
+
254
+ const key = result.api_key;
255
+ const sourceType = result.source_type ? `\n Type: ${result.source_type}` : "";
256
+ const text =
257
+ `Windsurf API Key extracted successfully\n\n` +
258
+ ` Key: ${key.slice(0, 30)}...${key.slice(-10)}\n` +
259
+ ` Length: ${key.length}\n` +
260
+ ` Source: ${result.db_path}${sourceType}\n\n` +
261
+ `Usage:\n` +
262
+ ` export WINDSURF_API_KEY="${key}"`;
263
+
264
+ return { content: [{ type: "text", text }] };
265
+ },
266
+ };
267
+ }
268
+
269
+ export function createServer({
270
+ config = readRuntimeConfig(),
271
+ deps = {},
272
+ } = {}) {
273
+ const server = new McpServer({
274
+ name: "windsurf-fast-context",
275
+ version: SERVER_VERSION,
276
+ instructions:
277
+ "Windsurf Fast Context — AI-driven semantic code search. " +
278
+ "Returns file paths with line ranges, grep keywords, and diagnostic [config] lines. " +
279
+ "Retry with lower tree_depth/max_turns or add exclude_paths when payload, timeout, or noisy-result issues occur.",
280
+ });
281
+
282
+ const fastContextTool = buildFastContextSearchTool({ config, deps });
283
+ server.tool(
284
+ fastContextTool.name,
285
+ fastContextTool.description,
286
+ fastContextTool.schema,
287
+ fastContextTool.handler,
288
+ );
289
+
290
+ if (!config.hideExtractWindsurfKeyTool) {
291
+ const extractKeyTool = buildExtractWindsurfKeyTool({ deps });
292
+ server.tool(
293
+ extractKeyTool.name,
294
+ extractKeyTool.description,
295
+ extractKeyTool.schema,
296
+ extractKeyTool.handler,
297
+ );
298
+ }
299
+
300
+ return server;
301
+ }
302
+
303
+ export function isDirectRun(argvPath = process.argv[1], moduleUrl = import.meta.url) {
304
+ if (!argvPath) return false;
305
+ try {
306
+ return realpathSync(argvPath) === realpathSync(fileURLToPath(moduleUrl));
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ // ─── Start ─────────────────────────────────────────────────
313
+
314
+ async function main() {
315
+ const transport = new StdioServerTransport();
316
+ const server = createServer();
317
+ await server.connect(transport);
318
+ }
319
+
320
+ if (isDirectRun()) {
321
+ main().catch((err) => {
322
+ console.error("Fatal error:", err);
323
+ process.exit(1);
324
+ });
325
+ }
package/src/tree.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import { lstatSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+
4
+ const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY;
5
+
6
+ function shouldExclude(name, exclude = []) {
7
+ return exclude.some((pattern) => {
8
+ if (pattern instanceof RegExp) return pattern.test(name);
9
+ if (typeof pattern === "string") return pattern === name;
10
+ return false;
11
+ });
12
+ }
13
+
14
+ function readEntries(dir, exclude) {
15
+ return readdirSync(dir, { withFileTypes: true })
16
+ .filter((entry) => !shouldExclude(entry.name, exclude))
17
+ .sort((a, b) => {
18
+ const aDir = a.isDirectory();
19
+ const bDir = b.isDirectory();
20
+ if (aDir !== bDir) return aDir ? -1 : 1;
21
+ return a.name.localeCompare(b.name);
22
+ });
23
+ }
24
+
25
+ function isDirectoryEntry(parent, entry) {
26
+ if (typeof entry.isSymbolicLink === "function" && entry.isSymbolicLink()) {
27
+ return false;
28
+ }
29
+ if (typeof entry.isDirectory === "function") {
30
+ return entry.isDirectory();
31
+ }
32
+ try {
33
+ return lstatSync(join(parent, entry.name)).isDirectory();
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function renderChildren(dir, depth, maxDepth, exclude, prefix, lines) {
40
+ if (depth >= maxDepth) return;
41
+
42
+ let entries;
43
+ try {
44
+ entries = readEntries(dir, exclude);
45
+ } catch (e) {
46
+ if (depth === 0) throw e;
47
+ lines.push(`${prefix}(inaccessible: ${e.message})`);
48
+ return;
49
+ }
50
+
51
+ entries.forEach((entry, index) => {
52
+ const isLast = index === entries.length - 1;
53
+ const branch = isLast ? "└── " : "├── ";
54
+ lines.push(`${prefix}${branch}${entry.name}`);
55
+
56
+ if (isDirectoryEntry(dir, entry)) {
57
+ const nextPrefix = prefix + (isLast ? " " : "│ ");
58
+ renderChildren(join(dir, entry.name), depth + 1, maxDepth, exclude, nextPrefix, lines);
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * 渲染目录树文本,作为轻量内置 tree 实现。
65
+ *
66
+ * @param {string} root
67
+ * @param {{ maxDepth?: number, exclude?: Array<RegExp|string>, rootLabel?: string }} [options]
68
+ * @returns {string}
69
+ */
70
+ export function renderTree(root, options = {}) {
71
+ const maxDepth = Number.isFinite(options.maxDepth) && options.maxDepth >= 0
72
+ ? options.maxDepth
73
+ : DEFAULT_MAX_DEPTH;
74
+ const rootLabel = options.rootLabel || basename(root) || root;
75
+ const lines = [rootLabel];
76
+
77
+ if (maxDepth > 0) {
78
+ renderChildren(root, 0, maxDepth, options.exclude || [], "", lines);
79
+ }
80
+
81
+ return lines.join("\n");
82
+ }