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.
@@ -0,0 +1,167 @@
1
+ import { Resolver } from "node:dns";
2
+ import { request as httpsRequest } from "node:https";
3
+ import { BlockList, isIP } from "node:net";
4
+ // Fetches an OAuth Client ID Metadata Document for the authorization endpoint of `serve --public-url`. The URL is one a
5
+ // stranger chose, so the address check is the boundary against server-side request forgery: the connection may go only
6
+ // to a public address, checked on the address the connection resolves and uses, not on a separate lookup made first, and
7
+ // again on the socket once it connects. The name is resolved by a resolver of the fetch's own, whose queries run on the
8
+ // event loop rather than in libuv's thread pool and are cancelled when the fetch ends, so no lookup outlives the fetch
9
+ // (and its slot in the authorization endpoint's cap) or holds up the thread pool's other work. Nothing of the incoming request is sent, no redirect is followed, and the answer
10
+ // is cut off at a size and a deadline. Nothing here logs anything.
11
+ /** How long a fetch may take, from the request to the last byte, as a webhook delivery may. */
12
+ export const METADATA_TIMEOUT_MS = 5000;
13
+ /** The largest document read, the size the Client ID Metadata Document draft recommends; a larger one is refused. */
14
+ export const MAX_METADATA_BYTES = 5 * 1024;
15
+ /** A fetch refused or failed. Its message is fixed text that never quotes the URL or the answer. */
16
+ export class MetadataFetchError extends Error {
17
+ }
18
+ // Every range that is not a public unicast address: in IPv4 loopback, private, link-local (169.254.169.254 among them),
19
+ // carrier-grade NAT, unspecified, documentation, benchmarking, multicast and reserved. In IPv6 only global unicast
20
+ // (2000::/3) is allowed, which leaves out loopback, unspecified, unique-local, link-local, multicast and the IPv4-mapped,
21
+ // -compatible and -translated forms; within it the ranges that embed an IPv4 address (NAT64, 6to4 and Teredo) and the
22
+ // documentation ones are refused whole rather than unpacked.
23
+ // Kept apart by family: an IPv6 rule such as ::ffff:0:0/96 would otherwise match every IPv4 address.
24
+ const blocked = { ipv4: new BlockList(), ipv6: new BlockList() };
25
+ for (const [net, prefix] of [
26
+ ["0.0.0.0", 8],
27
+ ["10.0.0.0", 8],
28
+ ["100.64.0.0", 10],
29
+ ["127.0.0.0", 8],
30
+ ["169.254.0.0", 16],
31
+ ["172.16.0.0", 12],
32
+ ["192.0.0.0", 24],
33
+ ["192.0.2.0", 24],
34
+ ["192.168.0.0", 16],
35
+ ["198.18.0.0", 15],
36
+ ["198.51.100.0", 24],
37
+ ["203.0.113.0", 24],
38
+ ["224.0.0.0", 4],
39
+ ["240.0.0.0", 4],
40
+ ]) {
41
+ blocked.ipv4.addSubnet(net, prefix, "ipv4");
42
+ }
43
+ for (const [net, prefix] of [
44
+ ["2001::", 32],
45
+ ["2001:db8::", 32],
46
+ ["2002::", 16],
47
+ ["3fff::", 20],
48
+ ]) {
49
+ blocked.ipv6.addSubnet(net, prefix, "ipv6");
50
+ }
51
+ const globalUnicast = new BlockList();
52
+ globalUnicast.addSubnet("2000::", 3, "ipv6");
53
+ /** Whether `address`, an IP address as a resolver or socket reports it, is a public unicast address. */
54
+ export function isPublicAddress(address) {
55
+ const family = address === undefined ? 0 : isIP(address);
56
+ if (family === 0)
57
+ return false;
58
+ try {
59
+ if (family === 4)
60
+ return !blocked.ipv4.check(address, "ipv4");
61
+ return globalUnicast.check(address, "ipv6") && !blocked.ipv6.check(address, "ipv6");
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ const refused = () => new MetadataFetchError("The address is not a public one.");
68
+ /**
69
+ * Fetches the document at `url`, an https URL `checkMetadataUrl` in oauth.ts accepted, and answers it parsed as JSON. It
70
+ * sends only `Accept: application/json` (and the `Host` and `Connection` headers Node adds); it rejects with
71
+ * `MetadataFetchError` for a host that is or resolves to an address that is not public, an answer other than 200
72
+ * (a redirect included), one over MAX_METADATA_BYTES or not JSON, a connection that fails, or past METADATA_TIMEOUT_MS.
73
+ * There is no cache: the authorization endpoint fetches once for each request whose `client_id` passes
74
+ * `checkMetadataUrl`, before checking its other parameters, with at most `maxSessions` fetches at once.
75
+ */
76
+ export function fetchClientMetadata(url, options = {}) {
77
+ const allowed = options.allowed ?? isPublicAddress;
78
+ const target = new URL(url);
79
+ const host = target.hostname.replace(/^\[(.*)\]$/, "$1");
80
+ // A literal address is connected to without a lookup, so it is checked here.
81
+ if (isIP(host) !== 0 && !allowed(host))
82
+ return Promise.reject(refused());
83
+ const timeoutMs = options.timeoutMs ?? METADATA_TIMEOUT_MS;
84
+ const resolver = options.resolver ?? new Resolver({ timeout: timeoutMs, tries: 1 });
85
+ return new Promise((resolve, reject) => {
86
+ let settled = false;
87
+ const finish = () => {
88
+ settled = true;
89
+ clearTimeout(deadline);
90
+ resolver.cancel();
91
+ };
92
+ const fail = (err) => {
93
+ if (settled)
94
+ return;
95
+ finish();
96
+ req.destroy();
97
+ reject(err instanceof MetadataFetchError ? err : new MetadataFetchError("The document could not be fetched."));
98
+ };
99
+ // The connection's own lookup: the addresses it returns are the ones it connects to, and any that is not public
100
+ // refuses the whole name. It asks for the families the connection takes, and fails only when every query does.
101
+ const lookup = (hostname, lookupOptions, callback) => {
102
+ const family = Number(String(lookupOptions.family ?? 0).replace(/^IPv/, "")) || 0;
103
+ const queries = [4, 6]
104
+ .filter((f) => family === 0 || family === f)
105
+ .map((f) => new Promise((done, failed) => resolver[f === 4 ? "resolve4" : "resolve6"](hostname, (err, found) => err ? failed(err) : done(found.map((address) => ({ address, family: f }))))));
106
+ void Promise.allSettled(queries).then((results) => {
107
+ const addresses = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
108
+ if (addresses.length === 0) {
109
+ const failure = results.find((r) => r.status === "rejected");
110
+ return callback(failure ? failure.reason : refused());
111
+ }
112
+ if (!addresses.every((a) => allowed(a.address)))
113
+ return callback(refused());
114
+ if (lookupOptions.all)
115
+ return callback(null, addresses);
116
+ callback(null, addresses[0].address, addresses[0].family);
117
+ });
118
+ };
119
+ const req = (options.request ?? httpsRequest)({
120
+ host,
121
+ port: target.port || undefined,
122
+ path: target.pathname,
123
+ method: "GET",
124
+ headers: { Accept: "application/json" },
125
+ agent: false,
126
+ lookup: lookup,
127
+ });
128
+ const deadline = setTimeout(() => fail(new MetadataFetchError("The document took too long.")), timeoutMs);
129
+ // Checked again on the socket once it connects, before the HTTP request is sent (TLS has begun its handshake by then).
130
+ req.on("socket", (socket) => {
131
+ socket.once("connect", () => {
132
+ if (!allowed(socket.remoteAddress))
133
+ fail(refused());
134
+ });
135
+ });
136
+ req.on("error", fail);
137
+ req.on("response", (res) => {
138
+ if (res.statusCode !== 200)
139
+ return fail(new MetadataFetchError("The document could not be fetched."));
140
+ if (Number(res.headers["content-length"]) > MAX_METADATA_BYTES)
141
+ return fail(new MetadataFetchError("The document is too large."));
142
+ const chunks = [];
143
+ let size = 0;
144
+ res.on("data", (chunk) => {
145
+ size += chunk.length;
146
+ if (size > MAX_METADATA_BYTES)
147
+ return fail(new MetadataFetchError("The document is too large."));
148
+ chunks.push(chunk);
149
+ });
150
+ res.on("error", fail);
151
+ res.on("end", () => {
152
+ if (settled)
153
+ return;
154
+ let doc;
155
+ try {
156
+ doc = JSON.parse(Buffer.concat(chunks).toString("utf8"));
157
+ }
158
+ catch {
159
+ return fail(new MetadataFetchError("The document is not JSON."));
160
+ }
161
+ finish();
162
+ resolve(doc);
163
+ });
164
+ });
165
+ req.end();
166
+ });
167
+ }
@@ -0,0 +1,85 @@
1
+ import { addToken } from "./accounts.js";
2
+ import { registerWithIdentity, resetAgentKey, validateName } from "./agents.js";
3
+ import { shellQuote } from "./wait.js";
4
+ // `message-cp connect`: what a client needs to act as one agent of an account over HTTP, made in one step on the
5
+ // server. The token and key are returned for the CLI to print once, to stdout; only their hashes are stored.
6
+ export class ConnectError extends Error {
7
+ }
8
+ /**
9
+ * The public URL `raw` clients reach the server at, parsed with `new URL()`: https, or http only for a literal loopback
10
+ * address (127.x.x.x or [::1], not `localhost`), with no username, password, query or fragment, since a token is sent to
11
+ * it. `connect --url` and `serve --public-url` both check it here.
12
+ */
13
+ export function publicUrl(raw) {
14
+ let url;
15
+ try {
16
+ url = new URL(raw);
17
+ }
18
+ catch {
19
+ throw new ConnectError(`--url is not a URL: give the server's public address, for example https://message-cp.example.com.`);
20
+ }
21
+ const loopback = /^127(\.\d{1,3}){3}$/.test(url.hostname) || url.hostname === "[::1]";
22
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
23
+ throw new ConnectError("--url must be https (plain http only for 127.x.x.x or [::1]): the token would cross the network in the clear.");
24
+ }
25
+ // `href` keeps an empty `?` or `#`, which `search` and `hash` report as "".
26
+ if (url.username || url.password || url.href.includes("?") || url.href.includes("#")) {
27
+ throw new ConnectError("--url must not carry a username, password, query or fragment.");
28
+ }
29
+ return url;
30
+ }
31
+ /** The `/mcp` endpoint for the public URL `raw`, checked by `publicUrl`. A trailing `/mcp` is the same as none. */
32
+ export function mcpEndpoint(raw) {
33
+ const url = publicUrl(raw);
34
+ const endpoint = new URL(url.href);
35
+ // Set as the path, never resolved as a reference, so no path can change the host.
36
+ endpoint.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/mcp$/, "")}/mcp`;
37
+ return endpoint;
38
+ }
39
+ /**
40
+ * Makes the account's agent `name` with a new key, or, with `newKey`, gives its existing agent of that name (any case) a
41
+ * new key as `resetAgentKey` does, ending its sessions; then makes an agent token for the account bound to that agent. One transaction:
42
+ * `registerWithIdentity`, `resetAgentKey` and `addToken` run inside it as savepoints, so a failure leaves no agent, key
43
+ * or token behind. Refuses, changing nothing, an unknown account, an invalid name, a person's name, and an existing
44
+ * agent without `newKey`, since its key is stored only as a hash and cannot be shown again. The token is bound to the
45
+ * agent: it acts as that agent with no headers or `register`, and as no other name.
46
+ */
47
+ export const connectAgent = (db, account, name, newKey) => db.transaction(() => {
48
+ const acct = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
49
+ if (!acct)
50
+ throw new ConnectError(`No account named '${account}'.`);
51
+ validateName(name);
52
+ const live = db
53
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
54
+ .get(acct.id, name);
55
+ if (live?.person === 1)
56
+ throw new ConnectError(`The name '${live.name}' belongs to a person, not an agent.`);
57
+ if (live && !newKey) {
58
+ throw new ConnectError(`Agent '${live.name}' of account '${acct.name}' already exists. Its key is stored only as a hash, so it cannot be shown again; pass --new-key to give it a new one, which ends its current sessions.`);
59
+ }
60
+ let agent;
61
+ if (live)
62
+ agent = { ...resetAgentKey(db, acct.name, live.name), id: live.id };
63
+ else {
64
+ const { result, identity } = registerWithIdentity(db, acct.id, name);
65
+ agent = { id: identity.id, name: result.name, key: result.key };
66
+ }
67
+ // Bound to the agent, so the token alone acts as it, and as nothing else.
68
+ const token = addToken(db, acct.name, false, true, agent.id);
69
+ return { name: agent.name, account: acct.name, reset: live !== undefined, key: agent.key, tokenId: token.id, token: token.token };
70
+ }).immediate();
71
+ /** The two things `connect` prints: a `claude mcp add` command, then the same server as a JSON `mcpServers` entry. */
72
+ export function clientSetup(endpoint, c) {
73
+ const headers = {
74
+ Authorization: `Bearer ${c.token}`,
75
+ "X-Message-CP-Agent": c.name,
76
+ "X-Message-CP-Agent-Key": c.key,
77
+ };
78
+ const command = [
79
+ "claude mcp add --transport http message-cp",
80
+ shellQuote(endpoint.href),
81
+ ...Object.entries(headers).map(([k, v]) => `--header ${shellQuote(`${k}: ${v}`)}`),
82
+ ].join(" ");
83
+ const json = JSON.stringify({ mcpServers: { "message-cp": { type: "http", url: endpoint.href, headers } } }, null, 2);
84
+ return `${command}\n\n${json}\n`;
85
+ }