@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 +15 -3
- package/dist/index.d.ts +44 -6
- package/dist/index.js +190 -66
- package/package.json +1 -1
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.
|
|
41
|
-
|
|
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 {
|
|
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.
|
|
90
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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.
|
|
269
|
+
var VERSION = "0.2.1";
|
|
198
270
|
|
|
199
271
|
// src/toolset.ts
|
|
200
272
|
function drupalTools(options) {
|
|
201
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
456
|
+
const client = new Client2(
|
|
333
457
|
{ name: "drupalmcp-adk", version: VERSION },
|
|
334
458
|
{ capabilities: {} }
|
|
335
459
|
);
|
|
336
|
-
const transport = new
|
|
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()}`);
|