drupal-mcp-connector 0.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 +92 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/config/config.example.json +122 -0
- package/package.json +70 -0
- package/src/index.js +499 -0
- package/src/lib/backends/backend-interface.js +164 -0
- package/src/lib/backends/errors.js +31 -0
- package/src/lib/backends/graphql-filter.js +99 -0
- package/src/lib/backends/graphql-names.js +63 -0
- package/src/lib/backends/graphql-normalize.js +73 -0
- package/src/lib/backends/graphql-query.js +129 -0
- package/src/lib/backends/graphql-schema.js +226 -0
- package/src/lib/backends/graphql.js +391 -0
- package/src/lib/backends/index.js +128 -0
- package/src/lib/backends/jsonapi.js +403 -0
- package/src/lib/canonical.js +68 -0
- package/src/lib/config.js +257 -0
- package/src/lib/drupal-fetch.js +144 -0
- package/src/lib/errors.js +38 -0
- package/src/lib/http-auth.js +27 -0
- package/src/lib/oauth.js +177 -0
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +475 -0
- package/src/lib/validate.js +225 -0
- package/src/tools/drush.js +463 -0
- package/src/tools/entities.js +262 -0
- package/src/tools/graphql.js +175 -0
- package/src/tools/media.js +297 -0
- package/src/tools/nodes.js +247 -0
- package/src/tools/reports.js +609 -0
- package/src/tools/site.js +87 -0
- package/src/tools/taxonomy.js +202 -0
- package/src/tools/users.js +250 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loading, site resolution, and auth header generation.
|
|
3
|
+
*
|
|
4
|
+
* Security notes:
|
|
5
|
+
* - Credentials are loaded once, cached in memory, never logged.
|
|
6
|
+
* - validateBaseUrl() enforces HTTPS for non-localhost connections.
|
|
7
|
+
* - All exported functions are pure — no side effects beyond the cache.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { resolve } from "path";
|
|
12
|
+
import { validateBaseUrl } from "./validate.js";
|
|
13
|
+
import { SecurityError } from "./security.js";
|
|
14
|
+
import { getAccessToken } from "./oauth.js";
|
|
15
|
+
|
|
16
|
+
/** Connector version for the X-MCP-Client identity label. Keep in sync with package.json. */
|
|
17
|
+
export const CLIENT_VERSION = "0.6.0";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Identity headers sent on every outbound Drupal request. Lets governance layers
|
|
21
|
+
* label/identify connector traffic. ON by default; set MCP_CLIENT_ID to override
|
|
22
|
+
* the value, or to "" to disable entirely.
|
|
23
|
+
* @returns {Object<string,string>} Header map (empty when the identity is disabled).
|
|
24
|
+
*/
|
|
25
|
+
export function clientHeaders() {
|
|
26
|
+
const id = process.env.MCP_CLIENT_ID ?? `drupal-mcp-connector/${CLIENT_VERSION}`;
|
|
27
|
+
if (!id) return {};
|
|
28
|
+
return { "X-MCP-Client": id, "User-Agent": id };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* If a site declares apiTokenEnv and has no explicit apiToken, source the token
|
|
33
|
+
* from that environment variable (keeps secrets out of the config file).
|
|
34
|
+
* @param {object} site Raw site config.
|
|
35
|
+
* @returns {object} The site, possibly with apiToken populated from the env var.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveApiToken(site) {
|
|
38
|
+
if (site.apiToken || !site.apiTokenEnv) return site;
|
|
39
|
+
const fromEnv = new Map(Object.entries(process.env)).get(site.apiTokenEnv) || "";
|
|
40
|
+
return { ...site, apiToken: fromEnv };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* If a site declares an oauth block, source the client secret from the named
|
|
45
|
+
* env var when no explicit clientSecret is set, and apply defaults for tokenUrl
|
|
46
|
+
* and grant. Keeps the secret out of the config file.
|
|
47
|
+
* @param {object} site Raw site config.
|
|
48
|
+
* @returns {object} The site with a normalized oauth block (unchanged if no oauth).
|
|
49
|
+
*/
|
|
50
|
+
export function resolveOauth(site) {
|
|
51
|
+
if (!site.oauth) return site;
|
|
52
|
+
const oauth = { ...site.oauth };
|
|
53
|
+
if (!oauth.clientSecret && oauth.clientSecretEnv) {
|
|
54
|
+
oauth.clientSecret = new Map(Object.entries(process.env)).get(oauth.clientSecretEnv) || "";
|
|
55
|
+
}
|
|
56
|
+
oauth.tokenUrl = oauth.tokenUrl || "/oauth/token";
|
|
57
|
+
oauth.grant = oauth.grant || "client_credentials";
|
|
58
|
+
return { ...site, oauth };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a site has a usable OAuth2 client-credentials block (clientId plus a
|
|
63
|
+
* resolved clientSecret). Used to satisfy the strong-auth requirement.
|
|
64
|
+
* @param {object} site Resolved site config.
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
function hasValidOauth(site) {
|
|
68
|
+
return Boolean(site.oauth?.clientId && site.oauth?.clientSecret);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Opt-in strong-auth enforcement. When site.requireSecureAuth is true, the site
|
|
73
|
+
* must use HTTPS and either a Bearer apiToken or a valid OAuth2 client-credentials
|
|
74
|
+
* block — anonymous and basic auth are rejected.
|
|
75
|
+
* @param {object} site Resolved site config.
|
|
76
|
+
* @returns {void}
|
|
77
|
+
* @throws {SecurityError} if the site is not HTTPS, or lacks a Bearer/OAuth credential.
|
|
78
|
+
*/
|
|
79
|
+
export function assertSecureAuth(site) {
|
|
80
|
+
if (!site.requireSecureAuth) return;
|
|
81
|
+
if (!String(site.baseUrl || "").startsWith("https://")) {
|
|
82
|
+
throw new SecurityError(`Site "${site._name}": requireSecureAuth is set but baseUrl is not HTTPS.`);
|
|
83
|
+
}
|
|
84
|
+
if (!site.apiToken && !hasValidOauth(site)) {
|
|
85
|
+
throw new SecurityError(
|
|
86
|
+
`Site "${site._name}": requireSecureAuth is set but no Bearer apiToken or OAuth2 client ` +
|
|
87
|
+
"credentials are configured (anonymous and basic auth are not permitted). " +
|
|
88
|
+
"Provide apiToken/apiTokenEnv or an oauth block."
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** In-memory cache of the parsed config; populated once by loadConfig(). */
|
|
94
|
+
let _config = null;
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Load + validate config
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Load and validate the connector config, caching the result for the process
|
|
102
|
+
* lifetime. Reads config/config.json when present; otherwise falls back to a
|
|
103
|
+
* single-site config built from environment variables.
|
|
104
|
+
* @returns {object} The parsed, validated config object.
|
|
105
|
+
* @throws {Error|SecurityError} if validation fails (see validateConfig).
|
|
106
|
+
*/
|
|
107
|
+
export function loadConfig() {
|
|
108
|
+
if (_config) return _config;
|
|
109
|
+
|
|
110
|
+
// Config file takes priority; env vars are the single-site fallback.
|
|
111
|
+
try {
|
|
112
|
+
const configPath = resolve(process.cwd(), "config", "config.json");
|
|
113
|
+
_config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
114
|
+
} catch {
|
|
115
|
+
_config = {
|
|
116
|
+
defaultSite: "default",
|
|
117
|
+
sites: {
|
|
118
|
+
default: {
|
|
119
|
+
baseUrl: process.env.DRUPAL_BASE_URL || "",
|
|
120
|
+
apiToken: process.env.DRUPAL_API_TOKEN || "",
|
|
121
|
+
username: process.env.DRUPAL_USERNAME || "",
|
|
122
|
+
password: process.env.DRUPAL_PASSWORD || "",
|
|
123
|
+
graphqlEndpoint: process.env.DRUPAL_GRAPHQL_ENDPOINT || "/graphql",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
validateConfig(_config);
|
|
130
|
+
return _config;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate a config object in place, normalizing each site's baseUrl and warning
|
|
135
|
+
* on weak/ambiguous credential setups.
|
|
136
|
+
* @param {object} cfg Parsed config.
|
|
137
|
+
* @returns {void}
|
|
138
|
+
* @throws {Error} if `sites` is missing/malformed or a site lacks baseUrl.
|
|
139
|
+
* @throws {SecurityError} if a non-localhost baseUrl is not HTTPS (via validateBaseUrl).
|
|
140
|
+
*/
|
|
141
|
+
function validateConfig(cfg) {
|
|
142
|
+
if (!cfg.sites || typeof cfg.sites !== "object") {
|
|
143
|
+
throw new Error("Config error: 'sites' must be an object.");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const [name, site] of Object.entries(cfg.sites)) {
|
|
147
|
+
if (!site.baseUrl) {
|
|
148
|
+
throw new Error(`Config error: site "${name}" is missing "baseUrl".`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Enforce HTTPS for non-localhost — throws SecurityError for plain HTTP
|
|
152
|
+
site.baseUrl = validateBaseUrl(site.baseUrl, name);
|
|
153
|
+
|
|
154
|
+
if (!site.apiToken && !(site.username && site.password) && !site.oauth) {
|
|
155
|
+
console.warn(
|
|
156
|
+
`[drupal-mcp-connector] Warning: site "${name}" has no apiToken, username/password, or oauth block. ` +
|
|
157
|
+
"Unauthenticated requests will be limited to public content."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (site.apiToken && site.username) {
|
|
162
|
+
console.warn(
|
|
163
|
+
`[drupal-mcp-connector] Warning: site "${name}" has both apiToken and username set. ` +
|
|
164
|
+
"apiToken takes priority. Remove the unused credential."
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Site resolution
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve a named site config, falling back to the default.
|
|
176
|
+
* @param {string|undefined} siteName
|
|
177
|
+
* @returns {object} Site config with _name injected.
|
|
178
|
+
* @throws if the site name is unknown.
|
|
179
|
+
*/
|
|
180
|
+
export function getSiteConfig(siteName) {
|
|
181
|
+
const cfg = loadConfig();
|
|
182
|
+
const name = siteName || cfg.defaultSite;
|
|
183
|
+
const site = new Map(Object.entries(cfg.sites)).get(name);
|
|
184
|
+
|
|
185
|
+
if (!site) {
|
|
186
|
+
const available = Object.keys(cfg.sites).join(", ");
|
|
187
|
+
throw new Error(`Unknown site: "${name}". Configured sites: ${available}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const resolved = resolveOauth(resolveApiToken({ ...site, _name: name }));
|
|
191
|
+
assertSecureAuth(resolved);
|
|
192
|
+
return resolved;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* List the configured site names.
|
|
197
|
+
* @returns {string[]}
|
|
198
|
+
*/
|
|
199
|
+
export function listSiteNames() {
|
|
200
|
+
return Object.keys(loadConfig().sites);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Auth headers — never logged, never exposed in tool responses
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build the Authorization header for a site.
|
|
209
|
+
* Bearer token takes priority over Basic auth.
|
|
210
|
+
* @param {object} site
|
|
211
|
+
* @returns {object} Headers object
|
|
212
|
+
*/
|
|
213
|
+
export function authHeaders(site) {
|
|
214
|
+
if (site.apiToken) {
|
|
215
|
+
return { Authorization: `Bearer ${site.apiToken}` };
|
|
216
|
+
}
|
|
217
|
+
if (site.username && site.password) {
|
|
218
|
+
const creds = Buffer.from(`${site.username}:${site.password}`).toString("base64");
|
|
219
|
+
return { Authorization: `Basic ${creds}` };
|
|
220
|
+
}
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Async variant of authHeaders. For OAuth2 sites it resolves a Bearer token
|
|
226
|
+
* from the token manager (acquiring/refreshing as needed); for all other sites
|
|
227
|
+
* it falls back to the synchronous static-credential path.
|
|
228
|
+
* @param {object} site
|
|
229
|
+
* @returns {Promise<object>} Headers object
|
|
230
|
+
*/
|
|
231
|
+
export async function authHeadersAsync(site) {
|
|
232
|
+
if (site.oauth) {
|
|
233
|
+
return { Authorization: `Bearer ${await getAccessToken(site)}` };
|
|
234
|
+
}
|
|
235
|
+
return authHeaders(site);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// TLS config (for HTTP transport mode)
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Default HTTPS listen port for the HTTP transport when none is configured. */
|
|
243
|
+
const DEFAULT_TLS_PORT = 3443;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Returns TLS cert + key paths and listen port from config or environment
|
|
247
|
+
* variables. Used by the HTTP transport to set up HTTPS.
|
|
248
|
+
* @returns {{certPath: string|null, keyPath: string|null, port: number}}
|
|
249
|
+
*/
|
|
250
|
+
export function getTlsConfig() {
|
|
251
|
+
const cfg = loadConfig();
|
|
252
|
+
return {
|
|
253
|
+
certPath: cfg.tls?.certPath || process.env.TLS_CERT_PATH || null,
|
|
254
|
+
keyPath: cfg.tls?.keyPath || process.env.TLS_KEY_PATH || null,
|
|
255
|
+
port: Number(cfg.tls?.port || process.env.MCP_PORT || DEFAULT_TLS_PORT),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated HTTP wrappers for Drupal JSON:API and file uploads.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fetch from "node-fetch";
|
|
6
|
+
import { createReadStream, statSync } from "fs";
|
|
7
|
+
import { basename } from "path";
|
|
8
|
+
import { authHeadersAsync, clientHeaders } from "./config.js";
|
|
9
|
+
import { clearToken } from "./oauth.js";
|
|
10
|
+
|
|
11
|
+
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Standard JSON:API request against a site.
|
|
15
|
+
*
|
|
16
|
+
* For OAuth2 sites, a 401 triggers a single retry: the cached token is cleared,
|
|
17
|
+
* re-acquired, and the request is replayed once before the error surfaces.
|
|
18
|
+
* @param {object} site Resolved site config (provides baseUrl + auth).
|
|
19
|
+
* @param {string} path Path appended to site.baseUrl (e.g. "/jsonapi/node/article").
|
|
20
|
+
* @param {object} [options] node-fetch options (method, body, extra headers).
|
|
21
|
+
* @returns {Promise<object|null>} Parsed JSON body, or null for a 204 No Content.
|
|
22
|
+
* @throws {Error} on any non-2xx response, with Drupal error detail when available.
|
|
23
|
+
*/
|
|
24
|
+
export async function drupalFetch(site, path, options = {}) {
|
|
25
|
+
const url = `${site.baseUrl}${path}`;
|
|
26
|
+
|
|
27
|
+
async function attempt() {
|
|
28
|
+
return fetch(url, {
|
|
29
|
+
...options,
|
|
30
|
+
headers: {
|
|
31
|
+
"Content-Type": JSON_API_CONTENT_TYPE,
|
|
32
|
+
Accept: JSON_API_CONTENT_TYPE,
|
|
33
|
+
...clientHeaders(),
|
|
34
|
+
...(await authHeadersAsync(site)),
|
|
35
|
+
...(options.headers || {}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let res = await attempt();
|
|
41
|
+
|
|
42
|
+
// OAuth sites: a 401 may mean the token expired server-side. Refresh once.
|
|
43
|
+
if (res.status === 401 && site.oauth) {
|
|
44
|
+
clearToken(site);
|
|
45
|
+
res = await attempt();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const body = await res.text();
|
|
50
|
+
let detail = body;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(body);
|
|
53
|
+
// Drupal JSON:API surfaces errors in errors[].detail
|
|
54
|
+
if (parsed.errors?.length) {
|
|
55
|
+
detail = parsed.errors.map((e) => e.detail || e.title).join("; ");
|
|
56
|
+
}
|
|
57
|
+
} catch { /* use raw body */ }
|
|
58
|
+
throw new Error(`Drupal ${res.status} on ${options.method || "GET"} ${path}: ${detail}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (res.status === 204) return null; // No Content (e.g. DELETE success)
|
|
62
|
+
return res.json();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* GraphQL request — posts a JSON body to the site's GraphQL endpoint.
|
|
67
|
+
* @param {object} site Resolved site config (provides baseUrl + auth).
|
|
68
|
+
* @param {object} body GraphQL request body, e.g. { query, variables }.
|
|
69
|
+
* @returns {Promise<object>} Parsed GraphQL JSON response.
|
|
70
|
+
* @throws {Error} on any non-2xx response (clears the OAuth token cache on 401).
|
|
71
|
+
*/
|
|
72
|
+
export async function drupalGraphqlFetch(site, body) {
|
|
73
|
+
const endpoint = site.graphqlEndpoint || "/graphql";
|
|
74
|
+
const url = `${site.baseUrl}${endpoint}`;
|
|
75
|
+
|
|
76
|
+
const res = await fetch(url, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
...clientHeaders(),
|
|
82
|
+
...(await authHeadersAsync(site)),
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
// OAuth sites: a 401 likely means the token expired server-side. Clear the
|
|
89
|
+
// cached token so the next request re-acquires, then surface the error.
|
|
90
|
+
if (res.status === 401 && site.oauth) clearToken(site);
|
|
91
|
+
const text = await res.text();
|
|
92
|
+
throw new Error(`GraphQL request failed ${res.status}: ${text}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return res.json();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* File upload via Drupal's JSON:API file upload endpoint.
|
|
100
|
+
*
|
|
101
|
+
* Endpoint pattern:
|
|
102
|
+
* POST /jsonapi/{entity_type}/{bundle}/{field_name}
|
|
103
|
+
*
|
|
104
|
+
* With headers:
|
|
105
|
+
* Content-Type: application/octet-stream
|
|
106
|
+
* Content-Disposition: file; filename="foo.jpg"
|
|
107
|
+
*
|
|
108
|
+
* Returns a File entity (not a Media entity — call createMedia next).
|
|
109
|
+
* @param {object} site Resolved site config (provides baseUrl + auth).
|
|
110
|
+
* @param {string} entityType Target entity type (e.g. "media").
|
|
111
|
+
* @param {string} bundle Target bundle (e.g. "image").
|
|
112
|
+
* @param {string} fieldName File field on the bundle (e.g. "field_media_image").
|
|
113
|
+
* @param {string} filePath Local path to the file to upload.
|
|
114
|
+
* @returns {Promise<object>} Parsed JSON:API File entity response.
|
|
115
|
+
* @throws {Error} on any non-2xx response.
|
|
116
|
+
*/
|
|
117
|
+
export async function drupalUploadFile(site, entityType, bundle, fieldName, filePath) {
|
|
118
|
+
const filename = basename(filePath);
|
|
119
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is a caller-supplied local upload path (validated upstream); fs access is the intended behavior
|
|
120
|
+
const stat = statSync(filePath);
|
|
121
|
+
const url = `${site.baseUrl}/jsonapi/${entityType}/${bundle}/${fieldName}`;
|
|
122
|
+
|
|
123
|
+
const res = await fetch(url, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: {
|
|
126
|
+
"Content-Type": "application/octet-stream",
|
|
127
|
+
"Content-Disposition": `file; filename="${filename}"`,
|
|
128
|
+
Accept: JSON_API_CONTENT_TYPE,
|
|
129
|
+
...clientHeaders(),
|
|
130
|
+
...(await authHeadersAsync(site)),
|
|
131
|
+
},
|
|
132
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is a caller-supplied local upload path (validated upstream); fs access is the intended behavior
|
|
133
|
+
body: createReadStream(filePath),
|
|
134
|
+
// node-fetch requires explicit size for streams to set Content-Length
|
|
135
|
+
size: stat.size,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const body = await res.text();
|
|
140
|
+
throw new Error(`File upload failed ${res.status}: ${body}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return res.json();
|
|
144
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error utilities for consistent MCP tool error responses.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a thrown error into the MCP CallTool error response shape.
|
|
7
|
+
* @param {Error|*} err The caught error (or any thrown value).
|
|
8
|
+
* @returns {{content: Array<{type: string, text: string}>, isError: true}}
|
|
9
|
+
*/
|
|
10
|
+
export function toolError(err) {
|
|
11
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
14
|
+
isError: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wrap a successful result into the MCP CallTool response shape (JSON-stringified).
|
|
20
|
+
* @param {*} data Serializable result payload.
|
|
21
|
+
* @returns {{content: Array<{type: string, text: string}>}}
|
|
22
|
+
*/
|
|
23
|
+
export function toolResult(data) {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a plain string message (for confirmations, warnings, etc.).
|
|
31
|
+
* @param {string} text Message text.
|
|
32
|
+
* @returns {{content: Array<{type: string, text: string}>}}
|
|
33
|
+
*/
|
|
34
|
+
export function toolMessage(text) {
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: "text", text }],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional bearer-token authentication for the HTTPS MCP transport.
|
|
3
|
+
* makeBearerCheck(token) returns a predicate over the Authorization header.
|
|
4
|
+
* A falsy token disables auth (predicate always true) — opt-in by design.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { timingSafeEqual } from "crypto";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build a predicate that validates an HTTP Authorization header against an
|
|
11
|
+
* expected bearer token.
|
|
12
|
+
* @param {?string} token Expected token; falsy disables auth (predicate is always true).
|
|
13
|
+
* @returns {(authorizationHeader: any) => boolean} True when the header carries the token.
|
|
14
|
+
*/
|
|
15
|
+
export function makeBearerCheck(token) {
|
|
16
|
+
if (!token) return () => true; // auth disabled
|
|
17
|
+
const expected = Buffer.from(String(token));
|
|
18
|
+
return (authorizationHeader) => {
|
|
19
|
+
if (typeof authorizationHeader !== "string") return false;
|
|
20
|
+
const prefix = "Bearer ";
|
|
21
|
+
if (!authorizationHeader.startsWith(prefix)) return false;
|
|
22
|
+
const provided = Buffer.from(authorizationHeader.slice(prefix.length));
|
|
23
|
+
// Length check first: timingSafeEqual throws on unequal-length buffers, and
|
|
24
|
+
// the comparison itself stays constant-time to avoid leaking the token.
|
|
25
|
+
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
|
26
|
+
};
|
|
27
|
+
}
|
package/src/lib/oauth.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 token manager for the client_credentials grant against Drupal
|
|
3
|
+
* simple_oauth.
|
|
4
|
+
*
|
|
5
|
+
* Security notes:
|
|
6
|
+
* - The client secret is read from the resolved site config (sourced from an
|
|
7
|
+
* env var, see resolveOauth). It is sent only in the token request body and
|
|
8
|
+
* is never logged or included in thrown errors.
|
|
9
|
+
* - Tokens are cached in memory per site and silently re-acquired before
|
|
10
|
+
* expiry (60s skew) or when forcibly cleared on a 401.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fetch from "node-fetch";
|
|
14
|
+
|
|
15
|
+
/** Re-acquire this many ms before the stated expiry to absorb clock skew. */
|
|
16
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-site token cache, keyed by site._name. A value is either a resolved
|
|
20
|
+
* { token, expiresAt, refreshToken } entry or an in-flight Promise of one
|
|
21
|
+
* (so concurrent acquires share a single token request).
|
|
22
|
+
*/
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Error thrown when the token endpoint returns a non-2xx response.
|
|
27
|
+
* Carries the HTTP status but never the client secret.
|
|
28
|
+
*/
|
|
29
|
+
export class OAuthError extends Error {
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} message Human-readable error (never includes the secret).
|
|
32
|
+
* @param {number} [status] HTTP status from the token endpoint, if any.
|
|
33
|
+
*/
|
|
34
|
+
constructor(message, status) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "OAuthError";
|
|
37
|
+
this.status = status;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the url-encoded token request body for either grant.
|
|
43
|
+
* @param {object} oauth Resolved oauth block (clientId, clientSecret, grant, scopes).
|
|
44
|
+
* @param {boolean} useRefresh Use the refresh_token grant instead of the base grant.
|
|
45
|
+
* @param {?string} refreshToken Refresh token to send when useRefresh is true.
|
|
46
|
+
* @returns {string} application/x-www-form-urlencoded body.
|
|
47
|
+
*/
|
|
48
|
+
function buildBody(oauth, useRefresh, refreshToken) {
|
|
49
|
+
const params = new URLSearchParams();
|
|
50
|
+
if (useRefresh) {
|
|
51
|
+
params.set("grant_type", "refresh_token");
|
|
52
|
+
params.set("refresh_token", refreshToken);
|
|
53
|
+
} else {
|
|
54
|
+
params.set("grant_type", oauth.grant || "client_credentials");
|
|
55
|
+
}
|
|
56
|
+
params.set("client_id", oauth.clientId);
|
|
57
|
+
params.set("client_secret", oauth.clientSecret);
|
|
58
|
+
if (Array.isArray(oauth.scopes) && oauth.scopes.length > 0) {
|
|
59
|
+
params.set("scope", oauth.scopes.join(" "));
|
|
60
|
+
}
|
|
61
|
+
return params.toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Perform a single token-endpoint request and normalize the response.
|
|
66
|
+
* @param {object} site Resolved site config with an oauth block.
|
|
67
|
+
* @param {boolean} useRefresh Whether to use the refresh_token grant.
|
|
68
|
+
* @param {?string} refreshToken Refresh token for the refresh grant.
|
|
69
|
+
* @returns {Promise<{token: string, expiresAt: number, refreshToken: ?string}>}
|
|
70
|
+
* @throws {OAuthError} on a non-2xx response or a missing access_token.
|
|
71
|
+
*/
|
|
72
|
+
async function requestToken(site, useRefresh, refreshToken) {
|
|
73
|
+
const { oauth } = site;
|
|
74
|
+
const url = `${site.baseUrl}${oauth.tokenUrl || "/oauth/token"}`;
|
|
75
|
+
|
|
76
|
+
const res = await fetch(url, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
},
|
|
82
|
+
body: buildBody(oauth, useRefresh, refreshToken),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
throw new OAuthError(
|
|
87
|
+
`OAuth token request to ${site._name} failed with status ${res.status}.`,
|
|
88
|
+
res.status
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = await res.json();
|
|
93
|
+
if (!data.access_token || typeof data.access_token !== "string") {
|
|
94
|
+
throw new OAuthError(
|
|
95
|
+
`OAuth token response from ${site._name} is missing access_token.`,
|
|
96
|
+
res.status
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const expiresIn = Number(data.expires_in) || 0;
|
|
100
|
+
return {
|
|
101
|
+
token: data.access_token,
|
|
102
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
103
|
+
refreshToken: data.refresh_token || null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Acquire a token, attempting the refresh grant when a refresh token is cached.
|
|
109
|
+
* If the refresh grant fails (revoked/expired), fall back once to a fresh
|
|
110
|
+
* client_credentials grant; only surface the error if that also fails.
|
|
111
|
+
* @param {object} site Resolved site config with an oauth block.
|
|
112
|
+
* @param {boolean} useRefresh Attempt the refresh grant first.
|
|
113
|
+
* @param {?string} refreshToken Refresh token for the refresh grant.
|
|
114
|
+
* @returns {Promise<{token: string, expiresAt: number, refreshToken: ?string}>}
|
|
115
|
+
* @throws {OAuthError} if the base grant fails.
|
|
116
|
+
*/
|
|
117
|
+
async function acquireToken(site, useRefresh, refreshToken) {
|
|
118
|
+
if (!useRefresh) {
|
|
119
|
+
return requestToken(site, false, null);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return await requestToken(site, true, refreshToken);
|
|
123
|
+
} catch {
|
|
124
|
+
return requestToken(site, false, null);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Return a valid access token for the site, acquiring or refreshing as needed.
|
|
130
|
+
* Concurrent callers share a single in-flight token request via the cache.
|
|
131
|
+
* @param {object} site Resolved site config with an oauth block.
|
|
132
|
+
* @returns {Promise<string>} A non-expired access token.
|
|
133
|
+
* @throws {OAuthError} if a token cannot be acquired.
|
|
134
|
+
*/
|
|
135
|
+
export async function getAccessToken(site) {
|
|
136
|
+
const key = site._name;
|
|
137
|
+
const cached = cache.get(key);
|
|
138
|
+
|
|
139
|
+
// A cached entry may be a resolved token object or an in-flight Promise from a
|
|
140
|
+
// concurrent acquire; normalize before reading its fields.
|
|
141
|
+
if (cached) {
|
|
142
|
+
const resolved = await Promise.resolve(cached);
|
|
143
|
+
if (Date.now() < resolved.expiresAt - EXPIRY_SKEW_MS) {
|
|
144
|
+
return resolved.token;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const useRefresh = Boolean(cached && !(cached instanceof Promise) && cached.refreshToken);
|
|
149
|
+
const refreshToken = useRefresh ? cached.refreshToken : null;
|
|
150
|
+
|
|
151
|
+
// Store the in-flight Promise before awaiting so concurrent callers share one
|
|
152
|
+
// request. Replace with the resolved entry on success; clear on failure so a
|
|
153
|
+
// transient error does not poison the cache.
|
|
154
|
+
const pending = acquireToken(site, useRefresh, refreshToken)
|
|
155
|
+
.then((entry) => {
|
|
156
|
+
cache.set(key, entry);
|
|
157
|
+
return entry;
|
|
158
|
+
})
|
|
159
|
+
.catch((err) => {
|
|
160
|
+
cache.delete(key);
|
|
161
|
+
throw err;
|
|
162
|
+
});
|
|
163
|
+
cache.set(key, pending);
|
|
164
|
+
|
|
165
|
+
const entry = await pending;
|
|
166
|
+
return entry.token;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Drop any cached token for the site, forcing a fresh acquire on the next call.
|
|
171
|
+
* Used by the 401 retry path.
|
|
172
|
+
* @param {object} site Resolved site config (keyed by site._name).
|
|
173
|
+
* @returns {void}
|
|
174
|
+
*/
|
|
175
|
+
export function clearToken(site) {
|
|
176
|
+
cache.delete(site._name);
|
|
177
|
+
}
|