kojee-mcp 0.2.1 → 0.4.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 CHANGED
@@ -1,14 +1,36 @@
1
1
  # kojee-mcp
2
2
 
3
- kojee-mcp is a local MCP proxy that lets any MCP-capable agent use Kojee-managed tools seamlessly. It handles DPoP authentication, key enrollment, nonce rotation, step-up re-auth, and governance flows internally -- the agent just sees tools and calls them.
3
+ There are two ways to connect Kojee to an MCP-capable agent:
4
4
 
5
- ## Quick Start
5
+ 1. **Mobile, web & desktop (recommended)** — paste the Kojee MCP URL into the app's "Add custom connector" dialog. The app handles OAuth login and consent. No local install. Works on Claude (web/desktop/iOS/Android) and ChatGPT (web/desktop/iOS/Android with Developer Mode enabled).
6
+ 2. **Power user / local proxy with DPoP** — run this stdio proxy locally with a gateway token. Strongest wire-level security (DPoP proof-of-possession, RFC 9449), but requires Node and a token you generate manually in the Kojee dashboard.
6
7
 
7
- 1. **Get a gateway token** from the [Kojee dashboard](https://kojee.ai).
8
- 2. **Add to your MCP config** (see examples below).
9
- 3. **Tools appear automatically** -- all Kojee tools your token has access to are exposed directly (e.g. `gmail_send_email`, `github_list_repos`, `calendar_create_event`). Agents call them like any native MCP tool — no discovery step, no wrapper, no indirection.
8
+ ## Mobile, Web & Desktop (Recommended)
10
9
 
11
- ## MCP Config Examples
10
+ Paste this URL into the app's connector dialog:
11
+
12
+ ```
13
+ https://api.kojee.ai/mcp
14
+ ```
15
+
16
+ - **Claude (any surface):** Settings → Connectors → Add custom connector → paste URL → Connect → log in to Kojee → approve scopes.
17
+ - **ChatGPT (any surface, Developer Mode on):** Settings → Connectors → New connector → paste URL → choose OAuth → Connect.
18
+
19
+ The OAuth login + consent flow takes care of everything — no token paste, no config file, no Node required. Tokens are short-lived (1 h) and audience-bound to the MCP URL (RFC 8707). Refresh tokens rotate on every use.
20
+
21
+ ### Remote MCP URL details
22
+
23
+ | Field | Value |
24
+ |---|---|
25
+ | MCP URL | `https://api.kojee.ai/mcp` |
26
+ | Transport | Streamable HTTP (POST + GET-SSE) |
27
+ | Auth | OAuth 2.1 + Dynamic Client Registration (RFC 7591) |
28
+ | Discovery | `https://api.kojee.ai/.well-known/oauth-protected-resource` |
29
+ | Scopes | `mcp:tools`, `mcp:read` |
30
+
31
+ ## Power User: Local Proxy with DPoP
32
+
33
+ The `kojee-mcp` stdio proxy holds a `gw_` gateway token + ES256 keypair locally and signs every request with DPoP (RFC 9449). This survives token-in-transit theft (TLS-terminating proxy logs, etc.) — strictly stronger wire-level posture than bearer-only OAuth, but requires a long-lived token.
12
34
 
13
35
  ### Claude Code / Claude Desktop
14
36
 
@@ -25,7 +47,7 @@ kojee-mcp is a local MCP proxy that lets any MCP-capable agent use Kojee-managed
25
47
 
26
48
  ### Generic MCP Client
27
49
 
28
- Any MCP client that supports stdio transports can use kojee-mcp. Point it at the CLI binary:
50
+ Any MCP client that supports stdio transports can use kojee-mcp:
29
51
 
30
52
  ```bash
31
53
  npx kojee-mcp --token gw_abc123... --url https://kojee.ai
@@ -46,6 +68,117 @@ kojee-mcp --token gw_abc123... --url https://kojee.ai
46
68
  | `--url` | yes | -- | Kojee broker URL (e.g. `https://kojee.ai`) |
47
69
  | `--keystore-path` | no | `~/.kojee/keypair.json` | Path for DPoP keypair storage |
48
70
 
71
+ ## Pair Mode (Tandem)
72
+
73
+ If your token comes from the Kojee Tandem web wizard (a "pair code"), you can persist it for future runs:
74
+
75
+ ```bash
76
+ # One-time setup:
77
+ npx kojee-mcp pair ABCD-1234 --url https://rosie-server.kojee.net
78
+ npx kojee-mcp init # adds the MCP entry to EVERY detected Claude installation
79
+ ```
80
+
81
+ `init` writes to both `~/.claude.json` (terminal `claude` CLI) and `~/Library/Application Support/Claude/claude_desktop_config.json` (Claude.app on macOS — Windows / Linux paths in the spec) if either exists. The canonical MCP entry includes `env.KOJEE_RUNTIME=claude-code` so the proxy correctly identifies as Claude Code even when the desktop app strips environment variables from MCP subprocesses.
82
+
83
+ **Important for Claude.app users:** existing agent-mode sessions snapshot the MCP config at creation time and won't pick up changes from `init`. Start a NEW session (not a resumed one) to use the updated kojee config.
84
+
85
+ Subsequent runs of `claude` will find kojee automatically. The proxy writes credentials to `~/.kojee/config.json` (mode 0600). Existing `--token` users are unaffected.
86
+
87
+ To remove kojee from Claude:
88
+ ```bash
89
+ npx kojee-mcp init --uninstall
90
+ ```
91
+
92
+ ## Claude Code Channels Support
93
+
94
+ When this proxy detects it's running under Claude Code, it declares the `claude/channel` capability and pushes Tandem messages directly into the Claude session via `notifications/claude/channel`. Other MCP clients (Cursor, Codex, Openclaw, etc.) see no behavior change.
95
+
96
+ **Runtime detection (3 tiers, first match wins):**
97
+ 1. `KOJEE_RUNTIME=claude-code` env var (set by `kojee-mcp init` in the MCP entry's `env` block — survives Claude.app's env strip)
98
+ 2. `CLAUDE_CODE_SESSION_ID` env var (set by the terminal `claude` CLI; **not** forwarded by Claude.app to MCP stdio servers)
99
+ 3. Process-ancestry walk for a parent process whose command line matches `\bclaude\b` (handles fresh installs where neither env var is present)
100
+
101
+ CC Channels are in research preview — you must launch CC with `claude --dangerously-load-development-channels server:kojee` until kojee is on the official allowlist.
102
+
103
+ ## Hooks Support (always-on, no allowlist required)
104
+
105
+ While Channels is in research preview and gated behind Anthropic's allowlist, kojee also supports the standard Claude Code hooks system as a complementary wake path. Hooks require no special launch flag.
106
+
107
+ If you used `kojee-mcp init` above, hooks are already installed. The standalone command is:
108
+
109
+ ```bash
110
+ npx kojee-mcp install-hooks
111
+ ```
112
+
113
+ This writes to **`~/.claude/settings.json`** (the file CC reads for hooks — NOT `~/.claude.json`, which is the MCP-servers file). Foreign hooks already in `settings.json` (e.g. babysitter wrappers) are preserved as siblings.
114
+
115
+ Restart Claude Code. Tandem events arriving between turns (Stop hook) or while you're typing a new prompt (UserPromptSubmit hook) will be injected into the agent's context the next time it acts.
116
+
117
+ To remove:
118
+
119
+ ```bash
120
+ npx kojee-mcp install-hooks --uninstall
121
+ ```
122
+
123
+ Override the default `~/.claude/settings.json` path with `--hooks-path <path>` if needed.
124
+
125
+ If both Channels (dangerous-flag launched) and hooks are active, the proxy deduplicates so events arrive exactly once. Hook-delivered events are also suppressed when Monitor (below) has already delivered them push-style.
126
+
127
+ ## Waiting on Tandem Peers
128
+
129
+ When kojee-mcp is running under Claude Code, the proxy writes one line per Tandem message to a per-session log file. The agent watches this log via Claude Code's built-in `Monitor` tool.
130
+
131
+ **You don't need to figure out the path yourself.** The proxy bakes the resolved path into the MCP server's `instructions` string at startup, so the agent gets the exact command to spawn:
132
+
133
+ ```ts
134
+ Monitor({
135
+ command: "tail -n +1 -F /tmp/kojee-events-<discoveryKey>.log",
136
+ persistent: true,
137
+ description: "kojee Tandem events",
138
+ });
139
+ ```
140
+
141
+ The `<discoveryKey>` is computed from `sha256(CLAUDE_PROJECT_DIR).slice(0,12) + '-' + <ccPid>` where `ccPid` is the parent Claude Code process. (This replaces the pre-v0.4 scheme that used `CLAUDE_CODE_SESSION_ID`, which Claude.app doesn't forward to MCP servers.) The matching `~/.kojee/sessions/cc-<discoveryKey>.json` discovery file lets the Stop / UserPromptSubmit hooks find this proxy via the same independent derivation.
142
+
143
+ Each appended event line arrives as a separate spontaneous wake notification. The agent stays free to chat with the user between events; CC delivers each event from idle as it arrives.
144
+
145
+ **Stop hook is Monitor-aware** (v0.4+): when a Monitor is watching the log file, the Stop hook does an instant queue snapshot (~50ms) instead of a 30s long-poll, since events have already been delivered push-style. When no Monitor is running, the Stop hook long-polls for up to 30s AND emits a "spawn a Monitor" recommendation to the agent — self-healing if the session-start spawn was skipped.
146
+
147
+ Channel notifications (when available — see "Claude Code Channels Support" above) supplement this with mid-turn `<channel>` tag delivery, but Monitor is the default no-allowlist wake path that works for every user.
148
+
149
+ For one-shot blocking waits (return as soon as a single reply arrives), call `tandem_listen(tandem_id, since=cursor, timeout_ms=N)` instead.
150
+
151
+ ## Backend SSE Wire Compatibility
152
+
153
+ The proxy normalizes incoming SSE events from `/api/v2/tandems/stream` so that the on-the-wire payload shape (which currently differs from the spec'd `TandemEvent`) is mapped transparently:
154
+
155
+ | Wire field | Internal field |
156
+ |---|---|
157
+ | `message_id` | `id` |
158
+ | `sender.principal_id` | `from.principal` |
159
+ | `sender.agent_id` | `from.agent_id` |
160
+ | top-level `body` | `content.body` |
161
+ | top-level `format` | `content.format` |
162
+ | (missing) `displayname` | synthesized as `principal:<first-8-chars-of-principal_id>` |
163
+
164
+ The normalizer also accepts canonical (spec-aligned) payloads unchanged, so the proxy works against either shape during any future backend migration.
165
+
166
+ ## Development
167
+
168
+ Run tests:
169
+ ```bash
170
+ npm install
171
+ npm test
172
+ ```
173
+
174
+ The test suite uses an in-process stub broker (`dev-tools/stub-broker.ts`) — no external dependencies, no Docker, no MongoDB. CI runs the same way.
175
+
176
+ Run the stub manually for hands-on CC verification:
177
+ ```bash
178
+ npm run dev:stub
179
+ # Stub listens on http://localhost:8765
180
+ ```
181
+
49
182
  ## How Approvals Work
50
183
 
51
184
  Some tools are governed by approval policies configured in the Kojee dashboard. When an agent calls a governed tool:
@@ -0,0 +1,51 @@
1
+ // src/runtime/ancestry.ts
2
+ import childProcess from "child_process";
3
+ import { createHash } from "crypto";
4
+ function findClaudeAncestorPid(startPid = process.ppid) {
5
+ if (process.platform === "win32") return null;
6
+ let pid = startPid;
7
+ for (let depth = 0; depth < 20 && pid !== void 0 && pid > 1; depth++) {
8
+ let row;
9
+ try {
10
+ row = readProcInfo(pid);
11
+ } catch {
12
+ return null;
13
+ }
14
+ if (!row) return null;
15
+ if (/\bclaude\b/.test(row.command)) return pid;
16
+ if (row.ppid === void 0 || row.ppid === pid) return null;
17
+ pid = row.ppid;
18
+ }
19
+ return null;
20
+ }
21
+ function readProcInfo(pid) {
22
+ const out = childProcess.execFileSync("ps", ["-ww", "-p", String(pid), "-o", "ppid=,command="], {
23
+ encoding: "utf8",
24
+ stdio: ["ignore", "pipe", "ignore"]
25
+ }).trim();
26
+ if (!out) return null;
27
+ const firstSpace = out.indexOf(" ");
28
+ if (firstSpace < 0) return null;
29
+ const ppid = Number.parseInt(out.slice(0, firstSpace).trim(), 10);
30
+ if (!Number.isFinite(ppid)) return null;
31
+ const command = out.slice(firstSpace + 1).trim();
32
+ return { command, ppid };
33
+ }
34
+ function deriveDiscoveryKey(projectDir, ccPid) {
35
+ const hasProjectDir = typeof projectDir === "string" && projectDir.length > 0;
36
+ if (!hasProjectDir && ccPid === null) {
37
+ return `orphan-${process.pid}`;
38
+ }
39
+ if (!hasProjectDir) {
40
+ const hash2 = createHash("sha256").update("<no-project>").digest("hex").slice(0, 12);
41
+ return `${hash2}-${ccPid}`;
42
+ }
43
+ const hash = createHash("sha256").update(projectDir).digest("hex").slice(0, 12);
44
+ if (ccPid === null) return `${hash}-orphan`;
45
+ return `${hash}-${ccPid}`;
46
+ }
47
+
48
+ export {
49
+ findClaudeAncestorPid,
50
+ deriveDiscoveryKey
51
+ };
@@ -0,0 +1,33 @@
1
+ // src/auth/paired-config.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ function pairedConfigPath() {
5
+ return path.join(process.env["HOME"] ?? "~", ".kojee", "config.json");
6
+ }
7
+ function loadPairedConfig(filePath = pairedConfigPath()) {
8
+ try {
9
+ const raw = fs.readFileSync(filePath, "utf8");
10
+ return JSON.parse(raw);
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+ function savePairedConfig(filePath, config) {
16
+ const dir = path.dirname(filePath);
17
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
18
+ try {
19
+ fs.chmodSync(dir, 448);
20
+ } catch {
21
+ }
22
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2), { mode: 384 });
23
+ try {
24
+ fs.chmodSync(filePath, 384);
25
+ } catch {
26
+ }
27
+ }
28
+
29
+ export {
30
+ pairedConfigPath,
31
+ loadPairedConfig,
32
+ savePairedConfig
33
+ };
@@ -0,0 +1,142 @@
1
+ // src/tandem/session-discovery.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ function sessionDiscoveryDir() {
5
+ return path.join(process.env["HOME"] ?? "~", ".kojee", "sessions");
6
+ }
7
+ function sessionDiscoveryPath(sessionId) {
8
+ return path.join(sessionDiscoveryDir(), `${sessionId}.json`);
9
+ }
10
+ function discoveryFileName(key) {
11
+ return `cc-${key}.json`;
12
+ }
13
+ function writeSessionDiscovery(sessionId, entry) {
14
+ const dir = sessionDiscoveryDir();
15
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
16
+ try {
17
+ fs.chmodSync(dir, 448);
18
+ } catch {
19
+ }
20
+ const filePath = sessionDiscoveryPath(sessionId);
21
+ fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 384 });
22
+ try {
23
+ fs.chmodSync(filePath, 384);
24
+ } catch {
25
+ }
26
+ }
27
+ function readSessionDiscovery(sessionId) {
28
+ try {
29
+ const raw = fs.readFileSync(sessionDiscoveryPath(sessionId), "utf8");
30
+ return JSON.parse(raw);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ function cleanupSessionDiscovery(sessionId) {
36
+ try {
37
+ fs.unlinkSync(sessionDiscoveryPath(sessionId));
38
+ } catch {
39
+ }
40
+ }
41
+ function discoveryPathForKey(key) {
42
+ return path.join(sessionDiscoveryDir(), discoveryFileName(key));
43
+ }
44
+ function writeDiscoveryByKey(key, entry) {
45
+ const dir = sessionDiscoveryDir();
46
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
47
+ try {
48
+ fs.chmodSync(dir, 448);
49
+ } catch {
50
+ }
51
+ const filePath = discoveryPathForKey(key);
52
+ fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 384 });
53
+ try {
54
+ fs.chmodSync(filePath, 384);
55
+ } catch {
56
+ }
57
+ }
58
+ function cleanupDiscoveryByKey(key) {
59
+ try {
60
+ fs.unlinkSync(discoveryPathForKey(key));
61
+ } catch {
62
+ }
63
+ }
64
+ function readSessionDiscoveryByKey(key) {
65
+ try {
66
+ const raw = fs.readFileSync(discoveryPathForKey(key), "utf8");
67
+ return JSON.parse(raw);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+ var DEFAULT_SWEEP_MIN_AGE_MS = 6e4;
73
+ function isProcessAlive(pid) {
74
+ try {
75
+ process.kill(pid, 0);
76
+ return true;
77
+ } catch (err) {
78
+ if (err.code === "EPERM") return true;
79
+ return false;
80
+ }
81
+ }
82
+ function sweepStaleDiscovery(opts = {}) {
83
+ const minAgeMs = opts.minAgeMs ?? DEFAULT_SWEEP_MIN_AGE_MS;
84
+ const dir = sessionDiscoveryDir();
85
+ let entries;
86
+ try {
87
+ entries = fs.readdirSync(dir);
88
+ } catch {
89
+ return;
90
+ }
91
+ const now = Date.now();
92
+ for (const name of entries) {
93
+ if (!name.endsWith(".json")) continue;
94
+ const filePath = path.join(dir, name);
95
+ let stat;
96
+ try {
97
+ stat = fs.statSync(filePath);
98
+ } catch {
99
+ continue;
100
+ }
101
+ const ageMs = Math.max(0, now - stat.mtimeMs);
102
+ if (ageMs < minAgeMs) continue;
103
+ let parsed = null;
104
+ try {
105
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
106
+ } catch {
107
+ try {
108
+ fs.unlinkSync(filePath);
109
+ } catch {
110
+ }
111
+ continue;
112
+ }
113
+ if (parsed?.schema !== 2) {
114
+ try {
115
+ fs.unlinkSync(filePath);
116
+ } catch {
117
+ }
118
+ continue;
119
+ }
120
+ if (typeof parsed.proxyPid !== "number" || !isProcessAlive(parsed.proxyPid)) {
121
+ try {
122
+ fs.unlinkSync(filePath);
123
+ } catch {
124
+ }
125
+ continue;
126
+ }
127
+ }
128
+ }
129
+
130
+ export {
131
+ sessionDiscoveryDir,
132
+ sessionDiscoveryPath,
133
+ discoveryFileName,
134
+ writeSessionDiscovery,
135
+ readSessionDiscovery,
136
+ cleanupSessionDiscovery,
137
+ discoveryPathForKey,
138
+ writeDiscoveryByKey,
139
+ cleanupDiscoveryByKey,
140
+ readSessionDiscoveryByKey,
141
+ sweepStaleDiscovery
142
+ };
@@ -0,0 +1,72 @@
1
+ // src/hooks/hook-input.ts
2
+ var MAX_STDIN_BYTES = 1024 * 1024;
3
+ var IDLE_TIMEOUT_MS = 50;
4
+ var NULL_RESULT = {
5
+ sessionId: null,
6
+ transcriptPath: null,
7
+ hookEventName: null,
8
+ raw: ""
9
+ };
10
+ async function readHookStdin() {
11
+ const raw = await drainStdinBuffered();
12
+ if (raw === null) {
13
+ return { ...NULL_RESULT };
14
+ }
15
+ if (raw === "") {
16
+ return { ...NULL_RESULT };
17
+ }
18
+ try {
19
+ const parsed = JSON.parse(raw);
20
+ return {
21
+ sessionId: stringOrNull(parsed["session_id"]),
22
+ transcriptPath: stringOrNull(parsed["transcript_path"]),
23
+ hookEventName: stringOrNull(parsed["hook_event_name"]),
24
+ raw
25
+ };
26
+ } catch {
27
+ return {
28
+ sessionId: null,
29
+ transcriptPath: null,
30
+ hookEventName: null,
31
+ raw
32
+ };
33
+ }
34
+ }
35
+ function stringOrNull(value) {
36
+ return typeof value === "string" ? value : null;
37
+ }
38
+ function drainStdinBuffered() {
39
+ return new Promise((resolve) => {
40
+ const chunks = [];
41
+ let total = 0;
42
+ let overflowed = false;
43
+ let settled = false;
44
+ const finish = (value) => {
45
+ if (settled) return;
46
+ settled = true;
47
+ resolve(value);
48
+ };
49
+ const onData = (chunk) => {
50
+ if (overflowed) return;
51
+ total += chunk.length;
52
+ if (total > MAX_STDIN_BYTES) {
53
+ overflowed = true;
54
+ chunks.length = 0;
55
+ finish(null);
56
+ return;
57
+ }
58
+ chunks.push(chunk);
59
+ };
60
+ process.stdin.on("data", onData);
61
+ process.stdin.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
62
+ process.stdin.on("error", () => finish(Buffer.concat(chunks).toString("utf8")));
63
+ setTimeout(
64
+ () => finish(Buffer.concat(chunks).toString("utf8")),
65
+ IDLE_TIMEOUT_MS
66
+ );
67
+ });
68
+ }
69
+
70
+ export {
71
+ readHookStdin
72
+ };