mcp-google-multi 6.0.0-alpha.30 → 6.0.0-alpha.32

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.
@@ -1,5 +1,4 @@
1
- import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
- import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
1
+ import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/server";
3
2
  export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
4
3
  /** Declared scalar kind per schema key; drives value coercion on RENAMED keys
5
4
  * only. Clients string-encode values for keys absent from the advertised
@@ -1,6 +1,6 @@
1
1
  // Wire-level tools/call argument normalization. Clients (LLMs) recurringly
2
2
  // snake_case a camelCase parameter (thread_id for threadId) and burn a retry
3
- // on the -32602. A schema-level fix is off the table: SDK 1.x advertises an
3
+ // on the -32602. A schema-level fix is off the table: the SDK advertises an
4
4
  // EMPTY input schema for any non-object wrapper (pipe/preprocess), so the
5
5
  // only seam that keeps tools/list intact is the JSON-RPC message itself —
6
6
  // which is versioned MCP spec, stabler than any SDK internal. The rename is
@@ -90,5 +90,12 @@ export function withArgNormalization(transport, shapeFor, log, onRename) {
90
90
  if (transport.setProtocolVersion) {
91
91
  wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
92
92
  }
93
+ // v2-only, called by Protocol.connect() before start(). A no-op today (both
94
+ // sides default to the same exported constant), but a proxy that silently
95
+ // eats a member the SDK calls is a bug waiting for the first caller that
96
+ // passes supportedProtocolVersions explicitly.
97
+ if (transport.setSupportedProtocolVersions) {
98
+ wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
99
+ }
93
100
  return wrapper;
94
101
  }
@@ -1,8 +1,7 @@
1
+ import type { McpServer, Transport } from "@modelcontextprotocol/server";
1
2
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import type { HttpConfig } from './http-config.js';
4
4
  import { type ArgShape } from './arg-normalize.js';
5
- import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
6
5
  export type AuthOutcome = {
7
6
  ok: true;
8
7
  } | {
@@ -1,10 +1,9 @@
1
- // B12: the Streamable HTTP transport host (cc-transport-hosting T2/T3). Owns the
1
+ import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
2
2
  // node:http server, the route table, the front guard (Host / Origin / DNS-rebind),
3
3
  // and the stateless per-request /mcp dispatch. The OAuth AS endpoints and Bearer
4
4
  // verification are a seam filled by B13 (oauth-authorization-server); B12 ships a
5
5
  // loopback-owner authenticator so the local-HTTP model works before the AS lands.
6
6
  import { createServer } from 'node:http';
7
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
7
  import { withArgNormalization } from './arg-normalize.js';
9
8
  // A hung handler that keeps the connection open would otherwise hold the global
10
9
  // serialize() lock forever. Generous by default so slow-but-valid calls (large
@@ -161,7 +160,7 @@ export class HttpTransportHost {
161
160
  // the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
162
161
  // exact-Host match is stricter than the front guard and would 403 valid
163
162
  // Hosts (double enforcement, differing rules).
164
- const transport = new StreamableHTTPServerTransport({
163
+ const transport = new NodeStreamableHTTPServerTransport({
165
164
  sessionIdGenerator: undefined,
166
165
  enableJsonResponse: true,
167
166
  enableDnsRebindingProtection: false,
package/dist/index.js CHANGED
@@ -5,8 +5,8 @@ import { assertServerAccountsConfigured } from './accounts.js';
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import path from 'node:path';
8
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
9
+ import { McpServer } from "@modelcontextprotocol/server";
10
10
  import { GENERATED_SERVICES } from './tools/generated/index.js';
11
11
  import { GENERATED_GATES, SERVICES } from './services.js';
12
12
  import { ToolRegistry, resolveDiscoveryMode } from './registry.js';
@@ -247,7 +247,7 @@ async function main() {
247
247
  let transport = new StdioServerTransport();
248
248
  if (stdioMetrics) {
249
249
  const { tapUsageMetrics } = await import('./metrics-tap.js');
250
- transport = tapUsageMetrics(transport, stdioMetrics);
250
+ transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
251
251
  }
252
252
  await server.connect(argNormalizationEnabled()
253
253
  ? withArgNormalization(transport, (n) => registry.argShape(n), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined)
@@ -338,7 +338,7 @@ async function main() {
338
338
  let httpTap;
339
339
  if (httpMetrics) {
340
340
  const { tapUsageMetrics } = await import('./metrics-tap.js');
341
- httpTap = (t) => tapUsageMetrics(t, httpMetrics);
341
+ httpTap = (t) => tapUsageMetrics(t, httpMetrics, (n) => registry.hasTool(n));
342
342
  }
343
343
  const host = new HttpTransportHost({
344
344
  server: httpServer,
@@ -1,3 +1,11 @@
1
- import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
1
+ import type { Transport } from "@modelcontextprotocol/server";
2
2
  import type { Metrics } from './usage-metrics.js';
3
- export declare function tapUsageMetrics(transport: Transport, metrics: Metrics): Transport;
3
+ export declare function isSchemaValidationText(text: string): boolean;
4
+ /**
5
+ * @param isKnownTool membership test against the REGISTERED tool set. The
6
+ * name on a `tools/call` frame is client-supplied, so a shape check alone
7
+ * would let a shape-valid but invented name reach disk and break the
8
+ * closed-vocabulary guarantee. A validation failure against a name the server
9
+ * never registered is a not-found, and is counted as one.
10
+ */
11
+ export declare function tapUsageMetrics(transport: Transport, metrics: Metrics, isKnownTool?: (name: string) => boolean): Transport;
@@ -1,6 +1,28 @@
1
1
  const PENDING_CAP = 1_000;
