drupal-mcp-connector 2.6.0 → 2.7.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/.claude/commands/drupal-list-sites.md +2 -2
- package/CHANGELOG.md +63 -0
- package/README.md +3 -1
- package/config/config.example.json +16 -1
- package/package.json +4 -3
- package/src/index.js +108 -21
- package/src/lib/config.js +42 -1
- package/src/lib/dispatch.js +36 -10
- package/src/lib/http-auth.js +541 -3
- package/src/lib/http-handler.js +62 -9
- package/src/lib/load-secrets.js +158 -0
- package/src/lib/mcp-server.js +32 -6
- package/src/lib/principal.js +372 -0
- package/src/lib/verify.js +40 -4
- package/src/tools/config.js +6 -0
- package/src/tools/site.js +51 -6
|
@@ -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/lib/mcp-server.js
CHANGED
|
@@ -7,6 +7,24 @@
|
|
|
7
7
|
|
|
8
8
|
import { Server } from "@modelcontextprotocol/server";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Whether a listed resource URI (possibly templated) covers a requested URI.
|
|
12
|
+
* @param {string} listed
|
|
13
|
+
* @param {string} requested
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
function resourceUriIsListed(listed, requested) {
|
|
17
|
+
if (listed === requested) return true;
|
|
18
|
+
if (typeof listed !== "string" || !listed.includes("{site}")) return false;
|
|
19
|
+
const marker = "{site}";
|
|
20
|
+
const at = listed.indexOf(marker);
|
|
21
|
+
const prefix = listed.slice(0, at);
|
|
22
|
+
const suffix = listed.slice(at + marker.length);
|
|
23
|
+
if (!requested.startsWith(prefix) || !requested.endsWith(suffix)) return false;
|
|
24
|
+
const captured = requested.slice(prefix.length, requested.length - suffix.length);
|
|
25
|
+
return captured.length > 0 && !captured.includes("/");
|
|
26
|
+
}
|
|
27
|
+
|
|
10
28
|
/**
|
|
11
29
|
* Create the server factory shared by HTTP and stdio transports.
|
|
12
30
|
*
|
|
@@ -14,9 +32,9 @@ import { Server } from "@modelcontextprotocol/server";
|
|
|
14
32
|
* @param {{name: string, version: string}} surface.serverInfo
|
|
15
33
|
* @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, call: (name: string, args: object, context: object) => Promise<object>}} surface.tools
|
|
16
34
|
* `definitions` is the full static surface (schema projection); the optional
|
|
17
|
-
* `list` hook decides what is DISCOVERABLE per request (governance
|
|
18
|
-
* @param {{definitions: Array<object>, read: (uri: string) => Promise<object>}} surface.resources
|
|
19
|
-
* @param {{definitions: Array<object>, get: (name: string, args: object) => Array<object>}} surface.prompts
|
|
35
|
+
* `list` hook decides what is DISCOVERABLE per request (governance + entitlement).
|
|
36
|
+
* @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, read: (uri: string) => Promise<object>}} surface.resources
|
|
37
|
+
* @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, get: (name: string, args: object) => Array<object>}} surface.prompts
|
|
20
38
|
* @returns {(context: import("@modelcontextprotocol/server").McpRequestContext) => Server}
|
|
21
39
|
*/
|
|
22
40
|
export function createConnectorServerFactory({ serverInfo, tools, resources, prompts }) {
|
|
@@ -37,9 +55,14 @@ export function createConnectorServerFactory({ serverInfo, tools, resources, pro
|
|
|
37
55
|
return server.projectCallToolResult(result, toolDefinitions.get(name)?.outputSchema);
|
|
38
56
|
});
|
|
39
57
|
|
|
40
|
-
server.setRequestHandler("resources/list", async () => ({
|
|
58
|
+
server.setRequestHandler("resources/list", async () => ({
|
|
59
|
+
resources: resources.list ? await resources.list() : resources.definitions,
|
|
60
|
+
}));
|
|
41
61
|
server.setRequestHandler("resources/read", async (request) => {
|
|
42
62
|
const { uri } = request.params;
|
|
63
|
+
const visible = resources.list ? await resources.list() : resources.definitions;
|
|
64
|
+
const listed = visible.some((resource) => resourceUriIsListed(resource.uri, uri));
|
|
65
|
+
if (!listed) throw new Error(`Unknown resource: "${uri}"`);
|
|
43
66
|
try {
|
|
44
67
|
const data = await resources.read(uri);
|
|
45
68
|
return {
|
|
@@ -50,10 +73,13 @@ export function createConnectorServerFactory({ serverInfo, tools, resources, pro
|
|
|
50
73
|
}
|
|
51
74
|
});
|
|
52
75
|
|
|
53
|
-
server.setRequestHandler("prompts/list", async () => ({
|
|
76
|
+
server.setRequestHandler("prompts/list", async () => ({
|
|
77
|
+
prompts: prompts.list ? await prompts.list() : prompts.definitions,
|
|
78
|
+
}));
|
|
54
79
|
server.setRequestHandler("prompts/get", async (request) => {
|
|
55
80
|
const { name, arguments: args } = request.params;
|
|
56
|
-
const
|
|
81
|
+
const visible = prompts.list ? await prompts.list() : prompts.definitions;
|
|
82
|
+
const known = visible.find((prompt) => prompt.name === name);
|
|
57
83
|
if (!known) throw new Error(`Unknown prompt: "${name}"`);
|
|
58
84
|
return { description: known.description, messages: prompts.get(name, args ?? {}) };
|
|
59
85
|
});
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound principal entitlement (#178).
|
|
3
|
+
*
|
|
4
|
+
* HTTPS resource-server requests carry a validated JWT identity. Discovery
|
|
5
|
+
* and invocation are filtered by that identity's server-resolved grants.
|
|
6
|
+
* Stdio, loopback shared-bearer, and unauthenticated loopback have no
|
|
7
|
+
* inbound principal and keep the existing site + source-governance filter
|
|
8
|
+
* so a local operator is not hollowed out.
|
|
9
|
+
*
|
|
10
|
+
* Caller-supplied site, environment, tenant, target, or scope fields are
|
|
11
|
+
* hints. They never become authority. Empty inbound scopes are no grants,
|
|
12
|
+
* not a wildcard.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
16
|
+
import { getDefaultSiteName, getInboundGrants } from "./config.js";
|
|
17
|
+
import { inferOperation } from "./operations.js";
|
|
18
|
+
import { resolveSecurityConfig, SecurityError } from "./security.js";
|
|
19
|
+
|
|
20
|
+
const identityStore = new AsyncLocalStorage();
|
|
21
|
+
|
|
22
|
+
/** Caller fields that look like a target but are never authority. */
|
|
23
|
+
export const TARGET_HINT_KEYS = Object.freeze(["site", "environment", "tenant", "target"]);
|
|
24
|
+
|
|
25
|
+
/** Always discoverable; they are how an operator sees a denial. */
|
|
26
|
+
export const DIAGNOSTIC_TOOLS = new Set([
|
|
27
|
+
"drupal_list_sites",
|
|
28
|
+
"drupal_governance_status",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const CONFIG_TOOLS = new Set([
|
|
32
|
+
"drupal_config_get",
|
|
33
|
+
"drupal_config_list",
|
|
34
|
+
"drupal_config_set",
|
|
35
|
+
"drupal_drush_config_export",
|
|
36
|
+
"drupal_drush_config_import",
|
|
37
|
+
"drupal_drush_config_status",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** inferOperation() leaves these as "read"; they self-gate in-handler. */
|
|
41
|
+
const WRITE_BY_NAME = new Set([
|
|
42
|
+
"drupal_entity_create",
|
|
43
|
+
"drupal_entity_update",
|
|
44
|
+
"drupal_entity_delete",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const FREE_FORM_TOOLS = new Set([
|
|
48
|
+
"drupal_graphql",
|
|
49
|
+
"drupal_graphql_introspect",
|
|
50
|
+
"drupal_drush_sql_query",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const WRITE_WORKFLOW_PROMPTS = new Set([
|
|
54
|
+
"drupal-create-article",
|
|
55
|
+
"drupal-seo-fix",
|
|
56
|
+
"drupal-user-cleanup",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const READ_WORKFLOW_PROMPTS = new Set([
|
|
60
|
+
"drupal-content-audit",
|
|
61
|
+
"drupal-full-audit",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run `fn` with `identity` as the request principal (null = local operator).
|
|
66
|
+
* @param {object|null} identity
|
|
67
|
+
* @param {Function} fn
|
|
68
|
+
* @returns {*}
|
|
69
|
+
*/
|
|
70
|
+
export function runWithIdentity(identity, fn) {
|
|
71
|
+
return identityStore.run({ identity: identity ?? null }, fn);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The inbound identity for the current request, or null on stdio / loopback.
|
|
76
|
+
* @returns {object|null}
|
|
77
|
+
*/
|
|
78
|
+
export function getRequestIdentity() {
|
|
79
|
+
return identityStore.getStore()?.identity ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Inbound scope required to discover or invoke a tool. Diagnostics need none.
|
|
84
|
+
* @param {string} toolName
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
export function requiredScopeForTool(toolName) {
|
|
88
|
+
if (DIAGNOSTIC_TOOLS.has(toolName)) return null;
|
|
89
|
+
if (toolName === "drupal_drush_sql_query") return "mcp_admin";
|
|
90
|
+
if (CONFIG_TOOLS.has(toolName)) return "mcp_config";
|
|
91
|
+
if (WRITE_BY_NAME.has(toolName)) return "mcp_write";
|
|
92
|
+
const op = inferOperation(toolName);
|
|
93
|
+
if (op === "write" || op === "delete") return "mcp_write";
|
|
94
|
+
return "mcp_read";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {object|null} identity
|
|
99
|
+
* @param {string|null} scope
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function principalHasScope(identity, scope) {
|
|
103
|
+
if (!scope) return true;
|
|
104
|
+
return (identity?.scopes ?? []).includes(scope);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Site names this principal may address. Unknown names in a grant are dropped.
|
|
109
|
+
*
|
|
110
|
+
* @param {object|null} identity
|
|
111
|
+
* @param {string[]} configuredNames
|
|
112
|
+
* @param {object|null} [grants] `auth.grants` map; `undefined` reads config.
|
|
113
|
+
* @returns {string[]}
|
|
114
|
+
*/
|
|
115
|
+
export function resolveGrantedSiteNames(identity, configuredNames, grants) {
|
|
116
|
+
if (!identity) return [...configuredNames];
|
|
117
|
+
const known = new Set(configuredNames);
|
|
118
|
+
const grantMap = grants === undefined ? getInboundGrants() : grants;
|
|
119
|
+
|
|
120
|
+
if (grantMap) {
|
|
121
|
+
const listed = identity.clientId
|
|
122
|
+
? new Map(Object.entries(grantMap)).get(identity.clientId)
|
|
123
|
+
: undefined;
|
|
124
|
+
if (!Array.isArray(listed)) return [];
|
|
125
|
+
return listed.map(String).filter((name) => known.has(name));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (Array.isArray(identity.sites)) {
|
|
129
|
+
return identity.sites.map(String).filter((name) => known.has(name));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return [...configuredNames];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {object|null} identity
|
|
137
|
+
* @param {Array<{_name: string}>} sites
|
|
138
|
+
* @param {object|null} [grants]
|
|
139
|
+
* @returns {Array<object>}
|
|
140
|
+
*/
|
|
141
|
+
export function resolveGrantedSites(identity, sites, grants) {
|
|
142
|
+
const allowed = new Set(
|
|
143
|
+
resolveGrantedSiteNames(identity, sites.map((site) => site._name), grants),
|
|
144
|
+
);
|
|
145
|
+
return sites.filter((site) => allowed.has(site._name));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {object} site
|
|
150
|
+
* @param {string} toolName
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
function siteAllowsTool(site, toolName) {
|
|
154
|
+
const sec = resolveSecurityConfig(site);
|
|
155
|
+
if (toolName === "drupal_graphql" || toolName === "drupal_graphql_introspect") {
|
|
156
|
+
return Boolean(sec.allowGraphql);
|
|
157
|
+
}
|
|
158
|
+
if (toolName === "drupal_drush_sql_query") {
|
|
159
|
+
return site.drushSsh?.rawSql === "governed";
|
|
160
|
+
}
|
|
161
|
+
if (toolName === "drupal_config_set") {
|
|
162
|
+
return Boolean(sec.allowConfigWrite) && !sec.readOnly;
|
|
163
|
+
}
|
|
164
|
+
if (toolName === "drupal_config_get" || toolName === "drupal_config_list") {
|
|
165
|
+
return Boolean(sec.allowConfigRead);
|
|
166
|
+
}
|
|
167
|
+
const op = inferOperation(toolName);
|
|
168
|
+
const writeLike = op === "write" || op === "delete" || WRITE_BY_NAME.has(toolName);
|
|
169
|
+
if (writeLike && sec.readOnly) return false;
|
|
170
|
+
if ((op === "delete" || toolName === "drupal_entity_delete") && !sec.allowDestructive) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} toolName
|
|
178
|
+
* @param {object|null} identity
|
|
179
|
+
* @param {Array<object>} sites
|
|
180
|
+
* @param {object|null} [grants]
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
export function principalMayUseTool(toolName, identity, sites, grants) {
|
|
184
|
+
if (!identity) return true;
|
|
185
|
+
if (DIAGNOSTIC_TOOLS.has(toolName)) return true;
|
|
186
|
+
if (!principalHasScope(identity, requiredScopeForTool(toolName))) return false;
|
|
187
|
+
const entitled = resolveGrantedSites(identity, sites, grants);
|
|
188
|
+
if (!entitled.length) return false;
|
|
189
|
+
if (FREE_FORM_TOOLS.has(toolName)) {
|
|
190
|
+
return entitled.some((site) => siteAllowsTool(site, toolName));
|
|
191
|
+
}
|
|
192
|
+
return entitled.some((site) => siteAllowsTool(site, toolName));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {Array<{name: string}>} definitions
|
|
197
|
+
* @param {Array<object>} sites
|
|
198
|
+
* @param {object|null} identity
|
|
199
|
+
* @param {object|null} [grants]
|
|
200
|
+
* @returns {Array<object>}
|
|
201
|
+
*/
|
|
202
|
+
export function filterToolsByPrincipal(definitions, sites, identity, grants) {
|
|
203
|
+
if (!identity) return definitions;
|
|
204
|
+
return definitions.filter((definition) =>
|
|
205
|
+
principalMayUseTool(definition.name, identity, sites, grants));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {object} [args]
|
|
210
|
+
* @returns {Array<{key: string, value: string}>}
|
|
211
|
+
*/
|
|
212
|
+
export function callerTargetHints(args = {}) {
|
|
213
|
+
const found = [];
|
|
214
|
+
for (const key of TARGET_HINT_KEYS) {
|
|
215
|
+
const value = new Map(Object.entries(args ?? {})).get(key);
|
|
216
|
+
if (typeof value === "string" && value.trim()) {
|
|
217
|
+
found.push({ key, value: value.trim() });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return found;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @param {object} site
|
|
225
|
+
* @param {string} source
|
|
226
|
+
* @returns {{name: string, baseUrl?: string, source: string}}
|
|
227
|
+
*/
|
|
228
|
+
export function describeTarget(site, source) {
|
|
229
|
+
return {
|
|
230
|
+
name: site._name,
|
|
231
|
+
baseUrl: site.baseUrl,
|
|
232
|
+
source,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Public site list for tools/resources. Never includes credentials.
|
|
238
|
+
*
|
|
239
|
+
* @param {object|null} identity
|
|
240
|
+
* @param {Array<object>} resolvable
|
|
241
|
+
* @param {string[]} configuredNames
|
|
242
|
+
* @param {object|null} [grants]
|
|
243
|
+
* @returns {{sites: string[], targets: Array<{name: string, baseUrl?: string, source: string}>}}
|
|
244
|
+
*/
|
|
245
|
+
export function visibleSiteTargets(identity, resolvable, configuredNames, grants) {
|
|
246
|
+
const granted = identity ? resolveGrantedSites(identity, resolvable, grants) : resolvable;
|
|
247
|
+
const names = identity
|
|
248
|
+
? granted.map((site) => site._name)
|
|
249
|
+
: [...configuredNames];
|
|
250
|
+
const byName = new Map(granted.map((site) => [site._name, site]));
|
|
251
|
+
const source = identity ? "grant" : "config";
|
|
252
|
+
return {
|
|
253
|
+
sites: names,
|
|
254
|
+
targets: names.map((name) => {
|
|
255
|
+
const site = byName.get(name);
|
|
256
|
+
return site ? describeTarget(site, source) : { name, source };
|
|
257
|
+
}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Resolve the site this principal may use for a call.
|
|
263
|
+
*
|
|
264
|
+
* @param {object} args
|
|
265
|
+
* @param {object} identity
|
|
266
|
+
* @param {Array<object>} sites
|
|
267
|
+
* @param {{grants?: object|null, defaultSite?: string}} [options]
|
|
268
|
+
* @returns {{site: object, source: string, name: string}}
|
|
269
|
+
* @throws {SecurityError}
|
|
270
|
+
*/
|
|
271
|
+
export function resolveAuthoritativeTarget(args, identity, sites, options = {}) {
|
|
272
|
+
const grantMap = options.grants === undefined ? getInboundGrants() : options.grants;
|
|
273
|
+
const entitled = resolveGrantedSites(identity, sites, grantMap);
|
|
274
|
+
const hints = callerTargetHints(args);
|
|
275
|
+
const unique = [...new Set(hints.map((hint) => hint.value))];
|
|
276
|
+
|
|
277
|
+
if (unique.length > 1) {
|
|
278
|
+
throw new SecurityError(
|
|
279
|
+
"Conflicting caller target hints do not select a single granted target.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (unique.length === 1) {
|
|
284
|
+
const site = entitled.find((entry) => entry._name === unique[0]);
|
|
285
|
+
if (!site) {
|
|
286
|
+
throw new SecurityError("Not entitled to the requested target.");
|
|
287
|
+
}
|
|
288
|
+
return { site, source: "hint", name: site._name };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const defaultName = options.defaultSite ?? getDefaultSiteName();
|
|
292
|
+
const fromDefault = entitled.find((entry) => entry._name === defaultName);
|
|
293
|
+
if (fromDefault) {
|
|
294
|
+
return { site: fromDefault, source: "default", name: fromDefault._name };
|
|
295
|
+
}
|
|
296
|
+
if (entitled.length === 1) {
|
|
297
|
+
return { site: entitled[0], source: "grant", name: entitled[0]._name };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
throw new SecurityError(
|
|
301
|
+
entitled.length
|
|
302
|
+
? "No authoritative target could be resolved from the principal grant."
|
|
303
|
+
: "Principal is not entitled to any configured target.",
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Deny unauthorized invocation. Returns the resolved target, or null for
|
|
309
|
+
* tools that do not address a site.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} params
|
|
312
|
+
* @returns {{site: object, source: string, name: string}|null}
|
|
313
|
+
* @throws {SecurityError}
|
|
314
|
+
*/
|
|
315
|
+
export function assertPrincipalEntitlement({
|
|
316
|
+
toolName,
|
|
317
|
+
args,
|
|
318
|
+
identity,
|
|
319
|
+
sites,
|
|
320
|
+
grants,
|
|
321
|
+
defaultSite,
|
|
322
|
+
}) {
|
|
323
|
+
if (!identity) return null;
|
|
324
|
+
if (!principalMayUseTool(toolName, identity, sites, grants)) {
|
|
325
|
+
throw new SecurityError(`Not entitled to invoke ${toolName}.`);
|
|
326
|
+
}
|
|
327
|
+
// list_sites has no target. governance_status without a hint reports every
|
|
328
|
+
// granted site — pinning it to the default would hide the rest.
|
|
329
|
+
if (toolName === "drupal_list_sites") return null;
|
|
330
|
+
if (toolName === "drupal_governance_status" && callerTargetHints(args).length === 0) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
return resolveAuthoritativeTarget(args, identity, sites, { grants, defaultSite });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {Array<object>} resources
|
|
338
|
+
* @param {object|null} identity
|
|
339
|
+
* @param {Array<object>} sites
|
|
340
|
+
* @param {object|null} [grants]
|
|
341
|
+
* @returns {Array<object>}
|
|
342
|
+
*/
|
|
343
|
+
export function filterResourcesByPrincipal(resources, identity, sites, grants) {
|
|
344
|
+
if (!identity) return resources;
|
|
345
|
+
const entitled = resolveGrantedSites(identity, sites, grants);
|
|
346
|
+
const canRead = principalHasScope(identity, "mcp_read") && entitled.length > 0;
|
|
347
|
+
return resources.filter((resource) => {
|
|
348
|
+
if (resource.uri === "drupal://sites") return true;
|
|
349
|
+
return canRead;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @param {Array<object>} prompts
|
|
355
|
+
* @param {object|null} identity
|
|
356
|
+
* @param {Array<{name: string}>} visibleTools
|
|
357
|
+
* @returns {Array<object>}
|
|
358
|
+
*/
|
|
359
|
+
export function filterPromptsByPrincipal(prompts, identity, visibleTools) {
|
|
360
|
+
if (!identity) return prompts;
|
|
361
|
+
const visible = new Set((visibleTools ?? []).map((tool) => tool.name));
|
|
362
|
+
return prompts.filter((prompt) => {
|
|
363
|
+
if (WRITE_WORKFLOW_PROMPTS.has(prompt.name)) {
|
|
364
|
+
return principalHasScope(identity, "mcp_write");
|
|
365
|
+
}
|
|
366
|
+
if (READ_WORKFLOW_PROMPTS.has(prompt.name)) {
|
|
367
|
+
return principalHasScope(identity, "mcp_read");
|
|
368
|
+
}
|
|
369
|
+
const toolName = prompt.name.replace(/-/g, "_");
|
|
370
|
+
return visible.has(toolName);
|
|
371
|
+
});
|
|
372
|
+
}
|
package/src/lib/verify.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
21
|
import { CLIENT_VERSION } from "./config.js";
|
|
22
|
+
import { resolveInboundAuthConfig, resolveInboundAuthMode } from "./http-auth.js";
|
|
22
23
|
|
|
23
24
|
/** Check outcome vocabulary.
|
|
24
25
|
*
|
|
@@ -44,6 +45,7 @@ export const STATIC_CHECKS = [
|
|
|
44
45
|
"entitlement",
|
|
45
46
|
"target_resolution",
|
|
46
47
|
"tenant_neutrality",
|
|
48
|
+
"inbound_auth",
|
|
47
49
|
];
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -222,13 +224,14 @@ export function configDigest(config) {
|
|
|
222
224
|
* credentials, no side effects.
|
|
223
225
|
*
|
|
224
226
|
* @param {object} config Parsed connector configuration.
|
|
225
|
-
* @param {{source?: string, now?: () => Date}} [options]
|
|
227
|
+
* @param {{source?: string, now?: () => Date, env?: NodeJS.ProcessEnv}} [options]
|
|
226
228
|
* `source` names what was verified (a path, or a label) for the evidence;
|
|
227
|
-
* `now` is injectable so a run is reproducible in tests
|
|
229
|
+
* `now` is injectable so a run is reproducible in tests;
|
|
230
|
+
* `env` is the process environment under verification (defaults to `process.env`).
|
|
228
231
|
* @returns {object} Evidence document: tool, version, subject, checks,
|
|
229
232
|
* residuals and a summary. Never contains secret values.
|
|
230
233
|
*/
|
|
231
|
-
export function verifyStatic(config, { source = "config", now = () => new Date() } = {}) {
|
|
234
|
+
export function verifyStatic(config, { source = "config", now = () => new Date(), env = process.env } = {}) {
|
|
232
235
|
const sites = Object.entries(config?.sites ?? {});
|
|
233
236
|
const named = (name, message) => `${name}: ${message}`;
|
|
234
237
|
const nothingToCheck = sites.length === 0;
|
|
@@ -364,12 +367,44 @@ export function verifyStatic(config, { source = "config", now = () => new Date()
|
|
|
364
367
|
const tenantNeutrality = check(
|
|
365
368
|
"tenant_neutrality",
|
|
366
369
|
"The configuration names no real tenant hosts or identifiers",
|
|
367
|
-
mentionedHosts(config?.sites ?? {})
|
|
370
|
+
mentionedHosts({ sites: config?.sites ?? {}, auth: config?.auth ?? {} })
|
|
368
371
|
.filter(({ host }) => !isNeutralHost(host))
|
|
369
372
|
.map(({ path, host }) => `${path}: "${host}" is not a documentation-reserved host; a shipped example must not name a real deployment.`),
|
|
370
373
|
{ skipped: nothingToCheck },
|
|
371
374
|
);
|
|
372
375
|
|
|
376
|
+
const inbound = resolveInboundAuthConfig(config, env);
|
|
377
|
+
const inboundFindings = [];
|
|
378
|
+
if (inbound.issuer || inbound.audience || inbound.resource) {
|
|
379
|
+
if (!inbound.issuer) inboundFindings.push("auth.issuer is missing.");
|
|
380
|
+
else if (!String(inbound.issuer).startsWith("https://")) {
|
|
381
|
+
inboundFindings.push("auth.issuer is not HTTPS.");
|
|
382
|
+
}
|
|
383
|
+
if (!inbound.audience) inboundFindings.push("auth.audience is missing.");
|
|
384
|
+
if (inbound.resource && !String(inbound.resource).startsWith("https://")) {
|
|
385
|
+
inboundFindings.push("auth.resource is not HTTPS.");
|
|
386
|
+
}
|
|
387
|
+
if (inbound.introspectionUrl && !String(inbound.introspectionUrl).startsWith("https://")) {
|
|
388
|
+
inboundFindings.push("auth.introspectionUrl is not HTTPS.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const transportName = env.MCP_TRANSPORT || "stdio";
|
|
392
|
+
if (transportName === "https" || transportName === "http") {
|
|
393
|
+
const decision = resolveInboundAuthMode({
|
|
394
|
+
bindHost: env.MCP_BIND_HOST || "0.0.0.0",
|
|
395
|
+
allowUnauth: env.MCP_ALLOW_UNAUTHENTICATED === "1",
|
|
396
|
+
sharedToken: env.MCP_AUTH_TOKEN || "",
|
|
397
|
+
resourceServer: inbound,
|
|
398
|
+
});
|
|
399
|
+
if (decision.mode === "fatal") inboundFindings.push(decision.reason);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const inboundAuth = check(
|
|
403
|
+
"inbound_auth",
|
|
404
|
+
"Network-facing HTTPS authenticates as an OAuth protected resource",
|
|
405
|
+
inboundFindings,
|
|
406
|
+
);
|
|
407
|
+
|
|
373
408
|
const checks = [
|
|
374
409
|
transport,
|
|
375
410
|
principalAuth,
|
|
@@ -379,6 +414,7 @@ export function verifyStatic(config, { source = "config", now = () => new Date()
|
|
|
379
414
|
entitlement,
|
|
380
415
|
targetResolution,
|
|
381
416
|
tenantNeutrality,
|
|
417
|
+
inboundAuth,
|
|
382
418
|
];
|
|
383
419
|
|
|
384
420
|
const counts = checks.reduce(
|