Skip to content

Commit 62f6f66

Browse files
committed
feat(init): interactive setup writes Claude Desktop / Cursor configs
1 parent 78d52b9 commit 62f6f66

3 files changed

Lines changed: 316 additions & 1 deletion

File tree

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@ Model Context Protocol server for [Parseable](https://www.parseable.com). Lets a
66
77
## Quickstart
88

9-
One command, interactive setup — detects Claude Desktop / Cursor, asks for your Parseable URL + credentials, writes their config files:
9+
### One-click install for VS Code
10+
11+
Click one of these buttons to install Parseable MCP Server in VS Code or VS Code Insiders:
12+
13+
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Parseable_MCP-007ACC?style=for-the-badge&logo=visualstudiocode)](vscode:mcp/install?%7B%22name%22%3A%22parseable%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40parseable%2Fparseable-mcp-server%22%5D%2C%22env%22%3A%7B%22PARSEABLE_URL%22%3A%22%22%2C%22PARSEABLE_USERNAME%22%3A%22%22%2C%22PARSEABLE_PASSWORD%22%3A%22%22%7D%7D)
14+
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Parseable_MCP-24bfa5?style=for-the-badge&logo=visualstudiocode)](vscode-insiders:mcp/install?%7B%22name%22%3A%22parseable%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40parseable%2Fparseable-mcp-server%22%5D%2C%22env%22%3A%7B%22PARSEABLE_URL%22%3A%22%22%2C%22PARSEABLE_USERNAME%22%3A%22%22%2C%22PARSEABLE_PASSWORD%22%3A%22%22%7D%7D)
15+
16+
After clicking, VS Code opens with the server entry pre-filled. Fill in `PARSEABLE_URL`, `PARSEABLE_USERNAME`, and `PARSEABLE_PASSWORD` when prompted.
17+
18+
### One command for Claude Desktop / Cursor
19+
20+
Interactive setup — detects Claude Desktop / Cursor, asks for your Parseable URL + credentials, writes their config files:
1021

