kontex-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 ADDED
@@ -0,0 +1,32 @@
1
+ # kontex-mcp
2
+
3
+ One command to connect [Kontex](https://www.kontex.so) to Claude Desktop,
4
+ Claude Code, and Cursor.
5
+
6
+ ```bash
7
+ npx kontex-mcp connect kx_your_key
8
+ ```
9
+
10
+ Get a key from the MCP keys page in your Kontex dashboard. Each key belongs
11
+ to one person and can be revoked there at any time.
12
+
13
+ What it does, per tool it finds on your machine:
14
+
15
+ - **Claude Desktop**: adds a `kontex` server to `claude_desktop_config.json`
16
+ that runs `npx mcp-remote` against `https://www.kontex.so/api/mcp` with your
17
+ key in an environment variable. Restart Claude Desktop afterwards.
18
+ - **Claude Code**: runs `claude mcp add --transport http --scope user kontex …`.
19
+ - **Cursor**: adds a `kontex` server with `url` and `headers` to
20
+ `~/.cursor/mcp.json`.
21
+
22
+ Other servers in those files are left untouched; the previous file is kept
23
+ as `.bak`. Tools that are not installed are skipped.
24
+
25
+ ```bash
26
+ npx kontex-mcp connect kx_… --dry-run # show the changes only
27
+ npx kontex-mcp connect kx_… --only cursor # one tool
28
+ npx kontex-mcp disconnect # remove Kontex from all three
29
+ ```
30
+
31
+ Requires Node 18 or newer. claude.ai and ChatGPT web connectors are not
32
+ covered: they need OAuth, which Kontex will add separately.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../lib/cli.js";
3
+
4
+ main(process.argv.slice(2)).then(
5
+ (code) => process.exit(code),
6
+ (err) => {
7
+ console.error(err instanceof Error ? err.message : String(err));
8
+ process.exit(1);
9
+ },
10
+ );
@@ -0,0 +1,39 @@
1
+ // @ts-check
2
+ import { spawnSync } from "node:child_process";
3
+ import { SERVER_NAME } from "./clients.js";
4
+
5
+ /** Claude Code keeps its own config; we go through its CLI. */
6
+
7
+ const BIN = process.platform === "win32" ? "claude.cmd" : "claude";
8
+
9
+ export function claudeCodeInstalled() {
10
+ const r = spawnSync(BIN, ["--version"], { stdio: "ignore", shell: process.platform === "win32" });
11
+ return !r.error && r.status === 0;
12
+ }
13
+
14
+ /** @param {{ url: string; key: string }} t */
15
+ export function claudeCodeAddArgs({ url, key }) {
16
+ return ["mcp", "add", "--transport", "http", "--scope", "user", SERVER_NAME, url, "--header", `Authorization: Bearer ${key}`];
17
+ }
18
+
19
+ export function claudeCodeRemoveArgs() {
20
+ return ["mcp", "remove", "--scope", "user", SERVER_NAME];
21
+ }
22
+
23
+ /**
24
+ * @param {string[]} args
25
+ * @returns {{ ok: boolean; output: string }}
26
+ */
27
+ export function runClaude(args) {
28
+ const r = spawnSync(BIN, args, { encoding: "utf8", shell: process.platform === "win32" });
29
+ const output = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
30
+ return { ok: !r.error && r.status === 0, output };
31
+ }
32
+
33
+ /**
34
+ * Human-readable form for --dry-run; the key is masked.
35
+ * @param {string[]} args
36
+ */
37
+ export function formatCommand(args) {
38
+ return ["claude", ...args.map((a) => (a.startsWith("Authorization: Bearer ") ? `"Authorization: Bearer kx_…"` : a))].join(" ");
39
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,203 @@
1
+ // @ts-check
2
+ import {
3
+ DEFAULT_URL,
4
+ claudeDesktopConfigPath,
5
+ claudeDesktopEntry,
6
+ cursorConfigPath,
7
+ cursorEntry,
8
+ dirExists,
9
+ looksLikeKey,
10
+ readJson,
11
+ withServer,
12
+ withoutServer,
13
+ writeJson,
14
+ } from "./clients.js";
15
+ import { claudeCodeAddArgs, claudeCodeInstalled, claudeCodeRemoveArgs, formatCommand, runClaude } from "./claude-code.js";
16
+ import path from "node:path";
17
+
18
+ const CLIENT_IDS = /** @type {const} */ (["claude-desktop", "claude-code", "cursor"]);
19
+ /** @typedef {(typeof CLIENT_IDS)[number]} ClientId */
20
+
21
+ const LABELS = { "claude-desktop": "Claude Desktop", "claude-code": "Claude Code", cursor: "Cursor" };
22
+
23
+ const HELP = `kontex-mcp — connect Kontex to your AI tools
24
+
25
+ Usage:
26
+ npx kontex-mcp connect <key> Add Kontex to Claude Desktop, Claude Code, and Cursor
27
+ npx kontex-mcp disconnect Remove Kontex from all three
28
+ npx kontex-mcp --help
29
+
30
+ Options:
31
+ --only <list> Comma-separated subset: claude-desktop, claude-code, cursor
32
+ --url <url> MCP endpoint (default: ${DEFAULT_URL})
33
+ --dry-run Show what would change without writing anything
34
+
35
+ Get a key from https://www.kontex.so/dashboard/mcp. Each key belongs to one
36
+ person; do not share it. Tools that are not installed are skipped.`;
37
+
38
+ /**
39
+ * @param {string[]} argv
40
+ * @returns {{ command: string | undefined; positional: string[]; only: ClientId[] | null; url: string; dryRun: boolean; help: boolean }}
41
+ */
42
+ export function parseArgs(argv) {
43
+ /** @type {string[]} */
44
+ const positional = [];
45
+ /** @type {ClientId[] | null} */
46
+ let only = null;
47
+ let url = DEFAULT_URL;
48
+ let dryRun = false;
49
+ let help = false;
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i];
52
+ if (a === "--help" || a === "-h") help = true;
53
+ else if (a === "--dry-run") dryRun = true;
54
+ else if (a === "--url") url = argv[++i] ?? url;
55
+ else if (a.startsWith("--url=")) url = a.slice(6);
56
+ else if (a === "--only" || a.startsWith("--only=")) {
57
+ const raw = a === "--only" ? (argv[++i] ?? "") : a.slice(7);
58
+ const ids = raw.split(",").map((s) => s.trim()).filter(Boolean);
59
+ const bad = ids.filter((id) => !CLIENT_IDS.includes(/** @type {ClientId} */ (id)));
60
+ if (bad.length) throw new Error(`Unknown client(s): ${bad.join(", ")}. Use: ${CLIENT_IDS.join(", ")}`);
61
+ only = /** @type {ClientId[]} */ (ids);
62
+ } else if (a.startsWith("-")) throw new Error(`Unknown option: ${a}`);
63
+ else positional.push(a);
64
+ }
65
+ const [command, ...rest] = positional;
66
+ return { command, positional: rest, only, url, dryRun, help };
67
+ }
68
+
69
+ /**
70
+ * @param {string[]} argv
71
+ * @param {{ log?: (s: string) => void; home?: string }} [io]
72
+ * @returns {Promise<number>} exit code
73
+ */
74
+ export async function main(argv, io = {}) {
75
+ const log = io.log ?? ((s) => console.log(s));
76
+ let opts;
77
+ try {
78
+ opts = parseArgs(argv);
79
+ } catch (e) {
80
+ log(e instanceof Error ? e.message : String(e));
81
+ return 2;
82
+ }
83
+ if (opts.help || !opts.command) {
84
+ log(HELP);
85
+ return opts.help ? 0 : 2;
86
+ }
87
+ const wanted = opts.only ?? [...CLIENT_IDS];
88
+ const paths = { desktop: claudeDesktopConfigPath({ home: io.home }), cursor: cursorConfigPath({ home: io.home }) };
89
+
90
+ if (opts.command === "connect") {
91
+ const key = opts.positional[0];
92
+ if (!key) {
93
+ log("Missing key. Usage: npx kontex-mcp connect <key>");
94
+ return 2;
95
+ }
96
+ if (!looksLikeKey(key)) {
97
+ log("That does not look like a Kontex key (they start with kx_). Copy it from the MCP keys page.");
98
+ return 2;
99
+ }
100
+ let url;
101
+ try {
102
+ url = new URL(opts.url).toString();
103
+ } catch {
104
+ log(`Invalid --url: ${opts.url}`);
105
+ return 2;
106
+ }
107
+ const target = { url, key };
108
+ log(opts.dryRun ? "Dry run: nothing will be written." : "Connecting Kontex to your AI tools…");
109
+ let configured = 0;
110
+ /** @type {string[]} */
111
+ const notes = [];
112
+
113
+ if (wanted.includes("claude-desktop")) {
114
+ if (dirExists(path.dirname(paths.desktop))) {
115
+ const next = withServer(readJson(paths.desktop), claudeDesktopEntry(target));
116
+ if (opts.dryRun) log(` would write ${paths.desktop}:\n${indent(JSON.stringify(next.mcpServers.kontex, null, 2))}`);
117
+ else writeJson(paths.desktop, next);
118
+ log(`✓ ${LABELS["claude-desktop"]}`);
119
+ configured++;
120
+ notes.push("Restart Claude Desktop to see Kontex.");
121
+ } else log(`– ${LABELS["claude-desktop"]} (not installed, skipped)`);
122
+ }
123
+
124
+ if (wanted.includes("claude-code")) {
125
+ if (claudeCodeInstalled()) {
126
+ const add = claudeCodeAddArgs(target);
127
+ if (opts.dryRun) log(` would run: ${formatCommand(claudeCodeRemoveArgs())} (if present)\n would run: ${formatCommand(add)}`);
128
+ else {
129
+ runClaude(claudeCodeRemoveArgs()); // replace a previous key; failure just means it was not there
130
+ const r = runClaude(add);
131
+ if (!r.ok) {
132
+ log(`✗ ${LABELS["claude-code"]}: ${r.output || "claude mcp add failed"}`);
133
+ return finish(log, configured, notes, 1);
134
+ }
135
+ }
136
+ log(`✓ ${LABELS["claude-code"]}`);
137
+ configured++;
138
+ } else log(`– ${LABELS["claude-code"]} (not installed, skipped)`);
139
+ }
140
+
141
+ if (wanted.includes("cursor")) {
142
+ if (dirExists(path.dirname(paths.cursor))) {
143
+ const next = withServer(readJson(paths.cursor), cursorEntry(target));
144
+ if (opts.dryRun) log(` would write ${paths.cursor}:\n${indent(JSON.stringify(next.mcpServers.kontex, null, 2))}`);
145
+ else writeJson(paths.cursor, next);
146
+ log(`✓ ${LABELS.cursor}`);
147
+ configured++;
148
+ } else log(`– ${LABELS.cursor} (not installed, skipped)`);
149
+ }
150
+
151
+ return finish(log, configured, notes, 0);
152
+ }
153
+
154
+ if (opts.command === "disconnect") {
155
+ log(opts.dryRun ? "Dry run: nothing will be written." : "Removing Kontex from your AI tools…");
156
+ let removed = 0;
157
+ for (const [id, file] of /** @type {const} */ ([["claude-desktop", paths.desktop], ["cursor", paths.cursor]])) {
158
+ if (!wanted.includes(id)) continue;
159
+ const { config, removed: had } = withoutServer(readJson(file));
160
+ if (had) {
161
+ if (!opts.dryRun) writeJson(file, config);
162
+ log(`✓ ${LABELS[id]}`);
163
+ removed++;
164
+ } else log(`– ${LABELS[id]} (nothing to remove)`);
165
+ }
166
+ if (wanted.includes("claude-code")) {
167
+ if (claudeCodeInstalled()) {
168
+ const r = opts.dryRun ? { ok: true } : runClaude(claudeCodeRemoveArgs());
169
+ log(r.ok ? `✓ ${LABELS["claude-code"]}` : `– ${LABELS["claude-code"]} (nothing to remove)`);
170
+ if (r.ok) removed++;
171
+ } else log(`– ${LABELS["claude-code"]} (not installed, skipped)`);
172
+ }
173
+ log(removed ? "Done. Your key still works until you revoke it on the MCP keys page." : "Kontex was not configured anywhere.");
174
+ return 0;
175
+ }
176
+
177
+ log(`Unknown command: ${opts.command}\n\n${HELP}`);
178
+ return 2;
179
+ }
180
+
181
+ /**
182
+ * @param {(s: string) => void} log
183
+ * @param {number} configured
184
+ * @param {string[]} notes
185
+ * @param {number} code
186
+ */
187
+ function finish(log, configured, notes, code) {
188
+ if (configured === 0) {
189
+ log("Nothing configured: none of Claude Desktop, Claude Code, or Cursor was found on this machine.");
190
+ log("Install one of them, or paste the manual snippet from the MCP keys page into another MCP client.");
191
+ return code || 1;
192
+ }
193
+ log(`Done.${notes.length ? " " + notes.join(" ") : ""}`);
194
+ return code;
195
+ }
196
+
197
+ /** @param {string} s */
198
+ function indent(s) {
199
+ return s
200
+ .split("\n")
201
+ .map((l) => " " + l)
202
+ .join("\n");
203
+ }
package/lib/clients.js ADDED
@@ -0,0 +1,117 @@
1
+ // @ts-check
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ /**
7
+ * Pure config logic for each MCP client. Nothing here touches the network;
8
+ * file access is limited to the helpers at the bottom so `--dry-run` can
9
+ * reuse the same merge functions.
10
+ */
11
+
12
+ export const SERVER_NAME = "kontex";
13
+ export const DEFAULT_URL = "https://www.kontex.so/api/mcp";
14
+ const ENV_NAME = "KONTEX_AUTH";
15
+
16
+ /** @typedef {{ url: string; key: string }} Target */
17
+ /** @typedef {Record<string, any>} JsonObject */
18
+
19
+ /**
20
+ * Kontex keys are `kx_` + 32 random bytes in base64url.
21
+ * @param {unknown} value
22
+ */
23
+ export function looksLikeKey(value) {
24
+ return typeof value === "string" && /^kx_[A-Za-z0-9_-]{30,}$/.test(value);
25
+ }
26
+
27
+ /**
28
+ * Claude Desktop reads a JSON file per OS. Its Windows build splits args on
29
+ * spaces, so the header value goes through an env var (mcp-remote README).
30
+ * @param {{ platform?: NodeJS.Platform; home?: string; appData?: string }} [opts]
31
+ */
32
+ export function claudeDesktopConfigPath(opts = {}) {
33
+ const platform = opts.platform ?? process.platform;
34
+ const home = opts.home ?? os.homedir();
35
+ if (platform === "darwin") return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
36
+ if (platform === "win32") return path.join(opts.appData ?? process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
37
+ return path.join(home, ".config", "Claude", "claude_desktop_config.json");
38
+ }
39
+
40
+ /** @param {{ home?: string }} [opts] */
41
+ export function cursorConfigPath(opts = {}) {
42
+ return path.join(opts.home ?? os.homedir(), ".cursor", "mcp.json");
43
+ }
44
+
45
+ /** @param {Target} t */
46
+ export function claudeDesktopEntry({ url, key }) {
47
+ return {
48
+ command: "npx",
49
+ args: ["-y", "mcp-remote", url, "--header", `Authorization:\${${ENV_NAME}}`],
50
+ env: { [ENV_NAME]: `Bearer ${key}` },
51
+ };
52
+ }
53
+
54
+ /** @param {Target} t */
55
+ export function cursorEntry({ url, key }) {
56
+ return { url, headers: { Authorization: `Bearer ${key}` } };
57
+ }
58
+
59
+ /**
60
+ * Returns a copy of `config` with our server set, every other server kept.
61
+ * @param {JsonObject | null | undefined} config
62
+ * @param {JsonObject} entry
63
+ */
64
+ export function withServer(config, entry) {
65
+ const base = config && typeof config === "object" && !Array.isArray(config) ? config : {};
66
+ const servers = base.mcpServers && typeof base.mcpServers === "object" ? base.mcpServers : {};
67
+ return { ...base, mcpServers: { ...servers, [SERVER_NAME]: entry } };
68
+ }
69
+
70
+ /**
71
+ * @param {JsonObject | null | undefined} config
72
+ * @returns {{ config: JsonObject; removed: boolean }}
73
+ */
74
+ export function withoutServer(config) {
75
+ const base = config && typeof config === "object" && !Array.isArray(config) ? config : {};
76
+ const servers = base.mcpServers && typeof base.mcpServers === "object" ? { ...base.mcpServers } : {};
77
+ const removed = SERVER_NAME in servers;
78
+ delete servers[SERVER_NAME];
79
+ return { config: { ...base, mcpServers: servers }, removed };
80
+ }
81
+
82
+ /**
83
+ * Reads a JSON config, tolerating a missing file. A file that exists but
84
+ * does not parse is an error: overwriting it could destroy other servers.
85
+ * @param {string} file
86
+ * @returns {JsonObject | null}
87
+ */
88
+ export function readJson(file) {
89
+ if (!fs.existsSync(file)) return null;
90
+ const text = fs.readFileSync(file, "utf8");
91
+ if (text.trim() === "") return null;
92
+ try {
93
+ return JSON.parse(text);
94
+ } catch {
95
+ throw new Error(`${file} is not valid JSON. Fix or remove it, then run this again.`);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Writes JSON with a `.bak` of the previous contents next to it.
101
+ * @param {string} file
102
+ * @param {JsonObject} data
103
+ */
104
+ export function writeJson(file, data) {
105
+ fs.mkdirSync(path.dirname(file), { recursive: true });
106
+ if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
107
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
108
+ }
109
+
110
+ /** @param {string} file */
111
+ export function dirExists(file) {
112
+ try {
113
+ return fs.statSync(file).isDirectory();
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "kontex-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Connect Kontex to Claude Desktop, Claude Code, and Cursor with one command.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "kontex-mcp": "bin/kontex-mcp.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/gzariaga/kontex.git",
21
+ "directory": "packages/kontex-mcp"
22
+ },
23
+ "homepage": "https://www.kontex.so",
24
+ "keywords": [
25
+ "kontex",
26
+ "mcp",
27
+ "claude",
28
+ "cursor",
29
+ "customer-intelligence"
30
+ ],
31
+ "scripts": {
32
+ "check": "tsc -p tsconfig.json"
33
+ }
34
+ }