@mjquinlan2000/zoom-users-mcp 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # @mjquinlan2000/zoom-users-mcp
2
+
3
+ MCP server for the [Zoom](https://zoom.us/) Users API. Exposes Zoom's Users REST API (users, groups, divisions, contact groups) as read-only MCP tools so AI assistants (Claude Desktop, etc.) can read data from Zoom accounts.
4
+
5
+ ## Quick Start
6
+
7
+ ```sh
8
+ ZOOM_ACCESS_TOKEN=your_token npx @mjquinlan2000/zoom-users-mcp
9
+ ```
10
+
11
+ ## Authentication
12
+
13
+ ### Option 1: Access token (simplest)
14
+
15
+ If you already have a Zoom access token, set it as an environment variable:
16
+
17
+ ```sh
18
+ export ZOOM_ACCESS_TOKEN=your_token
19
+ ```
20
+
21
+ ### Option 2: OAuth flow
22
+
23
+ If you have OAuth2 client credentials from the [Zoom Marketplace](https://marketplace.zoom.us/), you can use the built-in auth CLI to obtain tokens:
24
+
25
+ ```sh
26
+ export ZOOM_CLIENT_ID=your_client_id
27
+ export ZOOM_CLIENT_SECRET=your_client_secret
28
+ export ZOOM_REDIRECT_URI=http://127.0.0.1:8080/callback
29
+
30
+ # Opens a browser for OAuth2 authorization
31
+ npx @mjquinlan2000/zoom-users-mcp auth
32
+
33
+ # Refresh an expired token
34
+ npx @mjquinlan2000/zoom-users-mcp auth refresh
35
+ ```
36
+
37
+ Tokens are saved to `~/.config/zoom-users-mcp/tokens` (encrypted). See [Token Encryption](../README.md#token-encryption) for setup.
38
+
39
+ Tokens are automatically refreshed when they near expiration, as long as `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` are available.
40
+
41
+ > **Note:** Zoom uses Basic auth (`Authorization: Basic base64(clientId:clientSecret)`) for token exchange, which differs from most OAuth providers. This is handled automatically.
42
+
43
+ ## MCP Client Configuration
44
+
45
+ Add to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`):
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "zoom-users": {
51
+ "command": "npx",
52
+ "args": ["@mjquinlan2000/zoom-users-mcp"],
53
+ "env": {
54
+ "ZOOM_ACCESS_TOKEN": "your_token",
55
+ "NODE_MCP_SECRET_KEY": "your_64_char_hex_key"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Available Tools
63
+
64
+ The server registers 30 read-only tools covering all GET endpoints in the Zoom Users API.
65
+
66
+ ### Contact Groups
67
+
68
+ | Tool | Description |
69
+ |------|-------------|
70
+ | `list_contact_groups` | List all contact groups for the account |
71
+ | `get_contact_group` | Get a single contact group by ID |
72
+ | `list_contact_group_members` | List members of a specific contact group |
73
+
74
+ ### Divisions
75
+
76
+ | Tool | Description |
77
+ |------|-------------|
78
+ | `list_divisions` | List all divisions in the account |
79
+ | `get_division` | Get a single division by ID |
80
+ | `list_division_members` | List members of a specific division |
81
+
82
+ ### Groups
83
+
84
+ | Tool | Description |
85
+ |------|-------------|
86
+ | `list_groups` | List all groups in the account |
87
+ | `get_group` | Get a single group by ID |
88
+ | `list_group_admins` | List admins of a specific group |
89
+ | `list_group_channels` | List channels of a specific group |
90
+ | `get_group_lock_settings` | Get locked settings for a group |
91
+ | `list_group_members` | List members of a specific group |
92
+ | `get_group_settings` | Get meeting, recording, and telephony settings for a group |
93
+ | `get_group_webinar_registration_settings` | Get webinar registration settings for a group |
94
+
95
+ ### Users
96
+
97
+ | Tool | Description |
98
+ |------|-------------|
99
+ | `list_users` | List users with optional filters (status, page size, role) |
100
+ | `check_user_email` | Check if an email address is registered with Zoom |
101
+ | `get_user_zak` | Get the authenticated user's Zoom Access Key (ZAK) token |
102
+ | `get_user_summary` | Get user count summary by status (active, inactive, pending) |
103
+ | `check_user_pm_room` | Check if a personal meeting room name is available |
104
+ | `get_user` | Get a single user by ID or email (use `me` for authenticated user) |
105
+ | `list_user_assistants` | List assistants assigned to a user |
106
+ | `list_user_collaboration_devices` | List collaboration devices assigned to a user |
107
+ | `get_user_collaboration_device` | Get a specific collaboration device for a user |
108
+ | `get_user_meeting_summary_templates` | Get meeting summary templates for a user |
109
+ | `get_user_meeting_template` | Get a specific meeting template for a user |
110
+ | `get_user_permissions` | Get permissions assigned to a user |
111
+ | `get_user_presence_status` | Get a user's presence status (available, away, etc.) |
112
+ | `list_user_schedulers` | List schedulers who can schedule meetings for a user |
113
+ | `get_user_settings` | Get meeting, recording, and feature settings for a user |
114
+ | `get_user_token` | Get a user's token (regular or ZAK) |
115
+
116
+ ## Local Development
117
+
118
+ ```sh
119
+ git clone https://github.com/mjquinlan2000/node-mcps.git
120
+ cd node-mcps
121
+ yarn install
122
+ yarn build
123
+
124
+ # Run the server locally
125
+ yarn workspace @mjquinlan2000/zoom-users-mcp start
126
+
127
+ # Run tests
128
+ yarn workspace @mjquinlan2000/zoom-users-mcp test
129
+ yarn workspace @mjquinlan2000/zoom-users-mcp test:watch
130
+
131
+ # Regenerate typed client from API spec
132
+ yarn workspace @mjquinlan2000/zoom-users-mcp generate
133
+ ```
134
+
135
+ For local dev with an MCP client, use `tsx` directly:
136
+
137
+ ```json
138
+ {
139
+ "mcpServers": {
140
+ "zoom-users": {
141
+ "command": "tsx",
142
+ "args": ["/path/to/node-mcps/zoom-users-mcp/src/server.ts"],
143
+ "env": {
144
+ "ZOOM_ACCESS_TOKEN": "your_token",
145
+ "NODE_MCP_SECRET_KEY": "your_64_char_hex_key"
146
+ }
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
152
+ ## Project Structure
153
+
154
+ ```
155
+ src/
156
+ ├── server.ts # Entry point — CLI dispatch (server vs auth)
157
+ ├── create-server.ts # MCP server setup — registers all 30 tools
158
+ ├── tool-handler.ts # Generic tool handler factory
159
+ ├── helpers.ts # Utility functions
160
+ ├── auth.ts # OAuth2 config (delegates to shared oauth utility)
161
+ ├── zoom.ts # Configures generated HTTP client with base URL + auth
162
+ ├── client/ # Auto-generated typed API client (do not edit)
163
+ ├── *.test.ts # Tests
164
+ └── ...
165
+ ```
package/dist/auth.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare const getAccessToken: () => Promise<string>;
2
+ declare const runCli: (command?: string) => void;
3
+
4
+ export { getAccessToken, runCli };
package/dist/auth.js ADDED
@@ -0,0 +1,9 @@
1
+ import {
2
+ getAccessToken,
3
+ runCli
4
+ } from "./chunk-S7Y3ZVFE.js";
5
+ export {
6
+ getAccessToken,
7
+ runCli
8
+ };
9
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,295 @@
1
+ // ../shared/dist/oauth.js
2
+ import { execSync } from "child_process";
3
+ import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
4
+ import { mkdir, readFile, unlink, writeFile } from "fs/promises";
5
+ import { createServer } from "http";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+ function isRefreshable(tokens) {
9
+ return "refresh_token" in tokens;
10
+ }
11
+ function createAuth(config) {
12
+ const configDir = join(process.env.MCP_CONFIG_DIR ?? join(homedir(), ".config"), config.name);
13
+ const legacyTokensPath = join(configDir, "tokens.json");
14
+ const encryptedTokensPath = join(configDir, "tokens");
15
+ function getEncryptionKey() {
16
+ const key = process.env.NODE_MCP_SECRET_KEY;
17
+ if (!key) {
18
+ throw new Error("NODE_MCP_SECRET_KEY environment variable is required. Generate one with: openssl rand -hex 32");
19
+ }
20
+ const buf = Buffer.from(key, "hex");
21
+ if (buf.length !== 32) {
22
+ throw new Error("NODE_MCP_SECRET_KEY must be a 64-character hex string (32 bytes). Generate one with: openssl rand -hex 32");
23
+ }
24
+ return buf;
25
+ }
26
+ function encrypt(plaintext) {
27
+ const key = getEncryptionKey();
28
+ const iv = randomBytes(12);
29
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
30
+ const encrypted = Buffer.concat([
31
+ cipher.update(plaintext, "utf-8"),
32
+ cipher.final()
33
+ ]);
34
+ const authTag = cipher.getAuthTag();
35
+ return Buffer.concat([iv, authTag, encrypted]);
36
+ }
37
+ function decrypt(data) {
38
+ const key = getEncryptionKey();
39
+ const iv = data.subarray(0, 12);
40
+ const authTag = data.subarray(12, 28);
41
+ const ciphertext = data.subarray(28);
42
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
43
+ decipher.setAuthTag(authTag);
44
+ return decipher.update(ciphertext, void 0, "utf-8") + decipher.final("utf-8");
45
+ }
46
+ async function readTokens() {
47
+ try {
48
+ const data = await readFile(encryptedTokensPath);
49
+ return JSON.parse(decrypt(data));
50
+ } catch {
51
+ try {
52
+ const data = await readFile(legacyTokensPath, "utf-8");
53
+ const tokens = JSON.parse(data);
54
+ await writeTokens(tokens);
55
+ await unlink(legacyTokensPath);
56
+ return tokens;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+ }
62
+ async function writeTokens(tokens) {
63
+ await mkdir(configDir, { recursive: true });
64
+ await writeFile(encryptedTokensPath, encrypt(JSON.stringify(tokens)), {
65
+ mode: 384
66
+ });
67
+ }
68
+ function openBrowser(url) {
69
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
70
+ try {
71
+ execSync(`${cmd} '${url}'`, { stdio: "ignore" });
72
+ } catch {
73
+ }
74
+ }
75
+ async function exchangeCode(code, redirectUri, clientId, clientSecret) {
76
+ const useBasic = config.tokenAuthMethod === "basic";
77
+ const headers = {
78
+ "Content-Type": "application/x-www-form-urlencoded"
79
+ };
80
+ const bodyParams = {
81
+ grant_type: "authorization_code",
82
+ code,
83
+ redirect_uri: redirectUri
84
+ };
85
+ if (useBasic) {
86
+ headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
87
+ } else {
88
+ bodyParams.client_id = clientId;
89
+ bodyParams.client_secret = clientSecret;
90
+ }
91
+ const res = await fetch(config.tokenUrl, {
92
+ method: "POST",
93
+ headers,
94
+ body: new URLSearchParams(bodyParams)
95
+ });
96
+ if (!res.ok) {
97
+ const text = await res.text();
98
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
99
+ }
100
+ if (config.supportsRefresh) {
101
+ const data2 = await res.json();
102
+ return {
103
+ access_token: data2.access_token,
104
+ refresh_token: data2.refresh_token,
105
+ expires_at: Date.now() + data2.expires_in * 1e3
106
+ };
107
+ }
108
+ const data = await res.json();
109
+ return { access_token: data.access_token };
110
+ }
111
+ async function refreshAccessToken(refreshToken, clientId, clientSecret) {
112
+ const useBasic = config.tokenAuthMethod === "basic";
113
+ const headers = {
114
+ "Content-Type": "application/x-www-form-urlencoded"
115
+ };
116
+ const bodyParams = {
117
+ grant_type: "refresh_token",
118
+ refresh_token: refreshToken
119
+ };
120
+ if (useBasic) {
121
+ headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
122
+ } else {
123
+ bodyParams.client_id = clientId;
124
+ bodyParams.client_secret = clientSecret;
125
+ }
126
+ const res = await fetch(config.tokenUrl, {
127
+ method: "POST",
128
+ headers,
129
+ body: new URLSearchParams(bodyParams)
130
+ });
131
+ if (!res.ok) {
132
+ throw new Error("Run 'yarn auth' to re-authenticate.");
133
+ }
134
+ const data = await res.json();
135
+ return {
136
+ access_token: data.access_token,
137
+ refresh_token: data.refresh_token,
138
+ expires_at: Date.now() + data.expires_in * 1e3
139
+ };
140
+ }
141
+ async function getAccessToken2() {
142
+ const envToken = process.env[`${config.envPrefix}_ACCESS_TOKEN`];
143
+ if (envToken)
144
+ return envToken;
145
+ const tokens = await readTokens();
146
+ if (!tokens) {
147
+ throw new Error(`No access token available. Set ${config.envPrefix}_ACCESS_TOKEN or run 'yarn auth'.`);
148
+ }
149
+ if (config.supportsRefresh && isRefreshable(tokens)) {
150
+ const fiveMinutes = 5 * 60 * 1e3;
151
+ if (tokens.expires_at - Date.now() < fiveMinutes) {
152
+ const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];
153
+ const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];
154
+ if (!clientId || !clientSecret) {
155
+ throw new Error(`Token is expiring and ${config.envPrefix}_CLIENT_ID/${config.envPrefix}_CLIENT_SECRET are not set for refresh. Run 'yarn auth'.`);
156
+ }
157
+ const refreshed = await refreshAccessToken(tokens.refresh_token, clientId, clientSecret);
158
+ await writeTokens(refreshed);
159
+ return refreshed.access_token;
160
+ }
161
+ }
162
+ return tokens.access_token;
163
+ }
164
+ async function authorize() {
165
+ const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];
166
+ const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];
167
+ if (!clientId)
168
+ throw new Error(`${config.envPrefix}_CLIENT_ID environment variable is required.`);
169
+ if (!clientSecret)
170
+ throw new Error(`${config.envPrefix}_CLIENT_SECRET environment variable is required.`);
171
+ const redirectUri = process.env[`${config.envPrefix}_REDIRECT_URI`];
172
+ if (!redirectUri)
173
+ throw new Error(`${config.envPrefix}_REDIRECT_URI environment variable is required.`);
174
+ const redirectUrl = new URL(redirectUri);
175
+ const port = Number(redirectUrl.port) || (redirectUrl.protocol === "https:" ? 443 : 80);
176
+ const callbackPath = redirectUrl.pathname;
177
+ const state = randomBytes(16).toString("hex");
178
+ const { tokens } = await new Promise((resolve, reject) => {
179
+ const timeout = setTimeout(() => {
180
+ server.close();
181
+ reject(new Error("Authorization timed out after 120 seconds."));
182
+ }, 12e4);
183
+ const server = createServer(async (req, res) => {
184
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
185
+ if (url.pathname !== callbackPath) {
186
+ res.writeHead(404);
187
+ res.end("Not found");
188
+ return;
189
+ }
190
+ const returnedState = url.searchParams.get("state");
191
+ const code = url.searchParams.get("code");
192
+ const error = url.searchParams.get("error");
193
+ if (error) {
194
+ res.writeHead(400);
195
+ res.end(`Authorization error: ${error}`);
196
+ clearTimeout(timeout);
197
+ server.close();
198
+ reject(new Error(`Authorization denied: ${error}`));
199
+ return;
200
+ }
201
+ if (returnedState !== state) {
202
+ res.writeHead(400);
203
+ res.end("State mismatch \u2014 possible CSRF attack.");
204
+ return;
205
+ }
206
+ if (!code) {
207
+ res.writeHead(400);
208
+ res.end("Missing authorization code.");
209
+ return;
210
+ }
211
+ try {
212
+ const exchangedTokens = await exchangeCode(code, redirectUri, clientId, clientSecret);
213
+ res.writeHead(200, { "Content-Type": "text/html" });
214
+ res.end("<h1>Authorization successful!</h1><p>You can close this tab.</p>");
215
+ clearTimeout(timeout);
216
+ server.close();
217
+ resolve({ tokens: exchangedTokens });
218
+ } catch (err) {
219
+ res.writeHead(500);
220
+ res.end("Token exchange failed.");
221
+ clearTimeout(timeout);
222
+ server.close();
223
+ reject(err);
224
+ }
225
+ });
226
+ const params = {
227
+ response_type: "code",
228
+ client_id: clientId,
229
+ redirect_uri: redirectUri,
230
+ state
231
+ };
232
+ if (config.scope) {
233
+ params.scope = config.scope;
234
+ }
235
+ server.listen(port, "127.0.0.1", () => {
236
+ const authorizeUrl = `${config.authorizeUrl}?${new URLSearchParams(params)}`;
237
+ console.log("Opening browser for authorization...");
238
+ console.log(`If the browser doesn't open, visit:
239
+ ${authorizeUrl}
240
+ `);
241
+ openBrowser(authorizeUrl);
242
+ });
243
+ });
244
+ await writeTokens(tokens);
245
+ console.log(`Tokens saved to ${encryptedTokensPath}`);
246
+ }
247
+ async function refresh() {
248
+ const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];
249
+ const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];
250
+ if (!clientId)
251
+ throw new Error(`${config.envPrefix}_CLIENT_ID environment variable is required.`);
252
+ if (!clientSecret)
253
+ throw new Error(`${config.envPrefix}_CLIENT_SECRET environment variable is required.`);
254
+ const tokens = await readTokens();
255
+ if (!tokens || !isRefreshable(tokens)) {
256
+ throw new Error("No tokens found. Run 'yarn auth' first to authenticate.");
257
+ }
258
+ const refreshed = await refreshAccessToken(tokens.refresh_token, clientId, clientSecret);
259
+ await writeTokens(refreshed);
260
+ console.log(`Tokens refreshed and saved to ${encryptedTokensPath}`);
261
+ }
262
+ function runCli2(command) {
263
+ const cmd = command ?? process.argv[2];
264
+ let run;
265
+ if (cmd === "refresh" && config.supportsRefresh) {
266
+ run = refresh;
267
+ } else {
268
+ run = authorize;
269
+ }
270
+ run().catch((err) => {
271
+ console.error(err.message ?? err);
272
+ process.exit(1);
273
+ });
274
+ }
275
+ return { getAccessToken: getAccessToken2, runCli: runCli2 };
276
+ }
277
+
278
+ // src/auth.ts
279
+ var { getAccessToken, runCli } = createAuth({
280
+ name: "zoom-users-mcp",
281
+ authorizeUrl: "https://zoom.us/oauth/authorize",
282
+ tokenUrl: "https://zoom.us/oauth/token",
283
+ envPrefix: "ZOOM",
284
+ supportsRefresh: true,
285
+ tokenAuthMethod: "basic"
286
+ });
287
+ if (process.argv[1] && import.meta.filename === process.argv[1]) {
288
+ runCli();
289
+ }
290
+
291
+ export {
292
+ getAccessToken,
293
+ runCli
294
+ };
295
+ //# sourceMappingURL=chunk-S7Y3ZVFE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../shared/src/oauth.ts","../src/auth.ts"],"sourcesContent":["import { execSync } from \"node:child_process\";\nimport { createCipheriv, createDecipheriv, randomBytes } from \"node:crypto\";\nimport { mkdir, readFile, unlink, writeFile } from \"node:fs/promises\";\nimport {\n createServer,\n type IncomingMessage,\n type ServerResponse,\n} from \"node:http\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nexport interface OAuthConfig {\n name: string;\n authorizeUrl: string;\n tokenUrl: string;\n envPrefix: string;\n scope?: string;\n supportsRefresh: boolean;\n tokenAuthMethod?: \"body\" | \"basic\";\n}\n\ninterface BaseTokens {\n access_token: string;\n}\n\ninterface RefreshableTokens extends BaseTokens {\n refresh_token: string;\n expires_at: number;\n}\n\ntype StoredTokens = BaseTokens | RefreshableTokens;\n\nfunction isRefreshable(tokens: StoredTokens): tokens is RefreshableTokens {\n return \"refresh_token\" in tokens;\n}\n\ninterface RefreshableTokenResponse {\n access_token: string;\n refresh_token: string;\n expires_in: number;\n}\n\nexport function createAuth(config: OAuthConfig): {\n getAccessToken: () => Promise<string>;\n runCli: (command?: string) => void;\n} {\n const configDir = join(\n process.env.MCP_CONFIG_DIR ?? join(homedir(), \".config\"),\n config.name,\n );\n const legacyTokensPath = join(configDir, \"tokens.json\");\n const encryptedTokensPath = join(configDir, \"tokens\");\n\n function getEncryptionKey(): Buffer {\n const key = process.env.NODE_MCP_SECRET_KEY;\n if (!key) {\n throw new Error(\n \"NODE_MCP_SECRET_KEY environment variable is required. \" +\n \"Generate one with: openssl rand -hex 32\",\n );\n }\n const buf = Buffer.from(key, \"hex\");\n if (buf.length !== 32) {\n throw new Error(\n \"NODE_MCP_SECRET_KEY must be a 64-character hex string (32 bytes). \" +\n \"Generate one with: openssl rand -hex 32\",\n );\n }\n return buf;\n }\n\n function encrypt(plaintext: string): Buffer {\n const key = getEncryptionKey();\n const iv = randomBytes(12);\n const cipher = createCipheriv(\"aes-256-gcm\", key, iv);\n const encrypted = Buffer.concat([\n cipher.update(plaintext, \"utf-8\"),\n cipher.final(),\n ]);\n const authTag = cipher.getAuthTag();\n return Buffer.concat([iv, authTag, encrypted]);\n }\n\n function decrypt(data: Buffer): string {\n const key = getEncryptionKey();\n const iv = data.subarray(0, 12);\n const authTag = data.subarray(12, 28);\n const ciphertext = data.subarray(28);\n const decipher = createDecipheriv(\"aes-256-gcm\", key, iv);\n decipher.setAuthTag(authTag);\n return (\n decipher.update(ciphertext, undefined, \"utf-8\") + decipher.final(\"utf-8\")\n );\n }\n\n async function readTokens(): Promise<StoredTokens | null> {\n try {\n const data = await readFile(encryptedTokensPath);\n return JSON.parse(decrypt(data)) as StoredTokens;\n } catch {\n // Fall back to legacy plaintext tokens.json\n try {\n const data = await readFile(legacyTokensPath, \"utf-8\");\n const tokens = JSON.parse(data) as StoredTokens;\n await writeTokens(tokens);\n await unlink(legacyTokensPath);\n return tokens;\n } catch {\n return null;\n }\n }\n }\n\n async function writeTokens(tokens: StoredTokens): Promise<void> {\n await mkdir(configDir, { recursive: true });\n await writeFile(encryptedTokensPath, encrypt(JSON.stringify(tokens)), {\n mode: 0o600,\n });\n }\n\n function openBrowser(url: string): void {\n const cmd =\n process.platform === \"darwin\"\n ? \"open\"\n : process.platform === \"win32\"\n ? \"start\"\n : \"xdg-open\";\n try {\n execSync(`${cmd} '${url}'`, { stdio: \"ignore\" });\n } catch {\n // No browser available (e.g. headless server) — URL is already printed to console\n }\n }\n\n async function exchangeCode(\n code: string,\n redirectUri: string,\n clientId: string,\n clientSecret: string,\n ): Promise<StoredTokens> {\n const useBasic = config.tokenAuthMethod === \"basic\";\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n };\n const bodyParams: Record<string, string> = {\n grant_type: \"authorization_code\",\n code,\n redirect_uri: redirectUri,\n };\n if (useBasic) {\n headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;\n } else {\n bodyParams.client_id = clientId;\n bodyParams.client_secret = clientSecret;\n }\n const res = await fetch(config.tokenUrl, {\n method: \"POST\",\n headers,\n body: new URLSearchParams(bodyParams),\n });\n if (!res.ok) {\n const text = await res.text();\n throw new Error(`Token exchange failed (${res.status}): ${text}`);\n }\n if (config.supportsRefresh) {\n const data = (await res.json()) as RefreshableTokenResponse;\n return {\n access_token: data.access_token,\n refresh_token: data.refresh_token,\n expires_at: Date.now() + data.expires_in * 1000,\n };\n }\n const data = (await res.json()) as { access_token: string };\n return { access_token: data.access_token };\n }\n\n async function refreshAccessToken(\n refreshToken: string,\n clientId: string,\n clientSecret: string,\n ): Promise<RefreshableTokens> {\n const useBasic = config.tokenAuthMethod === \"basic\";\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n };\n const bodyParams: Record<string, string> = {\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n };\n if (useBasic) {\n headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;\n } else {\n bodyParams.client_id = clientId;\n bodyParams.client_secret = clientSecret;\n }\n const res = await fetch(config.tokenUrl, {\n method: \"POST\",\n headers,\n body: new URLSearchParams(bodyParams),\n });\n if (!res.ok) {\n throw new Error(\"Run 'yarn auth' to re-authenticate.\");\n }\n const data = (await res.json()) as RefreshableTokenResponse;\n return {\n access_token: data.access_token,\n refresh_token: data.refresh_token,\n expires_at: Date.now() + data.expires_in * 1000,\n };\n }\n\n async function getAccessToken(): Promise<string> {\n const envToken = process.env[`${config.envPrefix}_ACCESS_TOKEN`];\n if (envToken) return envToken;\n\n const tokens = await readTokens();\n if (!tokens) {\n throw new Error(\n `No access token available. Set ${config.envPrefix}_ACCESS_TOKEN or run 'yarn auth'.`,\n );\n }\n\n if (config.supportsRefresh && isRefreshable(tokens)) {\n const fiveMinutes = 5 * 60 * 1000;\n if (tokens.expires_at - Date.now() < fiveMinutes) {\n const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];\n const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];\n if (!clientId || !clientSecret) {\n throw new Error(\n `Token is expiring and ${config.envPrefix}_CLIENT_ID/${config.envPrefix}_CLIENT_SECRET are not set for refresh. Run 'yarn auth'.`,\n );\n }\n const refreshed = await refreshAccessToken(\n tokens.refresh_token,\n clientId,\n clientSecret,\n );\n await writeTokens(refreshed);\n return refreshed.access_token;\n }\n }\n\n return tokens.access_token;\n }\n\n async function authorize(): Promise<void> {\n const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];\n const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];\n if (!clientId)\n throw new Error(\n `${config.envPrefix}_CLIENT_ID environment variable is required.`,\n );\n if (!clientSecret)\n throw new Error(\n `${config.envPrefix}_CLIENT_SECRET environment variable is required.`,\n );\n\n const redirectUri = process.env[`${config.envPrefix}_REDIRECT_URI`];\n if (!redirectUri)\n throw new Error(\n `${config.envPrefix}_REDIRECT_URI environment variable is required.`,\n );\n\n const redirectUrl = new URL(redirectUri);\n const port =\n Number(redirectUrl.port) ||\n (redirectUrl.protocol === \"https:\" ? 443 : 80);\n const callbackPath = redirectUrl.pathname;\n const state = randomBytes(16).toString(\"hex\");\n\n const { tokens } = await new Promise<{\n tokens: StoredTokens;\n }>((resolve, reject) => {\n const timeout = setTimeout(() => {\n server.close();\n reject(new Error(\"Authorization timed out after 120 seconds.\"));\n }, 120_000);\n\n const server = createServer(\n async (req: IncomingMessage, res: ServerResponse) => {\n const url = new URL(req.url ?? \"/\", `http://${req.headers.host}`);\n if (url.pathname !== callbackPath) {\n res.writeHead(404);\n res.end(\"Not found\");\n return;\n }\n\n const returnedState = url.searchParams.get(\"state\");\n const code = url.searchParams.get(\"code\");\n const error = url.searchParams.get(\"error\");\n\n if (error) {\n res.writeHead(400);\n res.end(`Authorization error: ${error}`);\n clearTimeout(timeout);\n server.close();\n reject(new Error(`Authorization denied: ${error}`));\n return;\n }\n\n if (returnedState !== state) {\n res.writeHead(400);\n res.end(\"State mismatch — possible CSRF attack.\");\n return;\n }\n\n if (!code) {\n res.writeHead(400);\n res.end(\"Missing authorization code.\");\n return;\n }\n\n try {\n const exchangedTokens = await exchangeCode(\n code,\n redirectUri,\n clientId,\n clientSecret,\n );\n res.writeHead(200, { \"Content-Type\": \"text/html\" });\n res.end(\n \"<h1>Authorization successful!</h1><p>You can close this tab.</p>\",\n );\n clearTimeout(timeout);\n server.close();\n resolve({ tokens: exchangedTokens });\n } catch (err) {\n res.writeHead(500);\n res.end(\"Token exchange failed.\");\n clearTimeout(timeout);\n server.close();\n reject(err);\n }\n },\n );\n\n const params: Record<string, string> = {\n response_type: \"code\",\n client_id: clientId,\n redirect_uri: redirectUri,\n state,\n };\n if (config.scope) {\n params.scope = config.scope;\n }\n\n server.listen(port, \"127.0.0.1\", () => {\n const authorizeUrl = `${config.authorizeUrl}?${new URLSearchParams(params)}`;\n\n console.log(\"Opening browser for authorization...\");\n console.log(`If the browser doesn't open, visit:\\n${authorizeUrl}\\n`);\n openBrowser(authorizeUrl);\n });\n });\n\n await writeTokens(tokens);\n console.log(`Tokens saved to ${encryptedTokensPath}`);\n }\n\n async function refresh(): Promise<void> {\n const clientId = process.env[`${config.envPrefix}_CLIENT_ID`];\n const clientSecret = process.env[`${config.envPrefix}_CLIENT_SECRET`];\n if (!clientId)\n throw new Error(\n `${config.envPrefix}_CLIENT_ID environment variable is required.`,\n );\n if (!clientSecret)\n throw new Error(\n `${config.envPrefix}_CLIENT_SECRET environment variable is required.`,\n );\n\n const tokens = await readTokens();\n if (!tokens || !isRefreshable(tokens)) {\n throw new Error(\n \"No tokens found. Run 'yarn auth' first to authenticate.\",\n );\n }\n\n const refreshed = await refreshAccessToken(\n tokens.refresh_token,\n clientId,\n clientSecret,\n );\n await writeTokens(refreshed);\n console.log(`Tokens refreshed and saved to ${encryptedTokensPath}`);\n }\n\n function runCli(command?: string): void {\n const cmd = command ?? process.argv[2];\n let run: () => Promise<void>;\n if (cmd === \"refresh\" && config.supportsRefresh) {\n run = refresh;\n } else {\n run = authorize;\n }\n run().catch((err) => {\n console.error(err.message ?? err);\n process.exit(1);\n });\n }\n\n return { getAccessToken, runCli };\n}\n","import { createAuth } from \"@mjquinlan2000/shared/oauth.js\";\n\nconst { getAccessToken, runCli } = createAuth({\n name: \"zoom-users-mcp\",\n authorizeUrl: \"https://zoom.us/oauth/authorize\",\n tokenUrl: \"https://zoom.us/oauth/token\",\n envPrefix: \"ZOOM\",\n supportsRefresh: true,\n tokenAuthMethod: \"basic\",\n});\n\nexport { getAccessToken, runCli };\n\nif (process.argv[1] && import.meta.filename === process.argv[1]) {\n runCli();\n}\n"],"mappings":"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","names":["data","getAccessToken","runCli"]}
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node