fast-cxt-mcp 1.0.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 +21 -0
- package/README.md +222 -0
- package/package.json +29 -0
- package/src/core.mjs +1206 -0
- package/src/executor.mjs +553 -0
- package/src/extract-key.mjs +235 -0
- package/src/protobuf.mjs +235 -0
- package/src/server.mjs +209 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windsurf API Key extraction from local installation.
|
|
3
|
+
*
|
|
4
|
+
* Cross-platform: macOS / Windows / Linux.
|
|
5
|
+
* Supports both legacy (plaintext windsurfAuthStatus) and new
|
|
6
|
+
* (Chromium safeStorage encrypted) storage formats.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir, platform } from "node:os";
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import Database from "better-sqlite3";
|
|
15
|
+
|
|
16
|
+
const SECRET_KEY = 'secret://{"extensionId":"codeium.windsurf","key":"windsurf_auth.sessions"}';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the platform-specific path to Windsurf's state.vscdb.
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function getDbPath() {
|
|
23
|
+
const plat = platform();
|
|
24
|
+
const home = homedir();
|
|
25
|
+
|
|
26
|
+
if (plat === "darwin") {
|
|
27
|
+
return join(home, "Library", "Application Support", "Windsurf", "User", "globalStorage", "state.vscdb");
|
|
28
|
+
} else if (plat === "win32") {
|
|
29
|
+
const appdata = process.env.APPDATA || "";
|
|
30
|
+
if (!appdata) throw new Error("Cannot determine APPDATA path");
|
|
31
|
+
return join(appdata, "Windsurf", "User", "globalStorage", "state.vscdb");
|
|
32
|
+
} else {
|
|
33
|
+
// Linux
|
|
34
|
+
const config = process.env.XDG_CONFIG_HOME || join(home, ".config");
|
|
35
|
+
return join(config, "Windsurf", "User", "globalStorage", "state.vscdb");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Legacy extraction (plaintext windsurfAuthStatus) ──────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Try extracting API key from legacy plaintext storage.
|
|
43
|
+
* @param {Database.Database} db
|
|
44
|
+
* @returns {{ api_key?: string } | null} null if key not found
|
|
45
|
+
*/
|
|
46
|
+
function extractFromLegacy(db) {
|
|
47
|
+
const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus'").get();
|
|
48
|
+
if (!row) return null;
|
|
49
|
+
|
|
50
|
+
let data;
|
|
51
|
+
try {
|
|
52
|
+
data = JSON.parse(row.value);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const apiKey = data.apiKey || "";
|
|
58
|
+
return apiKey ? { api_key: apiKey } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── New encrypted extraction (Chromium safeStorage) ───────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Retrieve the master password from system keychain.
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function getMasterPassword() {
|
|
68
|
+
const plat = platform();
|
|
69
|
+
|
|
70
|
+
if (plat === "darwin") {
|
|
71
|
+
return execSync(
|
|
72
|
+
'security find-generic-password -s "Windsurf Safe Storage" -a "Windsurf Key" -w',
|
|
73
|
+
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
|
|
74
|
+
).trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (plat === "linux") {
|
|
78
|
+
// Try libsecret (GNOME Keyring / KDE Wallet)
|
|
79
|
+
try {
|
|
80
|
+
return execSync(
|
|
81
|
+
'secret-tool lookup application windsurf service "Windsurf Safe Storage" account "Windsurf Key"',
|
|
82
|
+
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
|
|
83
|
+
).trim();
|
|
84
|
+
} catch {
|
|
85
|
+
// No keyring available — Chromium falls back to hardcoded password
|
|
86
|
+
return "peanuts";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Windows: not applicable (uses DPAPI directly, no master password)
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decrypt safeStorage data on macOS / Linux.
|
|
96
|
+
* Format: 'v10' (3 bytes) + AES-128-CBC ciphertext (PBKDF2 derived key).
|
|
97
|
+
* @param {Buffer} encrypted
|
|
98
|
+
* @param {string} masterPassword
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function decryptAesCbc(encrypted, masterPassword) {
|
|
102
|
+
const plat = platform();
|
|
103
|
+
const iterations = plat === "darwin" ? 1003 : 1;
|
|
104
|
+
const derivedKey = crypto.pbkdf2Sync(masterPassword, "saltysalt", iterations, 16, "sha1");
|
|
105
|
+
|
|
106
|
+
const ciphertext = encrypted.slice(3); // strip 'v10' prefix
|
|
107
|
+
const iv = Buffer.alloc(16, 0x20);
|
|
108
|
+
|
|
109
|
+
const decipher = crypto.createDecipheriv("aes-128-cbc", derivedKey, iv);
|
|
110
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decrypt safeStorage data on Windows via DPAPI (PowerShell).
|
|
115
|
+
* Electron on Windows encrypts with CryptProtectData directly — no 'v10' prefix.
|
|
116
|
+
* Uses -EncodedCommand to avoid cmd.exe quoting/escaping issues.
|
|
117
|
+
* @param {Buffer} encrypted
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function decryptDpapi(encrypted) {
|
|
121
|
+
const b64 = encrypted.toString("base64");
|
|
122
|
+
const psScript =
|
|
123
|
+
`Add-Type -AssemblyName System.Security;` +
|
|
124
|
+
`$bytes = [Convert]::FromBase64String('${b64}');` +
|
|
125
|
+
`$dec = [Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, 'CurrentUser');` +
|
|
126
|
+
`[Text.Encoding]::UTF8.GetString($dec)`;
|
|
127
|
+
|
|
128
|
+
// Encode the script as UTF-16LE base64 for -EncodedCommand
|
|
129
|
+
const encoded = Buffer.from(psScript, "utf16le").toString("base64");
|
|
130
|
+
|
|
131
|
+
return execSync(`powershell -NoProfile -EncodedCommand ${encoded}`, {
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
134
|
+
}).trim();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Try extracting API key from encrypted secret storage.
|
|
139
|
+
* @param {Database.Database} db
|
|
140
|
+
* @returns {{ api_key?: string, method?: string } | null}
|
|
141
|
+
*/
|
|
142
|
+
function extractFromSecret(db) {
|
|
143
|
+
const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(SECRET_KEY);
|
|
144
|
+
if (!row) return null;
|
|
145
|
+
|
|
146
|
+
let encryptedBuf;
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(row.value);
|
|
149
|
+
encryptedBuf = Buffer.from(parsed.data);
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let decrypted;
|
|
155
|
+
const plat = platform();
|
|
156
|
+
|
|
157
|
+
if (plat === "win32") {
|
|
158
|
+
// Windows: raw DPAPI blob (no 'v10' prefix)
|
|
159
|
+
decrypted = decryptDpapi(encryptedBuf);
|
|
160
|
+
} else {
|
|
161
|
+
// macOS / Linux: 'v10' + AES-128-CBC
|
|
162
|
+
const prefix = encryptedBuf.slice(0, 3).toString("utf8");
|
|
163
|
+
if (prefix !== "v10" && prefix !== "v11") return null;
|
|
164
|
+
const masterPassword = getMasterPassword();
|
|
165
|
+
decrypted = decryptAesCbc(encryptedBuf, masterPassword);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let sessions;
|
|
169
|
+
try {
|
|
170
|
+
sessions = JSON.parse(decrypted);
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// sessions is an array — take the first entry with an accessToken
|
|
176
|
+
const entry = Array.isArray(sessions)
|
|
177
|
+
? sessions.find((s) => s.accessToken)
|
|
178
|
+
: sessions;
|
|
179
|
+
|
|
180
|
+
const token = entry?.accessToken || "";
|
|
181
|
+
return token ? { api_key: token, method: "safeStorage" } : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Main entry point ──────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Extract API Key from Windsurf state.vscdb.
|
|
188
|
+
* Tries legacy plaintext first, then falls back to encrypted safeStorage.
|
|
189
|
+
* @param {string} [dbPath]
|
|
190
|
+
* @returns {{ api_key?: string, db_path: string, method?: string, error?: string, hint?: string }}
|
|
191
|
+
*/
|
|
192
|
+
export function extractKey(dbPath) {
|
|
193
|
+
if (!dbPath) {
|
|
194
|
+
dbPath = getDbPath();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!existsSync(dbPath)) {
|
|
198
|
+
return {
|
|
199
|
+
error: `Windsurf database not found: ${dbPath}`,
|
|
200
|
+
hint: "Ensure Windsurf is installed and logged in.",
|
|
201
|
+
db_path: dbPath,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let db;
|
|
206
|
+
try {
|
|
207
|
+
db = new Database(dbPath, { readonly: true });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return { error: `Failed to open database: ${e.message}`, db_path: dbPath };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
// 1) Legacy plaintext
|
|
214
|
+
const legacy = extractFromLegacy(db);
|
|
215
|
+
if (legacy) {
|
|
216
|
+
return { ...legacy, db_path: dbPath, method: "legacy" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 2) Encrypted safeStorage
|
|
220
|
+
const secret = extractFromSecret(db);
|
|
221
|
+
if (secret) {
|
|
222
|
+
return { ...secret, db_path: dbPath };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
error: "No API key found in database (tried legacy + safeStorage)",
|
|
227
|
+
hint: "Ensure Windsurf is logged in. You can also set WINDSURF_API_KEY manually.",
|
|
228
|
+
db_path: dbPath,
|
|
229
|
+
};
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return { error: `Extraction failed: ${e.message}`, db_path: dbPath };
|
|
232
|
+
} finally {
|
|
233
|
+
db.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/protobuf.mjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-written Protobuf encoder/decoder + Connect-RPC frame handling.
|
|
3
|
+
*
|
|
4
|
+
* Matches the Windsurf wire format exactly.
|
|
5
|
+
* Python bytearray → Node.js Buffer
|
|
6
|
+
* struct.pack(">I", len) → buf.writeUInt32BE
|
|
7
|
+
* gzip.compress/decompress → zlib.gzipSync/gunzipSync
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
11
|
+
|
|
12
|
+
// ─── Protobuf Encoder ──────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export class ProtobufEncoder {
|
|
15
|
+
constructor() {
|
|
16
|
+
/** @type {Buffer[]} */
|
|
17
|
+
this._chunks = [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Encode an unsigned varint into a Buffer.
|
|
22
|
+
* @param {number} value
|
|
23
|
+
* @returns {Buffer}
|
|
24
|
+
*/
|
|
25
|
+
_varint(value) {
|
|
26
|
+
const bytes = [];
|
|
27
|
+
while (value > 0x7f) {
|
|
28
|
+
bytes.push((value & 0x7f) | 0x80);
|
|
29
|
+
value >>>= 7;
|
|
30
|
+
}
|
|
31
|
+
bytes.push(value & 0x7f);
|
|
32
|
+
return Buffer.from(bytes);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Encode a field tag.
|
|
37
|
+
* @param {number} field
|
|
38
|
+
* @param {number} wire
|
|
39
|
+
* @returns {Buffer}
|
|
40
|
+
*/
|
|
41
|
+
_tag(field, wire) {
|
|
42
|
+
return this._varint((field << 3) | wire);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Write a varint field.
|
|
47
|
+
* @param {number} field
|
|
48
|
+
* @param {number} value
|
|
49
|
+
* @returns {ProtobufEncoder}
|
|
50
|
+
*/
|
|
51
|
+
writeVarint(field, value) {
|
|
52
|
+
this._chunks.push(this._tag(field, 0), this._varint(value));
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Write a length-delimited string field.
|
|
58
|
+
* @param {number} field
|
|
59
|
+
* @param {string} value
|
|
60
|
+
* @returns {ProtobufEncoder}
|
|
61
|
+
*/
|
|
62
|
+
writeString(field, value) {
|
|
63
|
+
const data = Buffer.from(value, "utf-8");
|
|
64
|
+
this._chunks.push(this._tag(field, 2), this._varint(data.length), data);
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write a length-delimited bytes field.
|
|
70
|
+
* @param {number} field
|
|
71
|
+
* @param {Buffer|Uint8Array} value
|
|
72
|
+
* @returns {ProtobufEncoder}
|
|
73
|
+
*/
|
|
74
|
+
writeBytes(field, value) {
|
|
75
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
76
|
+
this._chunks.push(this._tag(field, 2), this._varint(buf.length), buf);
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write a nested message field.
|
|
82
|
+
* @param {number} field
|
|
83
|
+
* @param {ProtobufEncoder} sub
|
|
84
|
+
* @returns {ProtobufEncoder}
|
|
85
|
+
*/
|
|
86
|
+
writeMessage(field, sub) {
|
|
87
|
+
const data = sub.toBuffer();
|
|
88
|
+
this._chunks.push(this._tag(field, 2), this._varint(data.length), data);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return the encoded bytes as a Buffer.
|
|
94
|
+
* @returns {Buffer}
|
|
95
|
+
*/
|
|
96
|
+
toBuffer() {
|
|
97
|
+
return Buffer.concat(this._chunks);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Varint Decode ─────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Decode a varint from a buffer at the given offset.
|
|
105
|
+
* @param {Buffer} buf
|
|
106
|
+
* @param {number} offset
|
|
107
|
+
* @returns {[number, number]} [value, newOffset]
|
|
108
|
+
*/
|
|
109
|
+
export function decodeVarint(buf, offset) {
|
|
110
|
+
let value = 0;
|
|
111
|
+
let shift = 0;
|
|
112
|
+
while (offset < buf.length) {
|
|
113
|
+
const b = buf[offset++];
|
|
114
|
+
value |= (b & 0x7f) << shift;
|
|
115
|
+
shift += 7;
|
|
116
|
+
if (!(b & 0x80)) break;
|
|
117
|
+
}
|
|
118
|
+
return [value, offset];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Protobuf String Extraction ────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract all UTF-8 strings (length > 5) from raw protobuf data
|
|
125
|
+
* by parsing wire types. Matches Python proto_extract_strings().
|
|
126
|
+
* @param {Buffer} data
|
|
127
|
+
* @returns {string[]}
|
|
128
|
+
*/
|
|
129
|
+
export function extractStrings(data) {
|
|
130
|
+
const strings = [];
|
|
131
|
+
let i = 0;
|
|
132
|
+
while (i < data.length) {
|
|
133
|
+
// Read tag varint
|
|
134
|
+
let tag = 0;
|
|
135
|
+
let shift = 0;
|
|
136
|
+
while (i < data.length) {
|
|
137
|
+
const b = data[i++];
|
|
138
|
+
tag |= (b & 0x7f) << shift;
|
|
139
|
+
shift += 7;
|
|
140
|
+
if (!(b & 0x80)) break;
|
|
141
|
+
}
|
|
142
|
+
const wire = tag & 0x7;
|
|
143
|
+
if (wire === 0) {
|
|
144
|
+
// Varint — skip
|
|
145
|
+
while (i < data.length) {
|
|
146
|
+
const b = data[i++];
|
|
147
|
+
if (!(b & 0x80)) break;
|
|
148
|
+
}
|
|
149
|
+
} else if (wire === 1) {
|
|
150
|
+
// 64-bit fixed
|
|
151
|
+
i += 8;
|
|
152
|
+
} else if (wire === 2) {
|
|
153
|
+
// Length-delimited
|
|
154
|
+
let length = 0;
|
|
155
|
+
shift = 0;
|
|
156
|
+
while (i < data.length) {
|
|
157
|
+
const b = data[i++];
|
|
158
|
+
length |= (b & 0x7f) << shift;
|
|
159
|
+
shift += 7;
|
|
160
|
+
if (!(b & 0x80)) break;
|
|
161
|
+
}
|
|
162
|
+
if (i + length <= data.length) {
|
|
163
|
+
const raw = data.subarray(i, i + length);
|
|
164
|
+
try {
|
|
165
|
+
const text = raw.toString("utf-8");
|
|
166
|
+
if (text.length > 5) {
|
|
167
|
+
strings.push(text);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Not valid UTF-8, skip
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
i += length;
|
|
174
|
+
} else if (wire === 5) {
|
|
175
|
+
// 32-bit fixed
|
|
176
|
+
i += 4;
|
|
177
|
+
} else {
|
|
178
|
+
// Unknown wire type — stop
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return strings;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Connect-RPC Frame Encode/Decode ───────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Encode protobuf bytes into a gzip-compressed Connect-RPC frame.
|
|
189
|
+
* Frame format: 1-byte flags + 4-byte big-endian length + payload
|
|
190
|
+
* @param {Buffer} protoBytes
|
|
191
|
+
* @param {boolean} [compress=true]
|
|
192
|
+
* @returns {Buffer}
|
|
193
|
+
*/
|
|
194
|
+
export function connectFrameEncode(protoBytes, compress = true) {
|
|
195
|
+
let payload;
|
|
196
|
+
let flags;
|
|
197
|
+
if (compress) {
|
|
198
|
+
payload = gzipSync(protoBytes);
|
|
199
|
+
flags = 1; // gzip compressed
|
|
200
|
+
} else {
|
|
201
|
+
payload = protoBytes;
|
|
202
|
+
flags = 0;
|
|
203
|
+
}
|
|
204
|
+
const header = Buffer.alloc(5);
|
|
205
|
+
header[0] = flags;
|
|
206
|
+
header.writeUInt32BE(payload.length, 1);
|
|
207
|
+
return Buffer.concat([header, payload]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Decode Connect-RPC frames from raw response data.
|
|
212
|
+
* Handles gzip-compressed frames (flags 1 or 3).
|
|
213
|
+
* @param {Buffer} data
|
|
214
|
+
* @returns {Buffer[]}
|
|
215
|
+
*/
|
|
216
|
+
export function connectFrameDecode(data) {
|
|
217
|
+
const frames = [];
|
|
218
|
+
let i = 0;
|
|
219
|
+
while (i + 5 <= data.length) {
|
|
220
|
+
const flags = data[i];
|
|
221
|
+
const length = data.readUInt32BE(i + 1);
|
|
222
|
+
i += 5;
|
|
223
|
+
let payload = data.subarray(i, i + length);
|
|
224
|
+
i += length;
|
|
225
|
+
if (flags === 1 || flags === 3) {
|
|
226
|
+
try {
|
|
227
|
+
payload = gunzipSync(payload);
|
|
228
|
+
} catch {
|
|
229
|
+
// Decompression failed — use raw payload
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
frames.push(Buffer.from(payload));
|
|
233
|
+
}
|
|
234
|
+
return frames;
|
|
235
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
*
|
|
13
|
+
* Start:
|
|
14
|
+
* node src/server.mjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
import { searchWithContent, extractKeyInfo } from "./core.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse an integer env var with optional clamping.
|
|
25
|
+
* @param {string} name
|
|
26
|
+
* @param {number} defaultValue
|
|
27
|
+
* @param {{ min?: number, max?: number }} [opts]
|
|
28
|
+
* @returns {number}
|
|
29
|
+
*/
|
|
30
|
+
function readIntEnv(name, defaultValue, opts = {}) {
|
|
31
|
+
const raw = process.env[name];
|
|
32
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
33
|
+
if (!Number.isFinite(parsed)) return defaultValue;
|
|
34
|
+
const min = typeof opts.min === "number" ? opts.min : null;
|
|
35
|
+
const max = typeof opts.max === "number" ? opts.max : null;
|
|
36
|
+
let value = parsed;
|
|
37
|
+
if (min !== null) value = Math.max(min, value);
|
|
38
|
+
if (max !== null) value = Math.min(max, value);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Read config from environment
|
|
43
|
+
const MAX_TURNS = readIntEnv("FC_MAX_TURNS", 3, { min: 1, max: 5 });
|
|
44
|
+
const MAX_COMMANDS = readIntEnv("FC_MAX_COMMANDS", 8, { min: 1, max: 20 });
|
|
45
|
+
const TIMEOUT_MS = readIntEnv("FC_TIMEOUT_MS", 30000, { min: 1000, max: 300000 });
|
|
46
|
+
|
|
47
|
+
const server = new McpServer({
|
|
48
|
+
name: "windsurf-fast-context",
|
|
49
|
+
version: "1.1.0",
|
|
50
|
+
instructions:
|
|
51
|
+
"Windsurf Fast Context — AI-driven semantic code search. " +
|
|
52
|
+
"Returns file paths with line ranges and grep keywords.\n" +
|
|
53
|
+
"Tunable parameters:\n" +
|
|
54
|
+
"- tree_depth (1-6, default 3): How much directory structure the remote AI sees. " +
|
|
55
|
+
"REDUCE if you get payload/size errors. INCREASE for small projects where deeper structure helps.\n" +
|
|
56
|
+
"- max_turns (1-5, default 3): How many search rounds. " +
|
|
57
|
+
"INCREASE if results are incomplete. Use 1 for quick lookups.\n" +
|
|
58
|
+
"- max_results (1-30, default 10): Maximum number of files to return.\n" +
|
|
59
|
+
"- exclude_paths (string array, default []): Directory/file patterns to exclude from tree. " +
|
|
60
|
+
"Use for large repos to reduce payload size (e.g. ['node_modules', 'dist', '.git']).\n" +
|
|
61
|
+
"The response includes [config] and [diagnostic] lines — read them to decide if you should retry with different parameters.",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ─── Tool: fast_context_search ─────────────────────────────
|
|
65
|
+
|
|
66
|
+
server.tool(
|
|
67
|
+
"fast_context_search",
|
|
68
|
+
"AI-driven semantic code search using Windsurf's Devstral model. " +
|
|
69
|
+
"Searches a codebase with natural language and returns relevant file paths with line ranges, " +
|
|
70
|
+
"plus suggested grep keywords for follow-up searches.\n" +
|
|
71
|
+
"Parameter tuning guide:\n" +
|
|
72
|
+
"- tree_depth: Controls how much directory structure the remote AI sees before searching. " +
|
|
73
|
+
"If you get a payload/size error, REDUCE this value. " +
|
|
74
|
+
"If search results are too shallow (missing files in deep subdirectories), INCREASE this value.\n" +
|
|
75
|
+
"- max_turns: Controls how many search-execute-feedback rounds the remote AI gets. " +
|
|
76
|
+
"If results are incomplete or the AI didn't find enough files, INCREASE this value. " +
|
|
77
|
+
"If you want a quick rough answer, use 1.\n" +
|
|
78
|
+
"Response includes a [config] line showing actual parameters used — use this to decide adjustments on retry.",
|
|
79
|
+
{
|
|
80
|
+
query: z.string().describe(
|
|
81
|
+
'Natural language search query (e.g. "where is auth handled", "database connection pool")'
|
|
82
|
+
),
|
|
83
|
+
project_path: z
|
|
84
|
+
.string()
|
|
85
|
+
.default("")
|
|
86
|
+
.describe("Absolute path to project root. Empty = current working directory."),
|
|
87
|
+
tree_depth: z
|
|
88
|
+
.number()
|
|
89
|
+
.int()
|
|
90
|
+
.min(1)
|
|
91
|
+
.max(6)
|
|
92
|
+
.default(3)
|
|
93
|
+
.describe(
|
|
94
|
+
"Directory tree depth for the initial repo map sent to the remote AI. " +
|
|
95
|
+
"Default 3. Use 1-2 for huge monorepos (>5000 files) or if you get payload size errors. " +
|
|
96
|
+
"Use 4-6 for small projects (<200 files) where you want the AI to see deeper structure. " +
|
|
97
|
+
"Auto falls back to a lower depth if tree output exceeds 250KB."
|
|
98
|
+
),
|
|
99
|
+
max_turns: z
|
|
100
|
+
.number()
|
|
101
|
+
.int()
|
|
102
|
+
.min(1)
|
|
103
|
+
.max(5)
|
|
104
|
+
.default(MAX_TURNS)
|
|
105
|
+
.describe(
|
|
106
|
+
"Number of search rounds. Each round: remote AI generates search commands → local execution → results sent back. " +
|
|
107
|
+
"Default 3. Use 1 for quick simple lookups. Use 4-5 for complex queries requiring deep tracing across many files. " +
|
|
108
|
+
"More rounds = better results but slower and uses more API quota."
|
|
109
|
+
),
|
|
110
|
+
max_results: z
|
|
111
|
+
.number()
|
|
112
|
+
.int()
|
|
113
|
+
.min(1)
|
|
114
|
+
.max(30)
|
|
115
|
+
.default(10)
|
|
116
|
+
.describe(
|
|
117
|
+
"Maximum number of files to return. Default 10. " +
|
|
118
|
+
"Use a smaller value (3-5) for focused queries. " +
|
|
119
|
+
"Use a larger value (15-30) for broad exploration queries."
|
|
120
|
+
),
|
|
121
|
+
exclude_paths: z
|
|
122
|
+
.array(z.string())
|
|
123
|
+
.default([])
|
|
124
|
+
.describe(
|
|
125
|
+
"Directory/file patterns to exclude from tree and search context. " +
|
|
126
|
+
"Useful for reducing payload size on large repos. " +
|
|
127
|
+
"Examples: ['node_modules', 'dist', '.git', 'build', 'coverage', '*.min.*']"
|
|
128
|
+
),
|
|
129
|
+
},
|
|
130
|
+
async ({ query, project_path, tree_depth, max_turns, max_results, exclude_paths }) => {
|
|
131
|
+
let projectPath = project_path || process.cwd();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const { statSync } = await import("node:fs");
|
|
135
|
+
if (!statSync(projectPath).isDirectory()) {
|
|
136
|
+
return { content: [{ type: "text", text: `Error: project path does not exist: ${projectPath}` }] };
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
return { content: [{ type: "text", text: `Error: project path does not exist: ${projectPath}` }] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const result = await searchWithContent({
|
|
144
|
+
query,
|
|
145
|
+
projectRoot: projectPath,
|
|
146
|
+
maxTurns: max_turns,
|
|
147
|
+
maxCommands: MAX_COMMANDS,
|
|
148
|
+
maxResults: max_results,
|
|
149
|
+
treeDepth: tree_depth,
|
|
150
|
+
timeoutMs: TIMEOUT_MS,
|
|
151
|
+
excludePaths: exclude_paths,
|
|
152
|
+
});
|
|
153
|
+
return { content: [{ type: "text", text: result }] };
|
|
154
|
+
} catch (e) {
|
|
155
|
+
const code = e.code || "UNKNOWN";
|
|
156
|
+
return { content: [{ type: "text", text:
|
|
157
|
+
`Error [${code}]: ${e.message}\n\n` +
|
|
158
|
+
`[hint] Suggestions based on error type:\n` +
|
|
159
|
+
` - Reduce tree_depth (current: ${tree_depth})\n` +
|
|
160
|
+
` - Add exclude_paths to filter large directories (e.g. ['node_modules', 'dist'])\n` +
|
|
161
|
+
` - Narrow project_path to a subdirectory\n` +
|
|
162
|
+
` - Reduce max_turns (current: ${max_turns})`
|
|
163
|
+
}] };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// ─── Tool: extract_windsurf_key ────────────────────────────
|
|
169
|
+
|
|
170
|
+
server.tool(
|
|
171
|
+
"extract_windsurf_key",
|
|
172
|
+
"Extract Windsurf API Key from local installation. " +
|
|
173
|
+
"Auto-detects OS (macOS/Windows/Linux) and reads the API key from " +
|
|
174
|
+
"Windsurf's local database. Set the result as WINDSURF_API_KEY env var.",
|
|
175
|
+
{},
|
|
176
|
+
async () => {
|
|
177
|
+
const result = extractKeyInfo();
|
|
178
|
+
|
|
179
|
+
if (result.error) {
|
|
180
|
+
const text = `Error: ${result.error}\n${result.hint || ""}\nDB path: ${result.db_path || "N/A"}`;
|
|
181
|
+
return { content: [{ type: "text", text }] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const key = result.api_key;
|
|
185
|
+
const method = result.method === "safeStorage" ? "safeStorage (encrypted)" : "legacy (plaintext)";
|
|
186
|
+
const text =
|
|
187
|
+
`Windsurf API Key extracted successfully\n\n` +
|
|
188
|
+
` Key: ${key.slice(0, 30)}...${key.slice(-10)}\n` +
|
|
189
|
+
` Length: ${key.length}\n` +
|
|
190
|
+
` Method: ${method}\n` +
|
|
191
|
+
` Source: ${result.db_path}\n\n` +
|
|
192
|
+
`Usage:\n` +
|
|
193
|
+
` export WINDSURF_API_KEY="${key}"`;
|
|
194
|
+
|
|
195
|
+
return { content: [{ type: "text", text }] };
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// ─── Start ─────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
async function main() {
|
|
202
|
+
const transport = new StdioServerTransport();
|
|
203
|
+
await server.connect(transport);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
main().catch((err) => {
|
|
207
|
+
console.error("Fatal error:", err);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
});
|