@powercess/qq-mcp 0.2.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.

Potentially problematic release.


This version of @powercess/qq-mcp might be problematic. Click here for more details.

package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@powercess/qq-mcp",
3
+ "version": "0.2.0",
4
+ "description": "MCP server that reads QQ (Windows NTQQ) chat records, contacts and database schema from local data — offline, no native dependencies.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "powercess",
8
+ "homepage": "https://github.com/powercess/qq-mcp#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/powercess/qq-mcp.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/powercess/qq-mcp/issues"
15
+ },
16
+ "keywords": [
17
+ "qq",
18
+ "ntqq",
19
+ "mcp",
20
+ "model-context-protocol",
21
+ "sqlcipher",
22
+ "chat-history",
23
+ "reverse-engineering",
24
+ "windows"
25
+ ],
26
+ "bin": {
27
+ "qq-mcp": "dist/index.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "scripts",
32
+ "src",
33
+ "docs",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "prepublishOnly": "npm run build",
43
+ "pretest": "tsc",
44
+ "start": "node dist/index.js",
45
+ "dev": "tsc -w",
46
+ "test": "node --test \"test/**/*.test.mjs\""
47
+ },
48
+ "engines": {
49
+ "node": ">=24"
50
+ },
51
+ "os": [
52
+ "win32"
53
+ ],
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.12.0",
56
+ "zod": "^3.24.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^22.0.0",
60
+ "typescript": "^5.6.0"
61
+ }
62
+ }
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Dump candidate passphrase tokens from a running process's memory.
3
+
4
+ Windows + Python 3 stdlib only. NTQQ keeps the SQLCipher device passphrase as a
5
+ short ASCII string in-process; this helper extracts every short printable token
6
+ so the caller can test them against a `.material` file or a database page.
7
+
8
+ Candidates are printed one per line on stdout; progress goes to stderr.
9
+ """
10
+ import ctypes
11
+ import ctypes.wintypes as w
12
+ import re
13
+ import sys
14
+ from ctypes import windll
15
+
16
+ PROCESS_QUERY_INFORMATION = 0x0400
17
+ PROCESS_VM_READ = 0x0010
18
+ MEM_COMMIT = 0x1000
19
+ PAGE_NOACCESS = 0x01
20
+ PAGE_GUARD = 0x100
21
+
22
+ MIN_LEN = 12
23
+ MAX_LEN = 32
24
+ # Windows emitted for longer runs: QQ passes the passphrase to SQLCipher as a
25
+ # NUL-terminated C string, but a copy can also sit inside a larger blob.
26
+ WINDOW_LENS = (16, 20)
27
+ MAX_WINDOWS_PER_RUN = 64
28
+ CHUNK = 4 * 1024 * 1024
29
+ DEFAULT_MAX_CANDIDATES = 200_000
30
+
31
+ NARROW = re.compile(rb"[\x20-\x7e]+")
32
+ WIDE = re.compile(rb"(?:[\x20-\x7e]\x00)+")
33
+
34
+
35
+ def looks_like_secret(text):
36
+ """Cheap prior: keys mix case, digits or punctuation; prose rarely does."""
37
+ classes = sum(
38
+ (
39
+ any(c.isdigit() for c in text),
40
+ any(c.isalpha() for c in text),
41
+ any(not c.isalnum() for c in text),
42
+ )
43
+ )
44
+ return classes >= 2
45
+
46
+
47
+ class MBI(ctypes.Structure):
48
+ _fields_ = [
49
+ ("BaseAddress", ctypes.c_void_p),
50
+ ("AllocationBase", ctypes.c_void_p),
51
+ ("AllocationProtect", w.DWORD),
52
+ ("PartitionId", ctypes.c_uint16),
53
+ ("RegionSize", ctypes.c_size_t),
54
+ ("State", w.DWORD),
55
+ ("Protect", w.DWORD),
56
+ ("Type", w.DWORD),
57
+ ]
58
+
59
+
60
+ k32 = windll.kernel32
61
+ k32.OpenProcess.restype = ctypes.c_void_p
62
+ k32.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD]
63
+ k32.VirtualQueryEx.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
64
+ k32.VirtualQueryEx.restype = ctypes.c_size_t
65
+ k32.ReadProcessMemory.argtypes = [
66
+ ctypes.c_void_p,
67
+ ctypes.c_void_p,
68
+ ctypes.c_void_p,
69
+ ctypes.c_size_t,
70
+ ctypes.POINTER(ctypes.c_size_t),
71
+ ]
72
+ k32.ReadProcessMemory.restype = w.BOOL
73
+ k32.CloseHandle.argtypes = [ctypes.c_void_p]
74
+
75
+
76
+ def collect(blob, seen, out, limit):
77
+ """Adds printable runs (and, for long runs, key-length windows) to `out`."""
78
+ for pattern, wide in ((NARROW, False), (WIDE, True)):
79
+ for match in pattern.finditer(blob):
80
+ raw = match.group(0)
81
+ text = raw.decode("utf-16-le" if wide else "ascii", "ignore")
82
+ candidates = [text] if MIN_LEN <= len(text) <= MAX_LEN else []
83
+ # NTQQ hands SQLCipher 16- or 20-byte passphrases; a copy can sit
84
+ # inside a longer blob (source text, marshalled constants), so the
85
+ # key-length windows of every longer run are candidates too.
86
+ if len(text) > min(WINDOW_LENS) and looks_like_secret(text):
87
+ for length in WINDOW_LENS:
88
+ limit_here = min(len(text) - length, MAX_WINDOWS_PER_RUN)
89
+ for start in range(0, limit_here):
90
+ candidates.append(text[start : start + length])
91
+ for candidate in candidates:
92
+ if candidate in seen:
93
+ continue
94
+ seen.add(candidate)
95
+ out.append(candidate)
96
+ if len(out) >= limit:
97
+ return True
98
+ return False
99
+
100
+
101
+ def scan(pid, seen, out, limit):
102
+ handle = k32.OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid)
103
+ if not handle:
104
+ print(f"open failed for pid {pid}", file=sys.stderr)
105
+ return
106
+ addr = 0x10000
107
+ try:
108
+ while addr < 0x7FFFFFFFFFFF:
109
+ mbi = MBI()
110
+ if k32.VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(MBI)) == 0:
111
+ break
112
+ size = mbi.RegionSize
113
+ if size <= 0:
114
+ break
115
+ base = mbi.BaseAddress or addr
116
+ if mbi.State == MEM_COMMIT and not (mbi.Protect & PAGE_NOACCESS) and not (mbi.Protect & PAGE_GUARD):
117
+ offset = 0
118
+ while offset < size:
119
+ chunk = min(CHUNK, size - offset)
120
+ buf = ctypes.create_string_buffer(chunk)
121
+ read = ctypes.c_size_t()
122
+ if (
123
+ k32.ReadProcessMemory(handle, ctypes.c_void_p(base + offset), buf, chunk, ctypes.byref(read))
124
+ and read.value
125
+ ):
126
+ if collect(buf.raw[: read.value], seen, out, limit):
127
+ return
128
+ offset += chunk
129
+ addr = base + size
130
+ finally:
131
+ k32.CloseHandle(handle)
132
+
133
+
134
+ def main(argv):
135
+ limit = DEFAULT_MAX_CANDIDATES
136
+ if "--max" in argv:
137
+ index = argv.index("--max")
138
+ limit = int(argv[index + 1])
139
+ del argv[index : index + 2]
140
+ pids = [int(p) for p in argv]
141
+ if not pids:
142
+ print("usage: scan_memory.py <pid> [pid ...] [--max N]", file=sys.stderr)
143
+ return 2
144
+ seen = set()
145
+ out = []
146
+ for pid in pids:
147
+ scan(pid, seen, out, limit)
148
+ print(f"pid {pid}: {len(out)} candidates so far", file=sys.stderr)
149
+ for text in out:
150
+ print(text, flush=True)
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Stdio smoke client: connects to the built MCP server and invokes tools.
3
+ * Usage: node scripts/smoke-client.mjs [tool] [argsJson]
4
+ */
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+ import { fileURLToPath } from "node:url";
8
+ import { dirname, join } from "node:path";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const tool = process.argv[2] ?? "get_db_schema";
12
+ const args = process.argv[3] ? JSON.parse(process.argv[3]) : {};
13
+
14
+ const transport = new StdioClientTransport({
15
+ command: process.execPath,
16
+ args: [join(__dirname, "..", "dist", "index.js")],
17
+ // The SDK filters env down to a safe allowlist; forward ours so
18
+ // QQ_MCP_PASSPHRASE / QQ_MCP_DATA_DIR reach the server like a real client.
19
+ env: Object.fromEntries(Object.entries(process.env).filter((entry) => typeof entry[1] === "string")),
20
+ });
21
+ const client = new Client({ name: "smoke", version: "0.0.1" });
22
+ await client.connect(transport);
23
+
24
+ const tools = await client.listTools();
25
+ console.log("tools:", tools.tools.map((t) => t.name).join(", "));
26
+
27
+ const res = await client.callTool({ name: tool, arguments: args });
28
+ console.log("result:", res.content?.[0]?.text ?? JSON.stringify(res));
29
+
30
+ await client.close();
package/src/index.ts ADDED
@@ -0,0 +1,341 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+
5
+ import { decryptMaterial, extractSchema, DEFAULT_KDF_ITER } from "./schema.js";
6
+ import {
7
+ chatStats,
8
+ ensurePlaintext,
9
+ findQQDataDirs,
10
+ listContacts,
11
+ listDataSources,
12
+ listGroupMembers,
13
+ openOffline,
14
+ plaintextCachePath,
15
+ queryMessages,
16
+ requirePassphrase,
17
+ type DataSource,
18
+ } from "./offline.js";
19
+ import { captureOfflineKey, findQQPids, pickOracle, verifyPassphraseEverywhere } from "./keyscan.js";
20
+ import { verifyPassphrase } from "./sqlcipher.js";
21
+ import { resolve } from "node:path";
22
+
23
+ function ok(payload: unknown) {
24
+ return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }] };
25
+ }
26
+
27
+ function fail(error: unknown) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ return { isError: true, content: [{ type: "text" as const, text: JSON.stringify({ error: message }, null, 2) }] };
30
+ }
31
+
32
+ /** Finds a local database by name, preferring the most recently written copy. */
33
+ function resolveSource(name: string, explicit?: string): DataSource {
34
+ if (explicit) {
35
+ const wanted = resolve(explicit);
36
+ const dir = wanted.replace(/[\\/][^\\/]*$/, "");
37
+ const match = listDataSources([dir]).find((s) => resolve(s.dbPath) === wanted);
38
+ if (match) return match;
39
+ throw new Error(`${explicit}: no NTQQ database found at that path`);
40
+ }
41
+ const candidates = listDataSources().filter((s) => s.name === name || s.name.startsWith(name));
42
+ const usable = candidates.filter((s) => s.pages > 0).sort((a, b) => b.mtimeMs - a.mtimeMs);
43
+ if (usable.length === 0) {
44
+ const dirs = findQQDataDirs();
45
+ throw new Error(`no local ${name} found (searched: ${dirs.join(", ") || "no NTQQ directories"})`);
46
+ }
47
+ return usable[0];
48
+ }
49
+
50
+ /** Sibling database in the same `nt_db` directory as `source`. */
51
+ function sibling(source: DataSource, name: string): string | undefined {
52
+ const dir = source.dbPath.replace(/[\\/][^\\/]*$/, "");
53
+ const found = listDataSources([dir]).find((s) => s.name === name && s.pages > 0);
54
+ return found?.dbPath;
55
+ }
56
+
57
+ const server = new McpServer({
58
+ name: "qq-mcp",
59
+ version: "0.2.0",
60
+ });
61
+
62
+ /**
63
+ * Offline: list locally installed NTQQ databases plus running QQ PIDs.
64
+ * First step of every offline workflow — pick a database, then decrypt it.
65
+ */
66
+ server.registerTool(
67
+ "list_qq_databases",
68
+ {
69
+ description:
70
+ "Lists NTQQ (Windows QQ NT) databases found under Documents/Tencent Files/*/nt_qq/nt_db, " +
71
+ "with page count, salt, format version and whether a .material key blob exists. Also " +
72
+ "reports running QQ PIDs. Works fully offline.",
73
+ inputSchema: {
74
+ dirs: z.array(z.string()).optional().describe("Extra nt_db directories to scan"),
75
+ },
76
+ },
77
+ async ({ dirs }) => {
78
+ try {
79
+ const searchDirs = [...(dirs ?? []), ...findQQDataDirs()];
80
+ const databases = listDataSources(searchDirs).map((s) => ({
81
+ name: s.name,
82
+ path: s.dbPath,
83
+ bytes: s.bytes,
84
+ pages: s.pages,
85
+ saltHex: s.saltHex,
86
+ formatVersion: s.formatVersion,
87
+ material: s.materialPath ?? null,
88
+ }));
89
+ const pids = await findQQPids().catch(() => []);
90
+ return ok({ searchedDirs: searchDirs, databases, qqPids: pids });
91
+ } catch (error) {
92
+ return fail(error);
93
+ }
94
+ }
95
+ );
96
+
97
+ /**
98
+ * Online (Windows): recover the device passphrase by scanning QQ's own memory
99
+ * and accepting the first candidate that decrypts a known artefact. The
100
+ * passphrase is never written to disk unless `save` is set.
101
+ */
102
+ server.registerTool(
103
+ "capture_offline_key",
104
+ {
105
+ description:
106
+ "Recovers the NTQQ device passphrase: scrapes short ASCII tokens from a running QQ " +
107
+ "process and verifies each against a .material file or database page. Requires a " +
108
+ "logged-in QQ and Python 3. Pass save=true to persist it to .qqkey (gitignored).",
109
+ inputSchema: {
110
+ pids: z.array(z.number().int().positive()).optional().describe("QQ PIDs; defaults to all running QQ.exe"),
111
+ materialPath: z.string().optional().describe("Any <db>-first.material file to verify against"),
112
+ dbPath: z.string().optional().describe("Database to verify against when no material is available"),
113
+ maxCandidates: z.number().int().positive().optional().default(200_000),
114
+ save: z.boolean().optional().default(false),
115
+ },
116
+ },
117
+ async (args) => {
118
+ try {
119
+ const result = await captureOfflineKey(args);
120
+ if (!result.passphrase) {
121
+ return ok({
122
+ found: false,
123
+ ...result,
124
+ note: "no candidate decrypted the oracle; make sure QQ is logged in and the target database belongs to this account",
125
+ });
126
+ }
127
+ return ok({ found: true, ...result });
128
+ } catch (error) {
129
+ return fail(error);
130
+ }
131
+ }
132
+ );
133
+
134
+ /** Offline: check a passphrase against a database (single target or every local database). */
135
+ server.registerTool(
136
+ "verify_offline_key",
137
+ {
138
+ description:
139
+ "Verifies an NTQQ device passphrase against a database's page 1 or a .material file. " +
140
+ "Use all=true to test every database found locally — one passphrase unlocks all of them.",
141
+ inputSchema: {
142
+ passphrase: z.string().optional().describe("Defaults to QQ_MCP_PASSPHRASE or .qqkey"),
143
+ dbPath: z.string().optional(),
144
+ materialPath: z.string().optional(),
145
+ all: z.boolean().optional().default(false),
146
+ },
147
+ },
148
+ async ({ passphrase, dbPath, materialPath, all }) => {
149
+ try {
150
+ const key = requirePassphrase(passphrase);
151
+ if (all) return ok({ results: verifyPassphraseEverywhere(key) });
152
+ if (materialPath) {
153
+ const { plaintext } = decryptMaterial(materialPath, key);
154
+ return ok({ materialPath, ok: plaintext.includes(Buffer.from("CREATE TABLE", "utf8")), byteLen: plaintext.length });
155
+ }
156
+ const oracle = pickOracle({ dbPath, sources: dbPath ? undefined : listDataSources() });
157
+ if (!oracle) return ok({ ok: false, note: "no database found to verify against" });
158
+ if (oracle.kind === "material") {
159
+ const { plaintext } = decryptMaterial(oracle.materialPath, key);
160
+ return ok({ oracle: oracle.materialPath, ok: plaintext.includes(Buffer.from("CREATE TABLE", "utf8")) });
161
+ }
162
+ return ok({ oracle: oracle.dbPath, ok: verifyPassphrase(oracle.dbPath, key) });
163
+ } catch (error) {
164
+ return fail(error);
165
+ }
166
+ }
167
+ );
168
+
169
+ /**
170
+ * Offline: decrypt a database (WAL replayed) into the plaintext cache and
171
+ * report its shape. This is the "offline key" payoff — no QQ required.
172
+ */
173
+ server.registerTool(
174
+ "decrypt_qq_database",
175
+ {
176
+ description:
177
+ "Decrypts an NTQQ SQLCipher database (AES-256-CBC page cipher, PBKDF2-HMAC-SHA512) " +
178
+ "into a plaintext SQLite file under exports/plain, replaying committed WAL frames. " +
179
+ "Returns the plaintext path plus table counts. Cached until the source changes.",
180
+ inputSchema: {
181
+ dbPath: z.string().optional().describe("Defaults to the newest local nt_msg.db"),
182
+ passphrase: z.string().optional().describe("Defaults to QQ_MCP_PASSPHRASE or .qqkey"),
183
+ force: z.boolean().optional().default(false),
184
+ },
185
+ },
186
+ async ({ dbPath, passphrase, force }) => {
187
+ try {
188
+ const source = resolveSource("nt_msg.db", dbPath);
189
+ const key = requirePassphrase(passphrase);
190
+ const result = ensurePlaintext(source.dbPath, key, { force });
191
+ const stats = chatStats(openOffline(source.dbPath, { passphrase: key, plaintextPath: result.path }));
192
+ return ok({
193
+ source: source.dbPath,
194
+ plaintext: result.path,
195
+ reusedCache: result.reused,
196
+ decryptMs: result.durationMs,
197
+ cachePath: plaintextCachePath(source.dbPath),
198
+ stats,
199
+ });
200
+ } catch (error) {
201
+ return fail(error);
202
+ }
203
+ }
204
+ );
205
+
206
+ /** Offline: query chat messages with decoded protobuf content. */
207
+ server.registerTool(
208
+ "query_chat_messages",
209
+ {
210
+ description:
211
+ "Queries decrypted chat records offline. Filters by peer QQ (c2c) or group number, " +
212
+ "time range and keyword (matched on decoded text). Each message carries typed " +
213
+ "segments (text/image/file/video/sticker/reply/forward/call/system).",
214
+ inputSchema: {
215
+ dbPath: z.string().optional().describe("Defaults to the newest local nt_msg.db"),
216
+ passphrase: z.string().optional(),
217
+ chat: z.enum(["c2c", "group", "all"]).optional().default("all"),
218
+ peerQq: z.number().int().positive().optional(),
219
+ groupQq: z.number().int().positive().optional(),
220
+ since: z.number().int().optional().describe("Unix seconds, inclusive"),
221
+ until: z.number().int().optional().describe("Unix seconds, inclusive"),
222
+ keyword: z.string().optional(),
223
+ limit: z.number().int().positive().max(5000).optional().default(100),
224
+ order: z.enum(["asc", "desc"]).optional().default("desc"),
225
+ maxScan: z.number().int().positive().optional().default(200_000),
226
+ },
227
+ },
228
+ async (args) => {
229
+ try {
230
+ const source = resolveSource("nt_msg.db", args.dbPath);
231
+ const key = requirePassphrase(args.passphrase);
232
+ const offline = openOffline(source.dbPath, { passphrase: key });
233
+ const result = queryMessages(offline, args);
234
+ return ok({ source: source.dbPath, plaintext: offline.plaintextPath, ...result });
235
+ } catch (error) {
236
+ return fail(error);
237
+ }
238
+ }
239
+ );
240
+
241
+ /** Offline: real contact book — friends, groups and the QQ↔UID mapping table. */
242
+ server.registerTool(
243
+ "list_contacts",
244
+ {
245
+ description:
246
+ "Lists contacts offline from recent_contact_v3_table (friends with QQ number, uid, " +
247
+ "remark/nickname and last-activity time), group_list in group_info.db (group number, " +
248
+ "name, member count) and the nt_uid_mapping_table QQ↔NT-UID map.",
249
+ inputSchema: {
250
+ dbPath: z.string().optional(),
251
+ passphrase: z.string().optional(),
252
+ limit: z.number().int().positive().max(5000).optional().default(500),
253
+ },
254
+ },
255
+ async ({ dbPath, passphrase, limit }) => {
256
+ try {
257
+ const source = resolveSource("nt_msg.db", dbPath);
258
+ const key = requirePassphrase(passphrase);
259
+ const offline = openOffline(source.dbPath, { passphrase: key });
260
+ const groupInfoPath = sibling(source, "group_info.db");
261
+ const contacts = listContacts(offline, { groupInfoPath, passphrase: key, limit });
262
+ return ok({
263
+ source: source.dbPath,
264
+ groupInfo: groupInfoPath ?? null,
265
+ friends: contacts.friends,
266
+ groups: contacts.groups,
267
+ uidMappings: contacts.uidMappings,
268
+ });
269
+ } catch (error) {
270
+ return fail(error);
271
+ }
272
+ }
273
+ );
274
+
275
+ /** Offline: group member roster from group_info.db. */
276
+ server.registerTool(
277
+ "list_group_members",
278
+ {
279
+ description:
280
+ "Lists the offline group member roster from group_info.db.group_member3: member QQ, " +
281
+ "NT uid, nickname, group card and last-speak time. Filter by groupQq for one group.",
282
+ inputSchema: {
283
+ groupQq: z.number().int().positive().optional(),
284
+ dbPath: z.string().optional().describe("Path to nt_msg.db, used to locate group_info.db"),
285
+ passphrase: z.string().optional(),
286
+ limit: z.number().int().positive().max(5000).optional().default(200),
287
+ },
288
+ },
289
+ async ({ groupQq, dbPath, passphrase, limit }) => {
290
+ try {
291
+ const source = resolveSource("nt_msg.db", dbPath);
292
+ const key = requirePassphrase(passphrase);
293
+ const groupInfoPath = sibling(source, "group_info.db");
294
+ if (!groupInfoPath) throw new Error("group_info.db not found next to the selected database");
295
+ const members = listGroupMembers(groupInfoPath, { passphrase: key, groupQq, limit });
296
+ return ok({ groupInfo: groupInfoPath, count: members.length, members });
297
+ } catch (error) {
298
+ return fail(error);
299
+ }
300
+ }
301
+ );
302
+
303
+ /**
304
+ * Offline: decrypt a QQNT `<db>.material` file (PBKDF2-HMAC-SHA512 + AES-256-CBC,
305
+ * IV=0) and list the database schema it embeds. Independent of any database.
306
+ */
307
+ server.registerTool(
308
+ "get_db_schema",
309
+ {
310
+ description:
311
+ "Decrypts a QQNT .material key-material file and returns the database schema " +
312
+ "(tables/indexes of nt_msg.db etc). Needs only the passphrase — no database access.",
313
+ inputSchema: {
314
+ materialPath: z.string().describe("Path to e.g. nt_msg.db-first.material"),
315
+ passphrase: z.string().optional().describe("Defaults to QQ_MCP_PASSPHRASE or .qqkey"),
316
+ kdfIter: z.number().int().positive().optional().default(DEFAULT_KDF_ITER),
317
+ },
318
+ },
319
+ async ({ materialPath, passphrase, kdfIter }) => {
320
+ try {
321
+ const key = requirePassphrase(passphrase);
322
+ const { salt, plaintext } = decryptMaterial(materialPath, key, kdfIter);
323
+ const tables = extractSchema(plaintext);
324
+ return ok({
325
+ saltHex: salt.toString("hex"),
326
+ byteLen: plaintext.length,
327
+ tableCount: tables.length,
328
+ tables: tables.map((t) => ({
329
+ name: t.name,
330
+ columns: t.columns ?? [],
331
+ statement: t.statement?.slice(0, 400) ?? null,
332
+ })),
333
+ });
334
+ } catch (error) {
335
+ return fail(error);
336
+ }
337
+ }
338
+ );
339
+
340
+ const transport = new StdioServerTransport();
341
+ await server.connect(transport);