@drupalmcp/adk 0.1.1 → 0.2.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/README.md CHANGED
@@ -37,9 +37,16 @@ unreachable. `drupalTools()` rewrites both on the way through. You can use
37
37
  the rewrite on its own with `normaliseSchema()`.
38
38
 
39
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.
40
+ HTTP 403 with a challenge naming the missing scope. That header is dropped
41
+ before the error reaches you, so the library keeps it and gives the model a
42
+ sentence that says what happened and that retrying will not help.
43
+
44
+ **Resolves its dependencies once, at import.** The tools are built on the
45
+ protocol SDK directly rather than on the agent kit's own MCP toolset, which
46
+ looks the SDK up lazily at the first tool call from whatever directory it
47
+ happens to be running in. That lookup fails inside the dev server's temporary
48
+ build folder, reporting a missing dependency that is sitting in
49
+ `node_modules`. Importing at the top of the file cannot fail that way.
43
50
 
44
51
  ## Agents defined in Drupal
45
52
 
@@ -65,6 +72,11 @@ is how a tone-of-voice rule reaches an agent running outside Drupal.
65
72
  long a definition is reused; it defaults to ten seconds and zero re-reads
66
73
  every turn.
67
74
 
75
+ If the site has no such agent, the call refuses and says where to look rather
76
+ than failing obscurely. Keep these agents in their own folder: the ADK dev
77
+ server loads every agent in the directory you point it at, and one that cannot
78
+ reach its definition will report that at startup.
79
+
68
80
  ## Choosing what the agent can reach
69
81
 