2
- export function tapUsageMetrics(transport, metrics) {
2
+ // The SDK synthesizes input-validation failures as isError RESULTS, skipping
3
+ // the handler entirely, so neither the registry wrapper nor the error-frame
4
+ // path sees them. The text is prose in both SDK eras (v1 prefixed it with
5
+ // "MCP error -32602: ", v2 dropped that prefix), while handler envelopes are
6
+ // always JSON starting with "{" — so this can never double count one.
7
+ const SCHEMA_VALIDATION_TEXT = /^(MCP error -32602: )?Input validation error:/;
8
+ export function isSchemaValidationText(text) {
9
+ return SCHEMA_VALIDATION_TEXT.test(text);
10
+ }
11
+ /**
12
+ * @param isKnownTool membership test against the REGISTERED tool set. The
13
+ * name on a `tools/call` frame is client-supplied, so a shape check alone
14
+ * would let a shape-valid but invented name reach disk and break the
15
+ * closed-vocabulary guarantee. A validation failure against a name the server
16
+ * never registered is a not-found, and is counted as one.
17
+ */
18
+ export function tapUsageMetrics(transport, metrics, isKnownTool = () => false) {
3
19
  const pending = new Map();
20
+ const recordValidation = (tool) => {
21
+ if (tool !== undefined && isKnownTool(tool))
22
+ metrics.recordRpc('schema_validation', tool);
23
+ else
24
+ metrics.recordRpc('tool_not_found');
25
+ };
4
26
  const wrapper = {
5
27
  start: () => transport.start(),
6
28
  send: (message, options) => {
@@ -14,23 +36,16 @@ export function tapUsageMetrics(transport, metrics) {
14
36
  if (/unknown tool|not found/i.test(m.error.message ?? ''))
15
37
  metrics.recordRpc('tool_not_found');
16
38
  else
17
- metrics.recordRpc('schema_validation', tool);
39
+ recordValidation(tool);
18
40
  }
19
41
  else {
20
42
  metrics.recordRpc(m.error.code);
21
43
  }
22
44
  }
23
45
  else if (m.result?.isError === true) {
24
- // SDK 1.x synthesizes input-validation failures as isError RESULTS
25
- // ("MCP error -32602: ..."), skipping the handler entirely, so the
26
- // registry wrapper never sees them either. Handler envelopes are
27
- // JSON text and never carry this prefix, so nothing double counts.
28
46
  const first = m.result.content?.[0]?.text;
29
- if (typeof first === 'string' && first.startsWith('MCP error -32602:')) {
30
- if (/tool \S+ not found|unknown tool/i.test(first))
31
- metrics.recordRpc('tool_not_found');
32
- else
33
- metrics.recordRpc('schema_validation', tool);
47
+ if (typeof first === 'string' && isSchemaValidationText(first)) {
48
+ recordValidation(tool);
34
49
  }
35
50
  }
36
51
  }
@@ -71,5 +86,10 @@ export function tapUsageMetrics(transport, metrics) {
71
86
  if (transport.setProtocolVersion) {
72
87
  wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
73
88
  }
89
+ // v2-only; see the same forward in arg-normalize.ts. Both proxies compose,
90
+ // so a member dropped by either layer never reaches the real transport.
91
+ if (transport.setSupportedProtocolVersions) {
92
+ wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
93
+ }
74
94
  return wrapper;
75
95
  }
@@ -1,4 +1,4 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from 'zod';
3
3
  import { type Policy } from './write-control.js';
4
4
  import type { ArgShape } from './arg-normalize.js';
@@ -58,6 +58,10 @@ export declare class ToolRegistry {
58
58
  catalog(service: string, query?: string): CatalogOperation[];
59
59
  /** The metrics recorder, for hook sites (escape hatch); null when off. */
60
60
  get usageMetrics(): Metrics | null;
61
+ /** Membership test against the REGISTERED tool set (hidden tools included:
62
+ * they stay callable). The metrics tap uses this so a client-supplied name
63
+ * can never enter the closed vocabulary. */
64
+ hasTool(name: string): boolean;
61
65
  /** Op-name vocabulary for a service, split by provenance so the capped
62
66
  * discover descriptions can list curated ops and only summarize the
63
67
  * generated long tail. */
package/dist/registry.js CHANGED
@@ -1,4 +1,3 @@
1
- import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
1
  import { z } from 'zod';
3
2
  import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-control.js';
4
3
  import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
@@ -256,6 +255,12 @@ export class ToolRegistry {
256
255
  get usageMetrics() {
257
256
  return this.metrics;
258
257
  }
258
+ /** Membership test against the REGISTERED tool set (hidden tools included:
259
+ * they stay callable). The metrics tap uses this so a client-supplied name
260
+ * can never enter the closed vocabulary. */
261
+ hasTool(name) {
262
+ return this.tools.some((t) => t.name === name);
263
+ }
259
264
  /** Op-name vocabulary for a service, split by provenance so the capped
260
265
  * discover descriptions can list curated ops and only summarize the
261
266
  * generated long tail. */
@@ -320,7 +325,11 @@ export class ToolRegistry {
320
325
  if (this.tools.length === 0) {
321
326
  throw new Error('installListHandler() requires at least one registered tool');
322
327
  }
323
- this.server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
328
+ this.server.server.setRequestHandler('tools/list', async () => ({
329
+ // The wire Tool is hand-built because the SDK's own types drop the
330
+ // anthropic/* _meta keys honoring clients read. z.toJSONSchema emits a
331
+ // valid draft-7 object schema by construction, which the SDK's recursive
332
+ // JSON-Schema type cannot infer from our cached `unknown`.
324
333
  tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
325
334
  }));
326
335
  }
@@ -1,4 +1,4 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
2
  export interface SetupOptions {
3
3
  /** Public base URL when serving over HTTP; used for the Web client redirect. */
4
4
  publicUrl?: string;
@@ -47,7 +47,7 @@ export function registerSetupPrompt(server) {
47
47
  server.registerPrompt('setup', {
48
48
  title: 'Set up mcp-google-multi',
49
49
  description: 'Guided Google Cloud Console prelude: project, APIs, consent screen, OAuth client, and credentials. The one-time browser setup that has no API.',
50
- argsSchema: { publicUrl: z.string().optional().describe('Public base URL when serving over HTTP (for the Web OAuth client redirect). Omit for stdio.') },
50
+ argsSchema: z.object({ publicUrl: z.string().optional().describe('Public base URL when serving over HTTP (for the Web OAuth client redirect). Omit for stdio.') }),
51
51
  }, (args) => ({
52
52
  messages: [
53
53
  {
@@ -1,4 +1,4 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import type { ToolRegistry } from '../registry.js';
3
3
  export interface AddForm {
4
4
  alias: string;
@@ -27,6 +27,16 @@ export type AddValidation = {
27
27
  /** Validate the collected form against the alias rules, dup check, and the
28
28
  * bundle catalog. Pure (no I/O) for unit testing. */
29
29
  export declare function validateAddForm(input: Partial<AddForm>, existingAliases: string[]): AddValidation;
30
+ /** URL-mode elicitation params. `elicitationId` is REQUIRED by the spec
31
+ * schema; omitting it made every url-mode request fail client-side
32
+ * validation, so the branch silently never worked. Pure, so the shape is
33
+ * testable against the SDK's own schema without a live client. */
34
+ export declare function urlElicitationParams(alias: string, url: string, elicitationId: string): {
35
+ mode: 'url';
36
+ elicitationId: string;
37
+ message: string;
38
+ url: string;
39
+ };
30
40
  /** Map direct tool arguments onto the elicitation form shape: the argument-
31
41
  * mode fallback for clients without form elicitation. All bundle picks travel
32
42
  * through otherBundles, which validateAddForm resolves and validates. Pure. */
@@ -71,6 +71,18 @@ export function validateAddForm(input, existingAliases) {
71
71
  }
72
72
  return { ok: true, alias, email, bundles, admin: input.admin === true };
73
73
  }
74
+ /** URL-mode elicitation params. `elicitationId` is REQUIRED by the spec
75
+ * schema; omitting it made every url-mode request fail client-side
76
+ * validation, so the branch silently never worked. Pure, so the shape is
77
+ * testable against the SDK's own schema without a live client. */
78
+ export function urlElicitationParams(alias, url, elicitationId) {
79
+ return {
80
+ mode: 'url',
81
+ elicitationId,
82
+ message: `Authorize the "${alias}" Google account in your browser.`,
83
+ url,
84
+ };
85
+ }
74
86
  /** Map direct tool arguments onto the elicitation form shape: the argument-
75
87
  * mode fallback for clients without form elicitation. All bundle picks travel
76
88
  * through otherBundles, which validateAddForm resolves and validates. Pure. */
@@ -125,11 +137,16 @@ async function runConsent(server, alias) {
125
137
  const scopes = resolveScopesForAccount(alias);
126
138
  const url = client.generateAuthUrl({ access_type: 'offline', prompt: 'consent', scope: scopes, login_hint: cfg.email, state: expectedState });
127
139
  const consent = loop.finish(client, expectedState);
140
+ // Capability probe: the spec advertises each elicitation mode as a PRESENT
141
+ // object, not a boolean, so test presence rather than truthiness.
128
142
  const caps = server.server.getClientCapabilities?.();
129
143
  let opened = false;
130
- if (caps?.elicitation?.url) {
144
+ if (caps?.elicitation?.url !== undefined) {
131
145
  try {
132
- const r = await server.server.elicitInput({ mode: 'url', message: `Authorize the "${alias}" Google account in your browser.`, url });
146
+ // elicitationId is REQUIRED by the spec schema; omitting it made every
147
+ // url-mode request fail client-side validation, so this branch always
148
+ // fell through to the server-side browser open.
149
+ const r = await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
133
150
  if (r.action !== 'accept') {
134
151
  loop.close();
135
152
  return { ok: false, text: 'confirmation_declined: consent was cancelled; the account row was kept but no token was stored (doctor will show it as "missing").' };
@@ -148,7 +165,9 @@ async function runConsent(server, alias) {
148
165
  tokens = await consent;
149
166
  }
150
167
  catch (e) {
151
- return { ok: false, text: `${e.message}${opened ? '' : `\nOpen this URL to authorize:\n${url}`}` };
168
+ // Always surface the URL: the browser hand-off can succeed and consent
169
+ // still fail (declined, timed out), and the URL is the only recovery.
170
+ return { ok: false, text: `${e.message}\nOpen this URL to authorize:\n${url}` };
152
171
  }
153
172
  writeToken(alias, tokens);
154
173
  return { ok: true, missing: scopeGrantDiff(scopes, typeof tokens.scope === 'string' ? tokens.scope : undefined) };
@@ -196,15 +215,16 @@ export function registerAccountWizardTools(registry, server) {
196
215
  input = argsToAddForm(a);
197
216
  }
198
217
  else {
218
+ // Modes are advertised as PRESENT objects, not booleans.
199
219
  const caps = server.server.getClientCapabilities?.();
200
- if (!caps?.elicitation?.form) {
220
+ if (caps?.elicitation?.form === undefined) {
201
221
  return textResult('E_NO_FORM_ELICITATION: this client does not support the interactive form. Call account_add again with arguments instead, e.g. {"alias": "work", "email": "you@example.com"} (optional: "bundles" as a comma-separated list, "allBundles": true, "admin": true).', true);
202
222
  }
203
223
  const form = await server.server.elicitInput({ message: 'Add a Google account', requestedSchema: addFormSchema() });
204
224
  if (form.action !== 'accept') {
205
225
  return textResult('confirmation_declined: no account was added.');
206
226
  }
207
- input = form.content ?? {};
227
+ input = (form.content ?? {});
208
228
  }
209
229
  const validated = validateAddForm(input, getAccountSet().aliases);
210
230
  if (!validated.ok)
@@ -18,6 +18,7 @@ const RETRY_WINDOW_MS = 10 * 60_000;
18
18
  const BIGRAM_GAP_MS = 5 * 60_000;
19
19
  const BIGRAM_CAP = 1_500;
20
20
  const ERR_SLUG_CAP = 32;
21
+ const VALIDATION_CAP = 200;
21
22
  const ESCAPE_CAP = 500;
22
23
  const EVENTS_ROTATE_BYTES = 4 * 1024 * 1024;
23
24
  const RETENTION_DAYS = 180;
@@ -184,6 +185,9 @@ function applyCaps(day) {
184
185
  };
185
186
  for (const t of Object.values(day.tools))
186
187
  t.err = cap(t.err, ERR_SLUG_CAP);
188
+ // Bounded by the registered tool set via the tap's membership test, but
189
+ // capped anyway: this map is the only one fed by a wire-supplied key.
190
+ day.validation = cap(day.validation, VALIDATION_CAP);
187
191
  day.escape.methods = cap(day.escape.methods, ESCAPE_CAP, (n) => { day.escape._overflow += n; });
188
192
  day.bigrams = cap(day.bigrams, BIGRAM_CAP, (n) => { day.bigrams._overflow = (day.bigrams._overflow ?? 0) + n; });
189
193
  return day;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.30",
3
+ "version": "6.0.0-alpha.32",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",
@@ -81,7 +81,8 @@
81
81
  "@googleapis/slides": "^10.0.0",
82
82
  "@googleapis/tasks": "^17.0.0",
83
83
  "@googleapis/webmasters": "^9.0.0",
84
- "@modelcontextprotocol/sdk": "^1.30.0",
84
+ "@modelcontextprotocol/node": "2.0.0",
85
+ "@modelcontextprotocol/server": "2.0.0",
85
86
  "googleapis-common": "^9.0.0",
86
87
  "jose": "^6.2.8",
87
88
  "markdown-it": "15.0.0",
@@ -115,7 +116,6 @@
115
116
  },
116
117
  "overrides": {
117
118
  "tmp": "^0.2.4",
118
- "fast-uri": "^3.1.6",
119
119
  "hono": "^4.13.5",
120
120
  "qs": "^6.16.0"
121
121
  }