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,284 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getAccountSet, invalidateAccountSet } from '../accounts.js';
|
|
3
|
+
import { mutateConfigFile } from '../config-file.js';
|
|
4
|
+
import { writeToken } from '../token-store.js';
|
|
5
|
+
import { resolveScopesForAccount } from '../auth.js';
|
|
6
|
+
import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
|
|
7
|
+
import { openUrl } from '../open-url.js';
|
|
8
|
+
import { buildConsentClient, awaitLoopbackConsent, hasClientCredentials, } from '../oauth-consent.js';
|
|
9
|
+
import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
|
|
10
|
+
// B7: the elicitation-driven account_add / account_reauth wizard. It rebuilds
|
|
11
|
+
// interactive account management on the mutable config.json registry so a
|
|
12
|
+
// running server can add an account without the deployer editing env + running
|
|
13
|
+
// a CLI. Google consent reuses the loopback flow (leg C); the HTTP-transport
|
|
14
|
+
// consent path (${BASE}/authorize) is owned by the OAuth AS and lands with that
|
|
15
|
+
// cluster — account_add over http is gated behind it.
|
|
16
|
+
const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
|
|
17
|
+
// The common optional bundles get their own checkbox (MCP elicitation has no
|
|
18
|
+
// multi-select, so a boolean per bundle IS the checklist); the long tail stays
|
|
19
|
+
// a comma-separated field for power users, and `allBundles` grants every one.
|
|
20
|
+
const CHECKBOX_BUNDLES = ['forms', 'chat'];
|
|
21
|
+
/** Every optional bundle (admin is granted via its own checkbox, not here). */
|
|
22
|
+
export function allOptionalBundles() {
|
|
23
|
+
return Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin');
|
|
24
|
+
}
|
|
25
|
+
/** elicitation/create form schema (flat primitives only, per the MCP spec). */
|
|
26
|
+
export function addFormSchema() {
|
|
27
|
+
return {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
alias: { type: 'string', title: 'Account alias', description: 'Short id, e.g. "work" (letters, digits, _ or -).' },
|
|
31
|
+
email: { type: 'string', title: 'Google email', description: 'The account\'s Google address (used as the login hint).' },
|
|
32
|
+
allBundles: { type: 'boolean', title: 'All optional scopes', description: 'Grant every optional bundle (biggest consent screen). Overrides the individual choices below.', default: false },
|
|
33
|
+
forms: { type: 'boolean', title: 'Google Forms', description: 'Build forms and read their responses.', default: false },
|
|
34
|
+
chat: { type: 'boolean', title: 'Google Chat', description: 'Read/send Chat messages and manage spaces (Workspace only).', default: false },
|
|
35
|
+
otherBundles: { type: 'string', title: 'Other scope bundles (comma-separated)', description: 'Advanced, optional: e.g. "slides,gmail_settings". See docs for the full list. Leave blank for base + the checkboxes above.' },
|
|
36
|
+
admin: { type: 'boolean', title: 'Grant Workspace admin scopes', description: 'Only for a Workspace super-admin account. Adds admin scopes.', default: false },
|
|
37
|
+
},
|
|
38
|
+
required: ['alias', 'email'],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Validate the collected form against the alias rules, dup check, and the
|
|
42
|
+
* bundle catalog. Pure (no I/O) for unit testing. */
|
|
43
|
+
export function validateAddForm(input, existingAliases) {
|
|
44
|
+
const alias = (input.alias ?? '').trim();
|
|
45
|
+
const email = (input.email ?? '').trim();
|
|
46
|
+
if (!ALIAS_RE.test(alias)) {
|
|
47
|
+
return { ok: false, slug: 'E_VALIDATION', message: `Invalid alias "${alias}". Use letters, digits, "_" or "-".` };
|
|
48
|
+
}
|
|
49
|
+
if (existingAliases.includes(alias)) {
|
|
50
|
+
return { ok: false, slug: 'E_ALIAS_EXISTS', message: `Alias "${alias}" already exists; use account_reauth to re-authenticate it, or pick another name.` };
|
|
51
|
+
}
|
|
52
|
+
if (email === '') {
|
|
53
|
+
return { ok: false, slug: 'E_VALIDATION', message: 'Email is required (used as the Google login hint).' };
|
|
54
|
+
}
|
|
55
|
+
if (input.allBundles === true) {
|
|
56
|
+
// "All optional scopes" supersedes the individual picks.
|
|
57
|
+
return { ok: true, alias, email, bundles: allOptionalBundles(), admin: input.admin === true };
|
|
58
|
+
}
|
|
59
|
+
const picked = CHECKBOX_BUNDLES.filter((b) => input[b] === true);
|
|
60
|
+
const rawOther = (input.otherBundles ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
61
|
+
const bundles = [...new Set(resolveBundleAliases([...picked, ...rawOther]))];
|
|
62
|
+
for (const b of bundles) {
|
|
63
|
+
if (b === 'admin') {
|
|
64
|
+
return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: '"admin" is not a bundle — use the admin checkbox instead.' };
|
|
65
|
+
}
|
|
66
|
+
if (!(b in BUNDLE_CATALOG)) {
|
|
67
|
+
const hint = closestBundle(b);
|
|
68
|
+
return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: `Unknown bundle "${b}"${hint ? ` — did you mean "${hint}"?` : ''}. Known: ${Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin').join(', ')}.` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, alias, email, bundles, admin: input.admin === true };
|
|
72
|
+
}
|
|
73
|
+
/** Scopes requested by the profile but NOT granted at consent (granular
|
|
74
|
+
* consent / unchecked bundles). Pure. */
|
|
75
|
+
export function scopeGrantDiff(requested, grantedScope) {
|
|
76
|
+
const granted = new Set((grantedScope ?? '').split(' ').filter(Boolean));
|
|
77
|
+
return requested.filter((s) => !granted.has(s));
|
|
78
|
+
}
|
|
79
|
+
/** Persist a new account row (+ a per-account scope profile when bundles/admin
|
|
80
|
+
* were chosen) through the atomic registry mutation path (BR2). */
|
|
81
|
+
function writeAccountRow(alias, email, bundles, admin) {
|
|
82
|
+
mutateConfigFile((current) => {
|
|
83
|
+
const next = { ...current, accounts: { ...(current.accounts ?? {}) } };
|
|
84
|
+
const row = { email };
|
|
85
|
+
if (bundles.length > 0) {
|
|
86
|
+
next.scopeProfiles = { ...(next.scopeProfiles ?? {}), [alias]: { bundles } };
|
|
87
|
+
row.scopeProfile = alias;
|
|
88
|
+
}
|
|
89
|
+
if (admin)
|
|
90
|
+
row.admin = true;
|
|
91
|
+
next.accounts[alias] = row;
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function textResult(text, isError = false) {
|
|
96
|
+
return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
|
|
97
|
+
}
|
|
98
|
+
/** Run Google consent for `alias` and persist the token. Opens the browser via
|
|
99
|
+
* URL-mode elicitation when the client supports it, else server-side + prints
|
|
100
|
+
* the URL. Returns the granted-scope diff for the S4 report. */
|
|
101
|
+
async function runConsent(server, alias) {
|
|
102
|
+
const { randomBytes } = await import('node:crypto');
|
|
103
|
+
const cfg = getAccountSet().configs[alias];
|
|
104
|
+
if (!cfg)
|
|
105
|
+
return { ok: false, text: `E_VALIDATION: account "${alias}" is not in the live registry (env-sourced accounts are not editable here).` };
|
|
106
|
+
const client = buildConsentClient();
|
|
107
|
+
const expectedState = randomBytes(32).toString('hex');
|
|
108
|
+
const scopes = resolveScopesForAccount(alias);
|
|
109
|
+
const url = client.generateAuthUrl({ access_type: 'offline', prompt: 'consent', scope: scopes, login_hint: cfg.email, state: expectedState });
|
|
110
|
+
// Start the loopback listener BEFORE opening the browser so it can't miss the
|
|
111
|
+
// redirect. Any startup error (e.g. port in use) surfaces synchronously.
|
|
112
|
+
const consent = awaitLoopbackConsent(client, expectedState);
|
|
113
|
+
const caps = server.server.getClientCapabilities?.();
|
|
114
|
+
let opened = false;
|
|
115
|
+
if (caps?.elicitation?.url) {
|
|
116
|
+
try {
|
|
117
|
+
const r = await server.server.elicitInput({ mode: 'url', message: `Authorize the "${alias}" Google account in your browser.`, url });
|
|
118
|
+
if (r.action !== 'accept') {
|
|
119
|
+
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").' };
|
|
120
|
+
}
|
|
121
|
+
opened = true;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// fall through to server-side open
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!opened) {
|
|
128
|
+
openUrl(url);
|
|
129
|
+
}
|
|
130
|
+
let tokens;
|
|
131
|
+
try {
|
|
132
|
+
tokens = await consent;
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return { ok: false, text: `${e.message}${opened ? '' : `\nOpen this URL to authorize:\n${url}`}` };
|
|
136
|
+
}
|
|
137
|
+
writeToken(alias, tokens);
|
|
138
|
+
return { ok: true, missing: scopeGrantDiff(scopes, typeof tokens.scope === 'string' ? tokens.scope : undefined) };
|
|
139
|
+
}
|
|
140
|
+
function s4Text(alias, missing) {
|
|
141
|
+
if (missing.length === 0)
|
|
142
|
+
return `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`;
|
|
143
|
+
return `⚠ "${alias}" authenticated, but ${missing.length} requested scope(s) were NOT granted (E_SCOPE_NOT_GRANTED) — you may have unchecked some on the consent screen. Re-run account_reauth to grant them. The account is usable for the granted scopes.`;
|
|
144
|
+
}
|
|
145
|
+
const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
|
|
146
|
+
export function registerAccountWizardTools(registry, server) {
|
|
147
|
+
// Registered as META (always-visible, like account_list) so onboarding tools
|
|
148
|
+
// are reachable in lazy mode without discover_all first; registerMeta still
|
|
149
|
+
// preserves the requiresUserInteraction clientMeta and skips the fan-out path.
|
|
150
|
+
const registerMeta = registry.registerMeta;
|
|
151
|
+
registerMeta('account_add', {
|
|
152
|
+
_meta: REQUIRES_INTERACTION,
|
|
153
|
+
annotations: { openWorldHint: true },
|
|
154
|
+
description: 'Add a new Google account interactively: collects alias/email/scope bundles via a form, writes the registry, and runs Google consent in the browser — no file editing or restart needed. Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
|
|
155
|
+
inputSchema: {},
|
|
156
|
+
}, async () => {
|
|
157
|
+
try {
|
|
158
|
+
// Env-sourced registry: GOOGLE_ACCOUNTS is the exclusive source and
|
|
159
|
+
// config.json accounts are ignored, so a wizard add would be a phantom
|
|
160
|
+
// write. Refuse up front (BR-10) rather than write a row nothing reads.
|
|
161
|
+
if (process.env.GOOGLE_ACCOUNTS?.trim()) {
|
|
162
|
+
return textResult('E_ENV_ACCOUNTS_MODE: accounts are defined by the GOOGLE_ACCOUNTS environment variable, so new accounts cannot be added interactively (config.json is ignored while it is set). Either add the alias to GOOGLE_ACCOUNTS and run account_reauth, or run `mcp-google-multi migrate-config` to move accounts into config.json and unset GOOGLE_ACCOUNTS.', true);
|
|
163
|
+
}
|
|
164
|
+
if (!hasClientCredentials()) {
|
|
165
|
+
return textResult('E_CLIENT_CREDENTIALS_MISSING: GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET are not set. Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them.', true);
|
|
166
|
+
}
|
|
167
|
+
const caps = server.server.getClientCapabilities?.();
|
|
168
|
+
if (!caps?.elicitation?.form) {
|
|
169
|
+
return textResult('This client does not support form elicitation. Add the account from the CLI instead: `npx mcp-google-multi account add --alias <alias> --email <email> [--profile a,b] [--admin]`.', true);
|
|
170
|
+
}
|
|
171
|
+
// S1: collect the registry row.
|
|
172
|
+
const form = await server.server.elicitInput({ message: 'Add a Google account', requestedSchema: addFormSchema() });
|
|
173
|
+
if (form.action !== 'accept') {
|
|
174
|
+
return textResult('confirmation_declined: no account was added.');
|
|
175
|
+
}
|
|
176
|
+
const validated = validateAddForm(form.content ?? {}, getAccountSet().aliases);
|
|
177
|
+
if (!validated.ok)
|
|
178
|
+
return textResult(`${validated.slug}: ${validated.message}`, true);
|
|
179
|
+
// S2: atomic write + make the alias callable without a restart (BR3).
|
|
180
|
+
writeAccountRow(validated.alias, validated.email, validated.bundles, validated.admin);
|
|
181
|
+
invalidateAccountSet();
|
|
182
|
+
// S3 + S4: consent + validate.
|
|
183
|
+
const consent = await runConsent(server, validated.alias);
|
|
184
|
+
if (!consent.ok)
|
|
185
|
+
return textResult(consent.text, true);
|
|
186
|
+
// B15: offer to register the server with another MCP client.
|
|
187
|
+
return textResult(`${s4Text(validated.alias, consent.missing)}\nTip: run account_write_config to register this server with another MCP client (Claude Desktop / Cursor / Claude Code).`);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
return textResult(`account_add failed: ${e.message}`, true);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
registerMeta('account_reauth', {
|
|
194
|
+
_meta: REQUIRES_INTERACTION,
|
|
195
|
+
annotations: { openWorldHint: true },
|
|
196
|
+
description: 'Re-authenticate an existing Google account (recover a dead refresh token, or grant scopes after a profile change). Runs Google consent in the browser. Pass the account alias.',
|
|
197
|
+
inputSchema: {
|
|
198
|
+
// Plain string (NOT the account enum) so this never joins the
|
|
199
|
+
// multi-account fan-out path; validated against the registry below.
|
|
200
|
+
alias: z.string().describe('Existing account alias to re-authenticate'),
|
|
201
|
+
},
|
|
202
|
+
}, async (args) => {
|
|
203
|
+
try {
|
|
204
|
+
const alias = args.alias ?? '';
|
|
205
|
+
if (!hasClientCredentials()) {
|
|
206
|
+
return textResult('E_CLIENT_CREDENTIALS_MISSING: GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET are not set.', true);
|
|
207
|
+
}
|
|
208
|
+
if (!getAccountSet().aliases.includes(alias)) {
|
|
209
|
+
return textResult(`E_VALIDATION: unknown account "${alias}". Known: ${getAccountSet().aliases.join(', ')}. Use account_add for a new one.`, true);
|
|
210
|
+
}
|
|
211
|
+
const consent = await runConsent(server, alias);
|
|
212
|
+
if (!consent.ok)
|
|
213
|
+
return textResult(consent.text, true);
|
|
214
|
+
return textResult(s4Text(alias, consent.missing));
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
return textResult(`account_reauth failed: ${e.message}`, true);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
registerMeta('account_write_config', {
|
|
221
|
+
_meta: REQUIRES_INTERACTION,
|
|
222
|
+
annotations: { openWorldHint: true },
|
|
223
|
+
description: "Register this server with your MCP client (Claude Code / Claude Desktop / Cursor) so you don't hand-edit JSON. Default: returns the exact snippet/command to add. Pass write:true to write detected file-based configs in place (backs up first, never clobbers a malformed file). Secrets are never inlined.",
|
|
224
|
+
inputSchema: {
|
|
225
|
+
client: z.enum(['claude-code', 'claude-desktop', 'cursor']).optional().describe('Target one client; default = all detected'),
|
|
226
|
+
name: z.string().optional().describe('Server name in the client config (default mcp-google-multi)'),
|
|
227
|
+
write: z.boolean().optional().describe('Write file-based configs in place (default false = show the snippet only)'),
|
|
228
|
+
},
|
|
229
|
+
}, async (args) => {
|
|
230
|
+
try {
|
|
231
|
+
const a = (args ?? {});
|
|
232
|
+
const name = a.name?.trim() || DEFAULT_SERVER_NAME;
|
|
233
|
+
let mode = 'stdio';
|
|
234
|
+
let resourceUri;
|
|
235
|
+
try {
|
|
236
|
+
const { resolveHttpConfig } = await import('../http-config.js');
|
|
237
|
+
const m = resolveMode(resolveHttpConfig());
|
|
238
|
+
mode = m.mode;
|
|
239
|
+
resourceUri = m.resourceUri;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// stdio fallback on any config error
|
|
243
|
+
}
|
|
244
|
+
let entry;
|
|
245
|
+
try {
|
|
246
|
+
entry = buildServerEntry({ name, mode, resourceUri });
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
return textResult(e.message, true);
|
|
250
|
+
}
|
|
251
|
+
let clients = detectClients();
|
|
252
|
+
if (a.client)
|
|
253
|
+
clients = clients.filter((c) => c.id === a.client);
|
|
254
|
+
const present = clients.filter((c) => c.present);
|
|
255
|
+
const targets = a.client ? clients : present.length ? present : clients;
|
|
256
|
+
const blocks = [];
|
|
257
|
+
for (const client of targets) {
|
|
258
|
+
const instr = renderInstruction(client, name, entry);
|
|
259
|
+
if (client.managed === 'cli') {
|
|
260
|
+
blocks.push(`${client.label} — run:\n ${instr.text}`);
|
|
261
|
+
}
|
|
262
|
+
else if (a.write) {
|
|
263
|
+
const res = applyFileEntry(client, name, entry);
|
|
264
|
+
blocks.push(res.ok
|
|
265
|
+
? `${client.label} — ${res.action} "${name}" in ${res.path}${res.backup ? ` (backup ${res.backup})` : ''}`
|
|
266
|
+
: `${client.label} — ${res.message}\n${res.snippet ?? ''}`);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
blocks.push(`${client.label} — add to ${instr.path}:\n${instr.text}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (targets.length === 0) {
|
|
273
|
+
blocks.push(`No known MCP client detected. Add this under "mcpServers":\n${JSON.stringify({ [name]: entry }, null, 2)}`);
|
|
274
|
+
}
|
|
275
|
+
const note = mode === 'http'
|
|
276
|
+
? `Remote HTTP (${resourceUri}); authentication is via the OAuth flow, so no secrets are stored in the client config.`
|
|
277
|
+
: 'stdio: secrets stay in ~/.config/mcp-google-multi/.env; the client entry carries none.';
|
|
278
|
+
return textResult(`${blocks.join('\n\n')}\n\n${note}`);
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
return textResult(`account_write_config failed: ${e.message}`, true);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
|
+
import { type ScopesReport } from '../scope-observability.js';
|
|
2
3
|
export interface AccountHealthDeps {
|
|
3
4
|
hasToken: (alias: string) => boolean;
|
|
4
5
|
readToken: (alias: string) => {
|
|
@@ -8,22 +9,24 @@ export interface AccountHealthDeps {
|
|
|
8
9
|
} | null;
|
|
9
10
|
fileExists: (p: string) => boolean;
|
|
10
11
|
now: () => number;
|
|
12
|
+
/** Scopes required by registered tools — feeds doctor's tool-grained
|
|
13
|
+
* "registered but not requestable" view (B9). account_list stays compact:
|
|
14
|
+
* its report universe is profile ∪ granted only. */
|
|
15
|
+
registeredScopes?: () => string[];
|
|
11
16
|
}
|
|
12
17
|
export type TokenStatus = 'ok' | 'expired_refreshable' | 'needs_reauth' | 'missing' | 'decrypt_error';
|
|
13
18
|
export interface AccountHealth {
|
|
14
19
|
alias: string;
|
|
15
20
|
email: string;
|
|
16
21
|
admin: boolean;
|
|
22
|
+
source: 'config' | 'env';
|
|
23
|
+
sourceNote?: string;
|
|
17
24
|
token: {
|
|
18
25
|
status: TokenStatus;
|
|
19
26
|
expiryDate?: string;
|
|
20
27
|
hint?: string;
|
|
21
28
|
};
|
|
22
|
-
scopes:
|
|
23
|
-
configured: number;
|
|
24
|
-
granted: number;
|
|
25
|
-
missing: string[];
|
|
26
|
-
};
|
|
29
|
+
scopes: ScopesReport;
|
|
27
30
|
}
|
|
28
31
|
export declare function deriveAccountHealth(alias: string, deps?: AccountHealthDeps): AccountHealth;
|
|
29
32
|
export declare function registerAccountTools(registry: ToolRegistry, deps?: AccountHealthDeps): void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { getAccountSet, refreshAccountSetIfStale } from '../accounts.js';
|
|
3
|
+
import { buildScopesReport } from '../scope-observability.js';
|
|
3
4
|
import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
|
|
4
5
|
import { hasToken, readToken } from '../token-store.js';
|
|
5
6
|
const DEFAULT_DEPS = {
|
|
@@ -9,11 +10,23 @@ const DEFAULT_DEPS = {
|
|
|
9
10
|
now: Date.now,
|
|
10
11
|
};
|
|
11
12
|
export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
|
|
12
|
-
const config =
|
|
13
|
+
const config = getAccountSet().configs[alias];
|
|
13
14
|
const admin = getAdminAccounts().includes(alias);
|
|
14
15
|
const configured = resolveScopesForAccount(alias);
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
// BR-10: env-sourced accounts are not persisted in config.json, so the
|
|
17
|
+
// account wizard cannot edit them — the deployer edits env instead.
|
|
18
|
+
const base = {
|
|
19
|
+
alias,
|
|
20
|
+
email: config.email,
|
|
21
|
+
admin,
|
|
22
|
+
source: config.source,
|
|
23
|
+
...(config.source === 'env'
|
|
24
|
+
? { sourceNote: 'Defined by GOOGLE_ACCOUNTS env (not editable via config.json)' }
|
|
25
|
+
: {}),
|
|
26
|
+
};
|
|
27
|
+
const profileSet = new Set(configured);
|
|
28
|
+
const registered = deps.registeredScopes?.() ?? [];
|
|
29
|
+
const noScopes = buildScopesReport(new Set(), profileSet, registered);
|
|
17
30
|
if (!deps.hasToken(alias)) {
|
|
18
31
|
const legacy = deps.fileExists(config.tokenPath);
|
|
19
32
|
return {
|
|
@@ -39,7 +52,8 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
|
|
|
39
52
|
};
|
|
40
53
|
}
|
|
41
54
|
const granted = typeof token?.scope === 'string' ? token.scope.split(' ').filter(Boolean) : [];
|
|
42
|
-
const
|
|
55
|
+
const report = buildScopesReport(new Set(granted), profileSet, registered);
|
|
56
|
+
const missing = report.requestable;
|
|
43
57
|
const expiry = typeof token?.expiry_date === 'number' ? token.expiry_date : undefined;
|
|
44
58
|
const refreshable = typeof token?.refresh_token === 'string' && token.refresh_token.length > 0;
|
|
45
59
|
let status;
|
|
@@ -60,21 +74,30 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
|
|
|
60
74
|
? 'Re-auth to grant the missing scopes'
|
|
61
75
|
: undefined,
|
|
62
76
|
},
|
|
63
|
-
scopes:
|
|
77
|
+
scopes: report,
|
|
64
78
|
};
|
|
65
79
|
}
|
|
66
80
|
export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
|
|
67
|
-
registry.registerMeta
|
|
81
|
+
const registerMeta = registry.registerMeta;
|
|
82
|
+
registerMeta('account_list', {
|
|
68
83
|
description: 'List the configured Google accounts: alias, email, admin flag, token health ' +
|
|
69
84
|
'(ok / expired_refreshable / needs_reauth / missing / decrypt_error), and granted vs configured scopes. ' +
|
|
70
85
|
'Use this to see which account aliases are available and healthy.',
|
|
71
86
|
inputSchema: {},
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
_meta: { 'anthropic/alwaysLoad': true },
|
|
88
|
+
}, async () => {
|
|
89
|
+
refreshAccountSetIfStale();
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: 'text',
|
|
94
|
+
text: JSON.stringify({
|
|
95
|
+
defaultAccount: getAccountSet().defaultAccount ?? null,
|
|
96
|
+
defaultAccountSource: getAccountSet().defaultAccountSource ?? null,
|
|
97
|
+
accounts: getAccountSet().aliases.map((alias) => deriveAccountHealth(alias, deps)),
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
});
|
|
80
103
|
}
|
package/dist/tools/admin.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { coerceBoolean } from './_coerce.js';
|
|
3
3
|
import { admin as adminClient } from '@googleapis/admin';
|
|
4
|
-
import {
|
|
4
|
+
import { accountAliasSchema } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
6
|
import { handleGoogleApiError } from './_errors.js';
|
|
7
|
-
const accountEnum =
|
|
7
|
+
const accountEnum = accountAliasSchema.optional();
|
|
8
8
|
// Admin SDK requires Workspace super-admin (or delegated admin) on the account — personal @gmail.com accounts 403 on every endpoint.
|
|
9
9
|
export function registerAdminTools(server) {
|
|
10
10
|
// ─── Reports / audit log ───────────────────────────────────────────────
|
package/dist/tools/calendar.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { coerceArray, coerceBoolean } from './_coerce.js';
|
|
3
3
|
import { calendar as calendarClient } from '@googleapis/calendar';
|
|
4
|
-
import {
|
|
4
|
+
import { accountAliasSchema } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
6
|
import { handleGoogleApiError } from './_errors.js';
|
|
7
7
|
import { sliceClean } from '../trim.js';
|
|
8
|
-
const accountEnum =
|
|
8
|
+
const accountEnum = accountAliasSchema.optional();
|
|
9
9
|
export function registerCalendarTools(server) {
|
|
10
10
|
server.registerTool('calendar_list_calendars', {
|
|
11
11
|
description: 'List all calendars for a Google account',
|
package/dist/tools/chat.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { coerceJson } from './_coerce.js';
|
|
3
3
|
import { chat as chatClient } from '@googleapis/chat';
|
|
4
|
-
import {
|
|
4
|
+
import { accountAliasSchema } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
6
|
import { handleGoogleApiError } from './_errors.js';
|
|
7
|
-
const accountEnum =
|
|
7
|
+
const accountEnum = accountAliasSchema.optional();
|
|
8
8
|
export function registerChatTools(server) {
|
|
9
9
|
server.registerTool('chat_spaces_list', {
|
|
10
10
|
description: 'List Google Chat spaces the user is a member of',
|
package/dist/tools/contacts.d.ts
CHANGED
|
@@ -1,2 +1,42 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
|
+
export interface ResolveCandidate {
|
|
3
|
+
resourceName: string;
|
|
4
|
+
displayName: string;
|
|
5
|
+
source: 'contacts' | 'otherContacts';
|
|
6
|
+
emails: {
|
|
7
|
+
value: string;
|
|
8
|
+
type?: string;
|
|
9
|
+
primary?: boolean;
|
|
10
|
+
}[];
|
|
11
|
+
hasOrg: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Rank candidates and return one confident match, an explicit ambiguous list,
|
|
14
|
+
* or null. Candidates without any email are dropped first. Pure (no I/O) so the
|
|
15
|
+
* tie-break is unit-testable. */
|
|
16
|
+
export declare function resolveContacts(candidates: ResolveCandidate[], name: string, maxCandidates: number): {
|
|
17
|
+
query: string;
|
|
18
|
+
resolved: null;
|
|
19
|
+
ambiguous?: undefined;
|
|
20
|
+
candidates?: undefined;
|
|
21
|
+
} | {
|
|
22
|
+
query: string;
|
|
23
|
+
resolved: {
|
|
24
|
+
name: string;
|
|
25
|
+
email: string;
|
|
26
|
+
resourceName: string;
|
|
27
|
+
source: "contacts" | "otherContacts";
|
|
28
|
+
};
|
|
29
|
+
ambiguous?: undefined;
|
|
30
|
+
candidates?: undefined;
|
|
31
|
+
} | {
|
|
32
|
+
query: string;
|
|
33
|
+
ambiguous: true;
|
|
34
|
+
candidates: {
|
|
35
|
+
name: string;
|
|
36
|
+
email: string;
|
|
37
|
+
resourceName: string;
|
|
38
|
+
source: "contacts" | "otherContacts";
|
|
39
|
+
}[];
|
|
40
|
+
resolved?: undefined;
|
|
41
|
+
};
|
|
2
42
|
export declare function registerContactsTools(server: ToolRegistry): void;
|
package/dist/tools/contacts.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { people as peopleClient } from '@googleapis/people';
|
|
3
|
-
import {
|
|
3
|
+
import { accountAliasSchema } from '../accounts.js';
|
|
4
4
|
import { getClient } from '../client.js';
|
|
5
|
+
import { coerceBoolean } from './_coerce.js';
|
|
5
6
|
import { handleGoogleApiError } from './_errors.js';
|
|
6
|
-
const accountEnum =
|
|
7
|
+
const accountEnum = accountAliasSchema.optional();
|
|
7
8
|
const PERSON_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,addresses,photos,memberships';
|
|
8
9
|
function formatContact(person) {
|
|
9
10
|
return {
|
|
@@ -31,6 +32,94 @@ function formatContact(person) {
|
|
|
31
32
|
photo: person.photos?.[0]?.url ?? '',
|
|
32
33
|
};
|
|
33
34
|
}
|
|
35
|
+
/** Accent-fold + lowercase for name comparison ("Rym" == "Rÿm"). */
|
|
36
|
+
const foldName = (s) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim();
|
|
37
|
+
/** Canonical-address preference within one contact: explicit primary, then
|
|
38
|
+
* People type order work > home > other. */
|
|
39
|
+
function emailRank(e) {
|
|
40
|
+
if (e.primary)
|
|
41
|
+
return 3;
|
|
42
|
+
const t = (e.type ?? '').toLowerCase();
|
|
43
|
+
if (t === 'work')
|
|
44
|
+
return 2;
|
|
45
|
+
if (t === 'home')
|
|
46
|
+
return 1;
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
function pickEmail(c) {
|
|
50
|
+
return [...c.emails].sort((a, b) => emailRank(b) - emailRank(a))[0];
|
|
51
|
+
}
|
|
52
|
+
/** Deterministic rank tuple; higher wins, compared lexicographically so the
|
|
53
|
+
* first discriminating rule decides (A10 tie-break table). */
|
|
54
|
+
function scoreTuple(c, foldedQuery) {
|
|
55
|
+
const exact = foldName(c.displayName) === foldedQuery ? 1 : 0; // 1. exact over prefix
|
|
56
|
+
const saved = c.source === 'contacts' ? 1 : 0; // 2. saved over other-contact
|
|
57
|
+
const bestEmail = c.emails.length ? Math.max(...c.emails.map(emailRank)) : 0; // 3. canonical address
|
|
58
|
+
const complete = c.hasOrg ? 1 : 0; // 4. fuller record
|
|
59
|
+
return [exact, saved, bestEmail, complete];
|
|
60
|
+
}
|
|
61
|
+
function cmpTuple(a, b) {
|
|
62
|
+
for (let i = 0; i < a.length; i++) {
|
|
63
|
+
if (a[i] !== b[i])
|
|
64
|
+
return b[i] - a[i];
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
const projectCandidate = (c) => ({
|
|
69
|
+
name: c.displayName,
|
|
70
|
+
email: pickEmail(c).value,
|
|
71
|
+
resourceName: c.resourceName,
|
|
72
|
+
source: c.source,
|
|
73
|
+
});
|
|
74
|
+
/** Rank candidates and return one confident match, an explicit ambiguous list,
|
|
75
|
+
* or null. Candidates without any email are dropped first. Pure (no I/O) so the
|
|
76
|
+
* tie-break is unit-testable. */
|
|
77
|
+
export function resolveContacts(candidates, name, maxCandidates) {
|
|
78
|
+
const withEmail = candidates.filter((c) => c.emails.some((e) => e.value));
|
|
79
|
+
if (withEmail.length === 0)
|
|
80
|
+
return { query: name, resolved: null };
|
|
81
|
+
const folded = foldName(name);
|
|
82
|
+
const ranked = withEmail
|
|
83
|
+
.map((c) => ({ c, t: scoreTuple(c, folded) }))
|
|
84
|
+
.sort((a, b) => cmpTuple(a.t, b.t));
|
|
85
|
+
const top = ranked[0];
|
|
86
|
+
const tiedTop = ranked.filter((r) => cmpTuple(r.t, top.t) === 0);
|
|
87
|
+
if (tiedTop.length === 1) {
|
|
88
|
+
return { query: name, resolved: projectCandidate(top.c) };
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
query: name,
|
|
92
|
+
ambiguous: true,
|
|
93
|
+
candidates: tiedTop.slice(0, maxCandidates).map((r) => projectCandidate(r.c)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** People person -> ResolveCandidate projection. */
|
|
97
|
+
function toCandidate(person, source) {
|
|
98
|
+
return {
|
|
99
|
+
resourceName: person?.resourceName ?? '',
|
|
100
|
+
displayName: person?.names?.[0]?.displayName ?? '',
|
|
101
|
+
source,
|
|
102
|
+
emails: (person?.emailAddresses ?? []).map((e) => ({
|
|
103
|
+
value: e.value ?? '',
|
|
104
|
+
type: e.type ?? '',
|
|
105
|
+
primary: e.metadata?.primary === true,
|
|
106
|
+
})),
|
|
107
|
+
hasOrg: (person?.organizations?.length ?? 0) > 0,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Merge saved + other contacts, deduped by resourceName (a saved match wins
|
|
111
|
+
* over the same person surfaced as an other-contact). */
|
|
112
|
+
function dedupeCandidates(list) {
|
|
113
|
+
const byResource = new Map();
|
|
114
|
+
for (const c of list) {
|
|
115
|
+
const key = c.resourceName || `${c.source}:${c.displayName}:${c.emails[0]?.value ?? ''}`;
|
|
116
|
+
const existing = byResource.get(key);
|
|
117
|
+
if (!existing || (existing.source === 'otherContacts' && c.source === 'contacts')) {
|
|
118
|
+
byResource.set(key, c);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [...byResource.values()];
|
|
122
|
+
}
|
|
34
123
|
export function registerContactsTools(server) {
|
|
35
124
|
server.registerTool('contacts_search', {
|
|
36
125
|
description: 'Search contacts by name, email, phone, or organization',
|
|
@@ -63,6 +152,50 @@ export function registerContactsTools(server) {
|
|
|
63
152
|
return handleContactsError(error, account);
|
|
64
153
|
}
|
|
65
154
|
});
|
|
155
|
+
server.registerTool('contacts_resolve', {
|
|
156
|
+
annotations: { openWorldHint: true },
|
|
157
|
+
description: 'Resolve a free-text name/alias to ONE canonical email address (ranked + tie-broken), so you need not hand-expand transliteration OR-queries. Returns a single confident match, an explicit ambiguous candidate list, or null when nothing matches. For picking a recipient before gmail_send.',
|
|
158
|
+
inputSchema: {
|
|
159
|
+
account: accountEnum.describe('Google account alias'),
|
|
160
|
+
name: z.string().min(1).describe('Free-text name or alias to resolve (e.g. "Rym")'),
|
|
161
|
+
maxCandidates: z.number().min(1).max(10).default(5).optional()
|
|
162
|
+
.describe('Max candidates to return when ambiguous (1-10, default: 5)'),
|
|
163
|
+
includeOtherContacts: coerceBoolean.optional()
|
|
164
|
+
.describe('Also search auto-saved "other contacts" (people you have emailed but not saved). Default true.'),
|
|
165
|
+
},
|
|
166
|
+
}, async ({ account, name, maxCandidates, includeOtherContacts }) => {
|
|
167
|
+
try {
|
|
168
|
+
const auth = await getClient(account);
|
|
169
|
+
const people = peopleClient({ version: 'v1', auth });
|
|
170
|
+
// Warmup request required by the People API before searchContacts returns hits.
|
|
171
|
+
await people.people.searchContacts({ query: '', readMask: 'names' });
|
|
172
|
+
const saved = await people.people.searchContacts({
|
|
173
|
+
query: name,
|
|
174
|
+
readMask: 'names,emailAddresses,organizations',
|
|
175
|
+
});
|
|
176
|
+
const candidates = (saved.data.results ?? [])
|
|
177
|
+
.map((r) => toCandidate(r.person, 'contacts'));
|
|
178
|
+
if (includeOtherContacts !== false) {
|
|
179
|
+
try {
|
|
180
|
+
const other = await people.otherContacts.search({
|
|
181
|
+
query: name,
|
|
182
|
+
readMask: 'names,emailAddresses',
|
|
183
|
+
});
|
|
184
|
+
candidates.push(...(other.data.results ?? []).map((r) => toCandidate(r.person, 'otherContacts')));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// otherContacts unavailable (scope/5xx): degrade to saved-contacts only.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const result = resolveContacts(dedupeCandidates(candidates), name, maxCandidates ?? 5);
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
return handleContactsError(error, account);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
66
199
|
server.registerTool('contacts_get', {
|
|
67
200
|
description: 'Get a single contact by resource name',
|
|
68
201
|
inputSchema: {
|
package/dist/tools/docs.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
|
+
export declare const DOCS_INSERT_CHUNK = 5000;
|
|
3
|
+
export declare function splitInsertText(text: string, maxLen?: number): string[];
|
|
4
|
+
export declare function buildInsertRequests(text: string, index?: number): any[];
|
|
2
5
|
export declare function extractPlainTextInRange(body: any, start: number, end: number): string;
|
|
3
6
|
export type HeadingLevel = 'title' | 1 | 2 | 3 | 4 | 5 | 6;
|
|
4
7
|
export interface HeadingEntry {
|