70
82
  ```ts
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { MCPToolset } from '@google/adk/tools/mcp';
2
- import { LlmAgent } from '@google/adk';
1
+ import { BaseToolset, ReadonlyContext, BaseTool, LlmAgent } from '@google/adk';
3
2
 
4
3
  /**
5
4
  * OAuth 2 client credentials against a Drupal site running drupal/mcp.
@@ -59,6 +58,14 @@ declare class DrupalOAuth {
59
58
 
60
59
  /**
61
60
  * The Drupal site's MCP tools, ready to hand to an ADK agent.
61
+ *
62
+ * These are built on the protocol SDK directly rather than on the agent kit's
63
+ * own MCP toolset. The kit resolves the SDK lazily at the moment of the first
64
+ * tool call, from whatever directory it happens to be running in, and when its
65
+ * dev server transpiles an agent into a temporary folder that lookup can fail
66
+ * with a message claiming the SDK is not installed. Importing it here, at the
67
+ * top of the file, means it is resolved once when this module loads, and
68
+ * bundled with the agent by anything that bundles.
62
69
  */
63
70
 
64
71
  interface DrupalToolsOptions {
@@ -86,10 +93,41 @@ interface DrupalToolsOptions {
86
93
  *
87
94
  * The bearer token is attached per request rather than frozen into the
88
95
  * transport, so a token expiring mid-conversation is replaced without the
89
- * agent noticing. Tool schemas are rewritten on the way through; see
90
- * {@link normaliseSchema}.
96
+ * agent noticing.
97
+ */
98
+ declare function drupalTools(options: DrupalToolsOptions): DrupalToolset;
99
+ /**
100
+ * An ADK toolset backed by one MCP session against a Drupal site.
91
101
  */
92
- declare function drupalTools(options: DrupalToolsOptions): MCPToolset;
102
+ declare class DrupalToolset extends BaseToolset {
103
+ private readonly baseUrl;
104
+ private readonly auth;
105
+ private readonly only;
106
+ private readonly except;
107
+ private readonly namePrefix;
108
+ /** The last authentication challenge the site sent, if any. */
109
+ private challenge;
110
+ private client;
111
+ private connecting;
112
+ constructor(options: DrupalToolsOptions);
113
+ /**
114
+ * {@inheritdoc}
115
+ */
116
+ getTools(_context?: ReadonlyContext): Promise<BaseTool[]>;
117
+ /**
118
+ * {@inheritdoc}
119
+ */
120
+ close(): Promise<void>;
121
+ /**
122
+ * Runs one tool, translating a refusal into something the model can use.
123
+ *
124
+ * @internal
125
+ */
126
+ call(name: string, args: Record<string, unknown>): Promise<unknown>;
127
+ /** The connected client, opening the session on first use. */
128
+ private connect;
129
+ private open;
130
+ }
93
131
 
94
132
  /**
95
133
  * Agents defined in Drupal, running in ADK.
@@ -209,6 +247,6 @@ declare function scopeFromChallenge(challenge: string | null | undefined): strin
209
247
  declare function describeRefusal(error: unknown): string | null;
210
248
 
211
249
  /** Kept in step with package.json; sent as the user agent. */
212
- declare const VERSION = "0.1.1";
250
+ declare const VERSION = "0.2.1";
213
251
 
214
252
  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 CHANGED
@@ -100,7 +100,9 @@ var DrupalOAuth = class _DrupalOAuth {
100
100
  };
101
101
 
102
102
  // src/toolset.ts
103
- import { MCPToolset } from "@google/adk/tools/mcp";
103
+ import { BaseTool, BaseToolset } from "@google/adk";
104
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
105
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
104
106
 
105
107
  // src/errors.ts
106
108
  var INSUFFICIENT_SCOPE_CODE = -32002;
@@ -147,6 +149,9 @@ function messageOf(error) {
147
149
  return "";
148
150
  }
149
151
 
152
+ // src/gemini-schema.ts
153
+ import { Type } from "@google/genai";
154
+
150
155
  // src/schema.ts
151
156
  var SCHEMA_VALUED_KEYS = ["items", "additionalProperties", "not", "if", "then", "else"];
152
157
  var SCHEMA_MAP_KEYS = ["properties", "patternProperties", "definitions", "$defs"];
@@ -193,79 +198,198 @@ function walk(node, isProperty) {
193
198
  return result;
194
199
  }
195
200
 
201
+ // src/gemini-schema.ts
202
+ var TYPES = {
203
+ string: Type.STRING,
204
+ number: Type.NUMBER,
205
+ integer: Type.INTEGER,
206
+ boolean: Type.BOOLEAN,
207
+ array: Type.ARRAY,
208
+ object: Type.OBJECT,
209
+ null: Type.NULL
210
+ };
211
+ function toGeminiSchema(input) {
212
+ if (!input || typeof input !== "object") {
213
+ return void 0;
214
+ }
215
+ return convert(normaliseSchema(input));
216
+ }
217
+ function convert(node) {
218
+ const branches = Array.isArray(node.anyOf) ? node.anyOf : null;
219
+ let source = node;
220
+ let nullable = false;
221
+ if (branches) {
222
+ const real = branches.filter((b) => b?.type !== "null");
223
+ nullable = real.length !== branches.length;
224
+ if (real.length === 1) {
225
+ const { anyOf: _drop, ...rest } = node;
226
+ source = { ...rest, ...real[0] };
227
+ }
228
+ }
229
+ const declared = typeof source.type === "string" ? source.type : inferType(source);
230
+ const schema = { type: TYPES[declared] ?? Type.STRING };
231
+ if (nullable || source.nullable === true) {
232
+ schema.nullable = true;
233
+ }
234
+ if (typeof source.description === "string") {
235
+ schema.description = source.description;
236
+ } else if (typeof source.title === "string") {
237
+ schema.description = source.title;
238
+ }
239
+ if (Array.isArray(source.enum) && source.enum.every((v) => typeof v === "string")) {
240
+ schema.enum = source.enum;
241
+ }
242
+ if (schema.type === Type.OBJECT) {
243
+ const properties = source.properties;
244
+ if (properties) {
245
+ schema.properties = Object.fromEntries(
246
+ Object.entries(properties).map(([name, value]) => [name, convert(value)])
247
+ );
248
+ }
249
+ const required = source.required?.filter(
250
+ (name) => !properties || name in properties
251
+ );
252
+ if (required?.length) {
253
+ schema.required = required;
254
+ }
255
+ }
256
+ if (schema.type === Type.ARRAY) {
257
+ schema.items = source.items ? convert(source.items) : { type: Type.STRING };
258
+ }
259
+ return schema;
260
+ }
261
+ function inferType(node) {
262
+ if (node.properties) return "object";
263
+ if (node.items) return "array";
264
+ if (Array.isArray(node.enum)) return "string";
265
+ return "string";
266
+ }
267
+
196
268
  // src/version.ts
197
- var VERSION = "0.1.1";
269
+ var VERSION = "0.2.1";
198
270
 
199
271
  // src/toolset.ts
200
272
  function drupalTools(options) {
201
- const { auth, only, except, 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
- toolFilter(only, except),
223
- prefix
224
- );
225
- return prepareTools(toolset, lastChallenge);
273
+ return new DrupalToolset(options);
226
274
  }
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);
275
+ var DrupalToolset = class extends BaseToolset {
276
+ baseUrl;
277
+ auth;
278
+ only;
279
+ except;
280
+ namePrefix;
281
+ /** The last authentication challenge the site sent, if any. */
282
+ challenge = null;
283
+ client = null;
284
+ connecting = null;
285
+ constructor(options) {
286
+ super(options.only ?? [], options.prefix);
287
+ this.auth = options.auth;
288
+ this.baseUrl = (options.baseUrl ?? options.auth.baseUrl).replace(/\/+$/, "");
289
+ this.only = options.only?.length ? new Set(options.only) : null;
290
+ this.except = new Set(options.except ?? []);
291
+ this.namePrefix = options.prefix ?? "";
292
+ }
293
+ /**
294
+ * {@inheritdoc}
295
+ */
296
+ async getTools(_context) {
297
+ const client = await this.connect();
298
+ const listed = (await client.listTools()).tools;
299
+ return listed.filter((tool) => !this.except.has(tool.name) && (this.only === null || this.only.has(tool.name))).map((tool) => new DrupalTool(tool, this, this.namePrefix));
300
+ }
301
+ /**
302
+ * {@inheritdoc}
303
+ */
304
+ async close() {
305
+ const client = this.client;
306
+ this.client = null;
307
+ this.connecting = null;
308
+ await client?.close().catch(() => void 0);
309
+ }
310
+ /**
311
+ * Runs one tool, translating a refusal into something the model can use.
312
+ *
313
+ * @internal
314
+ */
315
+ async call(name, args) {
316
+ const client = await this.connect();
317
+ try {
318
+ return await client.callTool({ name, arguments: args });
319
+ } catch (error) {
320
+ const explained = describeRefusal(error) ?? describeRefusal(this.challenge);
321
+ if (!explained) {
322
+ throw error;
235
323
  }
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
- };
324
+ const scope = scopeFromChallenge(this.challenge);
325
+ throw new Error(
326
+ scope && !explained.includes(scope) ? explained.replace("is not allowed to do that", `does not hold the ${scope} scope`) : explained
327
+ );
249
328
  }
250
- return tools;
251
- };
252
- return toolset;
253
- }
254
- function toolFilter(only, except) {
255
- if (!except?.length) {
256
- return only;
257
329
  }
258
- const blocked = new Set(except);
259
- const wanted = only ? new Set(only) : null;
260
- return (tool) => !blocked.has(tool.name) && (wanted === null || wanted.has(tool.name));
261
- }
262
- function withScope(message, scope) {
263
- return message.replace("is not allowed to do that", `does not hold the ${scope} scope`);
264
- }
330
+ /** The connected client, opening the session on first use. */
331
+ async connect() {
332
+ if (this.client) {
333
+ return this.client;
334
+ }
335
+ this.connecting ??= this.open().finally(() => {
336
+ this.connecting = null;
337
+ });
338
+ return this.connecting;
339
+ }
340
+ async open() {
341
+ const client = new Client({ name: "drupalmcp-adk", version: VERSION }, { capabilities: {} });
342
+ const transport = new StreamableHTTPClientTransport(new URL(`${this.baseUrl}/mcp`), {
343
+ fetch: async (input, init) => {
344
+ const headers = new Headers(init?.headers);
345
+ headers.set("authorization", `Bearer ${await this.auth.token()}`);
346
+ headers.set("user-agent", `drupalmcp-adk/${VERSION}`);
347
+ const response = await globalThis.fetch(input, { ...init, headers });
348
+ const challenge = response.headers.get("www-authenticate");
349
+ if (challenge) {
350
+ this.challenge = challenge;
351
+ }
352
+ return response;
353
+ }
354
+ });
355
+ await client.connect(transport);
356
+ this.client = client;
357
+ return client;
358
+ }
359
+ };
360
+ var DrupalTool = class extends BaseTool {
361
+ constructor(definition, toolset, prefix) {
362
+ super({
363
+ name: prefix + definition.name,
364
+ description: definition.description ?? definition.name
365
+ });
366
+ this.definition = definition;
367
+ this.toolset = toolset;
368
+ }
369
+ definition;
370
+ toolset;
371
+ /**
372
+ * {@inheritdoc}
373
+ */
374
+ _getDeclaration() {
375
+ const parameters = toGeminiSchema(this.definition.inputSchema);
376
+ return {
377
+ name: this.name,
378
+ description: this.description,
379
+ ...parameters ? { parameters } : {}
380
+ };
381
+ }
382
+ /**
383
+ * {@inheritdoc}
384
+ */
385
+ async runAsync(request) {
386
+ return this.toolset.call(this.definition.name, request.args ?? {});
387
+ }
388
+ };
265
389
 
266
390
  // src/agents.ts
267
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
268
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
391
+ import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
392
+ import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
269
393
  import { LlmAgent } from "@google/adk";
270
394
  var LIST_URI = "drupal://agents";
271
395
  var DEFAULT_CACHE_MS = 1e4;
@@ -329,11 +453,11 @@ var DefinitionReader = class {
329
453
  }
330
454
  /** Whatever the site publishes at this resource URI, parsed as JSON. */
331
455
  async readUri(uri) {
332
- const client = new Client(
456
+ const client = new Client2(
333
457
  { name: "drupalmcp-adk", version: VERSION },
334
458
  { capabilities: {} }
335
459
  );
336
- const transport = new StreamableHTTPClientTransport(new URL(`${this.baseUrl}/mcp`), {
460
+ const transport = new StreamableHTTPClientTransport2(new URL(`${this.baseUrl}/mcp`), {
337
461
  fetch: async (input, init) => {
338
462
  const headers = new Headers(init?.headers);
339
463
  headers.set("authorization", `Bearer ${await this.auth.token()}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drupalmcp/adk",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Use a Drupal site from a Google ADK agent: OAuth, scoped MCP tools, and agents defined in Drupal.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",