threadctx-mcp 0.1.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/README.md +110 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +69 -0
- package/dist/cloud-client.d.ts +25 -0
- package/dist/cloud-client.js +50 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +82 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/local-store.d.ts +31 -0
- package/dist/local-store.js +81 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +110 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# threadctx-mcp
|
|
2
|
+
|
|
3
|
+
Shared memory MCP server for AI coding agents. Works identically with
|
|
4
|
+
**Claude Code** and **Cursor** — same package, same config shape, no
|
|
5
|
+
per-client integration work.
|
|
6
|
+
|
|
7
|
+
## Modes
|
|
8
|
+
|
|
9
|
+
- **Local (default, free, no signup):** memory stored in SQLite at
|
|
10
|
+
`~/.threadctx/local.json` — **zero native dependencies**, so
|
|
11
|
+
`npx threadctx-mcp` installs instantly on any machine with Node 18+ (no
|
|
12
|
+
compiler, no node-gyp step). No network calls except to whichever LLM
|
|
13
|
+
provider your agent already uses. Matching is keyword-based, scoped to
|
|
14
|
+
the current repo (detected via `git remote`).
|
|
15
|
+
- **Cloud (paid Team tier+):** memory shared across everyone on the repo,
|
|
16
|
+
with real semantic search. Requires an API key from
|
|
17
|
+
[threadctx.dev](https://threadctx.dev) (or your own self-hosted
|
|
18
|
+
deployment — see `../cloud/README.md`).
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Local mode — nothing to configure
|
|
24
|
+
npx threadctx-mcp
|
|
25
|
+
|
|
26
|
+
# Cloud mode — prints the exact MCP config block to paste
|
|
27
|
+
npx threadctx-mcp init --mode=cloud --api-key=tctx_xxx
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This writes a `.threadctx.json` file (just `{ "mode": "cloud" }`) to the
|
|
31
|
+
current directory. It is safe to commit — it never contains your API key.
|
|
32
|
+
The key is read from the `THREADCTX_API_KEY` environment variable at
|
|
33
|
+
runtime (set it in your MCP client's `env` block, as shown below), so
|
|
34
|
+
secrets stay out of version control by construction.
|
|
35
|
+
|
|
36
|
+
## Claude Code setup
|
|
37
|
+
|
|
38
|
+
Add to your Claude Code MCP config (`claude mcp add` or edit
|
|
39
|
+
`~/.claude/mcp.json` directly):
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"threadctx": {
|
|
45
|
+
"command": "npx",
|
|
46
|
+
"args": ["-y", "threadctx-mcp"],
|
|
47
|
+
"env": {
|
|
48
|
+
"THREADCTX_MODE": "cloud",
|
|
49
|
+
"THREADCTX_API_KEY": "tctx_xxx"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Cursor setup
|
|
57
|
+
|
|
58
|
+
Add the same block to `.cursor/mcp.json` in your project root (or via
|
|
59
|
+
Cursor Settings → Tools & MCP):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"threadctx": {
|
|
65
|
+
"command": "npx",
|
|
66
|
+
"args": ["-y", "threadctx-mcp"],
|
|
67
|
+
"env": {
|
|
68
|
+
"THREADCTX_MODE": "cloud",
|
|
69
|
+
"THREADCTX_API_KEY": "tctx_xxx"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That's it — the same package and config work in both clients because
|
|
77
|
+
MCP is a portable, open protocol.
|
|
78
|
+
|
|
79
|
+
## Environment variables
|
|
80
|
+
|
|
81
|
+
| Variable | Required | Description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `THREADCTX_MODE` | no | `local` (default) or `cloud` |
|
|
84
|
+
| `THREADCTX_API_KEY` | only in cloud mode | issued via `cloud/scripts/create-tenant.ts` |
|
|
85
|
+
| `THREADCTX_API_URL` | no | defaults to `https://threadctx.dev/api/v1`; override for self-hosting |
|
|
86
|
+
| `THREADCTX_REPO` | no | overrides repo auto-detection from `git remote` |
|
|
87
|
+
| `THREADCTX_DB_PATH` | no | local-mode store path; defaults to `~/.threadctx/local.json` |
|
|
88
|
+
|
|
89
|
+
## Local development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm install
|
|
93
|
+
npm run dev # runs the server via tsx, watches for changes
|
|
94
|
+
npm run build # compiles to dist/ for publishing
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## How the tools work
|
|
98
|
+
|
|
99
|
+
- `memory_write(content, tags?)` — the agent calls this after resolving a
|
|
100
|
+
non-obvious bug, making an architectural decision, or learning
|
|
101
|
+
something worth remembering.
|
|
102
|
+
- `memory_query(task_description, max_results?)` — the agent calls this
|
|
103
|
+
before starting risky or repeated work. Results are returned with a
|
|
104
|
+
consistent attribution footer (`· via threadctx — shared team memory (N
|
|
105
|
+
hits)`) so the same string is recognizable whether you're reading
|
|
106
|
+
Claude Code's terminal output or Cursor's agent panel.
|
|
107
|
+
|
|
108
|
+
Tool descriptions are deliberately written to bias the model toward
|
|
109
|
+
calling `memory_query` proactively — MCP is pull-based, so the agent has
|
|
110
|
+
to be prompted by the description to use it; it isn't automatic.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { startServer } from './server.js';
|
|
5
|
+
const [, , command, ...rest] = process.argv;
|
|
6
|
+
function parseFlags(args) {
|
|
7
|
+
const flags = {};
|
|
8
|
+
for (const arg of args) {
|
|
9
|
+
const match = arg.match(/^--([^=]+)=(.*)$/);
|
|
10
|
+
if (match)
|
|
11
|
+
flags[match[1]] = match[2];
|
|
12
|
+
}
|
|
13
|
+
return flags;
|
|
14
|
+
}
|
|
15
|
+
function runInit(args) {
|
|
16
|
+
const flags = parseFlags(args);
|
|
17
|
+
const mode = flags.mode === 'cloud' ? 'cloud' : 'local';
|
|
18
|
+
// The config file is meant to be committable, so it deliberately never holds
|
|
19
|
+
// the API key — that is read from the THREADCTX_API_KEY environment variable
|
|
20
|
+
// at runtime (set it in your MCP client's `env` block). This keeps secrets
|
|
21
|
+
// out of version control by construction.
|
|
22
|
+
const config = { mode };
|
|
23
|
+
if (mode === 'cloud' && flags['api-url'])
|
|
24
|
+
config.apiUrl = flags['api-url'];
|
|
25
|
+
const apiKey = flags['api-key'];
|
|
26
|
+
if (mode === 'cloud' && !apiKey) {
|
|
27
|
+
console.error('Cloud mode needs an API key. Pass --api-key=<tctx_...> so this command can');
|
|
28
|
+
console.error('show you the exact config block to paste.');
|
|
29
|
+
console.error('Example: npx threadctx-mcp init --mode=cloud --api-key=tctx_xxx');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
const configPath = join(process.cwd(), '.threadctx.json');
|
|
33
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
34
|
+
console.log(`✅ Wrote ${configPath} (mode: ${mode}) — safe to commit, contains no secret.`);
|
|
35
|
+
console.log('');
|
|
36
|
+
if (mode === 'cloud') {
|
|
37
|
+
console.log('Add threadctx to your MCP client config (~/.claude/mcp.json and/or .cursor/mcp.json).');
|
|
38
|
+
console.log('Same block for Claude Code and Cursor:');
|
|
39
|
+
console.log('');
|
|
40
|
+
console.log(JSON.stringify({
|
|
41
|
+
mcpServers: {
|
|
42
|
+
threadctx: {
|
|
43
|
+
command: 'npx',
|
|
44
|
+
args: ['-y', 'threadctx-mcp'],
|
|
45
|
+
env: { THREADCTX_MODE: 'cloud', THREADCTX_API_KEY: apiKey },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}, null, 2));
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log('Keep your API key in that env block (or your shell) — not in .threadctx.json.');
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
console.log('Local mode is ready — just add threadctx to your MCP client config. No key needed.');
|
|
54
|
+
console.log('See the README for the exact block (identical for Claude Code and Cursor).');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function main() {
|
|
58
|
+
if (command === 'init') {
|
|
59
|
+
runInit(rest);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// No subcommand: this is what Claude Code / Cursor actually launch as the
|
|
63
|
+
// MCP server process (they invoke `npx threadctx-mcp` with no arguments).
|
|
64
|
+
await startServer();
|
|
65
|
+
}
|
|
66
|
+
main().catch((err) => {
|
|
67
|
+
console.error('[threadctx] fatal error:', err);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface CloudWriteResponse {
|
|
2
|
+
status: string;
|
|
3
|
+
memory_id: string;
|
|
4
|
+
}
|
|
5
|
+
export interface CloudQueryResponse {
|
|
6
|
+
context_bundle: string;
|
|
7
|
+
referenced_memory_ids: string[];
|
|
8
|
+
debug?: {
|
|
9
|
+
num_candidates: number;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Thin HTTP client for the threadctx cloud API. Talks to either the
|
|
14
|
+
* hosted threadctx.dev service or a self-hosted deployment (set
|
|
15
|
+
* THREADCTX_API_URL to override).
|
|
16
|
+
*/
|
|
17
|
+
export declare class CloudClient {
|
|
18
|
+
private apiUrl;
|
|
19
|
+
private apiKey;
|
|
20
|
+
private actorId?;
|
|
21
|
+
constructor(apiUrl: string, apiKey: string, actorId?: string | undefined);
|
|
22
|
+
private request;
|
|
23
|
+
write(repo: string, content: string, tags: string[]): Promise<CloudWriteResponse>;
|
|
24
|
+
query(repo: string, taskDescription: string, maxResults: number): Promise<CloudQueryResponse>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin HTTP client for the threadctx cloud API. Talks to either the
|
|
3
|
+
* hosted threadctx.dev service or a self-hosted deployment (set
|
|
4
|
+
* THREADCTX_API_URL to override).
|
|
5
|
+
*/
|
|
6
|
+
export class CloudClient {
|
|
7
|
+
apiUrl;
|
|
8
|
+
apiKey;
|
|
9
|
+
actorId;
|
|
10
|
+
constructor(apiUrl, apiKey, actorId) {
|
|
11
|
+
this.apiUrl = apiUrl;
|
|
12
|
+
this.apiKey = apiKey;
|
|
13
|
+
this.actorId = actorId;
|
|
14
|
+
}
|
|
15
|
+
async request(path, body) {
|
|
16
|
+
const headers = {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
19
|
+
};
|
|
20
|
+
// Anonymous per-developer id for seat accounting (see config.resolveActorId).
|
|
21
|
+
if (this.actorId)
|
|
22
|
+
headers['X-Threadctx-Actor'] = this.actorId;
|
|
23
|
+
const res = await fetch(`${this.apiUrl}${path}`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers,
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const text = await res.text().catch(() => '');
|
|
30
|
+
if (res.status === 402) {
|
|
31
|
+
throw new Error('threadctx free-tier quota reached for this period. Upgrade at https://threadctx.dev/pricing.');
|
|
32
|
+
}
|
|
33
|
+
if (res.status === 401) {
|
|
34
|
+
throw new Error('threadctx API key is invalid or missing. Check THREADCTX_API_KEY.');
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`threadctx cloud request failed (${res.status}): ${text}`);
|
|
37
|
+
}
|
|
38
|
+
return res.json();
|
|
39
|
+
}
|
|
40
|
+
write(repo, content, tags) {
|
|
41
|
+
return this.request('/memory/write', { repo, content, tags });
|
|
42
|
+
}
|
|
43
|
+
query(repo, taskDescription, maxResults) {
|
|
44
|
+
return this.request('/memory/query', {
|
|
45
|
+
repo,
|
|
46
|
+
task_description: taskDescription,
|
|
47
|
+
max_results: maxResults,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
const DEFAULT_API_URL = 'https://threadctx.dev/api/v1';
|
|
7
|
+
function readFileConfig() {
|
|
8
|
+
const projectConfigPath = join(process.cwd(), '.threadctx.json');
|
|
9
|
+
const homeConfigPath = join(os.homedir(), '.threadctx', 'config.json');
|
|
10
|
+
const path = existsSync(projectConfigPath)
|
|
11
|
+
? projectConfigPath
|
|
12
|
+
: existsSync(homeConfigPath)
|
|
13
|
+
? homeConfigPath
|
|
14
|
+
: null;
|
|
15
|
+
if (!path)
|
|
16
|
+
return {};
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
console.error(`[threadctx] Failed to parse config at ${path}:`, err);
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function detectRepoName() {
|
|
26
|
+
try {
|
|
27
|
+
const remote = execSync('git config --get remote.origin.url', {
|
|
28
|
+
cwd: process.cwd(),
|
|
29
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
})
|
|
31
|
+
.toString()
|
|
32
|
+
.trim();
|
|
33
|
+
const match = remote.match(/[:/]([^/]+\/[^/]+?)(\.git)?$/);
|
|
34
|
+
return match ? match[1] : 'unknown-repo';
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 'unknown-repo';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A stable, anonymous per-developer id used only for seat accounting in cloud
|
|
42
|
+
* mode (distinct-actor counting vs. purchased seats). It's a random UUID
|
|
43
|
+
* persisted once at ~/.threadctx/actor — no email, no machine fingerprint, no
|
|
44
|
+
* PII. Can be overridden with THREADCTX_ACTOR_ID (e.g. to pin per-CI identities).
|
|
45
|
+
*/
|
|
46
|
+
function resolveActorId(dbPath) {
|
|
47
|
+
if (process.env.THREADCTX_ACTOR_ID)
|
|
48
|
+
return process.env.THREADCTX_ACTOR_ID;
|
|
49
|
+
const actorPath = join(dirname(dbPath), 'actor');
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(actorPath)) {
|
|
52
|
+
const existing = readFileSync(actorPath, 'utf-8').trim();
|
|
53
|
+
if (existing)
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
const id = randomUUID();
|
|
57
|
+
mkdirSync(dirname(actorPath), { recursive: true });
|
|
58
|
+
writeFileSync(actorPath, id, 'utf-8');
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// If disk isn't writable, fall back to an ephemeral id — seat counts will
|
|
63
|
+
// be slightly inflated for this session, which is acceptable and safe.
|
|
64
|
+
return randomUUID();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function loadConfig() {
|
|
68
|
+
const fileConfig = readFileConfig();
|
|
69
|
+
const apiKey = process.env.THREADCTX_API_KEY ?? fileConfig.apiKey;
|
|
70
|
+
const mode = process.env.THREADCTX_MODE ??
|
|
71
|
+
fileConfig.mode ??
|
|
72
|
+
(apiKey ? 'cloud' : 'local');
|
|
73
|
+
const dbPath = process.env.THREADCTX_DB_PATH ?? join(os.homedir(), '.threadctx', 'local.json');
|
|
74
|
+
return {
|
|
75
|
+
mode,
|
|
76
|
+
apiKey,
|
|
77
|
+
apiUrl: process.env.THREADCTX_API_URL ?? fileConfig.apiUrl ?? DEFAULT_API_URL,
|
|
78
|
+
repo: process.env.THREADCTX_REPO ?? fileConfig.repo ?? detectRepoName(),
|
|
79
|
+
dbPath,
|
|
80
|
+
actorId: resolveActorId(dbPath),
|
|
81
|
+
};
|
|
82
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface LocalMemory {
|
|
2
|
+
id: string;
|
|
3
|
+
content: string;
|
|
4
|
+
tags: string[];
|
|
5
|
+
created_at: string;
|
|
6
|
+
score: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Local mode storage: a single JSON file on disk, zero network calls,
|
|
10
|
+
* zero accounts, and — deliberately — zero native dependencies.
|
|
11
|
+
*
|
|
12
|
+
* We avoid a native SQLite addon on purpose: the headline install path is
|
|
13
|
+
* `npx threadctx-mcp`, and a node-gyp compile step is the most common way
|
|
14
|
+
* that "30-second install" promise breaks (toolchain missing, Node ABI
|
|
15
|
+
* mismatch, etc.). A flat JSON file is more than enough for one developer's
|
|
16
|
+
* own session history (hundreds–thousands of entries) and works on every
|
|
17
|
+
* Node >= 18 without a compiler.
|
|
18
|
+
*
|
|
19
|
+
* Matching is simple keyword overlap rather than semantic search — that keeps
|
|
20
|
+
* local mode embedding-free (no API key required). Cloud mode (CloudClient)
|
|
21
|
+
* gets real semantic search via Upstash Vector.
|
|
22
|
+
*/
|
|
23
|
+
export declare class LocalStore {
|
|
24
|
+
private dbPath;
|
|
25
|
+
private memories;
|
|
26
|
+
constructor(dbPath: string);
|
|
27
|
+
private load;
|
|
28
|
+
private persist;
|
|
29
|
+
write(repo: string, content: string, tags: string[]): string;
|
|
30
|
+
query(repo: string, taskDescription: string, maxResults: number): LocalMemory[];
|
|
31
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Local mode storage: a single JSON file on disk, zero network calls,
|
|
6
|
+
* zero accounts, and — deliberately — zero native dependencies.
|
|
7
|
+
*
|
|
8
|
+
* We avoid a native SQLite addon on purpose: the headline install path is
|
|
9
|
+
* `npx threadctx-mcp`, and a node-gyp compile step is the most common way
|
|
10
|
+
* that "30-second install" promise breaks (toolchain missing, Node ABI
|
|
11
|
+
* mismatch, etc.). A flat JSON file is more than enough for one developer's
|
|
12
|
+
* own session history (hundreds–thousands of entries) and works on every
|
|
13
|
+
* Node >= 18 without a compiler.
|
|
14
|
+
*
|
|
15
|
+
* Matching is simple keyword overlap rather than semantic search — that keeps
|
|
16
|
+
* local mode embedding-free (no API key required). Cloud mode (CloudClient)
|
|
17
|
+
* gets real semantic search via Upstash Vector.
|
|
18
|
+
*/
|
|
19
|
+
export class LocalStore {
|
|
20
|
+
dbPath;
|
|
21
|
+
memories;
|
|
22
|
+
constructor(dbPath) {
|
|
23
|
+
this.dbPath = dbPath;
|
|
24
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
25
|
+
this.memories = this.load();
|
|
26
|
+
}
|
|
27
|
+
load() {
|
|
28
|
+
if (!existsSync(this.dbPath))
|
|
29
|
+
return [];
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(readFileSync(this.dbPath, 'utf-8'));
|
|
32
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Corrupt or partially-written file: don't crash the agent's session.
|
|
36
|
+
// Start fresh in memory; the next write rewrites a valid file.
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
persist() {
|
|
41
|
+
// Atomic write: serialize to a temp file then rename, so a crash mid-write
|
|
42
|
+
// can never leave a half-written (and unparseable) store behind.
|
|
43
|
+
const tmp = `${this.dbPath}.${process.pid}.tmp`;
|
|
44
|
+
writeFileSync(tmp, JSON.stringify(this.memories, null, 2), 'utf-8');
|
|
45
|
+
renameSync(tmp, this.dbPath);
|
|
46
|
+
}
|
|
47
|
+
write(repo, content, tags) {
|
|
48
|
+
const id = randomUUID();
|
|
49
|
+
this.memories.push({
|
|
50
|
+
id,
|
|
51
|
+
repo,
|
|
52
|
+
content,
|
|
53
|
+
tags,
|
|
54
|
+
created_at: new Date().toISOString(),
|
|
55
|
+
});
|
|
56
|
+
this.persist();
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
query(repo, taskDescription, maxResults) {
|
|
60
|
+
const keywords = taskDescription
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.split(/\W+/)
|
|
63
|
+
.filter((w) => w.length > 3);
|
|
64
|
+
return this.memories
|
|
65
|
+
.filter((m) => m.repo === repo)
|
|
66
|
+
.map((m) => {
|
|
67
|
+
const haystack = `${m.content} ${m.tags.join(' ')}`.toLowerCase();
|
|
68
|
+
const score = keywords.reduce((acc, k) => acc + (haystack.includes(k) ? 1 : 0), 0);
|
|
69
|
+
return {
|
|
70
|
+
id: m.id,
|
|
71
|
+
content: m.content,
|
|
72
|
+
tags: m.tags,
|
|
73
|
+
created_at: m.created_at,
|
|
74
|
+
score,
|
|
75
|
+
};
|
|
76
|
+
})
|
|
77
|
+
.filter((m) => m.score > 0)
|
|
78
|
+
.sort((a, b) => b.score - a.score || (a.created_at < b.created_at ? 1 : -1))
|
|
79
|
+
.slice(0, maxResults);
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startServer(): Promise<void>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { LocalStore } from './local-store.js';
|
|
6
|
+
import { CloudClient } from './cloud-client.js';
|
|
7
|
+
// Consistent attribution string across every surface (Claude Code terminal
|
|
8
|
+
// output, Cursor's agent panel, future surfaces). See spec section 2.4 —
|
|
9
|
+
// the same short string everywhere is what makes the brand legible.
|
|
10
|
+
const attributionFooter = (n) => `· via threadctx — shared team memory (${n} hit${n === 1 ? '' : 's'})`;
|
|
11
|
+
export async function startServer() {
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
if (config.mode === 'cloud' && !config.apiKey) {
|
|
14
|
+
console.error('[threadctx] THREADCTX_MODE=cloud but no API key was found. Falling back to local mode. ' +
|
|
15
|
+
'Set THREADCTX_API_KEY or run `npx threadctx init --mode=cloud --api-key=...`.');
|
|
16
|
+
}
|
|
17
|
+
const useCloud = config.mode === 'cloud' && Boolean(config.apiKey);
|
|
18
|
+
const localStore = useCloud ? null : new LocalStore(config.dbPath);
|
|
19
|
+
const cloudClient = useCloud ? new CloudClient(config.apiUrl, config.apiKey, config.actorId) : null;
|
|
20
|
+
const repo = config.repo;
|
|
21
|
+
const server = new Server({ name: 'threadctx', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
22
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
23
|
+
tools: [
|
|
24
|
+
{
|
|
25
|
+
name: 'memory_write',
|
|
26
|
+
description: 'Store a learning, decision, fix, or gotcha for this repository so other agents and ' +
|
|
27
|
+
'teammates can find it later. Call this whenever you resolve a non-obvious bug, make an ' +
|
|
28
|
+
'architectural decision, or discover something that would save someone time in the future.',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
content: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'The learning to remember, written so a future reader has full context.',
|
|
35
|
+
},
|
|
36
|
+
tags: {
|
|
37
|
+
type: 'array',
|
|
38
|
+
items: { type: 'string' },
|
|
39
|
+
description: 'Optional short tags, e.g. ["incident", "retry-logic"].',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ['content'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'memory_query',
|
|
47
|
+
description: "Retrieve relevant past learnings, fixes, decisions, or gotchas from the team's shared " +
|
|
48
|
+
'memory before starting risky or repeated work — e.g. touching a service that has caused ' +
|
|
49
|
+
'incidents before, or implementing something similar to past work. Call this before, not after.',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
task_description: { type: 'string', description: 'What you are about to do, in plain language.' },
|
|
54
|
+
max_results: { type: 'number', description: 'Max number of memories to return (default 5).' },
|
|
55
|
+
},
|
|
56
|
+
required: ['task_description'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}));
|
|
61
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
62
|
+
const { name, arguments: args } = request.params;
|
|
63
|
+
try {
|
|
64
|
+
if (name === 'memory_write') {
|
|
65
|
+
const content = String(args?.content ?? '');
|
|
66
|
+
const rawTags = args?.tags;
|
|
67
|
+
const tags = Array.isArray(rawTags) ? rawTags.map(String) : [];
|
|
68
|
+
if (!content.trim())
|
|
69
|
+
throw new Error('content is required');
|
|
70
|
+
const id = localStore
|
|
71
|
+
? localStore.write(repo, content, tags)
|
|
72
|
+
: (await cloudClient.write(repo, content, tags)).memory_id;
|
|
73
|
+
return { content: [{ type: 'text', text: `Stored memory ${id} for ${repo}.` }] };
|
|
74
|
+
}
|
|
75
|
+
if (name === 'memory_query') {
|
|
76
|
+
const taskDescription = String(args?.task_description ?? '');
|
|
77
|
+
const rawMaxResults = args?.max_results;
|
|
78
|
+
const maxResults = typeof rawMaxResults === 'number' ? rawMaxResults : 5;
|
|
79
|
+
if (!taskDescription.trim())
|
|
80
|
+
throw new Error('task_description is required');
|
|
81
|
+
let bundle;
|
|
82
|
+
let hitCount;
|
|
83
|
+
if (localStore) {
|
|
84
|
+
const hits = localStore.query(repo, taskDescription, maxResults);
|
|
85
|
+
hitCount = hits.length;
|
|
86
|
+
bundle = hits.map((h) => `- ${h.content}`).join('\n');
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const result = await cloudClient.query(repo, taskDescription, maxResults);
|
|
90
|
+
hitCount = result.referenced_memory_ids?.length ?? 0;
|
|
91
|
+
bundle = result.context_bundle ?? '';
|
|
92
|
+
}
|
|
93
|
+
if (hitCount === 0) {
|
|
94
|
+
return { content: [{ type: 'text', text: 'No relevant team memory found for this task.' }] };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text', text: `${bundle}\n\n${attributionFooter(hitCount)}` }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
104
|
+
return { content: [{ type: 'text', text: `threadctx error: ${message}` }], isError: true };
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
const transport = new StdioServerTransport();
|
|
108
|
+
await server.connect(transport);
|
|
109
|
+
console.error(`[threadctx] MCP server running in ${useCloud ? 'cloud' : 'local'} mode for repo "${repo}".`);
|
|
110
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "threadctx-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared memory MCP server for AI coding agents. Local-only by default; point it at threadctx.dev (or your own deployment) to share memory across your team.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"threadctx": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"files": ["dist"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"dev": "tsx watch src/cli.ts",
|
|
14
|
+
"start": "node dist/cli.js",
|
|
15
|
+
"test": "npm run build && node test/smoke.mjs",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.0.4"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.14.10",
|
|
23
|
+
"tsx": "^4.16.2",
|
|
24
|
+
"typescript": "^5.5.4"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.18.0"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://threadctx.dev",
|
|
31
|
+
"repository": { "type": "git", "url": "git+https://github.com/threadctx-dev/threadctx.git", "directory": "mcp-server" },
|
|
32
|
+
"bugs": { "url": "https://github.com/threadctx-dev/threadctx/issues" },
|
|
33
|
+
"publishConfig": { "access": "public" },
|
|
34
|
+
"keywords": ["mcp", "model-context-protocol", "claude-code", "cursor", "ai-agent-memory", "shared-memory", "team-memory"]
|
|
35
|
+
}
|