message-cp 0.0.0-stage → 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 +45 -2
- package/dist/accounts.js +279 -0
- package/dist/agents.js +507 -0
- package/dist/approve.js +260 -0
- package/dist/channels.js +262 -0
- package/dist/cli.js +523 -0
- package/dist/client-metadata.js +167 -0
- package/dist/connect.js +85 -0
- package/dist/dashboard.js +4961 -0
- package/dist/db.js +605 -0
- package/dist/http.js +1933 -0
- package/dist/index.js +21 -0
- package/dist/init.js +960 -0
- package/dist/login.js +530 -0
- package/dist/messages.js +513 -0
- package/dist/oauth.js +384 -0
- package/dist/server.js +420 -0
- package/dist/sessions.js +53 -0
- package/dist/settings.js +13 -0
- package/dist/wait.js +137 -0
- package/dist/webhooks.js +464 -0
- package/package.json +44 -4
package/dist/init.js
ADDED
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { accessSync, chmodSync, closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { delimiter, dirname, join } from "node:path";
|
|
9
|
+
import { createInterface } from "node:readline/promises";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { NAME_PATTERN, NOT_REGISTERED, PurgeError, purgeAgent } from "./agents.js";
|
|
12
|
+
import { ConnectError, mcpEndpoint } from "./connect.js";
|
|
13
|
+
import { dbPath, openDb } from "./db.js";
|
|
14
|
+
import { shellQuote } from "./wait.js";
|
|
15
|
+
// `message-cp init`: adds the stdio server `npx -y message-cp` to one coding agent's user-level MCP configuration on
|
|
16
|
+
// this machine, then proves the server works by registering a test agent, sending it a message and reading it back.
|
|
17
|
+
// It never prints a token or key: the test agent's key is made here, stays in memory, and the agent is purged after.
|
|
18
|
+
// With `--url` and `--agent` it adds a team server over HTTP instead, carrying the token from MESSAGE_CP_HTTP_TOKEN in
|
|
19
|
+
// its headers, with the agent's name and its key from MESSAGE_CP_AGENT_KEY unless the token is bound to the agent, and
|
|
20
|
+
// nothing it prints holds the token or key. With `--from` it reads the URL, agent, token and key from the file
|
|
21
|
+
// `message-cp connect` wrote instead, and once the check passes also writes the token to ~/.message-cp/http-token for
|
|
22
|
+
// the wake-up command.
|
|
23
|
+
export class InitError extends Error {
|
|
24
|
+
}
|
|
25
|
+
export const CLIENT_IDS = ["claude-code", "codex", "cursor", "claude-desktop"];
|
|
26
|
+
export const CLIENT_NAMES = {
|
|
27
|
+
"claude-code": "Claude Code",
|
|
28
|
+
codex: "Codex",
|
|
29
|
+
cursor: "Cursor",
|
|
30
|
+
"claude-desktop": "Claude Desktop",
|
|
31
|
+
};
|
|
32
|
+
const SERVER_NAME = "message-cp";
|
|
33
|
+
const SERVER_COMMAND = "npx";
|
|
34
|
+
const SERVER_ARGS = ["-y", "message-cp"];
|
|
35
|
+
/** What to run by hand when no client is found, or to add the server to Claude Code yourself. */
|
|
36
|
+
export const MANUAL_ADD = "claude mcp add message-cp -- npx -y message-cp";
|
|
37
|
+
/** Whether an executable named `command` is in one of `path`'s directories (on Windows, also with .exe, .cmd or .bat). */
|
|
38
|
+
export function onPath(command, path, platform = process.platform) {
|
|
39
|
+
const exts = platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
40
|
+
return path
|
|
41
|
+
.split(platform === "win32" ? ";" : delimiter)
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.some((dir) => exts.some((ext) => {
|
|
44
|
+
try {
|
|
45
|
+
const file = join(dir, command + ext);
|
|
46
|
+
accessSync(file, platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
47
|
+
return statSync(file).isFile();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Claude Desktop's configuration directory, where its docs put `claude_desktop_config.json`: `~/Library/Application
|
|
56
|
+
* Support/Claude` on macOS and `%APPDATA%\Claude` on Windows. Its docs give none for Linux, so there it is undefined.
|
|
57
|
+
*/
|
|
58
|
+
export function claudeDesktopDir(m) {
|
|
59
|
+
if (m.platform === "darwin")
|
|
60
|
+
return join(m.home, "Library", "Application Support", "Claude");
|
|
61
|
+
if (m.platform === "win32")
|
|
62
|
+
return join(m.appData ?? join(m.home, "AppData", "Roaming"), "Claude");
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/** The JSON file `init` edits for Cursor or Claude Desktop. */
|
|
66
|
+
export function configFile(client, m) {
|
|
67
|
+
return client === "cursor" ? join(m.home, ".cursor", "mcp.json") : join(claudeDesktopDir(m), "claude_desktop_config.json");
|
|
68
|
+
}
|
|
69
|
+
/** The clients installed for this user, in `CLIENT_IDS` order. */
|
|
70
|
+
export function detectClients(m) {
|
|
71
|
+
const desktop = claudeDesktopDir(m);
|
|
72
|
+
const found = {
|
|
73
|
+
"claude-code": onPath("claude", m.path, m.platform),
|
|
74
|
+
codex: onPath("codex", m.path, m.platform),
|
|
75
|
+
cursor: existsSync(join(m.home, ".cursor")),
|
|
76
|
+
"claude-desktop": desktop !== undefined && existsSync(desktop),
|
|
77
|
+
};
|
|
78
|
+
return CLIENT_IDS.filter((c) => found[c]);
|
|
79
|
+
}
|
|
80
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
81
|
+
const STDIO_SERVER = {
|
|
82
|
+
entry: { command: SERVER_COMMAND, args: SERVER_ARGS },
|
|
83
|
+
same: (e) => isObject(e) && e.command === SERVER_COMMAND && JSON.stringify(e.args) === JSON.stringify(SERVER_ARGS),
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Adds `server.entry` as `message-cp` to the `mcpServers` of the JSON file `file`, keeping every other key and server.
|
|
87
|
+
* The new JSON is written to a temporary file beside the real one (through a symlink, beside its target) and renamed
|
|
88
|
+
* over it, so a failed write leaves the old file whole; an existing file keeps its mode, and a new one is 0600. If the
|
|
89
|
+
* file changed between the read and the rename, nothing is written and `init` says to run it again. A `message-cp`
|
|
90
|
+
* entry already there is left as it is: "present" if `server.same` holds for it (by default, if it runs
|
|
91
|
+
* `npx -y message-cp`), "differs" otherwise. Refuses, changing nothing, a file that is not a JSON object or whose
|
|
92
|
+
* `mcpServers` is not one, or, for a `secret` entry, a file group or others can read, and turns a file it cannot read
|
|
93
|
+
* or write into an InitError giving the reason.
|
|
94
|
+
*/
|
|
95
|
+
export function mergeServer(file, server = STDIO_SERVER) {
|
|
96
|
+
const reason = (err) => err.code ?? (err instanceof Error ? err.message : String(err));
|
|
97
|
+
let config = {};
|
|
98
|
+
let text;
|
|
99
|
+
const current = () => (existsSync(file) ? readFileSync(file, "utf8") : undefined);
|
|
100
|
+
if (existsSync(file)) {
|
|
101
|
+
try {
|
|
102
|
+
text = readFileSync(file, "utf8");
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
throw new InitError(`could not read ${file} (${reason(err)}); nothing was changed.`);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
config = JSON.parse(text);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new InitError(`${file} is not valid JSON; it was left unchanged. Fix or move it, then run message-cp init again.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!isObject(config))
|
|
115
|
+
throw new InitError(`${file} does not hold a JSON object; it was left unchanged.`);
|
|
116
|
+
const servers = config.mcpServers ?? {};
|
|
117
|
+
if (!isObject(servers))
|
|
118
|
+
throw new InitError(`${file} has an mcpServers that is not a JSON object; it was left unchanged.`);
|
|
119
|
+
const readable = (mode) => server.secret === true && process.platform !== "win32" && (mode & 0o044) !== 0;
|
|
120
|
+
const entry = servers[SERVER_NAME];
|
|
121
|
+
if (entry !== undefined) {
|
|
122
|
+
// A secret entry already there is "present" only while no other user can read it.
|
|
123
|
+
if (server.same(entry) && readable(existsSync(file) ? statSync(file).mode : 0)) {
|
|
124
|
+
throw new InitError(`${file} holds the token, and other users of this machine can read it; it was left as it is. To make it private, run: chmod 600 ${shellQuote(file)}, then run message-cp init again.`);
|
|
125
|
+
}
|
|
126
|
+
return server.same(entry) ? "present" : "differs";
|
|
127
|
+
}
|
|
128
|
+
config.mcpServers = { ...servers, [SERVER_NAME]: server.entry };
|
|
129
|
+
let tmp;
|
|
130
|
+
try {
|
|
131
|
+
const target = text === undefined ? file : realpathSync(file);
|
|
132
|
+
tmp = join(dirname(target), `.${SERVER_NAME}-init-${randomBytes(6).toString("hex")}.tmp`);
|
|
133
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
134
|
+
if (text !== undefined) {
|
|
135
|
+
// The mode the new file is given, checked here as well as in remoteRefusal, since it may have changed since.
|
|
136
|
+
const mode = statSync(target).mode & 0o777;
|
|
137
|
+
if (readable(mode)) {
|
|
138
|
+
throw new InitError(`${file} became readable by other users of this machine while message-cp init was running; it was left as it is. To make it private, run: chmod 600 ${shellQuote(file)}, then run message-cp init again.`);
|
|
139
|
+
}
|
|
140
|
+
chmodSync(tmp, mode);
|
|
141
|
+
}
|
|
142
|
+
if (current() !== text) {
|
|
143
|
+
throw new InitError(`${file} changed while message-cp init was running; it was left as it is. Run message-cp init again.`);
|
|
144
|
+
}
|
|
145
|
+
renameSync(tmp, target);
|
|
146
|
+
tmp = undefined;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err instanceof InitError)
|
|
150
|
+
throw err;
|
|
151
|
+
throw new InitError(`could not write ${file} (${reason(err)}); it was left as it was.`);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (tmp !== undefined)
|
|
155
|
+
rmSync(tmp, { force: true });
|
|
156
|
+
}
|
|
157
|
+
return "added";
|
|
158
|
+
}
|
|
159
|
+
/** The `claude` or `codex` command that adds, shows or removes the user-level server. */
|
|
160
|
+
export const cliCommands = {
|
|
161
|
+
"claude-code": {
|
|
162
|
+
command: "claude",
|
|
163
|
+
add: ["mcp", "add", "--scope", "user", SERVER_NAME, "--", SERVER_COMMAND, ...SERVER_ARGS],
|
|
164
|
+
get: ["mcp", "get", SERVER_NAME],
|
|
165
|
+
remove: ["mcp", "remove", "--scope", "user", SERVER_NAME],
|
|
166
|
+
},
|
|
167
|
+
codex: {
|
|
168
|
+
command: "codex",
|
|
169
|
+
add: ["mcp", "add", SERVER_NAME, "--", SERVER_COMMAND, ...SERVER_ARGS],
|
|
170
|
+
get: ["mcp", "get", SERVER_NAME, "--json"],
|
|
171
|
+
remove: ["mcp", "remove", SERVER_NAME],
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
/** The most `readConnectFile` reads: `connect` writes well under 2 KiB. */
|
|
175
|
+
const MAX_CONNECT_FILE = 64 * 1024;
|
|
176
|
+
/**
|
|
177
|
+
* The team server in the file `message-cp connect` wrote: the `mcpServers` JSON after its `claude mcp add` line, which
|
|
178
|
+
* must name exactly one server with a `url` and the three headers `clientSetup` in `src/connect.ts` writes. The URL goes
|
|
179
|
+
* through `mcpEndpoint`, the check `init --url` uses. Refuses a file it cannot read, one that is not a regular file or
|
|
180
|
+
* is over 64 KiB, one whose mode is wider than 0600 (except on Windows, which has no such modes), and one without
|
|
181
|
+
* exactly one usable URL, agent, token and key. No message holds the file's contents.
|
|
182
|
+
*/
|
|
183
|
+
export function readConnectFile(file, platform = process.platform) {
|
|
184
|
+
const refuse = (why) => new InitError(`${file} ${why}. Nothing was changed.`);
|
|
185
|
+
let text;
|
|
186
|
+
let fd;
|
|
187
|
+
try {
|
|
188
|
+
// Non-blocking, so a FIFO named by mistake is refused below instead of waiting for a writer.
|
|
189
|
+
fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
190
|
+
const st = fstatSync(fd);
|
|
191
|
+
if (!st.isFile())
|
|
192
|
+
throw refuse("is not a file");
|
|
193
|
+
if (platform !== "win32" && (st.mode & 0o177) !== 0) {
|
|
194
|
+
throw refuse(`holds a token and key, and its mode ${(st.mode & 0o777).toString(8).padStart(4, "0")} is wider than 0600; make it private with: chmod 600 ${shellQuote(file)}, then run message-cp init again`);
|
|
195
|
+
}
|
|
196
|
+
if (st.size > MAX_CONNECT_FILE)
|
|
197
|
+
throw refuse("is too large to be what message-cp connect wrote");
|
|
198
|
+
text = readFileSync(fd, "utf8");
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (err instanceof InitError)
|
|
202
|
+
throw err;
|
|
203
|
+
throw new InitError(`could not read ${file} (${err.code ?? (err instanceof Error ? err.message : String(err))}). Nothing was changed.`);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (fd !== undefined)
|
|
207
|
+
closeSync(fd);
|
|
208
|
+
}
|
|
209
|
+
const notConnect = (what) => refuse(`${what}; give the file message-cp connect wrote, unchanged`);
|
|
210
|
+
// The JSON starts at the first line that is only "{"; a parse error is never quoted, since it may quote the token.
|
|
211
|
+
const start = /^\{[ \t\r]*$/m.exec(text)?.index;
|
|
212
|
+
let config;
|
|
213
|
+
try {
|
|
214
|
+
config = start === undefined ? undefined : JSON.parse(text.slice(start));
|
|
215
|
+
}
|
|
216
|
+
catch { }
|
|
217
|
+
if (!isObject(config) || !isObject(config.mcpServers))
|
|
218
|
+
throw notConnect("holds no mcpServers JSON");
|
|
219
|
+
const servers = Object.values(config.mcpServers);
|
|
220
|
+
if (servers.length !== 1)
|
|
221
|
+
throw notConnect(`names ${servers.length} servers, not one`);
|
|
222
|
+
const entry = servers[0];
|
|
223
|
+
const headers = isObject(entry) && isObject(entry.headers) ? entry.headers : {};
|
|
224
|
+
const url = isObject(entry) ? entry.url : undefined;
|
|
225
|
+
const auth = headers.Authorization;
|
|
226
|
+
const token = typeof auth === "string" ? /^Bearer (.*)$/.exec(auth)?.[1] : undefined;
|
|
227
|
+
const agent = headers["X-Message-CP-Agent"];
|
|
228
|
+
const key = headers["X-Message-CP-Agent-Key"];
|
|
229
|
+
if (typeof url !== "string")
|
|
230
|
+
throw notConnect("has no server URL");
|
|
231
|
+
// Printable ASCII with no spaces, so each is a valid header value.
|
|
232
|
+
const word = (v) => typeof v === "string" && /^[\x21-\x7e]+$/.test(v);
|
|
233
|
+
if (!word(token))
|
|
234
|
+
throw notConnect("has no token");
|
|
235
|
+
if (typeof agent !== "string" || !NAME_PATTERN.test(agent) || agent.toLowerCase() === "all")
|
|
236
|
+
throw notConnect("has no usable agent name");
|
|
237
|
+
if (!word(key))
|
|
238
|
+
throw notConnect("has no agent key");
|
|
239
|
+
let endpoint;
|
|
240
|
+
try {
|
|
241
|
+
endpoint = mcpEndpoint(url);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
if (!(err instanceof ConnectError))
|
|
245
|
+
throw err;
|
|
246
|
+
throw refuse(`has a server URL init refuses: ${err.message.replace(/^--url /, "it ")}`);
|
|
247
|
+
}
|
|
248
|
+
return { endpoint, agent, token, key, from: file };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Writes `token` to `~/.message-cp/http-token` under `home`, where the wake-up command reads it, as a new 0600 file in a
|
|
252
|
+
* directory made 0700 if missing; an existing directory keeps its mode. A file already there is never replaced:
|
|
253
|
+
* "present" if it holds `token` as the wake-up command reads it (`$(cat ...)`, which drops only trailing newlines),
|
|
254
|
+
* "differs" if it holds anything else, even nothing. Throws an InitError, without the token, if it cannot write the file
|
|
255
|
+
* (removing what it created), or if the one already there is not a regular file or cannot be read.
|
|
256
|
+
*/
|
|
257
|
+
export function writeTokenFile(home, token) {
|
|
258
|
+
const dir = join(home, ".message-cp");
|
|
259
|
+
const file = join(dir, "http-token");
|
|
260
|
+
const reason = (err) => err.code ?? (err instanceof Error ? err.message : String(err));
|
|
261
|
+
let fd;
|
|
262
|
+
try {
|
|
263
|
+
mkdirSync(dir, { mode: 0o700, recursive: true });
|
|
264
|
+
// "wx": never through an existing file or symlink, so one made meanwhile is compared below, not overwritten.
|
|
265
|
+
fd = openSync(file, "wx", 0o600);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
if (err.code !== "EEXIST")
|
|
269
|
+
throw new InitError(`could not write ${file} (${reason(err)})`);
|
|
270
|
+
}
|
|
271
|
+
if (fd !== undefined) {
|
|
272
|
+
let failed;
|
|
273
|
+
try {
|
|
274
|
+
writeFileSync(fd, `${token}\n`);
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
failed = err;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
closeSync(fd);
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
failed ??= err;
|
|
284
|
+
}
|
|
285
|
+
if (failed === undefined)
|
|
286
|
+
return "written";
|
|
287
|
+
// This run made the file, so a partial one is removed: a rerun then writes it instead of finding it "differs".
|
|
288
|
+
try {
|
|
289
|
+
rmSync(file, { force: true });
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
throw new InitError(`could not write ${file} (${reason(failed)}), nor remove what was written (${reason(err)}); delete it, then run message-cp init again`);
|
|
293
|
+
}
|
|
294
|
+
throw new InitError(`could not write ${file} (${reason(failed)})`);
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
// Non-blocking, so a FIFO there (or a symlink to one) is refused below instead of waiting for a writer.
|
|
298
|
+
fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
299
|
+
if (!fstatSync(fd).isFile())
|
|
300
|
+
throw new InitError(`${file} is already there but is not a file; it was left as it is`);
|
|
301
|
+
return readFileSync(fd, "utf8").replace(/\n+$/, "") === token ? "present" : "differs";
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
if (err instanceof InitError)
|
|
305
|
+
throw err;
|
|
306
|
+
throw new InitError(`${file} is already there but could not be read (${reason(err)}); it was left as it is`);
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
try {
|
|
310
|
+
if (fd !== undefined)
|
|
311
|
+
closeSync(fd);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// Only read, so a failed close loses nothing.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The headers a client sends to act as the agent: only `Authorization` for a token bound to it, otherwise the same three
|
|
320
|
+
* `clientSetup` in `src/connect.ts` prints.
|
|
321
|
+
*/
|
|
322
|
+
export const remoteHeaders = (r) => r.bound
|
|
323
|
+
? { Authorization: `Bearer ${r.token}` }
|
|
324
|
+
: { Authorization: `Bearer ${r.token}`, "X-Message-CP-Agent": r.agent, "X-Message-CP-Agent-Key": r.key ?? "" };
|
|
325
|
+
/**
|
|
326
|
+
* Whether the header values `get` reads from an existing entry are the ones `r` sends. `get` returns every value under
|
|
327
|
+
* the name in any case, since header names are case-insensitive and the server reads them in lowercase; a header given
|
|
328
|
+
* twice is not the same, since the client may send either. For a bound token, the entry an earlier `init` wrote with the
|
|
329
|
+
* agent's name and key as well is the same too, since the server accepts a bound token with headers naming its own
|
|
330
|
+
* agent (in any case) and its key, but only when `r` has the key to compare: without it, a key reset since would leave
|
|
331
|
+
* the client refused while `init` called it set up.
|
|
332
|
+
*/
|
|
333
|
+
function sameHeaders(r, get) {
|
|
334
|
+
const one = (name) => {
|
|
335
|
+
const values = get(name);
|
|
336
|
+
return values.length === 1 ? values[0] : undefined;
|
|
337
|
+
};
|
|
338
|
+
if (one("Authorization") !== `Bearer ${r.token}`)
|
|
339
|
+
return false;
|
|
340
|
+
if (!r.bound)
|
|
341
|
+
return Object.entries(remoteHeaders(r)).every(([k, v]) => one(k) === v);
|
|
342
|
+
if (get("X-Message-CP-Agent").length === 0 && get("X-Message-CP-Agent-Key").length === 0)
|
|
343
|
+
return true;
|
|
344
|
+
const agent = one("X-Message-CP-Agent");
|
|
345
|
+
return typeof agent === "string" && agent.toLowerCase() === r.agent.toLowerCase() && r.key !== undefined && one("X-Message-CP-Agent-Key") === r.key;
|
|
346
|
+
}
|
|
347
|
+
/** `text` with the token and key replaced by `<redacted>`, so no line `init` passes on holds either. */
|
|
348
|
+
export function redact(text, r) {
|
|
349
|
+
if (r === undefined)
|
|
350
|
+
return text;
|
|
351
|
+
for (const secret of [r.token, r.key])
|
|
352
|
+
if (secret)
|
|
353
|
+
text = text.replaceAll(secret, "<redacted>");
|
|
354
|
+
return text;
|
|
355
|
+
}
|
|
356
|
+
/** The `claude mcp add` arguments for the team server, at user scope; the token and key are only in this array. */
|
|
357
|
+
export const claudeRemoteAdd = (r) => [
|
|
358
|
+
"mcp",
|
|
359
|
+
"add",
|
|
360
|
+
"--scope",
|
|
361
|
+
"user",
|
|
362
|
+
"--transport",
|
|
363
|
+
"http",
|
|
364
|
+
SERVER_NAME,
|
|
365
|
+
r.endpoint.href,
|
|
366
|
+
...Object.entries(remoteHeaders(r)).flatMap(([k, v]) => ["--header", `${k}: ${v}`]),
|
|
367
|
+
];
|
|
368
|
+
/**
|
|
369
|
+
* The Cursor entry for the team server: `url` and `headers`, with no `type`, as Cursor documents a remote server. An
|
|
370
|
+
* entry is the same only with the same URL and headers (`sameHeaders`), so one holding an old token or key is not.
|
|
371
|
+
*/
|
|
372
|
+
export const cursorRemote = (r) => ({
|
|
373
|
+
entry: { url: r.endpoint.href, headers: remoteHeaders(r) },
|
|
374
|
+
secret: true,
|
|
375
|
+
same: (e) => isObject(e) &&
|
|
376
|
+
e.url === r.endpoint.href &&
|
|
377
|
+
isObject(e.headers) &&
|
|
378
|
+
sameHeaders(r, (name) => Object.entries(e.headers)
|
|
379
|
+
.filter(([k]) => k.toLowerCase() === name.toLowerCase())
|
|
380
|
+
.map(([, v]) => v)),
|
|
381
|
+
});
|
|
382
|
+
/**
|
|
383
|
+
* Whether `claude mcp get`'s output describes the team server `r` acts as: `Type: http`, its `URL:` and the header
|
|
384
|
+
* lines (`sameHeaders`), so an entry holding an old token or key is not the same. The output holds the token and key, so
|
|
385
|
+
* it is only matched, never printed.
|
|
386
|
+
*/
|
|
387
|
+
function sameRemote(stdout, r) {
|
|
388
|
+
const field = (name) => new RegExp(`^\\s*${name}: (.*)$`, "m").exec(stdout)?.[1]?.trim();
|
|
389
|
+
const header = (name) => [...stdout.matchAll(new RegExp(`^\\s*${name}: (.*)$`, "gim"))].map((m) => m[1].trim());
|
|
390
|
+
return field("Type") === "http" && field("URL") === r.endpoint.href && sameHeaders(r, header);
|
|
391
|
+
}
|
|
392
|
+
/** Whether `get`'s output describes a stdio server that runs `npx -y message-cp`. */
|
|
393
|
+
function runsPackage(client, stdout) {
|
|
394
|
+
if (client === "codex") {
|
|
395
|
+
try {
|
|
396
|
+
const t = JSON.parse(stdout).transport;
|
|
397
|
+
return t?.type === "stdio" && t.command === SERVER_COMMAND && JSON.stringify(t.args) === JSON.stringify(SERVER_ARGS);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const field = (name) => new RegExp(`^\\s*${name}: (.*)$`, "m").exec(stdout)?.[1]?.trim();
|
|
404
|
+
return field("Type") === "stdio" && field("Command") === SERVER_COMMAND && field("Args") === SERVER_ARGS.join(" ");
|
|
405
|
+
}
|
|
406
|
+
const failed = (r, what, remote) => {
|
|
407
|
+
const detail = redact((r.stderr || r.stdout).trim().split("\n").pop() ?? "", remote);
|
|
408
|
+
return new InitError(`${what} failed (${r.status === null ? "no exit status" : `exit ${r.status}`})${detail ? `: ${detail}` : ""}.`);
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* Why `client` cannot be set up for the team server at `endpoint` on this machine, with what to do by hand, or
|
|
412
|
+
* undefined if it can. `codex mcp add` takes `--url` but no custom headers and no literal bearer token, so Codex joins
|
|
413
|
+
* through the server's OAuth flow instead, which init does not run: `codex mcp login`, approved in a browser by a
|
|
414
|
+
* person with a full or person token of the account; the endpoint is shell-quoted, since a path may hold `&` or `;`.
|
|
415
|
+
* Claude Desktop takes a team server only as a custom connector in its app. On Windows `claude` is a `.cmd` file that
|
|
416
|
+
* only a shell can start, and the token must not pass through a shell string. An existing Cursor file that group or
|
|
417
|
+
* others can read is refused, so the token and key are never written where other users can read them; its mode is left
|
|
418
|
+
* as it is.
|
|
419
|
+
*/
|
|
420
|
+
export function remoteRefusal(client, m, endpoint) {
|
|
421
|
+
if (client === "codex") {
|
|
422
|
+
return `Codex joins a team server through the server's OAuth flow (\`serve --public-url\`), which init does not run: run \`codex mcp add message-cp --url ${shellQuote(endpoint.href)}\`, then \`codex mcp login message-cp\` if add did not start the login, and have a person with a full or person token of the account approve it at the address Codex prints.`;
|
|
423
|
+
}
|
|
424
|
+
if (client === "claude-desktop") {
|
|
425
|
+
return `Claude Desktop takes a team server as a custom connector in its app: in Customize, Connectors, choose Add, then Add custom connector, give it a name and the URL ${endpoint.href}, choose No sign in, and add the three request headers.`;
|
|
426
|
+
}
|
|
427
|
+
if (client === "claude-code" && m.platform === "win32") {
|
|
428
|
+
return "on Windows, message-cp init cannot run `claude mcp add` without passing the token through a shell; run the `claude mcp add` command `message-cp connect` printed instead, or use --client cursor.";
|
|
429
|
+
}
|
|
430
|
+
if (client === "cursor" && m.platform !== "win32") {
|
|
431
|
+
// Checked before the token is written, not after: its mode is the user's to change, not init's.
|
|
432
|
+
const file = configFile("cursor", m);
|
|
433
|
+
let mode = 0;
|
|
434
|
+
try {
|
|
435
|
+
mode = statSync(file).mode;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// Not there yet: mergeServer makes it 0600.
|
|
439
|
+
}
|
|
440
|
+
if ((mode & 0o044) !== 0) {
|
|
441
|
+
return `${file} would hold the token, and other users of this machine can read it; to make it private, run: chmod 600 ${shellQuote(file)}, then run message-cp init again.`;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Adds the server to `client`'s user-level configuration, unless a `message-cp` entry is already there. Claude Code and
|
|
448
|
+
* Codex go through their own `mcp get` (exit 1 when there is no such server) and `mcp add`; Cursor and Claude Desktop
|
|
449
|
+
* through `mergeServer`. With `remote`, the entry is the team server over HTTP instead, for Claude Code or Cursor only.
|
|
450
|
+
*/
|
|
451
|
+
export function setUpClient(client, m, run, remote) {
|
|
452
|
+
const refused = remote === undefined ? undefined : remoteRefusal(client, m, remote.endpoint);
|
|
453
|
+
if (refused !== undefined)
|
|
454
|
+
throw new InitError(`${refused} Nothing was changed.`);
|
|
455
|
+
if (client === "cursor" || client === "claude-desktop") {
|
|
456
|
+
const file = configFile(client, m);
|
|
457
|
+
const state = remote === undefined ? mergeServer(file) : mergeServer(file, cursorRemote(remote));
|
|
458
|
+
return { client, state, changed: `in ${file}`, undo: `delete the "${SERVER_NAME}" entry under "mcpServers" in ${file}` };
|
|
459
|
+
}
|
|
460
|
+
const c = cliCommands[client];
|
|
461
|
+
const undo = [c.command, ...c.remove].join(" ");
|
|
462
|
+
// With `remote`, what is shown leaves out the headers, which hold the token (and key).
|
|
463
|
+
const add = remote === undefined ? c.add : claudeRemoteAdd(remote);
|
|
464
|
+
const target = remote === undefined ? [c.command, ...c.add].join(" ") : `${c.command} mcp add --scope user --transport http ${SERVER_NAME} ${shellQuote(remote.endpoint.href)}`;
|
|
465
|
+
const got = run(c.command, c.get);
|
|
466
|
+
const headers = remote?.bound ? "the token's Authorization header" : "the agent's three headers";
|
|
467
|
+
const changed = remote === undefined ? `with \`${target}\`` : `with \`${target}\` and ${headers}`;
|
|
468
|
+
if (got.status === 0) {
|
|
469
|
+
const same = remote === undefined ? runsPackage(client, got.stdout) : sameRemote(got.stdout, remote);
|
|
470
|
+
return { client, state: same ? "present" : "differs", changed, undo };
|
|
471
|
+
}
|
|
472
|
+
if (got.status !== 1)
|
|
473
|
+
throw failed(got, [c.command, ...c.get].join(" "), remote);
|
|
474
|
+
const added = run(c.command, add);
|
|
475
|
+
if (added.status !== 0)
|
|
476
|
+
throw failed(added, target, remote);
|
|
477
|
+
return { client, state: "added", changed, undo };
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Runs `claude` and `codex` from an empty temporary directory, so `mcp get` sees the user-level server and not a
|
|
481
|
+
* project's `.mcp.json` or local entry for the directory `init` happens to run in.
|
|
482
|
+
*/
|
|
483
|
+
export const realRunner = (command, args) => {
|
|
484
|
+
const cwd = mkdtempSync(join(tmpdir(), "message-cp-init-"));
|
|
485
|
+
try {
|
|
486
|
+
// `claude` and `codex` are `.cmd` files on Windows, which only a shell can start; the arguments are fixed words.
|
|
487
|
+
const r = spawnSync(command, args, { cwd, encoding: "utf8", shell: process.platform === "win32", timeout: 60_000 });
|
|
488
|
+
// Not started, timed out or killed: no exit status, and the reason in place of empty stderr.
|
|
489
|
+
const why = r.error?.message ?? (r.signal ? `killed by ${r.signal}` : "");
|
|
490
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: r.stderr || why };
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
/**
|
|
497
|
+
* The stdio server of the package running `init`: `npx -y message-cp` resolves only once the package is published.
|
|
498
|
+
* This is the built `dist/index.js` next to `dist/init.js`; run from `src/` with tsx, there is no `src/index.js`.
|
|
499
|
+
*/
|
|
500
|
+
export const ownServer = () => ({
|
|
501
|
+
command: process.execPath,
|
|
502
|
+
args: [fileURLToPath(new URL("./index.js", import.meta.url))],
|
|
503
|
+
});
|
|
504
|
+
const CALL_TIMEOUT_MS = 15_000;
|
|
505
|
+
/**
|
|
506
|
+
* Starts `server` over stdio with `env` and the database `dbPath(env)`, registers a test agent with a random name in the
|
|
507
|
+
* owner account, sends it a message from itself and reads it back. The test agent and its messages are then purged with
|
|
508
|
+
* `purgeAgent` from the same database, even when a step failed. Throws an InitError that names the step that failed
|
|
509
|
+
* (with the last line the server wrote to stderr, if it wrote any) and any failed clean-up; no message carries the
|
|
510
|
+
* agent's key.
|
|
511
|
+
*/
|
|
512
|
+
export async function proveServer(server, env = process.env) {
|
|
513
|
+
const db = dbPath(env);
|
|
514
|
+
const name = `init-check-${randomBytes(10).toString("hex")}`;
|
|
515
|
+
// Made here, so register returns no key, and the agent can be removed even if register's reply was lost.
|
|
516
|
+
const key = randomBytes(24).toString("hex");
|
|
517
|
+
const body = `message-cp init check ${name}`;
|
|
518
|
+
// The child acts only as the test agent: an agent named in the environment would be registered by the server first.
|
|
519
|
+
const childEnv = {};
|
|
520
|
+
for (const [k, v] of Object.entries(env))
|
|
521
|
+
if (v !== undefined && k !== "MESSAGE_CP_AGENT" && k !== "MESSAGE_CP_AGENT_KEY")
|
|
522
|
+
childEnv[k] = v;
|
|
523
|
+
childEnv.MESSAGE_CP_DB = db;
|
|
524
|
+
const client = new Client({ name: "message-cp-init", version: "1.0.0" });
|
|
525
|
+
let failure;
|
|
526
|
+
let step = "starting the server";
|
|
527
|
+
let stderr = "";
|
|
528
|
+
try {
|
|
529
|
+
const transport = new StdioClientTransport({ ...server, env: childEnv, stderr: "pipe" });
|
|
530
|
+
transport.stderr?.on("data", (chunk) => {
|
|
531
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-4000);
|
|
532
|
+
});
|
|
533
|
+
await client.connect(transport, { timeout: CALL_TIMEOUT_MS });
|
|
534
|
+
const call = async (tool, args) => {
|
|
535
|
+
step = tool;
|
|
536
|
+
const result = (await client.callTool({ name: tool, arguments: args }, undefined, { timeout: CALL_TIMEOUT_MS }));
|
|
537
|
+
const text = result.content?.[0]?.text ?? "";
|
|
538
|
+
if (result.isError)
|
|
539
|
+
throw new Error(`${tool} returned an error: ${text.slice(0, 200)}`);
|
|
540
|
+
return JSON.parse(text);
|
|
541
|
+
};
|
|
542
|
+
await call("register", { name, bio: "message-cp init check; removed when the check ends", key });
|
|
543
|
+
await call("send_message", { to: name, body });
|
|
544
|
+
const inbox = (await call("read_inbox", {}));
|
|
545
|
+
if (!inbox.some((m) => m.from === name && m.body === body))
|
|
546
|
+
throw new Error("read_inbox did not return the message just sent");
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
failure = err;
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
await client.close().catch(() => { });
|
|
553
|
+
}
|
|
554
|
+
let cleanup;
|
|
555
|
+
try {
|
|
556
|
+
const conn = openDb(db);
|
|
557
|
+
try {
|
|
558
|
+
// Only the registration this check made, the live agent holding its key: if register met an agent that already
|
|
559
|
+
// had the name, or the name has since been taken over, that agent is not this check's to remove.
|
|
560
|
+
const keyHash = createHash("sha256").update(key).digest("hex");
|
|
561
|
+
conn
|
|
562
|
+
.transaction(() => {
|
|
563
|
+
const live = conn
|
|
564
|
+
.prepare("SELECT key_hash FROM agents WHERE account_id = (SELECT id FROM accounts WHERE name = 'owner') AND name = ? AND removed_at IS NULL")
|
|
565
|
+
.get(name);
|
|
566
|
+
if (live?.key_hash === keyHash)
|
|
567
|
+
purgeAgent(conn, "owner", name);
|
|
568
|
+
})
|
|
569
|
+
.immediate();
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
// Not registered: there is nothing to remove.
|
|
573
|
+
if (!(err instanceof PurgeError))
|
|
574
|
+
throw err;
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
conn.close();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
cleanup = err;
|
|
582
|
+
}
|
|
583
|
+
const say = (err) => (err instanceof Error ? err.message : String(err)).replaceAll(key, "<redacted>");
|
|
584
|
+
if (failure !== undefined || cleanup !== undefined) {
|
|
585
|
+
const parts = [];
|
|
586
|
+
if (failure !== undefined) {
|
|
587
|
+
const last = stderr.trim().split("\n").pop()?.trim();
|
|
588
|
+
parts.push(`${step}: ${say(failure)}${last ? ` (server: ${say(last).slice(0, 300)})` : ""}`);
|
|
589
|
+
}
|
|
590
|
+
if (cleanup !== undefined)
|
|
591
|
+
parts.push(`could not remove the test agent '${name}' (${say(cleanup)}); remove it with: message-cp agent remove ${name}`);
|
|
592
|
+
throw new InitError(parts.join("; "));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/** A client of the team server `r`, sending `headers` on every request. */
|
|
596
|
+
function remoteClient(r, headers) {
|
|
597
|
+
const client = new Client({ name: "message-cp-init", version: "1.0.0" });
|
|
598
|
+
const transport = new StreamableHTTPClientTransport(r.endpoint, { requestInit: { headers } });
|
|
599
|
+
return { client, transport };
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Ends the session `transport` holds with DELETE /mcp, waiting at most `ms` for it, then closes the client, which
|
|
603
|
+
* aborts a DELETE still pending; neither failure matters, since the server ends an idle session itself.
|
|
604
|
+
*/
|
|
605
|
+
export async function endSession(client, transport, ms = CALL_TIMEOUT_MS) {
|
|
606
|
+
if (transport.sessionId !== undefined) {
|
|
607
|
+
let timer;
|
|
608
|
+
const timeout = new Promise((resolve) => {
|
|
609
|
+
timer = setTimeout(resolve, ms);
|
|
610
|
+
});
|
|
611
|
+
await Promise.race([transport.terminateSession().catch(() => { }), timeout]);
|
|
612
|
+
clearTimeout(timer);
|
|
613
|
+
}
|
|
614
|
+
await client.close().catch(() => { });
|
|
615
|
+
}
|
|
616
|
+
/** The server's own message from a refusal: the `error.message` of the JSON body the SDK quotes, if there is one. */
|
|
617
|
+
function serverMessage(err) {
|
|
618
|
+
const json = /\{.*\}/s.exec(err.message)?.[0];
|
|
619
|
+
try {
|
|
620
|
+
const message = JSON.parse(json ?? "").error?.message;
|
|
621
|
+
if (typeof message === "string")
|
|
622
|
+
return message.replace(/\.$/, "");
|
|
623
|
+
}
|
|
624
|
+
catch { }
|
|
625
|
+
return err.message;
|
|
626
|
+
}
|
|
627
|
+
/** Calls `tool` and returns its parsed JSON result, throwing an Error with the tool's own text when it fails. */
|
|
628
|
+
async function callTool(client, tool, args) {
|
|
629
|
+
const result = (await client.callTool({ name: tool, arguments: args }, undefined, { timeout: CALL_TIMEOUT_MS }));
|
|
630
|
+
const text = result.content?.[0]?.text ?? "";
|
|
631
|
+
if (result.isError)
|
|
632
|
+
throw new Error(`${tool} returned an error: ${text.slice(0, 200)}`);
|
|
633
|
+
return JSON.parse(text);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Checks the token, and the agent's key if there is one, before anything is written, and returns whether the token is
|
|
637
|
+
* bound to the agent. A session that sends only the token finds the agent with `get_agent`, since a session that names
|
|
638
|
+
* an agent registers it, and `init` makes no agent. On that session `read_conversation` (which marks nothing read) fails
|
|
639
|
+
* as not registered for an unbound token; for a bound one it succeeds, and `register` with a random name, refused before
|
|
640
|
+
* it does anything, says which agent the token is: one bound to another agent is refused. Then, with a key, a session
|
|
641
|
+
* with the three headers an unbound token's client sends, which acts as the agent from its first request. Every session
|
|
642
|
+
* is ended. Throws an InitError that says which was refused: the token (401, or 403 for a read-only one), the agent's
|
|
643
|
+
* name (not on the server, or not the bound token's agent) or its key (403, or none for an unbound token), or that the
|
|
644
|
+
* server could not be reached. No message holds the token or key.
|
|
645
|
+
*/
|
|
646
|
+
export async function checkRemote(r) {
|
|
647
|
+
const tokenIn = r.from === undefined ? "the token in MESSAGE_CP_HTTP_TOKEN" : `the token in ${r.from}`;
|
|
648
|
+
const keyIn = r.from === undefined ? "the key in MESSAGE_CP_AGENT_KEY" : `the key in ${r.from}`;
|
|
649
|
+
const refusal = (err, named) => {
|
|
650
|
+
const why = err instanceof Error ? err : new Error(String(err));
|
|
651
|
+
const code = why instanceof StreamableHTTPError ? why.code : undefined;
|
|
652
|
+
const message = redact(serverMessage(why), r).slice(0, 300);
|
|
653
|
+
if (code === 401)
|
|
654
|
+
return new InitError(`the server refused ${tokenIn} (401)`);
|
|
655
|
+
if (code === 403 && !named)
|
|
656
|
+
return new InitError(`the server refused ${tokenIn} (403): ${message}`);
|
|
657
|
+
if (code === 403)
|
|
658
|
+
return new InitError(`the server refused agent '${r.agent}' with ${keyIn} (403): ${message}`);
|
|
659
|
+
return new InitError(`could not open a session at ${r.endpoint.href}: ${message}`);
|
|
660
|
+
};
|
|
661
|
+
const said = (err) => redact(err instanceof Error ? err.message : String(err), r);
|
|
662
|
+
const plain = remoteClient(r, { Authorization: `Bearer ${r.token}` });
|
|
663
|
+
let bound = false;
|
|
664
|
+
try {
|
|
665
|
+
try {
|
|
666
|
+
await plain.client.connect(plain.transport, { timeout: CALL_TIMEOUT_MS });
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
throw refusal(err, false);
|
|
670
|
+
}
|
|
671
|
+
let agent;
|
|
672
|
+
try {
|
|
673
|
+
agent = await callTool(plain.client, "get_agent", { name: r.agent });
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
const message = said(err);
|
|
677
|
+
if (/No agent named/.test(message)) {
|
|
678
|
+
throw new InitError(`the server has no agent named '${r.agent}'; make it on the server with: message-cp connect ${shellQuote(r.agent)} --url <public url>`);
|
|
679
|
+
}
|
|
680
|
+
throw new InitError(`get_agent: ${message.slice(0, 300)}`);
|
|
681
|
+
}
|
|
682
|
+
if (agent.person)
|
|
683
|
+
throw new InitError(`'${r.agent}' is a person on the server, not an agent`);
|
|
684
|
+
try {
|
|
685
|
+
await callTool(plain.client, "read_conversation", { name: r.agent, limit: 1 });
|
|
686
|
+
bound = true;
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
const message = said(err);
|
|
690
|
+
if (!message.includes(NOT_REGISTERED))
|
|
691
|
+
throw new InitError(`read_conversation: ${message.slice(0, 300)}`);
|
|
692
|
+
}
|
|
693
|
+
if (bound) {
|
|
694
|
+
// Refused before it does anything, so nothing is registered: its message names the token's agent.
|
|
695
|
+
const name = `init-check-${randomBytes(10).toString("hex")}`;
|
|
696
|
+
let refused = null;
|
|
697
|
+
try {
|
|
698
|
+
await callTool(plain.client, "register", { name, bio: "message-cp init check", key: randomBytes(24).toString("hex") });
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
refused = /This token is already agent '([^']*)'/.exec(err instanceof Error ? err.message : "");
|
|
702
|
+
if (!refused)
|
|
703
|
+
throw new InitError(`register: ${said(err).slice(0, 300)}`);
|
|
704
|
+
}
|
|
705
|
+
if (!refused)
|
|
706
|
+
throw new InitError(`register: the token could read as an agent but could also register '${name}'; remove it on the server with: message-cp agent remove ${name}`);
|
|
707
|
+
if (refused[1].toLowerCase() !== r.agent.toLowerCase()) {
|
|
708
|
+
throw new InitError(`${tokenIn} is bound to agent '${refused[1]}', not '${r.agent}'`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
finally {
|
|
713
|
+
await endSession(plain.client, plain.transport);
|
|
714
|
+
}
|
|
715
|
+
if (r.key === undefined) {
|
|
716
|
+
if (bound)
|
|
717
|
+
return true;
|
|
718
|
+
throw new InitError(r.from === undefined
|
|
719
|
+
? `${tokenIn} is not bound to agent '${r.agent}', so it needs the agent's key: set MESSAGE_CP_AGENT_KEY, or use the token message-cp connect printed`
|
|
720
|
+
: `${tokenIn} is not bound to agent '${r.agent}', and there is no key to go with it`);
|
|
721
|
+
}
|
|
722
|
+
const named = remoteClient(r, { Authorization: `Bearer ${r.token}`, "X-Message-CP-Agent": r.agent, "X-Message-CP-Agent-Key": r.key });
|
|
723
|
+
try {
|
|
724
|
+
await named.client.connect(named.transport, { timeout: CALL_TIMEOUT_MS });
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
throw refusal(err, true);
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
await endSession(named.client, named.transport);
|
|
731
|
+
}
|
|
732
|
+
return bound;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Proves the team server works as `proveServer` does over stdio, without touching the configured agent (except with a
|
|
736
|
+
* bound token, below): on a session
|
|
737
|
+
* that sends only the token, registers a test agent with a random name and a key made here, sends it a message from
|
|
738
|
+
* itself, reads it back and unregisters it, then ends the session. If a step failed after `register`, it still tries to
|
|
739
|
+
* remove the test agent by name and key. A token `connect` made is bound to its agent and cannot register another name,
|
|
740
|
+
* so with one the agent sends itself a message instead and finds it with `read_conversation`, which marks nothing read:
|
|
741
|
+
* the agent's own unread mail stays unread, and so does the check's message. A bound token whose agent is not `r.agent`
|
|
742
|
+
* fails at `register` before anything is sent.
|
|
743
|
+
* Returns what the check did, for `runInit` to print. Throws an InitError naming the failed step and any failed
|
|
744
|
+
* clean-up; no message holds the token or either key.
|
|
745
|
+
*/
|
|
746
|
+
export async function proveRemote(r) {
|
|
747
|
+
const name = `init-check-${randomBytes(10).toString("hex")}`;
|
|
748
|
+
const key = randomBytes(24).toString("hex");
|
|
749
|
+
const body = `message-cp init check ${name}`;
|
|
750
|
+
const { client, transport } = remoteClient(r, { Authorization: `Bearer ${r.token}` });
|
|
751
|
+
const say = (err) => redact(err instanceof Error ? err.message : String(err), r).replaceAll(key, "<redacted>").slice(0, 300);
|
|
752
|
+
let step = "opening a session";
|
|
753
|
+
let registered = false;
|
|
754
|
+
let failure;
|
|
755
|
+
let cleanup;
|
|
756
|
+
let bound = false;
|
|
757
|
+
const call = (tool, args) => callTool(client, tool, args);
|
|
758
|
+
try {
|
|
759
|
+
await client.connect(transport, { timeout: CALL_TIMEOUT_MS });
|
|
760
|
+
step = "register";
|
|
761
|
+
// Counted as registered before the reply, which may be lost after the server has acted on it.
|
|
762
|
+
registered = true;
|
|
763
|
+
try {
|
|
764
|
+
await call("register", { name, bio: "message-cp init check; removed when the check ends", key });
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
const refused = /This token is already agent '([^']*)'/.exec(err instanceof Error ? err.message : "");
|
|
768
|
+
if (!refused)
|
|
769
|
+
throw err;
|
|
770
|
+
// Refused, so nothing was registered: the token is the agent, which proves the server by itself, but only when it
|
|
771
|
+
// is the agent this setup names, so the check never messages another agent.
|
|
772
|
+
registered = false;
|
|
773
|
+
if (refused[1].toLowerCase() !== r.agent.toLowerCase())
|
|
774
|
+
throw new Error(`the token is agent '${refused[1]}', not '${r.agent}'`);
|
|
775
|
+
bound = true;
|
|
776
|
+
}
|
|
777
|
+
if (bound) {
|
|
778
|
+
step = "send_message";
|
|
779
|
+
const self = `message-cp init check ${randomBytes(10).toString("hex")}`;
|
|
780
|
+
await call("send_message", { to: r.agent, body: self });
|
|
781
|
+
step = "read_conversation";
|
|
782
|
+
const sent = (await call("read_conversation", { name: r.agent, limit: 5 }));
|
|
783
|
+
if (!sent.some((m) => m.body === self))
|
|
784
|
+
throw new Error("read_conversation did not return the message just sent");
|
|
785
|
+
await endSession(client, transport);
|
|
786
|
+
return `as agent '${r.agent}' on the server, sent itself a message and found it with read_conversation; it stays unread in the agent's inbox.`;
|
|
787
|
+
}
|
|
788
|
+
step = "send_message";
|
|
789
|
+
await call("send_message", { to: name, body });
|
|
790
|
+
step = "read_inbox";
|
|
791
|
+
const inbox = (await call("read_inbox", {}));
|
|
792
|
+
if (!inbox.some((m) => m.from === name && m.body === body))
|
|
793
|
+
throw new Error("read_inbox did not return the message just sent");
|
|
794
|
+
step = "unregister";
|
|
795
|
+
await call("unregister", {});
|
|
796
|
+
registered = false;
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
failure = err;
|
|
800
|
+
}
|
|
801
|
+
if (registered) {
|
|
802
|
+
try {
|
|
803
|
+
await call("unregister", { name, key });
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
// Never registered: nothing to remove.
|
|
807
|
+
if (!/No agent named/i.test(err instanceof Error ? err.message : ""))
|
|
808
|
+
cleanup = err;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
await endSession(client, transport);
|
|
812
|
+
if (failure !== undefined || cleanup !== undefined) {
|
|
813
|
+
const parts = [];
|
|
814
|
+
if (failure !== undefined)
|
|
815
|
+
parts.push(`${step}: ${say(failure)}`);
|
|
816
|
+
if (cleanup !== undefined)
|
|
817
|
+
parts.push(`could not remove the test agent '${name}' (${say(cleanup)}); remove it on the server with: message-cp agent remove ${name}`);
|
|
818
|
+
throw new InitError(parts.join("; "));
|
|
819
|
+
}
|
|
820
|
+
return "registered a test agent on the server, sent it a message, read it back and removed it.";
|
|
821
|
+
}
|
|
822
|
+
/** `message-cp init`: returns the exit code, 0 only when the client is set up and the check passed. */
|
|
823
|
+
export async function runInit(o) {
|
|
824
|
+
const found = detectClients(o.machine);
|
|
825
|
+
const names = (ids) => ids.map((c) => CLIENT_NAMES[c]).join(", ");
|
|
826
|
+
if (found.length === 0) {
|
|
827
|
+
o.err("message-cp init: found none of Claude Code, Codex, Cursor or Claude Desktop. Nothing was changed.");
|
|
828
|
+
o.err(`To add message-cp to Claude Code by hand: ${MANUAL_ADD}`);
|
|
829
|
+
return 1;
|
|
830
|
+
}
|
|
831
|
+
o.out(`message-cp init: found ${names(found)}.`);
|
|
832
|
+
let client = o.client;
|
|
833
|
+
if (client !== undefined) {
|
|
834
|
+
if (!found.includes(client)) {
|
|
835
|
+
o.err(`message-cp init: ${CLIENT_NAMES[client]} was not found on this machine. Nothing was changed.`);
|
|
836
|
+
return 1;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
else if (found.length === 1) {
|
|
840
|
+
client = found[0];
|
|
841
|
+
}
|
|
842
|
+
else if (!o.interactive) {
|
|
843
|
+
o.err("message-cp init: more than one client was found, so choose one with --client:");
|
|
844
|
+
for (const c of found)
|
|
845
|
+
o.err(` message-cp init --client ${c}`);
|
|
846
|
+
o.err("Nothing was changed.");
|
|
847
|
+
return 1;
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
found.forEach((c, i) => o.out(` ${i + 1}) ${CLIENT_NAMES[c]}`));
|
|
851
|
+
const answer = (await o.ask(`Set up which? [1-${found.length}] `)).trim();
|
|
852
|
+
if (answer === "") {
|
|
853
|
+
o.err("message-cp init: no client was chosen. Nothing was changed.");
|
|
854
|
+
return 1;
|
|
855
|
+
}
|
|
856
|
+
const n = Number(answer);
|
|
857
|
+
if (!/^[0-9]+$/.test(answer) || n < 1 || n > found.length) {
|
|
858
|
+
o.err(`message-cp init: '${answer}' is not one of 1-${found.length}. Nothing was changed.`);
|
|
859
|
+
return 1;
|
|
860
|
+
}
|
|
861
|
+
client = found[n - 1];
|
|
862
|
+
}
|
|
863
|
+
const remote = o.remote === undefined ? undefined : { ...o.remote };
|
|
864
|
+
if (remote !== undefined) {
|
|
865
|
+
const refused = remoteRefusal(client, o.machine, remote.endpoint);
|
|
866
|
+
if (refused !== undefined) {
|
|
867
|
+
o.err(`message-cp init: ${refused} Nothing was changed.`);
|
|
868
|
+
return 1;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
remote.bound = (await (o.check ?? checkRemote)(remote)) === true;
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
if (!(err instanceof InitError))
|
|
875
|
+
throw err;
|
|
876
|
+
o.err(`message-cp init: ${redact(err.message, remote)}. Nothing was changed.`);
|
|
877
|
+
return 1;
|
|
878
|
+
}
|
|
879
|
+
o.out(remote.key === undefined
|
|
880
|
+
? `Server: ${remote.endpoint.href} accepted the token, which is agent '${remote.agent}'.`
|
|
881
|
+
: remote.bound
|
|
882
|
+
? `Server: ${remote.endpoint.href} accepted the token, which is agent '${remote.agent}', and its key.`
|
|
883
|
+
: `Server: ${remote.endpoint.href} accepted the token, and agent '${remote.agent}' with its key.`);
|
|
884
|
+
}
|
|
885
|
+
let setUp;
|
|
886
|
+
try {
|
|
887
|
+
setUp = setUpClient(client, o.machine, o.run, remote);
|
|
888
|
+
}
|
|
889
|
+
catch (err) {
|
|
890
|
+
if (!(err instanceof InitError))
|
|
891
|
+
throw err;
|
|
892
|
+
o.err(`message-cp init: ${redact(err.message, remote)}`);
|
|
893
|
+
return 1;
|
|
894
|
+
}
|
|
895
|
+
const label = CLIENT_NAMES[client];
|
|
896
|
+
if (setUp.state === "differs") {
|
|
897
|
+
o.err(remote === undefined
|
|
898
|
+
? `message-cp init: ${label} already has a server named message-cp that does not run \`npx -y message-cp\`; it was left as it is.`
|
|
899
|
+
: `message-cp init: ${label} already has a server named message-cp that is not ${remote.endpoint.href} as agent '${remote.agent}' with this ${remote.key === undefined ? "token" : "token and key"}; it was left as it is.`);
|
|
900
|
+
if (remote?.bound && remote.key === undefined) {
|
|
901
|
+
o.err("If it is an earlier init's entry with the agent's name and key, set MESSAGE_CP_AGENT_KEY so init can check that key, or remove it so init writes the token alone.");
|
|
902
|
+
}
|
|
903
|
+
o.err(`To remove it, then run message-cp init again: ${setUp.undo}`);
|
|
904
|
+
return 1;
|
|
905
|
+
}
|
|
906
|
+
o.out(setUp.state === "added" ? `${label}: added message-cp ${setUp.changed}.` : `${label}: already set up; nothing was changed.`);
|
|
907
|
+
let proved;
|
|
908
|
+
try {
|
|
909
|
+
proved = await o.prove();
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
o.err(`Check failed: ${redact(err instanceof Error ? err.message : String(err), remote)}`);
|
|
913
|
+
o.err(`The ${label} configuration was kept. To undo it: ${setUp.undo}`);
|
|
914
|
+
return 1;
|
|
915
|
+
}
|
|
916
|
+
o.out(proved
|
|
917
|
+
? `Check passed: ${proved}`
|
|
918
|
+
: remote === undefined
|
|
919
|
+
? "Check passed: started the stdio server, registered a test agent, sent it a message, read it back and removed it."
|
|
920
|
+
: "Check passed: registered a test agent on the server, sent it a message, read it back and removed it.");
|
|
921
|
+
o.out(`To undo: ${setUp.undo}`);
|
|
922
|
+
if (remote?.from === undefined)
|
|
923
|
+
return 0;
|
|
924
|
+
const tokenFile = join(o.machine.home, ".message-cp", "http-token");
|
|
925
|
+
let state;
|
|
926
|
+
try {
|
|
927
|
+
state = writeTokenFile(o.machine.home, remote.token);
|
|
928
|
+
}
|
|
929
|
+
catch (err) {
|
|
930
|
+
if (!(err instanceof InitError))
|
|
931
|
+
throw err;
|
|
932
|
+
o.err(`message-cp init: ${label} was set up and the check passed, but ${redact(err.message, remote)}; the wake-up command reads the token from that file when MESSAGE_CP_HTTP_TOKEN is not set.`);
|
|
933
|
+
return 1;
|
|
934
|
+
}
|
|
935
|
+
if (state === "differs") {
|
|
936
|
+
// Any token of the account wakes the agent, so a different one is not an error, but it is never replaced unasked.
|
|
937
|
+
o.err(`message-cp init: ${tokenFile} is already there and does not hold this token, so it was left as it is; the wake-up command reads the token from it when MESSAGE_CP_HTTP_TOKEN is not set.`);
|
|
938
|
+
o.err(`To use this agent's token there instead, delete ${tokenFile}, then run message-cp init --from ${shellQuote(remote.from)} again.`);
|
|
939
|
+
// The rerun needs the file, so it is not called deletable yet.
|
|
940
|
+
o.err(`Once that is done, or you keep the token already there, ${remote.from} can be deleted, and its copy on the server.`);
|
|
941
|
+
return 0;
|
|
942
|
+
}
|
|
943
|
+
o.out(state === "written" ? `Wake-up token: wrote ${tokenFile}.` : `Wake-up token: ${tokenFile} already holds this token.`);
|
|
944
|
+
o.out(`${remote.from} can now be deleted, and its copy on the server.`);
|
|
945
|
+
return 0;
|
|
946
|
+
}
|
|
947
|
+
/** Asks one question on the terminal and returns the answer ("" at end of input). */
|
|
948
|
+
export async function askOnTerminal(question) {
|
|
949
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
950
|
+
try {
|
|
951
|
+
// Ctrl-C or the end of input closes the interface without answering the question: no answer.
|
|
952
|
+
return await new Promise((resolve) => {
|
|
953
|
+
rl.once("close", () => resolve(""));
|
|
954
|
+
rl.question(question).then(resolve, () => resolve(""));
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
finally {
|
|
958
|
+
rl.close();
|
|
959
|
+
}
|
|
960
|
+
}
|