@powercess/qq-mcp
v0.3.0
Published
MCP server that reads QQ (Windows NTQQ) chat records, contacts and database schema from local data — offline, no native dependencies.
Maintainers
Readme
qq-mcp
MCP server that reads QQ (Windows NTQQ) local chat records, contacts and database schema offline — the outcome of a reverse-engineering session against NTQQ 9.9.35.52892 (full findings: docs/reverse-engineering.md).
TypeScript + Node.js (@modelcontextprotocol/sdk), stdio transport, no native
dependencies: the SQLCipher page cipher is implemented in this repo.
What works
| Tool | Mode | What it does |
|---|---|---|
| list_qq_databases | offline | Enumerates nt_qq/nt_db databases (pages, salt, format version, .material presence) and running QQ PIDs |
| capture_offline_key | online¹ | Recovers the device passphrase from QQ's process memory, verified against a .material file or database page |
| verify_offline_key | offline | Checks a passphrase against one database, a .material file, or every local database at once |
| decrypt_qq_database | offline | Decrypts a database (WAL replayed) into a cached plaintext SQLite file under ~/.qq-mcp/plain, with row counts |
| query_chat_messages | offline | Queries messages by domain (c2c, group, c2c_temp, dataline, discuss, service_assistant, ai_assistant), peer/group, time range and keyword; returns typed segments |
| query_guild_messages | offline | QQ频道 messages from guild_msg.db, same typed-segment decoding |
| query_friend_profiles | offline | Friend profile cards (profile_info_v6: nickname, avatar URL, signature, home page, birthday), the buddy list, and pending add-requests with verification Q&A |
| query_files | offline | File records: chat files with local nt_data paths (files_in_chat.db), file-transfer-assistant history with download URLs (file_assistant.db), rich-media index (rich_media.db) |
| query_group_details | offline | Group metadata from group_info.db: details (owner, member count/cap), bulletin (公告) text, essence (精华), notifications, member levels, avatars |
| query_emoji | offline | Personal favourite emoji (CDN URL + local path), system emoji and subscribed market packages from emoji.db |
| query_session_state | offline | Session/UI state in nt_msg.db: @me index, unread counts, pinned sessions, deleted sessions, drafts, hidden sessions, search history, message backups |
| query_online_status | offline | Online-status records from misc.db (uid → status text, e.g. 手机在线) |
| query_flash_transfer | offline | 闪传/闪照 rows from nt_flash_transfer.db (raw, unmodelled columns) |
| query_collection | offline | Collection KV store from collection.db |
| search_messages_fts | offline | Keyword search over the FTS message-index content tables (buddy/group/c2c_temp/dataline/discuss/guild) — LIKE over the plain content rows, since QQ's pinyin_letter tokenizer is not available outside QQ |
| list_contacts | offline | Friends (QQ, NT uid, name, last activity), groups (number, name, members) and the QQ↔uid map |
| list_group_members | offline | Group roster from group_info.db: member QQ, uid, nickname, card, last speak time |
| get_db_schema | offline | Decrypts a .material DDL blob and returns the full schema (87 tables for nt_msg.db) |
¹ online = QQ must be logged in for memory scraping. Everything else works with QQ closed, straight off the files on disk. Windows only.
Install
npm install -g @powercess/qq-mcp # or: npx @powercess/qq-mcpFrom a source checkout:
npm install
npm run build
npm test # 23 tests, incl. real-database integration
node scripts/smoke-client.mjs # list tools
node scripts/smoke-client.mjs query_chat_messages '{ "chat": "group", "groupQq": 123456, "limit": 20 }'Configure as an MCP server (installed globally):
{
"mcpServers": {
"qq-mcp": {
"command": "npx",
"args": ["-y", "@powercess/qq-mcp"],
"env": { "QQ_MCP_PASSPHRASE": "<16-char device passphrase>" }
}
}
}The device passphrase
NTQQ derives database keys in-process; the passphrase is not stored on disk. It is a short ASCII string (16 or 20 bytes) inside the QQ process, and the same passphrase unlocks every database on the device.
Acquire it once, either way:
# 1. automatic: scan a logged-in QQ and verify candidates against known artefacts
node scripts/smoke-client.mjs capture_offline_key '{ "save": true }' # writes ~/.qq-mcp/qqkey (0600)
# 2. manual: debugger route documented in docs/reverse-engineering.mdProvide it to the server via the passphrase argument, QQ_MCP_PASSPHRASE, or
~/.qq-mcp/qqkey (written by capture_offline_key { "save": true }). Other
knobs: QQ_MCP_HOME (runtime directory, default ~/.qq-mcp), QQ_MCP_CACHE_DIR
(plaintext cache, default $QQ_MCP_HOME/plain), QQ_MCP_DATA_DIR (extra nt_db
directories, ;-separated), QQ_MCP_PYTHON (Python for the memory scanner).
How the decryption works
file = [1024-byte NTQQ header][page 1][page 2]...
page1 = [16-byte salt][ciphertext][16-byte IV][32-byte tag]
pageN = [ciphertext][16-byte IV][32-byte tag]
key = PBKDF2-HMAC-SHA512(passphrase, salt, 4000, 32)
data = AES-256-CBC, IV read from the page tailVerified byte-exact against SQLCipher 4.12 (same row counts, same
quick_check verdict) on a 2.4 GB nt_msg.db. Decryption runs as a streaming
transform: ~7 s for 2.4 GB, bounded memory. Committed -wal frames are replayed
after their checksum chain is validated; frames past a torn write are dropped.
A WAL can hold committed frames for pages past the current file's end (QQ is
mid-checkpoint); the WAL's dbSize then wins as the page count and the extra
pages are taken from WAL frames, so the plaintext is not truncated.
See docs/reverse-engineering.md for how each constant was pinned down and what is still open.
Limitations (honest)
- The 32-byte page tag is not authenticated: no standard HMAC layout matched, and decryption does not need it. Forged pages would not be detected.
- A database that QQ is actively writing to can yield a snapshot that mixes two
generations: QQ checkpoints while the 2.4 GB file is being read. The tools
report this as
sourceChangedinstead of presenting a torn read as clean. Close QQ for a guaranteed-consistent snapshot. - The layout assumes SQLite's
reservedbyte is 48 or more (NTQQ writes 80, so the usable page area ends at 4016, before the IV/tag at 4048). A stock SQLite file, which packs cells up to the page end, cannot be re-wrapped byencryptPagewithout losing its last 48 bytes. - Some QQ databases ship damaged index pages (
database disk image is malformedon indexed queries); table b-trees are intact. Queries page by primary key, and keyword search decodes protobuf text in JS, soquery_chat_messagestakesmaxScanas a depth guard. guild1.db(QQ频道 module state) uses a separate key domain: the device passphrase does not decrypt it. All other databases share the one passphrase.settings.dbon some installs is left malformed by QQ's own write path and cannot be read even after repair attempts.- The FTS virtual tables use QQ's custom
pinyin_lettertokenizer, which stock SQLite lacks —search_messages_ftssearches the plain content tables with LIKE instead of a true tokenized index. - Blob fields in
query_group_details(bulletin/notify),query_session_stateandquery_online_statusare decoded best-effort into readable strings; unmodelled protobuf field ids are reported alongside. - Message content coverage is empirical: text, image, video, file, sticker,
contact card, interactive card (
ark), nudge (戳一戳), reply, forward, legacy forward, call and system messages are typed. Unmodelled protobuf field ids are reported per segment instead of being dropped — see the open-questions list in the docs. - Windows-only; memory scanning requires Python 3 (stdlib only).
Layout
src/sqlcipher.ts page cipher, key derivation, WAL replay, reconstruction
src/messages.ts protobuf decoding of message bodies into typed segments
src/offline.ts database discovery, key resolution, plaintext cache, message domains + file/profile/group/emoji/session queries
src/keyscan.ts passphrase capture (memory scrape + oracle verification)
src/schema.ts offline .material DDL decryption
src/index.ts MCP tool surface
scripts/ scan_memory.py (key candidates), smoke-client.mjs
test/ node:test suites + hermetic fixture builders
docs/ reverse-engineering findingsPrivacy
Decrypted databases and the captured key live under ~/.qq-mcp (override with
QQ_MCP_HOME), never in the package directory; in a source checkout exports/
and .qqkey are gitignored. Captured chat data and keys are never committed —
do not publish them.
