drupal-mcp-connector 2.6.0 → 2.6.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.6.1] - 2026-08-17
11
+
12
+ ### Fixed
13
+ - **A process no longer starts when every secret named by the active config
14
+ is unset (#199).** After 2.6.0, a client that spawned `node src/index.js`
15
+ (skipping the launcher) or whose `config.json` still used older
16
+ `clientSecretEnv` names could start with zero resolved sites and advertise
17
+ only `drupal_list_sites` and `drupal_governance_status`. The diagnostic
18
+ then told the operator to provide an oauth block that was already there.
19
+ 2.6.1 loads `config/secrets.map` (or the shipped example table) inside
20
+ `node`; names the unset variable; classifies
21
+ `drupal_governance_status` failures; and **refuses to start** when every
22
+ named secret is missing. On 2.6.0 the same recovery is: launch via
23
+ `bin/drupal-mcp-launch.sh` with a `config/secrets.map`
24
+ (`ENV_VAR=keychain-item`), then restart the MCP client.
25
+
10
26
  ## [2.6.0] - 2026-08-15
11
27
 
12
28
  ### Added
package/README.md CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
+ **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.6.1**, or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
+
12
14
  ---
13
15
 
14
16
  ## What It Does
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal \u2014 dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -32,6 +32,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
32
32
  import { toNodeHandler } from "@modelcontextprotocol/node";
33
33
 
34
34
  import { listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
35
+ import { loadLocalSecrets, secretLoadFatalMessage } from "./lib/load-secrets.js";
35
36
  import { makeBearerCheck } from "./lib/http-auth.js";
36
37
  import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
37
38
  import { createConnectorServerFactory } from "./lib/mcp-server.js";
@@ -43,6 +44,22 @@ import { filterDiscoverableTools } from "./lib/governance.js";
43
44
  import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
44
45
  import { buildToolPrompts, getToolPromptMessages } from "./lib/tool-prompts.js";
45
46
 
47
+ // Apply config/secrets.map (or the shipped example table) before any site
48
+ // resolution. MCP clients spawn this file directly; the shell launcher is
49
+ // not guaranteed to have run.
50
+ const secretLoad = loadLocalSecrets();
51
+ const secretFatal = secretLoadFatalMessage(secretLoad);
52
+ if (secretFatal) {
53
+ console.error(`[drupal-mcp-connector] FATAL: ${secretFatal}`);
54
+ process.exit(1);
55
+ }
56
+ if (secretLoad.unset.length) {
57
+ console.error(
58
+ "[drupal-mcp-connector] WARNING: config.json names secret env vars that are unset: " +
59
+ `${secretLoad.unset.join(", ")}. Those sites will fail closed.`
60
+ );
61
+ }
62
+
46
63
  // ---------------------------------------------------------------------------
47
64
  // MCP Resources — browsable, always-fresh site context
48
65
  // ---------------------------------------------------------------------------
package/src/lib/config.js CHANGED
@@ -85,8 +85,26 @@ export function assertSecureAuth(site) {
85
85
  throw new SecurityError(`Site "${site._name}": requireSecureAuth is set but baseUrl is not HTTPS.`);
86
86
  }
87
87
  if (!site.apiToken && !hasValidOauth(site)) {
88
+ const siteName = site._name ?? "";
89
+ if (site.oauth?.clientSecretEnv && !site.oauth.clientSecret) {
90
+ throw new SecurityError(
91
+ `Site "${siteName}": requireSecureAuth is set but oauth.clientSecretEnv ` +
92
+ `"${site.oauth.clientSecretEnv}" is not set in the environment.`
93
+ );
94
+ }
95
+ if (site.apiTokenEnv && !site.apiToken) {
96
+ throw new SecurityError(
97
+ `Site "${siteName}": requireSecureAuth is set but apiTokenEnv ` +
98
+ `"${site.apiTokenEnv}" is not set in the environment.`
99
+ );
100
+ }
101
+ if (site.oauth && !site.oauth.clientId) {
102
+ throw new SecurityError(
103
+ `Site "${siteName}": requireSecureAuth is set but the oauth block has no clientId.`
104
+ );
105
+ }
88
106
  throw new SecurityError(
89
- `Site "${site._name}": requireSecureAuth is set but no Bearer apiToken or OAuth2 client ` +
107
+ `Site "${siteName}": requireSecureAuth is set but no Bearer apiToken or OAuth2 client ` +
90
108
  "credentials are configured (anonymous and basic auth are not permitted). " +
91
109
  "Provide apiToken/apiTokenEnv or an oauth block."
92
110
  );
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Load connector secrets the same way for every entry point.
3
+ *
4
+ * MCP clients often spawn `node src/index.js` directly. The shell launcher
5
+ * cannot be the only place that applies `config/secrets.map`, or a process
6
+ * that skipped the launcher starts, resolves zero sites, and advertises only
7
+ * diagnostic tools — the 2.6.0/#180 failure on this machine.
8
+ *
9
+ * The default table matches config/config.example.json. A gitignored
10
+ * config/secrets.map replaces that table for a deployment whose env-var
11
+ * names differ. Per-item Keychain misses stay silent (inert break-glass).
12
+ * If the active config.json names secret env vars and none of them are set
13
+ * after this step, the caller must refuse to start.
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ /** Shipped env-var → Keychain-item pairs. Matches config/config.example.json. */
21
+ export const DEFAULT_SECRET_PAIRS = Object.freeze([
22
+ ["MCP_CONTENT_PRODUCTION_SECRET", "drupal-mcp-content-production"],
23
+ ["MCP_CONTENT_STAGING_SECRET", "drupal-mcp-content-staging"],
24
+ ["MCP_DEVELOPER_DEVELOPMENT_SECRET", "drupal-mcp-developer-development"],
25
+ ["MCP_ADMIN_BREAKGLASS_SECRET", "drupal-mcp-admin-breakglass"],
26
+ ]);
27
+
28
+ /**
29
+ * Parse a secrets.map body. `#` comments and malformed lines are ignored.
30
+ * @param {string} text
31
+ * @returns {Array<[string, string]>}
32
+ */
33
+ export function parseSecretMap(text) {
34
+ const pairs = [];
35
+ for (const raw of String(text).split(/\r?\n/)) {
36
+ const line = raw.replace(/#.*$/, "").trim();
37
+ if (!line || !line.includes("=")) continue;
38
+ const eq = line.indexOf("=");
39
+ const varName = line.slice(0, eq).trim();
40
+ const item = line.slice(eq + 1).trim();
41
+ if (varName && item) pairs.push([varName, item]);
42
+ }
43
+ return pairs;
44
+ }
45
+
46
+ /**
47
+ * Collect clientSecretEnv / apiTokenEnv names from a parsed config object.
48
+ * @param {object} cfg
49
+ * @returns {string[]}
50
+ */
51
+ export function namedSecretEnvVars(cfg) {
52
+ const names = [];
53
+ const seen = new Set();
54
+ const walk = (value) => {
55
+ if (!value || typeof value !== "object") return;
56
+ if (Array.isArray(value)) {
57
+ value.forEach(walk);
58
+ return;
59
+ }
60
+ for (const [key, val] of Object.entries(value)) {
61
+ if (key.startsWith("_")) continue;
62
+ if ((key === "clientSecretEnv" || key === "apiTokenEnv") && typeof val === "string") {
63
+ const name = val.trim();
64
+ if (name && !seen.has(name)) {
65
+ seen.add(name);
66
+ names.push(name);
67
+ }
68
+ continue;
69
+ }
70
+ walk(val);
71
+ }
72
+ };
73
+ walk(cfg);
74
+ return names;
75
+ }
76
+
77
+ /**
78
+ * Look up one macOS Keychain generic password. Returns "" when missing or
79
+ * when not on Darwin. Never throws. Never logs the value.
80
+ * @param {string} item
81
+ * @returns {string}
82
+ */
83
+ export function lookupKeychainItem(item) {
84
+ if (process.platform !== "darwin" || !item) return "";
85
+ try {
86
+ const value = execFileSync("security", ["find-generic-password", "-s", item, "-w"], {
87
+ encoding: "utf8",
88
+ stdio: ["ignore", "pipe", "ignore"],
89
+ });
90
+ return String(value).replace(/\n$/, "");
91
+ } catch {
92
+ return "";
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Apply the secret table and report how the active config lines up with env.
98
+ *
99
+ * @param {object} [options]
100
+ * @param {string} [options.cwd]
101
+ * @param {NodeJS.ProcessEnv} [options.env] Mutated when a lookup succeeds.
102
+ * @param {typeof readFileSync} [options.readFile]
103
+ * @param {(item: string) => string} [options.lookup]
104
+ * @returns {{pairs: number, resolved: number, named: string[], unset: string[]}}
105
+ */
106
+ export function loadLocalSecrets({
107
+ cwd = process.cwd(),
108
+ env = process.env,
109
+ readFile = readFileSync,
110
+ lookup = lookupKeychainItem,
111
+ } = {}) {
112
+ let pairs;
113
+ try {
114
+ pairs = parseSecretMap(readFile(join(cwd, "config", "secrets.map"), "utf8"));
115
+ } catch {
116
+ pairs = DEFAULT_SECRET_PAIRS;
117
+ }
118
+
119
+ let resolved = 0;
120
+ for (const [varName, item] of pairs) {
121
+ const current = new Map(Object.entries(env)).get(varName);
122
+ if (current) continue;
123
+ const value = lookup(item);
124
+ if (!value) continue;
125
+ Object.defineProperty(env, varName, {
126
+ value,
127
+ writable: true,
128
+ enumerable: true,
129
+ configurable: true,
130
+ });
131
+ resolved += 1;
132
+ }
133
+
134
+ let named = [];
135
+ try {
136
+ named = namedSecretEnvVars(JSON.parse(readFile(join(cwd, "config", "config.json"), "utf8")));
137
+ } catch {
138
+ named = [];
139
+ }
140
+
141
+ const envMap = new Map(Object.entries(env));
142
+ const unset = named.filter((name) => !envMap.get(name));
143
+ return { pairs: pairs.length, resolved, named, unset };
144
+ }
145
+
146
+ /**
147
+ * Refuse to boot a server that can only advertise diagnostic tools.
148
+ * @param {{named: string[], unset: string[]}} loaded
149
+ * @returns {string|null} Fatal message, or null when start is allowed.
150
+ */
151
+ export function secretLoadFatalMessage(loaded) {
152
+ if (!loaded.named.length || loaded.unset.length !== loaded.named.length) return null;
153
+ return (
154
+ `every clientSecretEnv/apiTokenEnv named in config.json is unset (${loaded.unset.join(", ")}). ` +
155
+ "Refusing to start. Map those names in config/secrets.map (ENV_VAR=keychain-item) " +
156
+ "or export them before launch."
157
+ );
158
+ }
package/src/tools/site.js CHANGED
@@ -58,9 +58,38 @@ async function listConfiguredSites() {
58
58
  * @param {object} args - { site? } (a named site narrows the report).
59
59
  * @returns {Promise<{sites: object[]}>} required/ok/reason per site.
60
60
  */
61
+ /**
62
+ * Classify a getSiteConfig failure so the diagnostic reason matches the cause.
63
+ * @param {string} message
64
+ * @returns {string}
65
+ */
66
+ export function classifySiteResolutionFailure(message) {
67
+ if (message.includes("Unknown site:")) return "unknown_site";
68
+ if (message.includes("baseUrl is not HTTPS")) return "insecure_base_url";
69
+ if (message.includes("is not set in the environment")) return "credential_unresolved";
70
+ return "site_unresolved";
71
+ }
72
+
61
73
  async function getGovernanceStatus({ site: siteName } = {}) {
62
74
  const names = siteName ? [siteName] : listSiteNames();
63
- return { sites: await governanceStatus(names.map((n) => getSiteConfig(n))) };
75
+ const resolved = [];
76
+ const unresolved = [];
77
+ for (const name of names) {
78
+ try {
79
+ resolved.push(getSiteConfig(name));
80
+ } catch (error) {
81
+ const detail = error instanceof Error ? error.message : "site could not be resolved";
82
+ unresolved.push({
83
+ site: name,
84
+ required: null,
85
+ ok: false,
86
+ reason: classifySiteResolutionFailure(detail),
87
+ detail,
88
+ checkedAt: null,
89
+ });
90
+ }
91
+ }
92
+ return { sites: [...unresolved, ...(await governanceStatus(resolved))] };
64
93
  }
65
94
 
66
95
  // ---------------------------------------------------------------------------
@@ -102,6 +131,8 @@ export const definitions = [
102
131
  },
103
132
  ];
104
133
 
134
+ export { getGovernanceStatus };
135
+
105
136
  export const handlers = {
106
137
  drupal_site_info: getSiteInfo,
107
138
  drupal_list_content_types: listContentTypes,