@drupalmcp/adk 0.1.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/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @drupalmcp/adk
2
+
3
+ Use a Drupal site from a [Google ADK](https://adk.dev) agent: OAuth
4
+ credentials, the site's tools, and the refusals that keep an agent honest.
5
+
6
+ ```bash
7
+ npm install @drupalmcp/adk @google/adk
8
+ ```
9
+
10
+ ```ts
11
+ import { LlmAgent } from '@google/adk';
12
+ import { DrupalOAuth, drupalTools } from '@drupalmcp/adk';
13
+
14
+ export const rootAgent = new LlmAgent({
15
+ name: 'site_editor',
16
+ model: 'gemini-flash-latest',
17
+ instruction: 'Help with the Drupal site.',
18
+ tools: [drupalTools({ auth: DrupalOAuth.fromEnv() })],
19
+ });
20
+ ```
21
+
22
+ `DrupalOAuth.fromEnv()` reads `DRUPAL_BASE_URL`, `DRUPAL_CLIENT_ID`,
23
+ `DRUPAL_CLIENT_SECRET` and the optional `DRUPAL_SCOPES`. The site prints the
24
+ first three when you run `drush mcp:setup`.
25
+
26
+ ## What it does for you
27
+
28
+ **Keeps the token fresh.** Drupal's access tokens last five minutes by
29
+ default. The bearer is attached per request, so a token expiring in the
30
+ middle of a conversation is replaced without the agent noticing.
31
+
32
+ **Makes the tool schemas readable.** Drupal describes an optional parameter
33
+ as `oneOf: [{type: 'string'}, {type: 'null'}]`, and an entity parameter with
34
+ no type at all. Gemini understands neither, so those parameters arrive
35
+ untyped and the model cannot fill them in: the whole write path is
36
+ unreachable. `drupalTools()` rewrites both on the way through. You can use
37
+ the rewrite on its own with `normaliseSchema()`.
38
+
39
+ **Explains a refusal.** A call the credential may not make comes back as
40
+ HTTP 403 with a challenge naming the missing scope. The MCP client drops
41
+ that header, so the library keeps it and gives the model a sentence that
42
+ says what happened and that retrying will not help.
43
+
44
+ ## Agents defined in Drupal
45
+
46
+ If the site has the [MCP Agents](https://www.drupal.org/project/mcp) module,
47
+ an agent's instructions can live in Drupal rather than in this file:
48
+
49
+ ```ts
50
+ import { DrupalOAuth, drupalAgent } from '@drupalmcp/adk';
51
+
52
+ export const rootAgent = await drupalAgent({
53
+ auth: DrupalOAuth.fromEnv(),
54
+ id: 'site_editor',
55
+ });
56
+ ```
57
+
58
+ The definition is read again on every turn, so editing the prompt at
59
+ Configuration → AI → Tools and automation → Agents changes the next answer
60
+ with nothing restarted. If the site also runs the Context Control Center,
61
+ whatever context it selects for that agent is appended under a heading, which
62
+ is how a tone-of-voice rule reaches an agent running outside Drupal.
63
+
64
+ `listAgents(auth)` returns what the site publishes. `cacheMs` controls how
65
+ long a definition is reused; it defaults to ten seconds and zero re-reads
66
+ every turn.
67
+
68
+ ## Choosing what the agent can reach
69
+
70
+ ```ts
71
+ drupalTools({ auth, only: ['tool_api__tool_belt_entity_list'] });
72
+ ```
73
+
74
+ `only` narrows the tool list. It is a convenience, not a boundary: what the
75
+ agent may actually do is decided by the credential's scopes on the server.
76
+
77
+ Apache-2.0. Part of [drupalmcp-ts](https://github.com/Omedia/drupalmcp-ts).
@@ -0,0 +1,208 @@
1
+ import { MCPToolset } from '@google/adk/tools/mcp';
2
+ import { LlmAgent } from '@google/adk';
3
+
4
+ /**
5
+ * OAuth 2 client credentials against a Drupal site running drupal/mcp.
6
+ *
7
+ * An agent runs unattended, so it authenticates as itself rather than as a
8
+ * person: the site's `agent-draft` and `agent-publish` clients each hold a
9
+ * fixed set of scopes, and what the agent may do is decided by which one you
10
+ * hand it. See https://drupalmcp.io/connecting-a-client/.
11
+ */
12
+ interface DrupalOAuthOptions {
13
+ /** Site root, with or without a trailing slash: `https://example.com`. */
14
+ baseUrl: string;
15
+ clientId: string;
16
+ clientSecret: string;
17
+ /**
18
+ * Scopes to request. Omit to take everything the client is granted, which
19
+ * is the usual case: the client is the credential, and its scopes are the
20
+ * boundary.
21
+ */
22
+ scopes?: string[];
23
+ /** Replace for tests, proxies, or a custom agent. Defaults to global fetch. */
24
+ fetch?: typeof globalThis.fetch;
25
+ }
26
+ /**
27
+ * Mints and caches one access token.
28
+ *
29
+ * A token lasts five minutes by default, so it is refreshed rather than held:
30
+ * every call goes through {@link token}, which returns the cached one until it
31
+ * is nearly expired. Concurrent callers share a single in-flight request.
32
+ */
33
+ declare class DrupalOAuth {
34
+ readonly baseUrl: string;
35
+ private readonly clientId;
36
+ private readonly clientSecret;
37
+ private readonly scopes;
38
+ private readonly fetchImpl;
39
+ private accessToken;
40
+ private expiresAt;
41
+ private granted;
42
+ private inFlight;
43
+ constructor(options: DrupalOAuthOptions);
44
+ /**
45
+ * Reads the four standard variables, so an agent file stays free of secrets.
46
+ *
47
+ * `DRUPAL_BASE_URL`, `DRUPAL_CLIENT_ID`, `DRUPAL_CLIENT_SECRET` and the
48
+ * optional `DRUPAL_SCOPES` (space or comma separated).
49
+ */
50
+ static fromEnv(env?: NodeJS.ProcessEnv): DrupalOAuth;
51
+ /** The scopes the site granted, known only after the first token. */
52
+ get grantedScopes(): string[];
53
+ /** A valid access token, minted or from cache. */
54
+ token(): Promise<string>;
55
+ /** Drops the cached token so the next call mints a fresh one. */
56
+ forget(): void;
57
+ private mint;
58
+ }
59
+
60
+ /**
61
+ * The Drupal site's MCP tools, ready to hand to an ADK agent.
62
+ */
63
+
64
+ interface DrupalToolsOptions {
65
+ /** Site root. Defaults to the auth client's own base URL. */
66
+ baseUrl?: string;
67
+ /** The credential. Build one with `DrupalOAuth.fromEnv()`. */
68
+ auth: DrupalOAuth;
69
+ /**
70
+ * Restrict the agent to these MCP tool names, for example
71
+ * `['tool_api__tool_belt_entity_list']`. Omit to expose everything the
72
+ * site publishes; the credential's scopes still decide what may run.
73
+ */
74
+ only?: string[];
75
+ /** Prefix added to every tool name, for telling two sites apart. */
76
+ prefix?: string;
77
+ }
78
+ /**
79
+ * Connects to `{baseUrl}/mcp` and returns the tools it publishes.
80
+ *
81
+ * The bearer token is attached per request rather than frozen into the
82
+ * transport, so a token expiring mid-conversation is replaced without the
83
+ * agent noticing. Tool schemas are rewritten on the way through; see
84
+ * {@link normaliseSchema}.
85
+ */
86
+ declare function drupalTools(options: DrupalToolsOptions): MCPToolset;
87
+
88
+ /**
89
+ * Agents defined in Drupal, running in ADK.
90
+ *
91
+ * The site holds the definition: what the agent is for, how it should behave,
92
+ * and whatever context the Context Control Center attaches to it. This module
93
+ * reads that definition and re-reads it every turn, so editing the prompt in
94
+ * the Drupal admin changes the next answer without restarting anything.
95
+ *
96
+ * Drupal never calls a model. It holds the definition; ADK runs it.
97
+ */
98
+
99
+ interface AgentDefinition {
100
+ id: string;
101
+ label: string;
102
+ description: string;
103
+ /** What the agent should do, as typed into the Drupal admin. */
104
+ instruction: string;
105
+ /** Context selected for this agent, or an empty string if there is none. */
106
+ context: string;
107
+ /** Changes whenever the instruction or the context does. */
108
+ version: string;
109
+ }
110
+ interface AgentSummary {
111
+ id: string;
112
+ label: string;
113
+ description: string;
114
+ has_context: boolean;
115
+ }
116
+ interface DrupalAgentOptions {
117
+ /** Site root. Defaults to the auth client's own base URL. */
118
+ baseUrl?: string;
119
+ auth: DrupalOAuth;
120
+ /** The agent's machine name in Drupal, for example `site_editor`. */
121
+ id: string;
122
+ /** Any ADK model. Defaults to `gemini-flash-latest`. */
123
+ model?: string;
124
+ /** Restrict the tools handed to this agent. See `drupalTools`. */
125
+ only?: string[];
126
+ /** How long to reuse a definition, in milliseconds. Zero re-reads always. */
127
+ cacheMs?: number;
128
+ }
129
+ /**
130
+ * Builds an ADK agent from a definition held in Drupal.
131
+ *
132
+ * The instruction is a provider rather than a string, so it is fetched again
133
+ * on every turn. A short cache keeps a burst of turns from hammering the
134
+ * site while still picking an edit up within seconds, which is what makes
135
+ * "change it in Drupal, ask again" work in front of an audience.
136
+ */
137
+ declare function drupalAgent(options: DrupalAgentOptions): Promise<LlmAgent>;
138
+ /**
139
+ * Every agent the site publishes.
140
+ */
141
+ declare function listAgents(auth: DrupalOAuth, baseUrl?: string): Promise<AgentSummary[]>;
142
+ /**
143
+ * The instruction and the context, as one prompt.
144
+ *
145
+ * The context is fenced and labelled so the model can tell the two apart: the
146
+ * instruction is what the agent is, the context is what the site wants it to
147
+ * keep in mind today.
148
+ */
149
+ declare function promptFrom(definition: AgentDefinition): string;
150
+
151
+ /**
152
+ * Makes Drupal's tool schemas legible to Gemini.
153
+ *
154
+ * Drupal's Tool API describes an optional parameter as a choice between a type
155
+ * and null: `{oneOf: [{type: 'string'}, {type: 'null'}]}`. That is correct
156
+ * JSON Schema, but ADK's converter only understands `type` and `anyOf`, so a
157
+ * `oneOf` property arrives at the model with no type at all and the model
158
+ * cannot fill it in. `anyOf` says exactly the same thing in a dialect both
159
+ * sides read, and ADK then folds the null branch into `nullable: true`.
160
+ *
161
+ * The second problem is a property with no type at all. Drupal emits one for
162
+ * every entity parameter, because its typed-data system has no JSON Schema for
163
+ * an entity and says so in a `$comment`. On the wire that parameter is a
164
+ * string: the handle (`handle:<uuid>`) that `entity_stub` and
165
+ * `entity_load_by_id` hand back. Without a type the model cannot fill it, so
166
+ * the whole write path is unreachable. Where ADK's own inference gives up, a
167
+ * string is both the safe default and, here, the right answer.
168
+ *
169
+ * Nothing else is touched: this is a translation, not a repair.
170
+ */
171
+ /** A JSON Schema fragment, as loose as the wire allows. */
172
+ type JsonSchema = Record<string, unknown>;
173
+ /**
174
+ * Rewrites a tool's input schema into the dialect ADK reads.
175
+ *
176
+ * Returns a new object; the input is never mutated. A schema that already has
177
+ * `anyOf` keeps it and its `oneOf` branches are appended, which cannot happen
178
+ * with anything Drupal emits but would otherwise lose information.
179
+ */
180
+ declare function normaliseSchema<T>(schema: T): T;
181
+
182
+ /**
183
+ * Turns the site's refusals into sentences an agent can repeat truthfully.
184
+ *
185
+ * When a credential lacks the scope a tool needs, drupal/mcp answers HTTP 403
186
+ * with JSON-RPC code -32002, the message `insufficient_scope`, and a
187
+ * `WWW-Authenticate` header naming the scope that was missing. Left raw, a
188
+ * model tends to either retry forever or tell the user it published something.
189
+ * Rewritten, it says what happened and stops.
190
+ */
191
+ /** JSON-RPC code drupal/mcp uses for a scope refusal. */
192
+ declare const INSUFFICIENT_SCOPE_CODE = -32002;
193
+ /** JSON-RPC code drupal/mcp uses when no usable credential was presented. */
194
+ declare const AUTHENTICATION_REQUIRED_CODE = -32001;
195
+ /** Pulls `scope="mcp:publish"` out of a WWW-Authenticate challenge. */
196
+ declare function scopeFromChallenge(challenge: string | null | undefined): string | null;
197
+ /**
198
+ * Describes a refusal in plain words, or returns null if this is not one.
199
+ *
200
+ * @param error
201
+ * Anything thrown or returned by a tool call.
202
+ */
203
+ declare function describeRefusal(error: unknown): string | null;
204
+
205
+ /** Kept in step with package.json; sent as the user agent. */
206
+ declare const VERSION = "0.1.0";
207
+
208
+ export { AUTHENTICATION_REQUIRED_CODE, type AgentDefinition, type AgentSummary, type DrupalAgentOptions, DrupalOAuth, type DrupalOAuthOptions, type DrupalToolsOptions, INSUFFICIENT_SCOPE_CODE, type JsonSchema, VERSION, describeRefusal, drupalAgent, drupalTools, listAgents, normaliseSchema, promptFrom, scopeFromChallenge };
package/dist/index.js ADDED
@@ -0,0 +1,371 @@
1
+ // src/auth.ts
2
+ var REFRESH_SKEW_SECONDS = 30;
3
+ var DEFAULT_LIFETIME_SECONDS = 300;
4
+ var DrupalOAuth = class _DrupalOAuth {
5
+ baseUrl;
6
+ clientId;
7
+ clientSecret;
8
+ scopes;
9
+ fetchImpl;
10
+ accessToken = null;
11
+ expiresAt = 0;
12
+ granted = [];
13
+ inFlight = null;
14
+ constructor(options) {
15
+ if (!options.baseUrl) {
16
+ throw new Error("DrupalOAuth needs a baseUrl, for example https://example.com");
17
+ }
18
+ if (!options.clientId || !options.clientSecret) {
19
+ throw new Error(
20
+ "DrupalOAuth needs a clientId and clientSecret. Run `drush mcp:setup --rotate-secrets` on the site to get a fresh pair."
21
+ );
22
+ }
23
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
24
+ this.clientId = options.clientId;
25
+ this.clientSecret = options.clientSecret;
26
+ this.scopes = options.scopes;
27
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
28
+ }
29
+ /**
30
+ * Reads the four standard variables, so an agent file stays free of secrets.
31
+ *
32
+ * `DRUPAL_BASE_URL`, `DRUPAL_CLIENT_ID`, `DRUPAL_CLIENT_SECRET` and the
33
+ * optional `DRUPAL_SCOPES` (space or comma separated).
34
+ */
35
+ static fromEnv(env = process.env) {
36
+ const scopes = env.DRUPAL_SCOPES?.split(/[\s,]+/).filter(Boolean);
37
+ return new _DrupalOAuth({
38
+ baseUrl: env.DRUPAL_BASE_URL ?? "",
39
+ clientId: env.DRUPAL_CLIENT_ID ?? "",
40
+ clientSecret: env.DRUPAL_CLIENT_SECRET ?? "",
41
+ ...scopes?.length ? { scopes } : {}
42
+ });
43
+ }
44
+ /** The scopes the site granted, known only after the first token. */
45
+ get grantedScopes() {
46
+ return [...this.granted];
47
+ }
48
+ /** A valid access token, minted or from cache. */
49
+ async token() {
50
+ const now = Date.now() / 1e3;
51
+ if (this.accessToken && now < this.expiresAt - REFRESH_SKEW_SECONDS) {
52
+ return this.accessToken;
53
+ }
54
+ this.inFlight ??= this.mint().finally(() => {
55
+ this.inFlight = null;
56
+ });
57
+ return this.inFlight;
58
+ }
59
+ /** Drops the cached token so the next call mints a fresh one. */
60
+ forget() {
61
+ this.accessToken = null;
62
+ this.expiresAt = 0;
63
+ }
64
+ async mint() {
65
+ const body = new URLSearchParams({
66
+ grant_type: "client_credentials",
67
+ client_id: this.clientId,
68
+ client_secret: this.clientSecret
69
+ });
70
+ if (this.scopes?.length) {
71
+ body.set("scope", this.scopes.join(" "));
72
+ }
73
+ const response = await this.fetchImpl(`${this.baseUrl}/oauth/token`, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/x-www-form-urlencoded" },
76
+ body
77
+ });
78
+ const text = await response.text();
79
+ if (!response.ok) {
80
+ throw new Error(
81
+ `Drupal refused the client credentials (HTTP ${response.status}): ${text.slice(0, 300)}`
82
+ );
83
+ }
84
+ let data;
85
+ try {
86
+ data = JSON.parse(text);
87
+ } catch {
88
+ throw new Error(
89
+ `The token endpoint answered with something that is not JSON. Check that ${this.baseUrl} is the site root: ${text.slice(0, 200)}`
90
+ );
91
+ }
92
+ if (!data.access_token) {
93
+ throw new Error(`The token endpoint returned no access_token: ${text.slice(0, 200)}`);
94
+ }
95
+ this.accessToken = data.access_token;
96
+ this.expiresAt = Date.now() / 1e3 + (data.expires_in ?? DEFAULT_LIFETIME_SECONDS);
97
+ this.granted = data.scope ? data.scope.split(/\s+/).filter(Boolean) : [];
98
+ return this.accessToken;
99
+ }
100
+ };
101
+
102
+ // src/toolset.ts
103
+ import { MCPToolset } from "@google/adk/tools/mcp";
104
+
105
+ // src/errors.ts
106
+ var INSUFFICIENT_SCOPE_CODE = -32002;
107
+ var AUTHENTICATION_REQUIRED_CODE = -32001;
108
+ function scopeFromChallenge(challenge) {
109
+ if (!challenge) {
110
+ return null;
111
+ }
112
+ const match = /scope="([^"]+)"/.exec(challenge);
113
+ return match?.[1] ?? null;
114
+ }
115
+ function describeRefusal(error) {
116
+ const text = messageOf(error);
117
+ if (!text) {
118
+ return null;
119
+ }
120
+ if (text.includes("insufficient_scope") || text.includes(String(INSUFFICIENT_SCOPE_CODE))) {
121
+ const scope = scopeFromChallenge(text) ?? scopeFromText(text);
122
+ return scope ? `The site refused: this credential does not hold the ${scope} scope. Say so plainly and do not try again; a different credential is needed.` : "The site refused: this credential is not allowed to do that. Say so plainly and do not try again.";
123
+ }
124
+ if (text.includes("authentication_required") || text.includes("-32001")) {
125
+ return "The site refused: no valid credential was presented. Check the client id and secret.";
126
+ }
127
+ return null;
128
+ }
129
+ function scopeFromText(text) {
130
+ const match = /\bmcp:[a-z]+/.exec(text);
131
+ return match?.[0] ?? null;
132
+ }
133
+ function messageOf(error) {
134
+ if (typeof error === "string") {
135
+ return error;
136
+ }
137
+ if (error instanceof Error) {
138
+ return `${error.message}`;
139
+ }
140
+ if (error && typeof error === "object") {
141
+ try {
142
+ return JSON.stringify(error);
143
+ } catch {
144
+ return "";
145
+ }
146
+ }
147
+ return "";
148
+ }
149
+
150
+ // src/schema.ts
151
+ var SCHEMA_VALUED_KEYS = ["items", "additionalProperties", "not", "if", "then", "else"];
152
+ var SCHEMA_MAP_KEYS = ["properties", "patternProperties", "definitions", "$defs"];
153
+ var SCHEMA_LIST_KEYS = ["anyOf", "allOf", "oneOf", "prefixItems"];
154
+ var TYPE_BEARING_KEYS = ["type", "properties", "$ref", "items", "enum", "const", "anyOf", "oneOf"];
155
+ function normaliseSchema(schema) {
156
+ return walk(schema, false);
157
+ }
158
+ function walk(node, isProperty) {
159
+ if (Array.isArray(node)) {
160
+ return node.map((item) => walk(item, false));
161
+ }
162
+ if (node === null || typeof node !== "object") {
163
+ return node;
164
+ }
165
+ const source = node;
166
+ const result = {};
167
+ for (const [key, value] of Object.entries(source)) {
168
+ if (key === "oneOf") {
169
+ continue;
170
+ }
171
+ if (SCHEMA_MAP_KEYS.includes(key) && value && typeof value === "object" && !Array.isArray(value)) {
172
+ const mapped = {};
173
+ const namesAreProperties = key === "properties" || key === "patternProperties";
174
+ for (const [name, child] of Object.entries(value)) {
175
+ mapped[name] = walk(child, namesAreProperties);
176
+ }
177
+ result[key] = mapped;
178
+ } else if (SCHEMA_LIST_KEYS.includes(key) && Array.isArray(value)) {
179
+ result[key] = value.map((item) => walk(item, false));
180
+ } else if (SCHEMA_VALUED_KEYS.includes(key)) {
181
+ result[key] = walk(value, false);
182
+ } else {
183
+ result[key] = value;
184
+ }
185
+ }
186
+ if (Array.isArray(source.oneOf)) {
187
+ const branches = source.oneOf.map((item) => walk(item, false));
188
+ result.anyOf = Array.isArray(result.anyOf) ? [...result.anyOf, ...branches] : branches;
189
+ }
190
+ if (isProperty && !TYPE_BEARING_KEYS.some((key) => key in source)) {
191
+ result.type = "string";
192
+ }
193
+ return result;
194
+ }
195
+
196
+ // src/version.ts
197
+ var VERSION = "0.1.0";
198
+
199
+ // src/toolset.ts
200
+ function drupalTools(options) {
201
+ const { auth, only, prefix } = options;
202
+ const baseUrl = (options.baseUrl ?? auth.baseUrl).replace(/\/+$/, "");
203
+ const lastChallenge = { value: null };
204
+ const toolset = new MCPToolset(
205
+ {
206
+ type: "StreamableHTTPConnectionParams",
207
+ url: `${baseUrl}/mcp`,
208
+ transportOptions: {
209
+ fetch: async (input, init) => {
210
+ const headers = new Headers(init?.headers);
211
+ headers.set("authorization", `Bearer ${await auth.token()}`);
212
+ headers.set("user-agent", `drupalmcp-adk/${VERSION}`);
213
+ const response = await globalThis.fetch(input, { ...init, headers });
214
+ const challenge = response.headers.get("www-authenticate");
215
+ if (challenge) {
216
+ lastChallenge.value = challenge;
217
+ }
218
+ return response;
219
+ }
220
+ }
221
+ },
222
+ only,
223
+ prefix
224
+ );
225
+ return prepareTools(toolset, lastChallenge);
226
+ }
227
+ function prepareTools(toolset, lastChallenge) {
228
+ const listTools = toolset.getTools.bind(toolset);
229
+ toolset.getTools = async (...args) => {
230
+ const tools = await listTools(...args);
231
+ for (const tool of tools) {
232
+ const carrier = tool;
233
+ if (carrier.mcpTool?.inputSchema) {
234
+ carrier.mcpTool.inputSchema = normaliseSchema(carrier.mcpTool.inputSchema);
235
+ }
236
+ const run = carrier.runAsync.bind(carrier);
237
+ carrier.runAsync = async (request) => {
238
+ try {
239
+ return await run(request);
240
+ } catch (error) {
241
+ const explained = describeRefusal(error) ?? describeRefusal(lastChallenge.value);
242
+ if (!explained) {
243
+ throw error;
244
+ }
245
+ const scope = scopeFromChallenge(lastChallenge.value);
246
+ throw new Error(scope && !explained.includes(scope) ? withScope(explained, scope) : explained);
247
+ }
248
+ };
249
+ }
250
+ return tools;
251
+ };
252
+ return toolset;
253
+ }
254
+ function withScope(message, scope) {
255
+ return message.replace("is not allowed to do that", `does not hold the ${scope} scope`);
256
+ }
257
+
258
+ // src/agents.ts
259
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
260
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
261
+ import { LlmAgent } from "@google/adk";
262
+ var LIST_URI = "drupal://agents";
263
+ var DEFAULT_CACHE_MS = 1e4;
264
+ async function drupalAgent(options) {
265
+ const { auth, id, only } = options;
266
+ const baseUrl = (options.baseUrl ?? auth.baseUrl).replace(/\/+$/, "");
267
+ const cacheMs = options.cacheMs ?? DEFAULT_CACHE_MS;
268
+ const reader = new DefinitionReader(baseUrl, auth);
269
+ const definition = await reader.read(id);
270
+ let cached = definition;
271
+ let fetchedAt = Date.now();
272
+ const instruction = async () => {
273
+ if (cacheMs === 0 || Date.now() - fetchedAt > cacheMs) {
274
+ try {
275
+ cached = await reader.read(id);
276
+ fetchedAt = Date.now();
277
+ } catch {
278
+ }
279
+ }
280
+ return promptFrom(cached);
281
+ };
282
+ return new LlmAgent({
283
+ name: definition.id,
284
+ model: options.model ?? "gemini-flash-latest",
285
+ description: definition.description || definition.label,
286
+ instruction,
287
+ tools: [drupalTools({ baseUrl, auth, ...only ? { only } : {} })]
288
+ });
289
+ }
290
+ async function listAgents(auth, baseUrl) {
291
+ const root = (baseUrl ?? auth.baseUrl).replace(/\/+$/, "");
292
+ const payload = await new DefinitionReader(root, auth).readUri(LIST_URI);
293
+ return payload.agents ?? [];
294
+ }
295
+ function promptFrom(definition) {
296
+ if (!definition.context) {
297
+ return definition.instruction;
298
+ }
299
+ return [
300
+ definition.instruction,
301
+ "",
302
+ "Site context. Apply it where it is relevant; it does not override what",
303
+ "the person in front of you is asking for.",
304
+ "",
305
+ definition.context
306
+ ].join("\n");
307
+ }
308
+ var DefinitionReader = class {
309
+ constructor(baseUrl, auth) {
310
+ this.baseUrl = baseUrl;
311
+ this.auth = auth;
312
+ }
313
+ baseUrl;
314
+ auth;
315
+ /** One agent's definition. */
316
+ async read(id) {
317
+ if (!/^[a-z0-9_]+$/.test(id)) {
318
+ throw new Error(`"${id}" is not a Drupal agent id; expected lowercase letters, digits and underscores.`);
319
+ }
320
+ return this.readUri(`${LIST_URI}/${id}`);
321
+ }
322
+ /** Whatever the site publishes at this resource URI, parsed as JSON. */
323
+ async readUri(uri) {
324
+ const client = new Client(
325
+ { name: "drupalmcp-adk", version: VERSION },
326
+ { capabilities: {} }
327
+ );
328
+ const transport = new StreamableHTTPClientTransport(new URL(`${this.baseUrl}/mcp`), {
329
+ fetch: async (input, init) => {
330
+ const headers = new Headers(init?.headers);
331
+ headers.set("authorization", `Bearer ${await this.auth.token()}`);
332
+ headers.set("user-agent", `drupalmcp-adk/${VERSION}`);
333
+ return globalThis.fetch(input, { ...init, headers });
334
+ }
335
+ });
336
+ try {
337
+ await client.connect(transport);
338
+ const result = await client.readResource({ uri });
339
+ const first = result.contents?.[0];
340
+ const text = first && "text" in first ? first.text : void 0;
341
+ if (typeof text !== "string") {
342
+ throw new Error(`The site returned no text for ${uri}.`);
343
+ }
344
+ return JSON.parse(text);
345
+ } catch (error) {
346
+ throw new Error(explain(uri, error));
347
+ } finally {
348
+ await client.close().catch(() => void 0);
349
+ }
350
+ }
351
+ };
352
+ function explain(uri, error) {
353
+ const message = error instanceof Error ? error.message : String(error);
354
+ if (message.includes("Resource not found")) {
355
+ return `The site has no agent at ${uri}. Check the machine name at Configuration \u2192 AI \u2192 Tools and automation \u2192 Agents, and that the MCP Agents module is installed.`;
356
+ }
357
+ return `Could not read ${uri} from Drupal: ${message}`;
358
+ }
359
+ export {
360
+ AUTHENTICATION_REQUIRED_CODE,
361
+ DrupalOAuth,
362
+ INSUFFICIENT_SCOPE_CODE,
363
+ VERSION,
364
+ describeRefusal,
365
+ drupalAgent,
366
+ drupalTools,
367
+ listAgents,
368
+ normaliseSchema,
369
+ promptFrom,
370
+ scopeFromChallenge
371
+ };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@drupalmcp/adk",
3
+ "version": "0.1.0",
4
+ "description": "Use a Drupal site from a Google ADK agent: OAuth, scoped MCP tools, and agents defined in Drupal.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24.13.0"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Omedia/drupalmcp-ts.git",
13
+ "directory": "packages/adk"
14
+ },
15
+ "homepage": "https://drupalmcp.io",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "drupal",
21
+ "mcp",
22
+ "adk",
23
+ "agent",
24
+ "ai",
25
+ "model-context-protocol"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "test": "vitest run",
40
+ "test:live": "DRUPALMCP_LIVE=1 vitest run"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.26.0"
44
+ },
45
+ "peerDependencies": {
46
+ "@google/adk": "^2.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@google/adk": "^2.1.0",
50
+ "tsup": "^8.5.0",
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^3.2.4"
53
+ }
54
+ }