mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2
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 +21 -16
- package/dist/accounts.d.ts +44 -5
- package/dist/accounts.js +267 -25
- package/dist/auth.d.ts +11 -4
- package/dist/auth.js +85 -78
- package/dist/client-config.d.ts +90 -0
- package/dist/client-config.js +282 -0
- package/dist/client.js +10 -4
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +117 -0
- package/dist/discover.js +57 -3
- package/dist/doctor.d.ts +86 -0
- package/dist/doctor.js +384 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +52 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -1
- package/dist/fanout.js +8 -5
- package/dist/fs-atomic.d.ts +3 -0
- package/dist/fs-atomic.js +123 -0
- package/dist/http-config.d.ts +35 -0
- package/dist/http-config.js +113 -0
- package/dist/http-transport.d.ts +59 -0
- package/dist/http-transport.js +269 -0
- package/dist/identity.d.ts +24 -0
- package/dist/identity.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +199 -18
- package/dist/master-key.d.ts +53 -0
- package/dist/master-key.js +242 -0
- package/dist/mcp-token.d.ts +81 -0
- package/dist/mcp-token.js +176 -0
- package/dist/migrate-config.d.ts +7 -0
- package/dist/migrate-config.js +103 -0
- package/dist/migrate-tokens.js +4 -3
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/oauth-as.d.ts +52 -0
- package/dist/oauth-as.js +457 -0
- package/dist/oauth-consent.d.ts +28 -0
- package/dist/oauth-consent.js +109 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +30 -0
- package/dist/reauth-hint.d.ts +2 -0
- package/dist/reauth-hint.js +15 -0
- package/dist/registry-transfer.d.ts +41 -0
- package/dist/registry-transfer.js +219 -0
- package/dist/registry.d.ts +27 -1
- package/dist/registry.js +117 -6
- package/dist/scope-catalog.d.ts +19 -0
- package/dist/scope-catalog.js +182 -0
- package/dist/scope-observability.d.ts +78 -0
- package/dist/scope-observability.js +192 -0
- package/dist/services.js +1 -1
- package/dist/setup-prompt.d.ts +9 -0
- package/dist/setup-prompt.js +59 -0
- package/dist/ssrf-guard.d.ts +48 -0
- package/dist/ssrf-guard.js +242 -0
- package/dist/token-store.js +23 -125
- package/dist/tools/_errors.d.ts +8 -2
- package/dist/tools/_errors.js +97 -6
- package/dist/tools/account-wizard.d.ts +33 -0
- package/dist/tools/account-wizard.js +284 -0
- package/dist/tools/accounts-tool.d.ts +8 -5
- package/dist/tools/accounts-tool.js +38 -15
- package/dist/tools/admin.js +2 -2
- package/dist/tools/calendar.js +2 -2
- package/dist/tools/chat.js +2 -2
- package/dist/tools/contacts.d.ts +40 -0
- package/dist/tools/contacts.js +135 -2
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.js +60 -9
- package/dist/tools/drive.d.ts +3 -0
- package/dist/tools/drive.js +60 -8
- package/dist/tools/forms.js +4 -2
- package/dist/tools/generated/_shared.d.ts +1 -3
- package/dist/tools/generated/_shared.js +3 -3
- package/dist/tools/generated/admin.js +175 -132
- package/dist/tools/generated/appsmarket.js +6 -2
- package/dist/tools/generated/calendar.js +45 -27
- package/dist/tools/generated/chat.js +81 -47
- package/dist/tools/generated/classroom.js +131 -104
- package/dist/tools/generated/cloudidentity.js +73 -61
- package/dist/tools/generated/cloudsearch.js +59 -49
- package/dist/tools/generated/contacts.js +22 -14
- package/dist/tools/generated/drive.js +51 -35
- package/dist/tools/generated/driveactivity.js +5 -1
- package/dist/tools/generated/drivelabels.js +30 -25
- package/dist/tools/generated/forms.js +7 -3
- package/dist/tools/generated/gmail.js +73 -58
- package/dist/tools/generated/groupsmigration.js +5 -1
- package/dist/tools/generated/groupssettings.js +7 -3
- package/dist/tools/generated/keep.js +12 -7
- package/dist/tools/generated/licensing.js +11 -7
- package/dist/tools/generated/meet.js +20 -13
- package/dist/tools/generated/postmaster.js +8 -4
- package/dist/tools/generated/reseller.js +22 -17
- package/dist/tools/generated/script.js +26 -16
- package/dist/tools/generated/sheets.js +11 -7
- package/dist/tools/generated/tasks.js +6 -2
- package/dist/tools/generated/vault.js +38 -33
- package/dist/tools/generated/workspaceevents.js +12 -7
- package/dist/tools/gmail-mime.d.ts +38 -8
- package/dist/tools/gmail-mime.js +75 -71
- package/dist/tools/gmail.d.ts +31 -0
- package/dist/tools/gmail.js +483 -99
- package/dist/tools/google-api.js +7 -4
- package/dist/tools/meet.js +2 -2
- package/dist/tools/searchconsole.js +2 -2
- package/dist/tools/sheets.js +6 -2
- package/dist/tools/slides.js +4 -2
- package/dist/tools/tasks.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/write-control.d.ts +24 -1
- package/dist/write-control.js +26 -1
- package/package.json +26 -7
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// B12: the Streamable HTTP transport host (cc-transport-hosting T2/T3). Owns the
|
|
2
|
+
// node:http server, the route table, the front guard (Host / Origin / DNS-rebind),
|
|
3
|
+
// and the stateless per-request /mcp dispatch. The OAuth AS endpoints and Bearer
|
|
4
|
+
// verification are a seam filled by B13 (oauth-authorization-server); B12 ships a
|
|
5
|
+
// loopback-owner authenticator so the local-HTTP model works before the AS lands.
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
+
// A hung handler that keeps the connection open would otherwise hold the global
|
|
9
|
+
// serialize() lock forever. Generous by default so slow-but-valid calls (large
|
|
10
|
+
// Drive exports, fan-out) still finish; the point is only to guarantee release.
|
|
11
|
+
const DISPATCH_TIMEOUT_DEFAULT = 120_000;
|
|
12
|
+
export function parseOwnerEmails(env = process.env) {
|
|
13
|
+
return (env.MCP_OWNER_EMAILS ?? '')
|
|
14
|
+
.split(',')
|
|
15
|
+
.map((s) => s.trim().toLowerCase())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
/** Front guard: an Origin, if present, must be allowlisted; a Host must be
|
|
19
|
+
* allowlisted. Absent Origin passes (native/CLI/backend clients — the claude.ai
|
|
20
|
+
* connector calls /mcp server-to-server with no Origin, BV-4). */
|
|
21
|
+
export function originAllowed(origin, allowed) {
|
|
22
|
+
if (!origin)
|
|
23
|
+
return true;
|
|
24
|
+
return allowed.includes(origin);
|
|
25
|
+
}
|
|
26
|
+
export function hostAllowed(host, allowed) {
|
|
27
|
+
if (allowed.length === 0)
|
|
28
|
+
return true;
|
|
29
|
+
if (!host)
|
|
30
|
+
return false;
|
|
31
|
+
// Port-agnostic, matching the SDK's DNS-rebind guard: allow an exact match or
|
|
32
|
+
// the bare hostname (allowedHosts carries both host[:port] and bare forms).
|
|
33
|
+
const bare = host.replace(/:\d+$/, '');
|
|
34
|
+
return allowed.includes(host) || allowed.includes(bare);
|
|
35
|
+
}
|
|
36
|
+
/** JSON-RPC method name(s) for an observability log line — no params, no PII. */
|
|
37
|
+
export function jsonRpcMethod(body) {
|
|
38
|
+
const one = (b) => b && typeof b === 'object' && 'method' in b ? String(b.method) : undefined;
|
|
39
|
+
if (Array.isArray(body))
|
|
40
|
+
return body.map(one).filter(Boolean).join(',') || 'batch';
|
|
41
|
+
return one(body) ?? 'unknown';
|
|
42
|
+
}
|
|
43
|
+
export class HttpTransportHost {
|
|
44
|
+
opts;
|
|
45
|
+
httpServer;
|
|
46
|
+
// Serialize the connect→dispatch critical section: the shared McpServer
|
|
47
|
+
// captures its transport per request (protocol.js), but the gap between
|
|
48
|
+
// connect() and the dispatch capturing it would still race under true
|
|
49
|
+
// concurrency. Single-owner HTTP traffic is effectively serial, so a mutex
|
|
50
|
+
// keeps correctness at negligible cost (and never rebuilds the registry).
|
|
51
|
+
lock = Promise.resolve();
|
|
52
|
+
constructor(opts) {
|
|
53
|
+
this.opts = opts;
|
|
54
|
+
}
|
|
55
|
+
serialize(fn) {
|
|
56
|
+
const run = this.lock.then(fn, fn);
|
|
57
|
+
this.lock = run.then(() => undefined, () => undefined);
|
|
58
|
+
return run;
|
|
59
|
+
}
|
|
60
|
+
log(line) {
|
|
61
|
+
this.opts.log?.(line);
|
|
62
|
+
}
|
|
63
|
+
async start() {
|
|
64
|
+
const { config } = this.opts;
|
|
65
|
+
const server = createServer((req, res) => {
|
|
66
|
+
this.handle(req, res).catch((e) => this.fail(res, 500, 'internal_error', e.message));
|
|
67
|
+
});
|
|
68
|
+
// Slow-loris mitigation behind the tunnel.
|
|
69
|
+
server.headersTimeout = 15_000;
|
|
70
|
+
server.requestTimeout = 30_000;
|
|
71
|
+
this.httpServer = server;
|
|
72
|
+
await new Promise((resolve, reject) => {
|
|
73
|
+
const onErr = (e) => reject(e);
|
|
74
|
+
server.once('error', onErr);
|
|
75
|
+
server.listen(config.port, config.host, () => {
|
|
76
|
+
server.off('error', onErr);
|
|
77
|
+
resolve();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
this.log(`listening on ${config.host}:${config.port} (public ${config.publicUrl}, transport ${config.transport})`);
|
|
81
|
+
}
|
|
82
|
+
async close() {
|
|
83
|
+
const s = this.httpServer;
|
|
84
|
+
if (!s)
|
|
85
|
+
return;
|
|
86
|
+
await new Promise((resolve) => {
|
|
87
|
+
s.close(() => resolve());
|
|
88
|
+
s.closeAllConnections?.();
|
|
89
|
+
});
|
|
90
|
+
this.httpServer = undefined;
|
|
91
|
+
}
|
|
92
|
+
/** The bound port (useful when listening on port 0 in tests). */
|
|
93
|
+
address() {
|
|
94
|
+
const a = this.httpServer?.address();
|
|
95
|
+
return a && typeof a === 'object' ? { port: a.port } : undefined;
|
|
96
|
+
}
|
|
97
|
+
async handle(req, res) {
|
|
98
|
+
// req.url is the path+query; parse against a FIXED base so a malformed/hostile
|
|
99
|
+
// Host header can't throw here (the Host is validated in the front guard).
|
|
100
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
101
|
+
const path = url.pathname;
|
|
102
|
+
if (path === '/health') {
|
|
103
|
+
if (req.method !== 'GET') {
|
|
104
|
+
res.setHeader('Allow', 'GET');
|
|
105
|
+
return this.fail(res, 405, 'method_not_allowed', 'GET /health only');
|
|
106
|
+
}
|
|
107
|
+
return this.health(res);
|
|
108
|
+
}
|
|
109
|
+
const route = this.opts.routes?.[path];
|
|
110
|
+
if (route) {
|
|
111
|
+
if (!this.frontGuard(req, res))
|
|
112
|
+
return;
|
|
113
|
+
const handled = await route(req, res, url);
|
|
114
|
+
if (!handled && !res.writableEnded)
|
|
115
|
+
this.fail(res, 404, 'not_found', `No handler for ${path}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (path === '/mcp')
|
|
119
|
+
return this.mcp(req, res);
|
|
120
|
+
this.fail(res, 404, 'not_found', `Unknown path ${path}`);
|
|
121
|
+
}
|
|
122
|
+
frontGuard(req, res) {
|
|
123
|
+
const { allowedHosts, allowedOrigins } = this.opts.config;
|
|
124
|
+
if (!hostAllowed(req.headers.host, allowedHosts)) {
|
|
125
|
+
this.log(`403 host_rejected host=${req.headers.host ?? ''}`);
|
|
126
|
+
this.fail(res, 403, 'host_rejected', 'Host not allowed (DNS-rebinding guard).');
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (!originAllowed(req.headers.origin, allowedOrigins)) {
|
|
130
|
+
this.log(`403 origin_rejected origin=${req.headers.origin ?? ''}`);
|
|
131
|
+
this.fail(res, 403, 'origin_rejected', 'Origin not allowed.');
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
async mcp(req, res) {
|
|
137
|
+
if (req.method !== 'POST') {
|
|
138
|
+
res.setHeader('Allow', 'POST');
|
|
139
|
+
return this.fail(res, 405, 'method_not_allowed', 'POST /mcp only (stateless mode; no GET SSE).');
|
|
140
|
+
}
|
|
141
|
+
if (!this.frontGuard(req, res))
|
|
142
|
+
return;
|
|
143
|
+
const auth = await this.opts.authenticate(req);
|
|
144
|
+
if (!auth.ok) {
|
|
145
|
+
for (const [k, v] of Object.entries(auth.headers ?? {}))
|
|
146
|
+
res.setHeader(k, v);
|
|
147
|
+
res.writeHead(auth.status, { 'Content-Type': 'application/json' });
|
|
148
|
+
res.end(auth.body);
|
|
149
|
+
this.log(`${auth.status} auth_failed path=/mcp`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let body;
|
|
153
|
+
try {
|
|
154
|
+
body = await this.readJson(req);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
return this.fail(res, 400, 'invalid_body', e.message);
|
|
158
|
+
}
|
|
159
|
+
// Host/Origin are enforced by the front guard above (uniformly for /mcp and
|
|
160
|
+
// the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
|
|
161
|
+
// exact-Host match is stricter than the front guard and would 403 valid
|
|
162
|
+
// Hosts (double enforcement, differing rules).
|
|
163
|
+
const transport = new StreamableHTTPServerTransport({
|
|
164
|
+
sessionIdGenerator: undefined,
|
|
165
|
+
enableJsonResponse: true,
|
|
166
|
+
enableDnsRebindingProtection: false,
|
|
167
|
+
});
|
|
168
|
+
// The shared McpServer holds exactly one connected transport at a time, so
|
|
169
|
+
// connect + dispatch + DISCONNECT all run inside the mutex: the next request
|
|
170
|
+
// can never hit "Already connected". The serialized section is guaranteed to
|
|
171
|
+
// settle three ways — the handler finishes, the client disconnects (res
|
|
172
|
+
// 'close'), or a dispatch deadline fires — so neither a client that holds the
|
|
173
|
+
// connection open nor a hung upstream can wedge the lock. The transport is
|
|
174
|
+
// closed in a finally (which resets server._transport) before the lock
|
|
175
|
+
// releases. (A shared initialized-state persists across stateless requests;
|
|
176
|
+
// benign for the single-owner design.)
|
|
177
|
+
const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
|
|
178
|
+
await this.serialize(async () => {
|
|
179
|
+
const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
|
|
180
|
+
let timer;
|
|
181
|
+
const timedOut = new Promise((resolve) => {
|
|
182
|
+
timer = setTimeout(() => resolve('timeout'), deadlineMs);
|
|
183
|
+
timer.unref?.();
|
|
184
|
+
});
|
|
185
|
+
await this.opts.server.connect(transport);
|
|
186
|
+
// Reflect the dispatch into a non-rejecting arm: if the deadline wins the
|
|
187
|
+
// race, an orphaned handler settling later must not surface as an unhandled
|
|
188
|
+
// rejection — but a genuine dispatch error still propagates (rethrown below).
|
|
189
|
+
let dispatchErr;
|
|
190
|
+
const dispatchArm = transport
|
|
191
|
+
.handleRequest(req, res, body)
|
|
192
|
+
.then(() => 'done', (e) => {
|
|
193
|
+
dispatchErr = e;
|
|
194
|
+
return 'error';
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const outcome = await Promise.race([dispatchArm, disconnected, timedOut]);
|
|
198
|
+
if (outcome === 'error')
|
|
199
|
+
throw dispatchErr;
|
|
200
|
+
if (outcome === 'timeout') {
|
|
201
|
+
this.log('504 dispatch_timeout path=/mcp');
|
|
202
|
+
if (!res.headersSent) {
|
|
203
|
+
res.writeHead(504, { 'Content-Type': 'application/json' });
|
|
204
|
+
res.end(JSON.stringify({ error: 'dispatch_timeout', message: 'the handler exceeded the dispatch deadline' }));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
res.destroy();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (outcome === 'done') {
|
|
211
|
+
// Success path: close the observability gap (failures already log; a
|
|
212
|
+
// completed dispatch logged nothing). Method only — no params, no PII.
|
|
213
|
+
this.log(`200 /mcp method=${jsonRpcMethod(body)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
if (timer)
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
await transport.close().catch(() => undefined);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
health(res) {
|
|
224
|
+
const { config, ownerConfigured, version } = this.opts;
|
|
225
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
226
|
+
// No secrets, no owner emails (cc-transport-hosting /health rule).
|
|
227
|
+
res.end(JSON.stringify({
|
|
228
|
+
status: 'ok',
|
|
229
|
+
transport: config.transport,
|
|
230
|
+
publicUrl: config.publicUrl,
|
|
231
|
+
ownerConfigured,
|
|
232
|
+
version,
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
readJson(req) {
|
|
236
|
+
const max = this.opts.maxBodyBytes ?? 4_000_000;
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
let size = 0;
|
|
239
|
+
const chunks = [];
|
|
240
|
+
req.on('data', (c) => {
|
|
241
|
+
size += c.length;
|
|
242
|
+
if (size > max) {
|
|
243
|
+
reject(new Error('request body too large'));
|
|
244
|
+
req.destroy();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
chunks.push(c);
|
|
248
|
+
});
|
|
249
|
+
req.on('end', () => {
|
|
250
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
251
|
+
if (raw.trim() === '')
|
|
252
|
+
return resolve(undefined);
|
|
253
|
+
try {
|
|
254
|
+
resolve(JSON.parse(raw));
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
reject(new Error('request body is not valid JSON'));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
req.on('error', reject);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
fail(res, status, error, message) {
|
|
264
|
+
if (res.writableEnded)
|
|
265
|
+
return;
|
|
266
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
267
|
+
res.end(JSON.stringify({ error, message }));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AccountSet } from './accounts.js';
|
|
2
|
+
import { getClient } from './client.js';
|
|
3
|
+
import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
|
|
4
|
+
import { type Policy, type Transport } from './write-control.js';
|
|
5
|
+
/**
|
|
6
|
+
* The one forward-compat seam (frozen public API): the free core builds
|
|
7
|
+
* exactly one context with subject "owner"; EE later instantiates N contexts
|
|
8
|
+
* from N registries. Nothing here may assume a global current-account.
|
|
9
|
+
*/
|
|
10
|
+
export interface IdentityContext {
|
|
11
|
+
subject: 'owner';
|
|
12
|
+
accounts: AccountSet;
|
|
13
|
+
policy: Policy;
|
|
14
|
+
getClient: typeof getClient;
|
|
15
|
+
tokenStore: {
|
|
16
|
+
readToken: typeof readToken;
|
|
17
|
+
writeToken: typeof writeToken;
|
|
18
|
+
updateToken: typeof updateToken;
|
|
19
|
+
hasToken: typeof hasToken;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export declare function buildIdentityContext(env?: NodeJS.ProcessEnv, opts?: {
|
|
23
|
+
transport?: Transport;
|
|
24
|
+
}): IdentityContext;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getAccountSet } from './accounts.js';
|
|
2
|
+
import { getClient } from './client.js';
|
|
3
|
+
import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
|
|
4
|
+
import { resolvePolicy } from './write-control.js';
|
|
5
|
+
export function buildIdentityContext(env = process.env, opts = {}) {
|
|
6
|
+
return {
|
|
7
|
+
subject: 'owner',
|
|
8
|
+
// Live getter: the seam must always see the current registry, never a
|
|
9
|
+
// snapshot pinned from before a wizard mutation or cross-process reload.
|
|
10
|
+
get accounts() {
|
|
11
|
+
return getAccountSet();
|
|
12
|
+
},
|
|
13
|
+
// The dispatch transport rides into the resolved policy as a reserved seam
|
|
14
|
+
// (cc-write-control B14); it does not change any write-control verdict.
|
|
15
|
+
policy: resolvePolicy(env, { transport: opts.transport }),
|
|
16
|
+
getClient,
|
|
17
|
+
tokenStore: { readToken, writeToken, updateToken, hasToken },
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
// First import: triggers accounts.js module load (env files + registry) before
|
|
3
|
+
// anything else. Named to also pull the server-only empty-registry guard (BR-4).
|
|
4
|
+
import { assertServerAccountsConfigured } from './accounts.js';
|
|
3
5
|
import { readFileSync } from 'node:fs';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import path from 'node:path';
|
|
@@ -11,12 +13,19 @@ import { ToolRegistry } from './registry.js';
|
|
|
11
13
|
import { registerDiscoverTools } from './discover.js';
|
|
12
14
|
import { registerEscapeTools } from './tools/google-api.js';
|
|
13
15
|
import { registerAccountTools } from './tools/accounts-tool.js';
|
|
16
|
+
import { registerDiagnoseTool } from './doctor.js';
|
|
17
|
+
import { registerAccountWizardTools } from './tools/account-wizard.js';
|
|
14
18
|
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
15
|
-
import {
|
|
19
|
+
import { isAllowed, describePolicy } from './write-control.js';
|
|
20
|
+
import { buildIdentityContext } from './identity.js';
|
|
21
|
+
import { registerSetupPrompt } from './setup-prompt.js';
|
|
22
|
+
import { applyNetTuning } from './net-tuning.js';
|
|
23
|
+
applyNetTuning();
|
|
16
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
25
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
18
|
-
function buildRegistry(server,
|
|
19
|
-
const
|
|
26
|
+
function buildRegistry(server, ctx, mode) {
|
|
27
|
+
const policy = ctx.policy;
|
|
28
|
+
const registry = new ToolRegistry(server, policy, mode);
|
|
20
29
|
const toolsets = getToolsets();
|
|
21
30
|
if (toolsets !== 'all') {
|
|
22
31
|
const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
|
|
@@ -31,7 +40,7 @@ function buildRegistry(server, policy) {
|
|
|
31
40
|
continue;
|
|
32
41
|
if (svc.enabled && !svc.enabled()) {
|
|
33
42
|
if (toolsets !== 'all') {
|
|
34
|
-
const hint = svc.name === 'admin' ? 'set GOOGLE_ADMIN_ACCOUNTS' : `add "${svc.name}" to GOOGLE_OPTIONAL_SCOPES`;
|
|
43
|
+
const hint = svc.name === 'admin' ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS)' : `add "${svc.name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`;
|
|
35
44
|
process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
|
|
36
45
|
}
|
|
37
46
|
continue;
|
|
@@ -55,11 +64,13 @@ function buildRegistry(server, policy) {
|
|
|
55
64
|
const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
|
|
56
65
|
throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
|
|
57
66
|
`Known services: ${known.join(', ')}. ` +
|
|
58
|
-
`Note: optional services
|
|
67
|
+
`Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
|
|
59
68
|
}
|
|
60
69
|
registerDiscoverTools(registry, policy);
|
|
61
70
|
registerEscapeTools(registry, policy);
|
|
62
71
|
registerAccountTools(registry);
|
|
72
|
+
registerDiagnoseTool(registry);
|
|
73
|
+
registerAccountWizardTools(registry, server);
|
|
63
74
|
return registry;
|
|
64
75
|
}
|
|
65
76
|
async function main() {
|
|
@@ -73,9 +84,40 @@ async function main() {
|
|
|
73
84
|
runMigrateTokens();
|
|
74
85
|
return;
|
|
75
86
|
}
|
|
87
|
+
if (process.argv.includes('migrate-config')) {
|
|
88
|
+
const { runMigrateConfig } = await import('./migrate-config.js');
|
|
89
|
+
runMigrateConfig();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (process.argv.includes('doctor')) {
|
|
93
|
+
const { runDoctorCli } = await import('./doctor.js');
|
|
94
|
+
process.exitCode = await runDoctorCli(process.argv, pkg.version);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (process.argv.includes('reset')) {
|
|
98
|
+
const { runResetCli } = await import('./doctor.js');
|
|
99
|
+
process.exitCode = await runResetCli(process.argv);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (process.argv.includes('write-client-config')) {
|
|
103
|
+
const { runWriteClientConfigCli } = await import('./client-config.js');
|
|
104
|
+
process.exitCode = await runWriteClientConfigCli(process.argv);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (process.argv.includes('account') && process.argv.includes('export')) {
|
|
108
|
+
const { runExportCli } = await import('./registry-transfer.js');
|
|
109
|
+
process.exitCode = await runExportCli(process.argv);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (process.argv.includes('account') && process.argv.includes('import')) {
|
|
113
|
+
const { runImportCli } = await import('./registry-transfer.js');
|
|
114
|
+
process.exitCode = await runImportCli(process.argv);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
76
117
|
if (process.argv.includes('config') && process.argv.includes('check')) {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
118
|
+
const ctx = buildIdentityContext();
|
|
119
|
+
const policy = ctx.policy;
|
|
120
|
+
const registry = buildRegistry(new McpServer({ name: 'mcp-google-multi', version: pkg.version }), ctx);
|
|
79
121
|
const cud = registry.tools.filter((t) => t.cud !== 'read');
|
|
80
122
|
const disabled = cud.filter((t) => !isAllowed(t, policy));
|
|
81
123
|
const counts = registry.visibleCount();
|
|
@@ -96,19 +138,158 @@ async function main() {
|
|
|
96
138
|
}
|
|
97
139
|
console.log(`Disabled: ${disabledLine}`);
|
|
98
140
|
console.log(`Services: ${registry.services().join(', ')}`);
|
|
99
|
-
console.log(`
|
|
141
|
+
console.log(`Discovery mode: ${registry.mode}${registry.mode === 'lazy' ? ' (expand at runtime with discover_all)' : ''}`);
|
|
142
|
+
console.log(`Tool surface: ${counts.eager} eager (discover + escape hatch), ${counts.revealed} advertised, ${counts.hidden} deferred`);
|
|
100
143
|
console.log(`Escape hatch: google_api_call CUD verdicts follow profile=${policy.profile} and your allow/deny globs`);
|
|
144
|
+
const { getAccountSet } = await import('./accounts.js');
|
|
145
|
+
const set = getAccountSet();
|
|
146
|
+
console.log(`Default account: ${set.defaultAccount ? `${set.defaultAccount} (${set.defaultAccountSource})` : '(none — "account" is required per call)'}`);
|
|
147
|
+
const { peekMasterKeyProvenance } = await import('./master-key.js');
|
|
148
|
+
const prov = peekMasterKeyProvenance();
|
|
149
|
+
console.log(`MASTER_KEY: ${prov === 'unprovisioned' ? 'unprovisioned (will be generated on first use)' : prov}`);
|
|
150
|
+
try {
|
|
151
|
+
const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
|
|
152
|
+
const http = resolveHttpConfig();
|
|
153
|
+
console.log(`Transport: ${http.transport}`);
|
|
154
|
+
if (transportIncludesHttp(http.transport)) {
|
|
155
|
+
console.log(` HTTP bind: ${http.host}:${http.port}`);
|
|
156
|
+
console.log(` Public URL: ${http.publicUrl} (resource ${http.resourceUri})`);
|
|
157
|
+
console.log(` Allowed hosts: ${http.allowedHosts.join(', ')}`);
|
|
158
|
+
console.log(` Allowed origins: ${http.allowedOrigins.join(', ')}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
console.log(`Transport: (config error) ${e.message}`);
|
|
163
|
+
}
|
|
101
164
|
return;
|
|
102
165
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
await
|
|
166
|
+
// BR-4: the SERVER never boots with an empty registry. The bootstrap and
|
|
167
|
+
// diagnostic CLIs above already returned; a fresh user configures accounts
|
|
168
|
+
// (env / migrate-config / account import / auth) before the server runs.
|
|
169
|
+
assertServerAccountsConfigured();
|
|
170
|
+
// Resolve (or provision) the master key BEFORE serving: the hard guard is
|
|
171
|
+
// specced "fatal at startup", never mid-dispatch.
|
|
172
|
+
const { resolveMasterKey } = await import('./master-key.js');
|
|
173
|
+
resolveMasterKey();
|
|
174
|
+
const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
|
|
175
|
+
let httpCfg;
|
|
176
|
+
try {
|
|
177
|
+
httpCfg = resolveHttpConfig();
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
process.stderr.write(`Fatal: ${e.message}\n`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
|
|
184
|
+
const wantHttp = transportIncludesHttp(httpCfg.transport);
|
|
185
|
+
// Build one McpServer + registry per transport at boot (P1 / BV gap #4:
|
|
186
|
+
// never rebuilt per request); `both` runs the two concurrently.
|
|
187
|
+
if (wantStdio) {
|
|
188
|
+
const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
189
|
+
const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }));
|
|
190
|
+
registry.installListHandler();
|
|
191
|
+
registerSetupPrompt(server);
|
|
192
|
+
await server.connect(new StdioServerTransport());
|
|
193
|
+
}
|
|
194
|
+
if (wantHttp) {
|
|
195
|
+
const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
|
|
196
|
+
// BR3 / C13: the owner allowlist is the entire multi-tenant collapse; refuse
|
|
197
|
+
// to open an ungated HTTP endpoint.
|
|
198
|
+
const owners = parseOwnerEmails(process.env);
|
|
199
|
+
if (owners.length === 0) {
|
|
200
|
+
process.stderr.write('E_OWNER_EMAILS_REQUIRED: MCP_TRANSPORT includes http but MCP_OWNER_EMAILS is empty. Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.\n');
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
// BR7: stateless HTTP cannot push tools/list_changed, so it forces curated.
|
|
204
|
+
const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
|
|
205
|
+
if (configuredMode && configuredMode !== 'curated') {
|
|
206
|
+
process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
|
|
207
|
+
}
|
|
208
|
+
const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
209
|
+
const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated');
|
|
210
|
+
registry.installListHandler();
|
|
211
|
+
registerSetupPrompt(httpServer);
|
|
212
|
+
// B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
|
|
213
|
+
const { buildAuthServer } = await import('./oauth-as.js');
|
|
214
|
+
const { jwtSecretFrom } = await import('./mcp-token.js');
|
|
215
|
+
const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
|
|
216
|
+
const { OAuth2Client } = await import('googleapis-common');
|
|
217
|
+
const { writeToken } = await import('./token-store.js');
|
|
218
|
+
const { resolveScopesForAccount } = await import('./auth.js');
|
|
219
|
+
const { configDir } = await import('./config-file.js');
|
|
220
|
+
const { getAccountSet } = await import('./accounts.js');
|
|
221
|
+
const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
|
|
222
|
+
// Only trust the id_token email when Google marks it verified (#8).
|
|
223
|
+
const verifiedEmailFromIdToken = (idToken) => {
|
|
224
|
+
if (!idToken)
|
|
225
|
+
return undefined;
|
|
226
|
+
try {
|
|
227
|
+
const [, payload] = idToken.split('.');
|
|
228
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
229
|
+
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
|
230
|
+
return verified ? claims.email : undefined;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const authServer = buildAuthServer({
|
|
237
|
+
base: httpCfg.publicUrl,
|
|
238
|
+
resourceUri: httpCfg.resourceUri,
|
|
239
|
+
secret: jwtSecretFrom(resolveJwtKey().key),
|
|
240
|
+
ownerEmails: owners,
|
|
241
|
+
cimdIssuers: (process.env.MCP_CIMD_ALLOWED_ISSUERS ?? 'claude.ai').split(',').map((s) => s.trim()).filter(Boolean),
|
|
242
|
+
accessTtlSec: Number(process.env.MCP_ACCESS_TTL) || undefined,
|
|
243
|
+
masterKey: resolveMasterKeyForDispatch().key,
|
|
244
|
+
refreshStorePath: path.join(configDir(), 'mcp-tokens.enc'),
|
|
245
|
+
}, {
|
|
246
|
+
buildGoogleAuthUrl: ({ flow, alias, state }) => {
|
|
247
|
+
if (flow === 'alias_reauth' && alias) {
|
|
248
|
+
const cfg = getAccountSet().configs[alias];
|
|
249
|
+
// openid+email so /callback can bind the returned identity to the alias.
|
|
250
|
+
return googleClient().generateAuthUrl({
|
|
251
|
+
access_type: 'offline',
|
|
252
|
+
prompt: 'consent',
|
|
253
|
+
scope: [...new Set([...resolveScopesForAccount(alias), 'openid', 'email'])],
|
|
254
|
+
login_hint: cfg?.email,
|
|
255
|
+
state,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return googleClient().generateAuthUrl({ scope: ['openid', 'email'], prompt: 'select_account', state });
|
|
259
|
+
},
|
|
260
|
+
exchangeCode: async (code, flow) => {
|
|
261
|
+
const { tokens } = await googleClient().getToken(code);
|
|
262
|
+
const email = verifiedEmailFromIdToken(tokens.id_token ?? undefined);
|
|
263
|
+
// owner_gate discards Google tokens (identity proof only); alias_reauth
|
|
264
|
+
// keeps them but still needs the email for the identity binding.
|
|
265
|
+
return { tokens: flow === 'owner_gate' ? {} : tokens, email };
|
|
266
|
+
},
|
|
267
|
+
writeToken: (alias, tokens) => writeToken(alias, tokens),
|
|
268
|
+
aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
|
|
269
|
+
log: (l) => process.stderr.write(`[as] ${l}\n`),
|
|
270
|
+
});
|
|
271
|
+
// BV-1: over HTTP, a dead/missing per-account token surfaces a clickable
|
|
272
|
+
// re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
|
|
273
|
+
const { setHttpReauthBase } = await import('./reauth-hint.js');
|
|
274
|
+
setHttpReauthBase(httpCfg.publicUrl);
|
|
275
|
+
const host = new HttpTransportHost({
|
|
276
|
+
server: httpServer,
|
|
277
|
+
config: httpCfg,
|
|
278
|
+
version: pkg.version,
|
|
279
|
+
ownerConfigured: owners.length > 0,
|
|
280
|
+
authenticate: authServer.authenticate,
|
|
281
|
+
routes: authServer.routes,
|
|
282
|
+
log: (l) => process.stderr.write(`[http] ${l}\n`),
|
|
283
|
+
});
|
|
284
|
+
await host.start();
|
|
285
|
+
process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
|
|
286
|
+
// Graceful shutdown so `docker run --init` (B16) forwards SIGTERM cleanly.
|
|
287
|
+
const shutdown = () => {
|
|
288
|
+
host.close().finally(() => process.exit(0));
|
|
289
|
+
};
|
|
290
|
+
process.once('SIGTERM', shutdown);
|
|
291
|
+
process.once('SIGINT', shutdown);
|
|
292
|
+
}
|
|
112
293
|
}
|
|
113
294
|
main().catch((err) => {
|
|
114
295
|
process.stderr.write(`Fatal error: ${err.message}\n`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type KeyProvenance = 'env' | 'keychain' | 'file' | 'generated';
|
|
2
|
+
export interface ResolvedKey {
|
|
3
|
+
key: string;
|
|
4
|
+
provenance: KeyProvenance;
|
|
5
|
+
}
|
|
6
|
+
interface KeychainEntry {
|
|
7
|
+
get(): string | null;
|
|
8
|
+
set(value: string): void;
|
|
9
|
+
del(): void;
|
|
10
|
+
}
|
|
11
|
+
export interface KeyDeps {
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
dir?: string;
|
|
14
|
+
keychain?: (account: string) => KeychainEntry | null;
|
|
15
|
+
hasAnyToken?: () => boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function __setKeychainFactoryForTest(fn: ((account: string) => KeychainEntry | null) | null): void;
|
|
18
|
+
/** BR-1 single choke point: no other module may read the env vars directly.
|
|
19
|
+
* Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
|
|
20
|
+
export declare function resolveMasterKey(deps?: KeyDeps): ResolvedKey;
|
|
21
|
+
/** Dispatch form: identical resolution, but failures THROW (caught by the tool
|
|
22
|
+
* handler into an error envelope) — a live server is never process.exit'd. */
|
|
23
|
+
export declare function resolveMasterKeyForDispatch(): ResolvedKey;
|
|
24
|
+
/** Side-effect-free provenance probe for diagnostics: NEVER generates, never
|
|
25
|
+
* fires the hard guard (config check must not mint keys or exit). */
|
|
26
|
+
export declare function peekMasterKeyProvenance(deps?: KeyDeps): KeyProvenance | 'unprovisioned';
|
|
27
|
+
/** MCP_JWT_KEY variant: NO hard guard — regenerating only invalidates
|
|
28
|
+
* outstanding MCP tokens and clients re-auth silently. */
|
|
29
|
+
export declare function resolveJwtKey(deps?: KeyDeps): ResolvedKey;
|
|
30
|
+
/**
|
|
31
|
+
* BR-5: after the FIRST successful token decrypt with an env-sourced key,
|
|
32
|
+
* mirror it into the keychain (only when the keychain holds no entry) so the
|
|
33
|
+
* key survives an env loss. Gating on a successful decrypt means a stale or
|
|
34
|
+
* wrong env key is never mirrored. Best-effort: failures are doctor material,
|
|
35
|
+
* never fatal.
|
|
36
|
+
*/
|
|
37
|
+
export declare function noteSuccessfulDecrypt(): void;
|
|
38
|
+
/** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
|
|
39
|
+
* entry) so the next run re-provisions. Never removes an env-provided key (the
|
|
40
|
+
* deployer owns that; `env:true` flags it so reset can warn). The caller MUST
|
|
41
|
+
* have wiped every .enc token first — regenerating with tokens present would
|
|
42
|
+
* brick them (enforced by the reset planner). */
|
|
43
|
+
export declare function deleteMasterKeyMaterial(deps?: {
|
|
44
|
+
dir?: string;
|
|
45
|
+
keychain?: (a: string) => KeychainEntry | null;
|
|
46
|
+
}): {
|
|
47
|
+
file: boolean;
|
|
48
|
+
keychain: boolean;
|
|
49
|
+
env: boolean;
|
|
50
|
+
};
|
|
51
|
+
/** Test hook: the production cache is process-lifetime by design. */
|
|
52
|
+
export declare function clearKeyCacheForTest(): void;
|
|
53
|
+
export {};
|