@chadanalytics/mcp-setup 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/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/chad-mcp-setup.js +166 -0
- package/package.json +38 -0
- package/src/config.js +222 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chad Analytics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# @chadanalytics/mcp-setup
|
|
2
|
+
|
|
3
|
+
Connect [Chad Analytics](https://chadanalytics.com)' hosted MCP server to Codex,
|
|
4
|
+
Claude Code, Cursor, or Claude Desktop without hand-editing client config.
|
|
5
|
+
|
|
6
|
+
The package is dependency-free and ready to run with `npx` on Node 18+.
|
|
7
|
+
|
|
8
|
+
## 1. Create a credential
|
|
9
|
+
|
|
10
|
+
In the Slack workspace connected to Chad, run:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
/chad mcp
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Only the person who connected Chad can issue or rotate the credential. Chad
|
|
17
|
+
shows it once. The current hosted credential is read-only, limited to the
|
|
18
|
+
connected site, replaces the previous credential, and expires after 90 days.
|
|
19
|
+
|
|
20
|
+
## 2. Run setup
|
|
21
|
+
|
|
22
|
+
Pass the token through the environment so it does not enter shell history or
|
|
23
|
+
the process argument list:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
CHAD_MCP_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup codex
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Configure more than one client:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
CHAD_MCP_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup cursor claude-code
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Configure every supported client:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
CHAD_MCP_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup all
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If the environment variable is omitted in an interactive terminal, setup asks
|
|
42
|
+
for the credential with a hidden prompt. It deliberately has no `--token`
|
|
43
|
+
option.
|
|
44
|
+
|
|
45
|
+
## Preview without changing files
|
|
46
|
+
|
|
47
|
+
Validate the planned client paths:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
npx @chadanalytics/mcp-setup all --dry-run
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Print isolated Chad config snippets with the credential redacted:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npx @chadanalytics/mcp-setup codex cursor --print
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Both modes work without a real token and never write files. Print mode does not
|
|
60
|
+
read or echo existing client configuration, so it cannot expose credentials for
|
|
61
|
+
other MCP servers.
|
|
62
|
+
|
|
63
|
+
## Where setup writes
|
|
64
|
+
|
|
65
|
+
| Client | User-level configuration |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| Codex | `~/.codex/config.toml` |
|
|
68
|
+
| Claude Code | `~/.claude.json` |
|
|
69
|
+
| Cursor | `~/.cursor/mcp.json` |
|
|
70
|
+
| Claude Desktop, macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
|
71
|
+
| Claude Desktop, Windows | `%APPDATA%\\Claude\\claude_desktop_config.json` |
|
|
72
|
+
| Claude Desktop, Linux | `~/.config/Claude/claude_desktop_config.json` |
|
|
73
|
+
|
|
74
|
+
Existing settings and other MCP servers are retained. The `chad` entry is
|
|
75
|
+
created or replaced. Files are written atomically and restricted to the current
|
|
76
|
+
user with mode `0600` on platforms that support POSIX permissions.
|
|
77
|
+
|
|
78
|
+
Because these clients need the bearer token on every hosted MCP request, the
|
|
79
|
+
token is stored in their private user configuration. Treat those files as
|
|
80
|
+
credentials. Never commit them, paste them into chat, or use project-shared MCP
|
|
81
|
+
configuration for this token. Rotate it with `/chad mcp` if it is exposed.
|
|
82
|
+
|
|
83
|
+
## Custom token variable or endpoint
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
MY_CHAD_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup codex \
|
|
87
|
+
--token-env MY_CHAD_TOKEN
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The production endpoint defaults to `https://chadanalytics.com/api/mcp`. A
|
|
91
|
+
different HTTPS endpoint can be selected for an authorized staging deployment:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
CHAD_MCP_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup codex \
|
|
95
|
+
--url https://staging.example.com/api/mcp
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Verify
|
|
99
|
+
|
|
100
|
+
Restart the configured client and ask:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
What traffic did I get today?
|
|
104
|
+
Which AI assistants sent visitors this week?
|
|
105
|
+
What pages did AI crawlers request?
|
|
106
|
+
What was my bounce rate in the last 24 hours?
|
|
107
|
+
Break down this week's visitors by country and device.
|
|
108
|
+
How many signups and how much attributed revenue did I get this month?
|
|
109
|
+
Show the drop-off in my signup funnel.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The hosted MCP server currently exposes these read-only tools:
|
|
113
|
+
|
|
114
|
+
| Tool | Returns |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `chad_websites_list` | Sites available to this credential |
|
|
117
|
+
| `chad_analytics_overview` | Visitors, sessions, bounce rate, duration, AI traffic, goals, and revenue |
|
|
118
|
+
| `chad_analytics_ai_traffic` | Human referrals from ChatGPT, Claude, Perplexity, Gemini, Copilot, Grok, and other detected AI tools |
|
|
119
|
+
| `chad_analytics_crawlers` | Server-side AI answer, indexing, training, and crawler requests |
|
|
120
|
+
| `chad_analytics_top_pages` | Most-viewed pages |
|
|
121
|
+
| `chad_analytics_dimensions` | Country, device, browser, or operating-system breakdown |
|
|
122
|
+
| `chad_visitor_journeys` | Chronological anonymous page and session journeys |
|
|
123
|
+
| `chad_goals_list` | Custom-goal completions, visitors, and values |
|
|
124
|
+
| `chad_revenue_overview` | Attributed revenue, payments, buyers, conversion rate, and revenue per visitor |
|
|
125
|
+
| `chad_funnels_list` | Saved funnel definitions |
|
|
126
|
+
| `chad_funnel_results` | Sequential reach, conversion, drop-off, and attributed revenue |
|
|
127
|
+
| `chad_segments_list` | Saved segments and active exclusions |
|
|
128
|
+
| `chad_integrations_status` | Connected provider status without credentials |
|
|
129
|
+
|
|
130
|
+
All tools are read-only. The credential is checked on every request and the
|
|
131
|
+
server limits results to its authorized Chad tenant and site IDs. A metric may
|
|
132
|
+
be empty until its tracker, goal, funnel, or provider connection is configured.
|
|
133
|
+
See `docs/mcp.md` in the Chad repository for the transport and security model.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
npm test
|
|
139
|
+
npm run check
|
|
140
|
+
npm pack --dry-run
|
|
141
|
+
```
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { stdin, stdout, stderr, env } = process;
|
|
5
|
+
const pkg = require("../package.json");
|
|
6
|
+
const {
|
|
7
|
+
CLIENTS,
|
|
8
|
+
DEFAULT_URL,
|
|
9
|
+
REDACTED_TOKEN,
|
|
10
|
+
install,
|
|
11
|
+
normalizeClients,
|
|
12
|
+
redact,
|
|
13
|
+
renderClientConfig,
|
|
14
|
+
resolveClientPath,
|
|
15
|
+
validateToken,
|
|
16
|
+
validateUrl,
|
|
17
|
+
} = require("../src/config");
|
|
18
|
+
|
|
19
|
+
const HELP = `Chad Analytics MCP setup
|
|
20
|
+
|
|
21
|
+
Safely connect Chad's hosted MCP server to your AI clients.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
CHAD_MCP_TOKEN=chad_mcp_... npx @chadanalytics/mcp-setup codex
|
|
25
|
+
CHAD_MCP_TOKEN=chad_mcp_... npx @chadanalytics/mcp-setup cursor claude-code
|
|
26
|
+
CHAD_MCP_TOKEN=chad_mcp_... npx @chadanalytics/mcp-setup all
|
|
27
|
+
|
|
28
|
+
Clients:
|
|
29
|
+
codex OpenAI Codex CLI, desktop app, and IDE extension
|
|
30
|
+
claude-code Claude Code, user scope
|
|
31
|
+
cursor Cursor, user scope
|
|
32
|
+
claude-desktop Claude Desktop
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
-c, --client <name> Add a client (repeatable; comma-separated also works)
|
|
36
|
+
--all Configure every supported client
|
|
37
|
+
--dry-run Validate and show target paths without writing
|
|
38
|
+
--print Print only Chad's redacted config snippets; do not write
|
|
39
|
+
--token-env <n> Read the credential from env var n (default CHAD_MCP_TOKEN)
|
|
40
|
+
--url <url> Override the hosted URL (default ${DEFAULT_URL})
|
|
41
|
+
-h, --help Show help
|
|
42
|
+
-v, --version Show version
|
|
43
|
+
|
|
44
|
+
Security:
|
|
45
|
+
The CLI does not accept a --token argument, so credentials do not enter shell
|
|
46
|
+
history or process arguments. Set the token environment variable or paste it
|
|
47
|
+
into the hidden prompt. Config files are written atomically with mode 0600.
|
|
48
|
+
--dry-run and --print always redact the token.
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
function parseArgs(argv) {
|
|
52
|
+
const options = { clients: [], tokenEnv: "CHAD_MCP_TOKEN" };
|
|
53
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
54
|
+
const arg = argv[index];
|
|
55
|
+
if (arg === "-h" || arg === "--help") options.help = true;
|
|
56
|
+
else if (arg === "-v" || arg === "--version") options.version = true;
|
|
57
|
+
else if (arg === "--all") options.clients.push("all");
|
|
58
|
+
else if (arg === "--dry-run") options.dryRun = true;
|
|
59
|
+
else if (arg === "--print") options.print = true;
|
|
60
|
+
else if (arg === "-c" || arg === "--client") {
|
|
61
|
+
if (!argv[index + 1]) throw new Error(`${arg} requires a client name.`);
|
|
62
|
+
options.clients.push(argv[++index]);
|
|
63
|
+
} else if (arg === "--token-env") {
|
|
64
|
+
if (!argv[index + 1]) throw new Error("--token-env requires an environment variable name.");
|
|
65
|
+
options.tokenEnv = argv[++index];
|
|
66
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(options.tokenEnv)) {
|
|
67
|
+
throw new Error("--token-env must be a valid environment variable name.");
|
|
68
|
+
}
|
|
69
|
+
} else if (arg === "--url") {
|
|
70
|
+
if (!argv[index + 1]) throw new Error("--url requires an HTTPS URL.");
|
|
71
|
+
options.url = argv[++index];
|
|
72
|
+
} else if (arg.startsWith("-")) {
|
|
73
|
+
throw new Error(`Unknown option "${arg}".`);
|
|
74
|
+
} else {
|
|
75
|
+
options.clients.push(arg);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return options;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function promptSecret(prompt = "Paste the private token from /chad mcp in Slack: ") {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
|
|
84
|
+
reject(new Error("Set CHAD_MCP_TOKEN because a hidden interactive prompt is not available."));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let value = "";
|
|
88
|
+
stdout.write(prompt);
|
|
89
|
+
stdin.setEncoding("utf8");
|
|
90
|
+
stdin.setRawMode(true);
|
|
91
|
+
stdin.resume();
|
|
92
|
+
const finish = (error) => {
|
|
93
|
+
stdin.setRawMode(false);
|
|
94
|
+
stdin.pause();
|
|
95
|
+
stdin.removeListener("data", onData);
|
|
96
|
+
stdout.write("\n");
|
|
97
|
+
if (error) reject(error);
|
|
98
|
+
else resolve(value);
|
|
99
|
+
};
|
|
100
|
+
const onData = (chunk) => {
|
|
101
|
+
for (const char of chunk) {
|
|
102
|
+
if (char === "\u0003") return finish(new Error("Cancelled."));
|
|
103
|
+
if (char === "\r" || char === "\n") return finish();
|
|
104
|
+
if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
|
|
105
|
+
else value += char;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
stdin.on("data", onData);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function main(argv = process.argv.slice(2)) {
|
|
113
|
+
const args = parseArgs(argv);
|
|
114
|
+
if (args.help) {
|
|
115
|
+
stdout.write(HELP);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (args.version) {
|
|
119
|
+
stdout.write(`${pkg.version}\n`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
const clients = normalizeClients(args.clients);
|
|
123
|
+
if (!clients.length) throw new Error(`Choose a client (${CLIENTS.join(", ")}) or use all.`);
|
|
124
|
+
|
|
125
|
+
let token = env[args.tokenEnv];
|
|
126
|
+
if (!token && (args.dryRun || args.print)) token = `chad_mcp_${"0".repeat(64)}`;
|
|
127
|
+
if (!token) token = await promptSecret();
|
|
128
|
+
|
|
129
|
+
if (args.print) {
|
|
130
|
+
validateToken(token);
|
|
131
|
+
const url = validateUrl(args.url || DEFAULT_URL);
|
|
132
|
+
for (const client of clients) {
|
|
133
|
+
stdout.write(`\n# ${client}: ${resolveClientPath(client)}\n`);
|
|
134
|
+
// Render an isolated Chad entry so --print can never echo unrelated
|
|
135
|
+
// credentials already stored in the user's client configuration.
|
|
136
|
+
stdout.write(redact(renderClientConfig(client, "", token, url), token));
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const plan = install({
|
|
142
|
+
clients,
|
|
143
|
+
token,
|
|
144
|
+
url: args.url,
|
|
145
|
+
dryRun: args.dryRun,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
for (const item of plan) {
|
|
149
|
+
stdout.write(`${args.dryRun ? "Would configure" : "Configured"} ${item.client}: ${item.path}\n`);
|
|
150
|
+
}
|
|
151
|
+
if (!args.dryRun) {
|
|
152
|
+
stdout.write("Restart the configured app, then ask: ‘What traffic did I get today?’\n");
|
|
153
|
+
} else {
|
|
154
|
+
stdout.write(`Token: ${REDACTED_TOKEN}\nNo files were changed.\n`);
|
|
155
|
+
}
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (require.main === module) {
|
|
160
|
+
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
|
161
|
+
stderr.write(`Error: ${String(error && error.message || error)}\n`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { HELP, main, parseArgs, promptSecret };
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chadanalytics/mcp-setup",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"mcpName": "com.chadanalytics/chad",
|
|
5
|
+
"description": "Safely connect Chad Analytics hosted MCP to Codex, Claude Code, Cursor, or Claude Desktop",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"bin": {
|
|
9
|
+
"chad-mcp-setup": "bin/chad-mcp-setup.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "src/config.js",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/config.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/*.test.js",
|
|
25
|
+
"check": "node --check bin/chad-mcp-setup.js && node --check src/config.js && npm test"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"chad analytics",
|
|
29
|
+
"mcp",
|
|
30
|
+
"model context protocol",
|
|
31
|
+
"codex",
|
|
32
|
+
"claude code",
|
|
33
|
+
"cursor"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
|
|
7
|
+
const DEFAULT_URL = "https://chadanalytics.com/api/mcp";
|
|
8
|
+
const SERVER_NAME = "chad";
|
|
9
|
+
const CLIENTS = ["codex", "claude-code", "cursor", "claude-desktop"];
|
|
10
|
+
const TOKEN_PATTERN = /^chad_mcp_[a-f0-9]{64}$/;
|
|
11
|
+
const REDACTED_TOKEN = "chad_mcp_<redacted>";
|
|
12
|
+
|
|
13
|
+
const CLIENT_ALIASES = Object.freeze({
|
|
14
|
+
codex: "codex",
|
|
15
|
+
claude: "claude-code",
|
|
16
|
+
"claude-code": "claude-code",
|
|
17
|
+
cursor: "cursor",
|
|
18
|
+
desktop: "claude-desktop",
|
|
19
|
+
"claude-desktop": "claude-desktop",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function validateToken(token) {
|
|
23
|
+
if (!TOKEN_PATTERN.test(String(token || ""))) {
|
|
24
|
+
throw new Error("CHAD_MCP_TOKEN must be the private chad_mcp_ credential issued by /chad mcp in Slack.");
|
|
25
|
+
}
|
|
26
|
+
return String(token);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateUrl(value) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(String(value || ""));
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error("The MCP URL must be a valid HTTPS URL.");
|
|
35
|
+
}
|
|
36
|
+
if (parsed.protocol !== "https:") throw new Error("The MCP URL must use HTTPS.");
|
|
37
|
+
if (parsed.username || parsed.password) throw new Error("The MCP URL must not contain credentials.");
|
|
38
|
+
return parsed.toString().replace(/\/$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeClients(values) {
|
|
42
|
+
const requested = Array.isArray(values) ? values : [values];
|
|
43
|
+
const expanded = requested
|
|
44
|
+
.flatMap((value) => String(value || "").split(","))
|
|
45
|
+
.map((value) => value.trim().toLowerCase())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
if (expanded.includes("all")) return [...CLIENTS];
|
|
48
|
+
const normalized = expanded.map((value) => {
|
|
49
|
+
const client = CLIENT_ALIASES[value];
|
|
50
|
+
if (!client) throw new Error(`Unknown client "${value}". Choose: ${CLIENTS.join(", ")}, or all.`);
|
|
51
|
+
return client;
|
|
52
|
+
});
|
|
53
|
+
return [...new Set(normalized)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveClientPath(client, options = {}) {
|
|
57
|
+
const home = options.home || os.homedir();
|
|
58
|
+
const platform = options.platform || process.platform;
|
|
59
|
+
const env = options.env || process.env;
|
|
60
|
+
switch (client) {
|
|
61
|
+
case "codex":
|
|
62
|
+
return path.join(home, ".codex", "config.toml");
|
|
63
|
+
case "claude-code":
|
|
64
|
+
return path.join(home, ".claude.json");
|
|
65
|
+
case "cursor":
|
|
66
|
+
return path.join(home, ".cursor", "mcp.json");
|
|
67
|
+
case "claude-desktop":
|
|
68
|
+
if (platform === "darwin") {
|
|
69
|
+
return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
70
|
+
}
|
|
71
|
+
if (platform === "win32") {
|
|
72
|
+
return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
73
|
+
}
|
|
74
|
+
return path.join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
75
|
+
default:
|
|
76
|
+
throw new Error(`Unsupported client "${client}".`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function jsonServer(token, url, includeType) {
|
|
81
|
+
return {
|
|
82
|
+
...(includeType ? { type: "http" } : {}),
|
|
83
|
+
url,
|
|
84
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mergeJsonConfig(existing, token, url, includeType) {
|
|
89
|
+
let parsed = {};
|
|
90
|
+
if (String(existing || "").trim()) {
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(existing);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error("Existing client configuration is not valid JSON; it was left unchanged.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
98
|
+
throw new Error("Existing client configuration must contain a JSON object; it was left unchanged.");
|
|
99
|
+
}
|
|
100
|
+
const currentServers = parsed.mcpServers;
|
|
101
|
+
if (currentServers !== undefined && (!currentServers || Array.isArray(currentServers) || typeof currentServers !== "object")) {
|
|
102
|
+
throw new Error("Existing mcpServers value must be a JSON object; it was left unchanged.");
|
|
103
|
+
}
|
|
104
|
+
return `${JSON.stringify({
|
|
105
|
+
...parsed,
|
|
106
|
+
mcpServers: {
|
|
107
|
+
...(currentServers || {}),
|
|
108
|
+
[SERVER_NAME]: jsonServer(token, url, includeType),
|
|
109
|
+
},
|
|
110
|
+
}, null, 2)}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tomlString(value) {
|
|
114
|
+
return JSON.stringify(String(value));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stripTomlServer(existing) {
|
|
118
|
+
const lines = String(existing || "").split(/\r?\n/);
|
|
119
|
+
const exact = /^\s*\[mcp_servers\.chad\]\s*(?:#.*)?$/;
|
|
120
|
+
const table = /^\s*\[([^\]]+)\]/;
|
|
121
|
+
const start = lines.findIndex((line) => exact.test(line));
|
|
122
|
+
if (start === -1) return String(existing || "").trimEnd();
|
|
123
|
+
let end = lines.length;
|
|
124
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
125
|
+
const match = lines[index].match(table);
|
|
126
|
+
if (match && match[1] !== "mcp_servers.chad" && !match[1].startsWith("mcp_servers.chad.")) {
|
|
127
|
+
end = index;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
lines.splice(start, end - start);
|
|
132
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mergeCodexConfig(existing, token, url) {
|
|
136
|
+
const before = stripTomlServer(existing);
|
|
137
|
+
const block = [
|
|
138
|
+
"[mcp_servers.chad]",
|
|
139
|
+
`url = ${tomlString(url)}`,
|
|
140
|
+
`http_headers = { Authorization = ${tomlString(`Bearer ${token}`)} }`,
|
|
141
|
+
].join("\n");
|
|
142
|
+
return `${before ? `${before}\n\n` : ""}${block}\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readExisting(filePath, fsApi = fs) {
|
|
146
|
+
try {
|
|
147
|
+
return fsApi.readFileSync(filePath, "utf8");
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error && error.code === "ENOENT") return "";
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderClientConfig(client, existing, token, url) {
|
|
155
|
+
if (client === "codex") return mergeCodexConfig(existing, token, url);
|
|
156
|
+
// Claude Code requires an explicit HTTP type. Cursor and Claude Desktop use
|
|
157
|
+
// the standard remote-server shape documented for config-based MCP clients.
|
|
158
|
+
return mergeJsonConfig(existing, token, url, client === "claude-code");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildPlan(options) {
|
|
162
|
+
const clients = normalizeClients(options.clients);
|
|
163
|
+
if (!clients.length) throw new Error("Choose at least one client, or use all.");
|
|
164
|
+
const token = validateToken(options.token);
|
|
165
|
+
const url = validateUrl(options.url || DEFAULT_URL);
|
|
166
|
+
const fsApi = options.fs || fs;
|
|
167
|
+
return clients.map((client) => {
|
|
168
|
+
const filePath = resolveClientPath(client, options);
|
|
169
|
+
const existing = readExisting(filePath, fsApi);
|
|
170
|
+
return {
|
|
171
|
+
client,
|
|
172
|
+
path: filePath,
|
|
173
|
+
existed: Boolean(existing),
|
|
174
|
+
content: renderClientConfig(client, existing, token, url),
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function atomicWrite(filePath, content, fsApi = fs) {
|
|
180
|
+
const directory = path.dirname(filePath);
|
|
181
|
+
fsApi.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
182
|
+
const tempPath = path.join(directory, `.${path.basename(filePath)}.chad-${process.pid}-${Date.now()}.tmp`);
|
|
183
|
+
try {
|
|
184
|
+
fsApi.writeFileSync(tempPath, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
185
|
+
fsApi.renameSync(tempPath, filePath);
|
|
186
|
+
fsApi.chmodSync(filePath, 0o600);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
try { fsApi.unlinkSync(tempPath); } catch {}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function install(options) {
|
|
194
|
+
const fsApi = options.fs || fs;
|
|
195
|
+
const plan = buildPlan(options);
|
|
196
|
+
if (!options.dryRun) {
|
|
197
|
+
for (const item of plan) atomicWrite(item.path, item.content, fsApi);
|
|
198
|
+
}
|
|
199
|
+
return plan;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function redact(value, token) {
|
|
203
|
+
return String(value).split(String(token)).join(REDACTED_TOKEN);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
CLIENTS,
|
|
208
|
+
DEFAULT_URL,
|
|
209
|
+
REDACTED_TOKEN,
|
|
210
|
+
TOKEN_PATTERN,
|
|
211
|
+
atomicWrite,
|
|
212
|
+
buildPlan,
|
|
213
|
+
install,
|
|
214
|
+
mergeCodexConfig,
|
|
215
|
+
mergeJsonConfig,
|
|
216
|
+
normalizeClients,
|
|
217
|
+
redact,
|
|
218
|
+
renderClientConfig,
|
|
219
|
+
resolveClientPath,
|
|
220
|
+
validateToken,
|
|
221
|
+
validateUrl,
|
|
222
|
+
};
|