@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 +77 -0
- package/dist/index.d.ts +208 -0
- package/dist/index.js +371 -0
- package/package.json +54 -0
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).
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|