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 +16 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/index.js +17 -0
- package/src/lib/config.js +19 -1
- package/src/lib/load-secrets.js +158 -0
- package/src/tools/site.js +32 -1
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
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 "${
|
|
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
|
-
|
|
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,
|