@commonlyai/mcp 0.3.10 → 0.3.12

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/client.js +79 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/mcp",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
package/src/client.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * HTTP client for the Commonly MCP server.
3
3
  *
4
4
  * Per ADR-010 §Auth contract:
5
- * - One token per process. `COMMONLY_AGENT_TOKEN` and `COMMONLY_API_URL` are
5
+ * - One token per process. The runtime credential and `COMMONLY_API_URL` are
6
6
  * read once at module load. Restart the host runtime to rotate.
7
7
  * - User-Agent must NOT be the default Node `fetch` UA — Cloudflare blocks
8
8
  * anonymous-looking clients (1010). The Python SDK hit this; same fix.
@@ -13,6 +13,8 @@
13
13
  * `HttpError`. No global state beyond the env-derived config object.
14
14
  */
15
15
 
16
+ import { readFileSync } from 'fs';
17
+
16
18
  const USER_AGENT = 'commonly-mcp/0.1.9';
17
19
 
18
20
  export class HttpError extends Error {
@@ -24,6 +26,78 @@ export class HttpError extends Error {
24
26
  }
25
27
  }
26
28
 
29
+ /**
30
+ * Read the runtime credential.
31
+ *
32
+ * THREE CHANNELS, resolved in this order by what the parent declares (TASK-078,
33
+ * ruled 2026-09-19 — "take the token out of the environment" — extended the
34
+ * same day to a file, so claude and codex can use it too):
35
+ *
36
+ * 1. `COMMONLY_TOKEN_FD` names an inherited pipe holding the credential. This
37
+ * is the channel the pi adapter uses: the token is never in this process's
38
+ * environment, so a same-user reader cannot get it from
39
+ * `/proc/<pid>/environ` or `ps eww <pid>` — which is exactly how the old
40
+ * channel leaked it.
41
+ * 2. `COMMONLY_TOKEN_FILE` names a file holding the credential, written 0600
42
+ * for one spawn. This is the channel claude and codex use, because neither
43
+ * of them can be handed a pipe we opened: claude expands `${VAR}` from its
44
+ * own environment, and codex forwards declared variables from its own. A
45
+ * path is not a secret, so the token stays out of both of their
46
+ * environments even though it reaches the grandchild.
47
+ * 3. `COMMONLY_AGENT_TOKEN` in the environment. Kept because the channel is a
48
+ * property of the PARENT, not of this package: an older daemon, another
49
+ * driver, or a hand-run server still passes it this way, and refusing the
50
+ * old channel would turn a security fix into an outage.
51
+ *
52
+ * A declared source is authoritative. A read that fails does NOT fall through
53
+ * to the next one: silently using the weaker channel after being told to use
54
+ * the stronger one is the class of bug this exists to remove.
55
+ *
56
+ * The fd is read to EOF and deliberately never closed — on macOS a `closeSync`
57
+ * after a failed read aborts the process (`kqueue.c`), and the descriptor goes
58
+ * away with the process anyway.
59
+ */
60
+ export const readToken = (env = process.env, { readImpl = readFileSync } = {}) => {
61
+ const declaredFd = env.COMMONLY_TOKEN_FD;
62
+ const declaredFile = env.COMMONLY_TOKEN_FILE;
63
+ const blank = (value) => value === undefined || String(value).trim() === '';
64
+
65
+ if (!blank(declaredFd)) {
66
+ const fd = Number.parseInt(String(declaredFd).trim(), 10);
67
+ if (!Number.isInteger(fd) || fd < 0 || String(fd) !== String(declaredFd).trim()) {
68
+ throw new Error(`COMMONLY_TOKEN_FD must be a file descriptor number, got '${declaredFd}'`);
69
+ }
70
+ let raw;
71
+ try {
72
+ raw = readImpl(fd, 'utf8');
73
+ } catch (err) {
74
+ throw new Error(`COMMONLY_TOKEN_FD=${fd} was declared but the credential could not be read from it: ${err.message}`);
75
+ }
76
+ const token = String(raw ?? '').trim();
77
+ if (!token) {
78
+ throw new Error(`COMMONLY_TOKEN_FD=${fd} carried an empty credential`);
79
+ }
80
+ return token;
81
+ }
82
+
83
+ if (!blank(declaredFile)) {
84
+ const tokenPath = String(declaredFile).trim();
85
+ let raw;
86
+ try {
87
+ raw = readImpl(tokenPath, 'utf8');
88
+ } catch (err) {
89
+ throw new Error(`COMMONLY_TOKEN_FILE=${tokenPath} was declared but the credential could not be read from it: ${err.message}`);
90
+ }
91
+ const token = String(raw ?? '').trim();
92
+ if (!token) {
93
+ throw new Error(`COMMONLY_TOKEN_FILE=${tokenPath} carried an empty credential`);
94
+ }
95
+ return token;
96
+ }
97
+
98
+ return env.COMMONLY_AGENT_TOKEN;
99
+ };
100
+
27
101
  /**
28
102
  * Build the per-process config from env. Returned object is a frozen handle
29
103
  * passed to `request` so the env is read exactly once and tests can inject a
@@ -32,17 +106,17 @@ export class HttpError extends Error {
32
106
  * Throws on missing required env vars — better than emitting silent 401s
33
107
  * later.
34
108
  */
35
- export const loadConfig = (env = process.env) => {
109
+ export const loadConfig = (env = process.env, opts = {}) => {
36
110
  const baseUrl = env.COMMONLY_API_URL;
37
- const token = env.COMMONLY_AGENT_TOKEN;
111
+ const token = readToken(env, opts);
38
112
  if (!baseUrl) {
39
113
  throw new Error('COMMONLY_API_URL is required (e.g. https://api.commonly.me)');
40
114
  }
41
115
  if (!token) {
42
- throw new Error('COMMONLY_AGENT_TOKEN is required (cm_agent_* runtime token)');
116
+ throw new Error('No runtime token: COMMONLY_TOKEN_FD must name a pipe, COMMONLY_TOKEN_FILE must name a file, or COMMONLY_AGENT_TOKEN must be set (cm_agent_* runtime token)');
43
117
  }
44
118
  if (!token.startsWith('cm_agent_')) {
45
- throw new Error('COMMONLY_AGENT_TOKEN must be a cm_agent_* runtime token');
119
+ throw new Error('The runtime token must be a cm_agent_* token, whichever channel delivered it');
46
120
  }
47
121
  return Object.freeze({
48
122
  baseUrl: baseUrl.replace(/\/$/, ''),