1122
```bash
1223
npx -y @parseable/parseable-mcp-server init

src/init.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { existsSync } from "node:fs";
2+
import { mkdir, readFile, writeFile } from "node:fs/promises";
3+
import { homedir, platform } from "node:os";
4+
import { dirname, join } from "node:path";
5+
import { createInterface, type Interface } from "node:readline/promises";
6+
7+
export interface ClientTarget {
8+
id: string;
9+
name: string;
10+
configPath: string;
11+
}
12+
13+
export interface InitArgs {
14+
url?: string;
15+
username?: string;
16+
password?: string;
17+
client?: string;
18+
}
19+
20+
export function getClientTargets(
21+
home: string = homedir(),
22+
plat: string = platform(),
23+
): ClientTarget[] {
24+
const targets: ClientTarget[] = [];
25+
26+
let claudePath: string;
27+
if (plat === "darwin") {
28+
claudePath = join(
29+
home,
30+
"Library",
31+
"Application Support",
32+
"Claude",
33+
"claude_desktop_config.json",
34+
);
35+
} else if (plat === "win32") {
36+
claudePath = join(process.env.APPDATA ?? home, "Claude", "claude_desktop_config.json");
37+
} else {
38+
claudePath = join(home, ".config", "Claude", "claude_desktop_config.json");
39+
}
40+
targets.push({ id: "claude-desktop", name: "Claude Desktop", configPath: claudePath });
41+
42+
targets.push({ id: "cursor", name: "Cursor", configPath: join(home, ".cursor", "mcp.json") });
43+
44+
return targets;
45+
}
46+
47+
export function parseInitArgs(argv: string[]): InitArgs {
48+
const args: InitArgs = {};
49+
for (let i = 0; i < argv.length; i++) {
50+
const a = argv[i];
51+
if (a === "--url" && argv[i + 1]) args.url = argv[++i];
52+
else if (a === "--username" && argv[i + 1]) args.username = argv[++i];
53+
else if (a === "--password" && argv[i + 1]) args.password = argv[++i];
54+
else if (a === "--client" && argv[i + 1]) args.client = argv[++i];
55+
}
56+
return args;
57+
}
58+
59+
export function mergeConfig(
60+
existing: Record<string, unknown>,
61+
creds: { url: string; username: string; password: string },
62+
): Record<string, unknown> {
63+
const entry = {
64+
command: "npx",
65+
args: ["-y", "@parseable/parseable-mcp-server"],
66+
env: {
67+
PARSEABLE_URL: creds.url,
68+
PARSEABLE_USERNAME: creds.username,
69+
PARSEABLE_PASSWORD: creds.password,
70+
},
71+
};
72+
73+
const servers = (existing.mcpServers as Record<string, unknown>) ?? {};
74+
servers.parseable = entry;
75+
return { ...existing, mcpServers: servers };
76+
}
77+
78+
async function writeClientConfig(
79+
target: ClientTarget,
80+
creds: { url: string; username: string; password: string },
81+
): Promise<void> {
82+
let existing: Record<string, unknown> = {};
83+
if (existsSync(target.configPath)) {
84+
const raw = await readFile(target.configPath, "utf8");
85+
if (raw.trim()) {
86+
try {
87+
existing = JSON.parse(raw);
88+
} catch {
89+
throw new Error(
90+
`Existing config at ${target.configPath} is not valid JSON. Refusing to overwrite.`,
91+
);
92+
}
93+
}
94+
await writeFile(`${target.configPath}.bak`, raw, "utf8");
95+
} else {
96+
await mkdir(dirname(target.configPath), { recursive: true });
97+
}
98+
99+
const merged = mergeConfig(existing, creds);
100+
await writeFile(target.configPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
101+
}
102+
103+
async function ask(rl: Interface, question: string, fallback?: string): Promise<string> {
104+
const q = fallback ? `${question} [${fallback}]: ` : `${question}: `;
105+
const answer = (await rl.question(q)).trim();
106+
return answer || fallback || "";
107+
}
108+
109+
export async function runInit(argv: string[] = process.argv.slice(3)): Promise<void> {
110+
const args = parseInitArgs(argv);
111+
112+
console.log("Parseable MCP server — interactive setup\n");
113+
114+
const all = getClientTargets();
115+
const detected = all.filter((t) => existsSync(t.configPath));
116+
117+
if (detected.length === 0) {
118+
console.log(
119+
"No MCP clients detected yet. Creating config files for both Claude Desktop and Cursor.",
120+
);
121+
console.log("If you only use one, you can delete the other later.\n");
122+
} else {
123+
console.log("Detected MCP clients:");
124+
for (const [i, t] of detected.entries()) {
125+
console.log(` ${i + 1}. ${t.name} (${t.configPath})`);
126+
}
127+
console.log();
128+
}
129+
130+
const rl = createInterface({ input: process.stdin, output: process.stdout });
131+
132+
let chosen: ClientTarget[];
133+
if (args.client) {
134+
const match = all.find((t) => t.id === args.client);
135+
if (!match) {
136+
rl.close();
137+
console.error(
138+
`Unknown client "${args.client}". Use one of: ${all.map((t) => t.id).join(", ")}`,
139+
);
140+
process.exit(1);
141+
}
142+
chosen = [match];
143+
} else if (detected.length === 0) {
144+
chosen = all;
145+
} else {
146+
const pick = await ask(rl, "Configure which? (comma-separated numbers, or 'all')", "all");
147+
if (pick === "all") {
148+
chosen = detected;
149+
} else {
150+
const indices = pick.split(",").map((s) => Number.parseInt(s.trim(), 10) - 1);
151+
chosen = indices.map((i) => detected[i]).filter(Boolean);
152+
}
153+
}
154+
155+
if (chosen.length === 0) {
156+
rl.close();
157+
console.error("No clients selected. Aborting.");
158+
process.exit(1);
159+
}
160+
161+
const url = args.url || (await ask(rl, "Parseable URL"));
162+
const username = args.username || (await ask(rl, "Username", "admin"));
163+
const password = args.password || (await ask(rl, "Password"));
164+
165+
rl.close();
166+
167+
if (!url || !username || !password) {
168+
console.error("URL, username, and password are all required.");
169+
process.exit(1);
170+
}
171+
172+
for (const target of chosen) {
173+
try {
174+
await writeClientConfig(target, { url, username, password });
175+
console.log(`✓ Configured ${target.name}: ${target.configPath}`);
176+
} catch (err) {
177+
console.error(`✗ Failed to configure ${target.name}: ${(err as Error).message}`);
178+
}
179+
}
180+
181+
console.log(`\nRestart ${chosen.map((t) => t.name).join(" / ")} to load 27 Parseable tools.`);
182+
}

test/init.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getClientTargets, mergeConfig, parseInitArgs } from "../src/init.js";
3+
4+
describe("parseInitArgs", () => {
5+
it("parses --url --username --password --client", () => {
6+
const a = parseInitArgs([
7+
"--url",
8+
"http://x",
9+
"--username",
10+
"admin",
11+
"--password",
12+
"pw",
13+
"--client",
14+
"cursor",
15+
]);
16+
expect(a).toEqual({
17+
url: "http://x",
18+
username: "admin",
19+
password: "pw",
20+
client: "cursor",
21+
});
22+
});
23+
24+
it("returns empty when no flags", () => {
25+
expect(parseInitArgs([])).toEqual({});
26+
});
27+
28+
it("ignores unknown flags", () => {
29+
expect(parseInitArgs(["--bogus", "v", "--url", "u"])).toEqual({ url: "u" });
30+
});
31+
32+
it("ignores flag without value", () => {
33+
expect(parseInitArgs(["--url"])).toEqual({});
34+
});
35+
});
36+
37+
describe("getClientTargets", () => {
38+
it("returns Claude Desktop + Cursor on macOS", () => {
39+
const t = getClientTargets("/home/user", "darwin");
40+
expect(t).toHaveLength(2);
41+
expect(t[0].id).toBe("claude-desktop");
42+
expect(t[0].configPath).toContain("Library/Application Support/Claude");
43+
expect(t[1].id).toBe("cursor");
44+
expect(t[1].configPath).toBe("/home/user/.cursor/mcp.json");
45+
});
46+
47+
it("uses APPDATA path shape on Windows", () => {
48+
const t = getClientTargets("C:\\Users\\u", "win32");
49+
expect(t[0].configPath).toMatch(/Claude/);
50+
});
51+
52+
it("uses ~/.config on Linux", () => {
53+
const t = getClientTargets("/home/u", "linux");
54+
expect(t[0].configPath).toBe("/home/u/.config/Claude/claude_desktop_config.json");
55+
});
56+
});
57+
58+
describe("mergeConfig", () => {
59+
it("adds parseable entry to empty config", () => {
60+
const merged = mergeConfig(
61+
{},
62+
{
63+
url: "http://x",
64+
username: "a",
65+
password: "b",
66+
},
67+
);
68+
expect(merged).toEqual({
69+
mcpServers: {
70+
parseable: {
71+
command: "npx",
72+
args: ["-y", "@parseable/parseable-mcp-server"],
73+
env: {
74+
PARSEABLE_URL: "http://x",
75+
PARSEABLE_USERNAME: "a",
76+
PARSEABLE_PASSWORD: "b",
77+
},
78+
},
79+
},
80+
});
81+
});
82+
83+
it("preserves other top-level keys", () => {
84+
const merged = mergeConfig(
85+
{
86+
mcpServers: {},
87+
preferences: { theme: "dark" },
88+
},
89+
{ url: "http://x", username: "a", password: "b" },
90+
);
91+
expect(merged.preferences).toEqual({ theme: "dark" });
92+
expect((merged.mcpServers as Record<string, unknown>).parseable).toBeDefined();
93+
});
94+
95+
it("preserves other mcpServers entries", () => {
96+
const merged = mergeConfig(
97+
{
98+
mcpServers: {
99+
github: { command: "node", args: ["x.js"] },
100+
},
101+
},
102+
{ url: "http://x", username: "a", password: "b" },
103+
);
104+
const servers = merged.mcpServers as Record<string, unknown>;
105+
expect(servers.github).toEqual({ command: "node", args: ["x.js"] });
106+
expect(servers.parseable).toBeDefined();
107+
});
108+
109+
it("overwrites an existing parseable entry", () => {
110+
const merged = mergeConfig(
111+
{
112+
mcpServers: {
113+
parseable: { command: "node", args: ["old.js"] },
114+
},
115+
},
116+
{ url: "http://new", username: "a", password: "b" },
117+
);
118+
const servers = merged.mcpServers as Record<string, unknown>;
119+
const parseable = servers.parseable as { env: Record<string, string> };
120+
expect(parseable.env.PARSEABLE_URL).toBe("http://new");
121+
});
122+
});

0 commit comments

Comments
 (0)