nworks 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { Command as Command14 } from "commander";
5
+
6
+ // src/version.ts
4
7
  import { createRequire } from "module";
5
- import { Command as Command13 } from "commander";
8
+ var require2 = createRequire(import.meta.url);
9
+ var pkg = require2("../package.json");
10
+ var VERSION = pkg.version;
6
11
 
7
12
  // src/commands/login.ts
8
13
  import { Command } from "commander";
@@ -11,10 +16,11 @@ import { existsSync as existsSync2 } from "fs";
11
16
  import { createInterface } from "readline/promises";
12
17
 
13
18
  // src/auth/config.ts
14
- import { readFile, writeFile, mkdir } from "fs/promises";
19
+ import { readFile, writeFile, mkdir, chmod, rename } from "fs/promises";
15
20
  import { existsSync } from "fs";
16
21
  import { homedir } from "os";
17
22
  import { join } from "path";
23
+ import { z } from "zod";
18
24
 
19
25
  // src/utils/error.ts
20
26
  var AuthError = class extends Error {
@@ -38,15 +44,45 @@ var ApiError = class extends Error {
38
44
  function hasServiceAccountCreds(creds) {
39
45
  return !!(creds.serviceAccount && creds.privateKeyPath && creds.botId);
40
46
  }
47
+ var IS_UNIX = process.platform !== "win32";
41
48
  var CONFIG_DIR = join(homedir(), ".config", "nworks");
42
49
  var CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
43
50
  var TOKEN_PATH = join(CONFIG_DIR, "token.json");
44
51
  var USER_TOKEN_PATH = join(CONFIG_DIR, "user-token.json");
52
+ var PROFILE_NAME_PATTERN = /^[\w.-]+$/;
53
+ var FORBIDDEN_PROFILE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
54
+ var DOTS_ONLY = /^\.+$/;
55
+ function validateProfileName(profile) {
56
+ if (!PROFILE_NAME_PATTERN.test(profile) || FORBIDDEN_PROFILE_KEYS.has(profile) || DOTS_ONLY.test(profile)) {
57
+ throw new AuthError(
58
+ `Invalid profile name "${profile}". Use only letters, digits, ".", "_", or "-" (and not "." / ".." alone).`
59
+ );
60
+ }
61
+ }
62
+ var credentialsSchema = z.object({
63
+ clientId: z.string(),
64
+ clientSecret: z.string(),
65
+ serviceAccount: z.string().optional(),
66
+ privateKeyPath: z.string().optional(),
67
+ botId: z.string().optional(),
68
+ domainId: z.string().optional()
69
+ }).passthrough();
45
70
  async function ensureConfigDir() {
46
71
  if (!existsSync(CONFIG_DIR)) {
47
- await mkdir(CONFIG_DIR, { recursive: true });
72
+ await mkdir(CONFIG_DIR, { recursive: true, ...IS_UNIX && { mode: 448 } });
73
+ } else if (IS_UNIX) {
74
+ await chmod(CONFIG_DIR, 448);
48
75
  }
49
76
  }
77
+ var tempFileCounter = 0;
78
+ async function writeSecureFile(path, data) {
79
+ const tempPath = `${path}.${process.pid}.${tempFileCounter++}.tmp`;
80
+ await writeFile(tempPath, data, { encoding: "utf-8", ...IS_UNIX && { mode: 384 } });
81
+ if (IS_UNIX) {
82
+ await chmod(tempPath, 384);
83
+ }
84
+ await rename(tempPath, path);
85
+ }
50
86
  function getCredentialsFromEnv() {
51
87
  const clientId = process.env["NWORKS_CLIENT_ID"];
52
88
  const clientSecret = process.env["NWORKS_CLIENT_SECRET"];
@@ -63,6 +99,7 @@ function getCredentialsFromEnv() {
63
99
  async function loadCredentials(profile = "default") {
64
100
  const envCreds = getCredentialsFromEnv();
65
101
  if (envCreds) return envCreds;
102
+ validateProfileName(profile);
66
103
  if (!existsSync(CREDENTIALS_PATH)) {
67
104
  throw new AuthError(
68
105
  "Not logged in. Run `nworks login` or set environment variables."
@@ -74,9 +111,16 @@ async function loadCredentials(profile = "default") {
74
111
  if (!creds) {
75
112
  throw new AuthError(`Profile "${profile}" not found in credentials.`);
76
113
  }
77
- return creds;
114
+ const parsed = credentialsSchema.safeParse(creds);
115
+ if (!parsed.success) {
116
+ throw new AuthError(
117
+ `Credentials for profile "${profile}" are malformed. Run \`nworks login\` to reconfigure.`
118
+ );
119
+ }
120
+ return parsed.data;
78
121
  }
79
122
  async function saveCredentials(creds, profile = "default") {
123
+ validateProfileName(profile);
80
124
  await ensureConfigDir();
81
125
  let profiles = {};
82
126
  if (existsSync(CREDENTIALS_PATH)) {
@@ -84,9 +128,10 @@ async function saveCredentials(creds, profile = "default") {
84
128
  profiles = JSON.parse(raw);
85
129
  }
86
130
  profiles[profile] = creds;
87
- await writeFile(CREDENTIALS_PATH, JSON.stringify(profiles, null, 2), "utf-8");
131
+ await writeSecureFile(CREDENTIALS_PATH, JSON.stringify(profiles, null, 2));
88
132
  }
89
133
  async function loadToken(profile = "default") {
134
+ validateProfileName(profile);
90
135
  if (!existsSync(TOKEN_PATH)) return null;
91
136
  const raw = await readFile(TOKEN_PATH, "utf-8");
92
137
  const tokens = JSON.parse(raw);
@@ -98,6 +143,7 @@ async function loadToken(profile = "default") {
98
143
  };
99
144
  }
100
145
  async function saveToken(token, profile = "default") {
146
+ validateProfileName(profile);
101
147
  await ensureConfigDir();
102
148
  let tokens = {};
103
149
  if (existsSync(TOKEN_PATH)) {
@@ -105,9 +151,10 @@ async function saveToken(token, profile = "default") {
105
151
  tokens = JSON.parse(raw);
106
152
  }
107
153
  tokens[profile] = token;
108
- await writeFile(TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
154
+ await writeSecureFile(TOKEN_PATH, JSON.stringify(tokens, null, 2));
109
155
  }
110
156
  async function loadUserToken(profile = "default") {
157
+ validateProfileName(profile);
111
158
  if (!existsSync(USER_TOKEN_PATH)) return null;
112
159
  const raw = await readFile(USER_TOKEN_PATH, "utf-8");
113
160
  const tokens = JSON.parse(raw);
@@ -121,6 +168,7 @@ async function loadUserToken(profile = "default") {
121
168
  };
122
169
  }
123
170
  async function saveUserToken(token, profile = "default") {
171
+ validateProfileName(profile);
124
172
  await ensureConfigDir();
125
173
  let tokens = {};
126
174
  if (existsSync(USER_TOKEN_PATH)) {
@@ -128,35 +176,32 @@ async function saveUserToken(token, profile = "default") {
128
176
  tokens = JSON.parse(raw);
129
177
  }
130
178
  tokens[profile] = token;
131
- await writeFile(USER_TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
179
+ await writeSecureFile(USER_TOKEN_PATH, JSON.stringify(tokens, null, 2));
132
180
  }
133
181
  async function clearCredentials(profile = "default") {
182
+ validateProfileName(profile);
134
183
  if (existsSync(CREDENTIALS_PATH)) {
135
184
  const raw = await readFile(CREDENTIALS_PATH, "utf-8");
136
185
  const profiles = JSON.parse(raw);
137
186
  delete profiles[profile];
138
- await writeFile(
139
- CREDENTIALS_PATH,
140
- JSON.stringify(profiles, null, 2),
141
- "utf-8"
142
- );
187
+ await writeSecureFile(CREDENTIALS_PATH, JSON.stringify(profiles, null, 2));
143
188
  }
144
189
  if (existsSync(TOKEN_PATH)) {
145
190
  const raw = await readFile(TOKEN_PATH, "utf-8");
146
191
  const tokens = JSON.parse(raw);
147
192
  delete tokens[profile];
148
- await writeFile(TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
193
+ await writeSecureFile(TOKEN_PATH, JSON.stringify(tokens, null, 2));
149
194
  }
150
195
  if (existsSync(USER_TOKEN_PATH)) {
151
196
  const raw = await readFile(USER_TOKEN_PATH, "utf-8");
152
197
  const tokens = JSON.parse(raw);
153
198
  delete tokens[profile];
154
- await writeFile(USER_TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
199
+ await writeSecureFile(USER_TOKEN_PATH, JSON.stringify(tokens, null, 2));
155
200
  }
156
201
  }
157
202
 
158
203
  // src/auth/jwt.ts
159
- import { readFile as readFile2 } from "fs/promises";
204
+ import { readFile as readFile2, stat } from "fs/promises";
160
205
  import jwt from "jsonwebtoken";
161
206
  async function createJWT(creds) {
162
207
  if (!creds.serviceAccount || !creds.privateKeyPath) {
@@ -165,6 +210,15 @@ async function createJWT(creds) {
165
210
  );
166
211
  }
167
212
  const privateKey = await readFile2(creds.privateKeyPath, "utf-8");
213
+ if (process.platform !== "win32") {
214
+ const fileStat = await stat(creds.privateKeyPath);
215
+ const mode = fileStat.mode & 511;
216
+ if (mode & 63) {
217
+ console.error(
218
+ `[nworks] Warning: Private key file has permissions ${mode.toString(8)}. Recommended: 600`
219
+ );
220
+ }
221
+ }
168
222
  const now = Math.floor(Date.now() / 1e3);
169
223
  const payload = {
170
224
  iss: creds.clientId,
@@ -201,7 +255,8 @@ async function refreshToken(profile = "default") {
201
255
  });
202
256
  if (!res.ok) {
203
257
  const text = await res.text();
204
- throw new AuthError(`Token exchange failed (${res.status}): ${text}`);
258
+ const truncated = text.length > 200 ? text.substring(0, 200) + "..." : text;
259
+ throw new AuthError(`Token exchange failed (${res.status}): ${truncated}`);
205
260
  }
206
261
  const data = await res.json();
207
262
  const expiresIn = Number(data.expires_in);
@@ -215,7 +270,7 @@ async function refreshToken(profile = "default") {
215
270
 
216
271
  // src/auth/oauth-user.ts
217
272
  import { createServer } from "http";
218
- import { URL } from "url";
273
+ import { URL as URL2 } from "url";
219
274
  var AUTH_URL2 = "https://auth.worksmobile.com/oauth2/v2.0/authorize";
220
275
  var TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
221
276
  var REDIRECT_PORT = 9876;
@@ -230,19 +285,19 @@ function buildAuthorizeUrl(clientId, scope, state) {
230
285
  });
231
286
  return `${AUTH_URL2}?${params.toString()}`;
232
287
  }
233
- async function startUserOAuthFlow(_scope, profile = "default") {
288
+ async function startUserOAuthFlow(_scope, profile, expectedState) {
234
289
  const creds = await loadCredentials(profile);
235
- const code = await waitForAuthCode();
290
+ const code = await waitForAuthCode(expectedState);
236
291
  return exchangeCodeForToken(code, creds.clientId, creds.clientSecret);
237
292
  }
238
- function waitForAuthCode() {
239
- return new Promise((resolve, reject) => {
293
+ function waitForAuthCode(expectedState) {
294
+ return new Promise((resolve3, reject) => {
240
295
  const timeout = setTimeout(() => {
241
296
  server.close();
242
297
  reject(new AuthError("OAuth login timed out (120s). Try again."));
243
298
  }, 12e4);
244
299
  const server = createServer((req, res) => {
245
- const url = new URL(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
300
+ const url = new URL2(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
246
301
  if (url.pathname !== "/callback") {
247
302
  res.writeHead(404);
248
303
  res.end("Not found");
@@ -250,6 +305,12 @@ function waitForAuthCode() {
250
305
  }
251
306
  const code = url.searchParams.get("code");
252
307
  const error = url.searchParams.get("error");
308
+ const state = url.searchParams.get("state");
309
+ if (state !== expectedState) {
310
+ res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
311
+ res.end("<h2>\uBCF4\uC548 \uC624\uB958</h2><p>state \uBD88\uC77C\uCE58. \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uB85C\uADF8\uC778\uC744 \uB2E4\uC2DC \uC9C4\uD589\uD558\uC138\uC694.</p>");
312
+ return;
313
+ }
253
314
  if (error) {
254
315
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
255
316
  res.end("<h2>\uB85C\uADF8\uC778 \uC2E4\uD328</h2><p>\uC774 \uCC3D\uC744 \uB2EB\uC544\uB3C4 \uB429\uB2C8\uB2E4.</p>");
@@ -270,9 +331,9 @@ function waitForAuthCode() {
270
331
  res.end("<h2>\uB85C\uADF8\uC778 \uC131\uACF5!</h2><p>\uC774 \uCC3D\uC744 \uB2EB\uACE0 \uD130\uBBF8\uB110\uB85C \uB3CC\uC544\uAC00\uC138\uC694.</p>");
271
332
  clearTimeout(timeout);
272
333
  server.close();
273
- resolve(code);
334
+ resolve3(code);
274
335
  });
275
- server.listen(REDIRECT_PORT, () => {
336
+ server.listen(REDIRECT_PORT, "127.0.0.1", () => {
276
337
  });
277
338
  server.on("error", (err) => {
278
339
  clearTimeout(timeout);
@@ -280,6 +341,17 @@ function waitForAuthCode() {
280
341
  });
281
342
  });
282
343
  }
344
+ async function safeErrorSummary(res) {
345
+ const text = await res.text();
346
+ try {
347
+ const body = JSON.parse(text);
348
+ if (body.error || body.error_description) {
349
+ return [body.error, body.error_description].filter(Boolean).join(": ");
350
+ }
351
+ } catch {
352
+ }
353
+ return text.length > 200 ? text.slice(0, 200) + "..." : text;
354
+ }
283
355
  async function exchangeCodeForToken(code, clientId, clientSecret) {
284
356
  const body = new URLSearchParams({
285
357
  grant_type: "authorization_code",
@@ -294,8 +366,7 @@ async function exchangeCodeForToken(code, clientId, clientSecret) {
294
366
  body: body.toString()
295
367
  });
296
368
  if (!res.ok) {
297
- const text = await res.text();
298
- throw new AuthError(`Token exchange failed (${res.status}): ${text}`);
369
+ throw new AuthError(`Token exchange failed (${res.status}): ${await safeErrorSummary(res)}`);
299
370
  }
300
371
  const data = await res.json();
301
372
  return {
@@ -319,8 +390,7 @@ async function refreshUserToken(refreshToken2, profile = "default") {
319
390
  body: body.toString()
320
391
  });
321
392
  if (!res.ok) {
322
- const text = await res.text();
323
- throw new AuthError(`Token refresh failed (${res.status}): ${text}`);
393
+ throw new AuthError(`Token refresh failed (${res.status}): ${await safeErrorSummary(res)}`);
324
394
  }
325
395
  const data = await res.json();
326
396
  return {
@@ -330,11 +400,91 @@ async function refreshUserToken(refreshToken2, profile = "default") {
330
400
  scope: data.scope
331
401
  };
332
402
  }
333
- function startOAuthCallbackServer(clientId, clientSecret) {
334
- return waitForAuthCode().then(
403
+ function startOAuthCallbackServer(clientId, clientSecret, expectedState) {
404
+ return waitForAuthCode(expectedState).then(
335
405
  (code) => exchangeCodeForToken(code, clientId, clientSecret)
336
406
  );
337
407
  }
408
+ async function revokeToken(token, clientId, clientSecret) {
409
+ try {
410
+ const res = await fetch("https://auth.worksmobile.com/oauth2/v2.0/revoke", {
411
+ method: "POST",
412
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
413
+ body: new URLSearchParams({
414
+ token,
415
+ client_id: clientId,
416
+ client_secret: clientSecret
417
+ }).toString()
418
+ });
419
+ if (!res.ok && process.env["NWORKS_VERBOSE"] === "1") {
420
+ console.error(`[nworks] Token revoke returned ${res.status}`);
421
+ }
422
+ } catch {
423
+ }
424
+ }
425
+
426
+ // src/auth/scopes.ts
427
+ var READ_SCOPES = [
428
+ "calendar.read",
429
+ "file.read",
430
+ "mail.read",
431
+ "task.read",
432
+ "user.read",
433
+ "board.read",
434
+ "contact.read"
435
+ ];
436
+ var ALL_SCOPES = [
437
+ "calendar",
438
+ "calendar.read",
439
+ "file",
440
+ "file.read",
441
+ "mail",
442
+ "mail.read",
443
+ "task",
444
+ "task.read",
445
+ "user.read",
446
+ "board",
447
+ "board.read",
448
+ "contact",
449
+ "contact.read"
450
+ ];
451
+ var SCOPE_DEPENDENCIES = {
452
+ calendar: ["calendar.read"],
453
+ file: ["file.read"],
454
+ mail: ["mail.read"],
455
+ task: ["task.read", "user.read"],
456
+ "task.read": ["user.read"],
457
+ board: ["board.read"],
458
+ contact: ["contact.read"]
459
+ };
460
+ var SCOPE_PRESETS = {
461
+ readonly: READ_SCOPES,
462
+ // default를 all로 두는 이유: 현재 제품의 "한 번 로그인으로 전체 기능" 약속을 보존한다.
463
+ // 읽기 전용을 기본값으로 하면 신규 사용자가 쓰기 기능(일정 생성 등)을 쓸 때 재로그인해야 한다.
464
+ // 안전을 최우선하려면 readonly로 바꾸면 되지만, 삭제류 도구는 이미 destructiveHint
465
+ // annotation으로 클라이언트 승인 게이트가 걸린다.
466
+ default: ALL_SCOPES,
467
+ all: ALL_SCOPES
468
+ };
469
+ function isPreset(value) {
470
+ return value === "readonly" || value === "default" || value === "all";
471
+ }
472
+ function expandScopeDependencies(scopes) {
473
+ const expanded = new Set(scopes);
474
+ for (const scope of scopes) {
475
+ const deps = SCOPE_DEPENDENCIES[scope];
476
+ if (deps) deps.forEach((d) => expanded.add(d));
477
+ }
478
+ return [...expanded];
479
+ }
480
+ function resolveScopes(input) {
481
+ if (!input) return SCOPE_PRESETS.default;
482
+ if (isPreset(input)) return SCOPE_PRESETS[input];
483
+ return expandScopeDependencies(input.split(" ").filter(Boolean));
484
+ }
485
+ function mergeScopes(existing, requested) {
486
+ return [.../* @__PURE__ */ new Set([...existing, ...requested])].join(" ");
487
+ }
338
488
 
339
489
  // src/output/format.ts
340
490
  function output(data, opts = {}) {
@@ -422,11 +572,24 @@ async function prompt(question) {
422
572
  rl.close();
423
573
  }
424
574
  }
425
- var loginCommand = new Command("login").description("Authenticate with NAVER WORKS").option("--user", "User OAuth login (opens browser)").option("--scope <scope>", "OAuth scope for user login", "calendar.read").option("--client-id <id>", "Client ID").option("--client-secret <secret>", "Client Secret").option("--service-account <account>", "Service Account ID").option("--private-key <path>", "Path to private key file (.key)").option("--bot-id <id>", "Bot ID").option("--domain-id <id>", "Domain ID (optional)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
575
+ async function promptSecret(question) {
576
+ process.stdout.write(question);
577
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
578
+ rl._writeToOutput = () => {
579
+ };
580
+ try {
581
+ const answer = await rl.question("");
582
+ process.stdout.write("\n");
583
+ return answer.trim();
584
+ } finally {
585
+ rl.close();
586
+ }
587
+ }
588
+ var loginCommand = new Command("login").description("Authenticate with NAVER WORKS").option("--user", "User OAuth login (opens browser)").option("--preset <preset>", "Scope preset: readonly | default | all (default: all)").option("--scope <scope>", "Advanced: raw space-separated scopes (overrides --preset)").option("--client-id <id>", "Client ID").option("--client-secret <secret>", "Client Secret").option("--service-account <account>", "Service Account ID").option("--private-key <path>", "Path to private key file (.key)").option("--bot-id <id>", "Bot ID").option("--domain-id <id>", "Domain ID (optional)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
426
589
  try {
427
590
  const profile = opts.profile;
428
591
  if (opts.user) {
429
- await handleUserLogin(opts.scope, profile, opts);
592
+ await handleUserLogin(profile, opts);
430
593
  } else {
431
594
  await handleServiceAccountLogin(opts);
432
595
  }
@@ -436,7 +599,7 @@ var loginCommand = new Command("login").description("Authenticate with NAVER WOR
436
599
  process.exitCode = 1;
437
600
  }
438
601
  });
439
- async function handleUserLogin(scope, profile, opts) {
602
+ async function handleUserLogin(profile, opts) {
440
603
  let clientId = opts.clientId;
441
604
  let clientSecret = opts.clientSecret;
442
605
  try {
@@ -447,7 +610,7 @@ async function handleUserLogin(scope, profile, opts) {
447
610
  }
448
611
  if (process.stdin.isTTY) {
449
612
  if (!clientId) clientId = await prompt("Client ID: ");
450
- if (!clientSecret) clientSecret = await prompt("Client Secret: ");
613
+ if (!clientSecret) clientSecret = await promptSecret("Client Secret: ");
451
614
  }
452
615
  if (!clientId || !clientSecret) {
453
616
  throw new Error(
@@ -460,22 +623,31 @@ async function handleUserLogin(scope, profile, opts) {
460
623
  } catch {
461
624
  await saveCredentials({ clientId, clientSecret }, profile);
462
625
  }
626
+ const requestedScopes = resolveScopes(opts.preset ?? opts.scope);
627
+ let existingScopes = [];
628
+ try {
629
+ const existingToken = await loadUserToken(profile);
630
+ existingScopes = existingToken?.scope?.split(" ").filter(Boolean) ?? [];
631
+ } catch {
632
+ }
633
+ const mergedScope = mergeScopes(existingScopes, requestedScopes);
463
634
  const state = randomBytes(16).toString("hex");
464
- const authorizeUrl = buildAuthorizeUrl(clientId, scope, state);
635
+ const authorizeUrl = buildAuthorizeUrl(clientId, mergedScope, state);
465
636
  console.error(`
466
637
  Opening browser for NAVER WORKS login...`);
467
638
  console.error(`If the browser does not open, visit this URL:
468
639
  `);
469
640
  console.error(` ${authorizeUrl}
470
641
  `);
471
- const { exec } = await import("child_process");
472
- const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
473
- if (process.platform === "win32") {
474
- exec(`start "" "${authorizeUrl}"`);
642
+ const { execFile } = await import("child_process");
643
+ if (process.platform === "darwin") {
644
+ execFile("open", [authorizeUrl]);
645
+ } else if (process.platform === "win32") {
646
+ execFile("cmd", ["/c", "start", "", authorizeUrl]);
475
647
  } else {
476
- exec(`${openCmd} "${authorizeUrl}"`);
648
+ execFile("xdg-open", [authorizeUrl]);
477
649
  }
478
- const tokenData = await startUserOAuthFlow(scope, profile);
650
+ const tokenData = await startUserOAuthFlow(mergedScope, profile, state);
479
651
  await saveUserToken(tokenData, profile);
480
652
  output(
481
653
  {
@@ -502,7 +674,7 @@ async function handleServiceAccountLogin(opts) {
502
674
  if (!botId) botId = process.env["NWORKS_BOT_ID"];
503
675
  if (process.stdin.isTTY) {
504
676
  if (!clientId) clientId = await prompt("Client ID: ");
505
- if (!clientSecret) clientSecret = await prompt("Client Secret: ");
677
+ if (!clientSecret) clientSecret = await promptSecret("Client Secret: ");
506
678
  if (!serviceAccount)
507
679
  serviceAccount = await prompt("Service Account: ");
508
680
  if (!privateKeyPath)
@@ -564,10 +736,10 @@ var REQUIRED_SCOPES = {
564
736
  "board.create": "board"
565
737
  };
566
738
  var ERROR_HINTS_CLI = {
567
- FORBIDDEN: "\uAD8C\uD55C\uC774 \uBD80\uC871\uD569\uB2C8\uB2E4. Developer Console\uC5D0\uC11C OAuth Scope\uB97C \uD655\uC778\uD558\uC138\uC694.",
568
- ACCESS_DENIED: "\uC811\uADFC\uC774 \uAC70\uBD80\uB410\uC2B5\uB2C8\uB2E4. Admin\uC5D0\uC11C Bot\uC744 \uCD94\uAC00\uD588\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694.",
569
- SERVICE_ACCOUNT_NOT_ALLOWED: "\uC11C\uBE44\uC2A4 \uACC4\uC815\uC73C\uB85C\uB294 \uC774 API\uB97C \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. `nworks login --user`\uB85C User OAuth \uB85C\uADF8\uC778\uD558\uC138\uC694.",
570
- UNAUTHORIZED: "\uC778\uC99D\uC774 \uB9CC\uB8CC\uB410\uC2B5\uB2C8\uB2E4. `nworks login`\uC73C\uB85C \uB2E4\uC2DC \uB85C\uADF8\uC778\uD558\uC138\uC694."
739
+ FORBIDDEN: "Insufficient permission. Check the OAuth scopes in the Developer Console.",
740
+ ACCESS_DENIED: "Access denied. Make sure the Bot is added in the Admin console.",
741
+ SERVICE_ACCOUNT_NOT_ALLOWED: "This API is not available with a Service Account. Log in with User OAuth via `nworks login --user`.",
742
+ UNAUTHORIZED: "Authentication expired. Log in again with `nworks login`."
571
743
  };
572
744
  var ERROR_HINTS_MCP = {
573
745
  FORBIDDEN: "\uAD8C\uD55C\uC774 \uBD80\uC871\uD569\uB2C8\uB2E4. Developer Console\uC5D0\uC11C OAuth Scope\uB97C \uD655\uC778\uD558\uC138\uC694.",
@@ -615,7 +787,7 @@ function buildScopeHint(area, mode) {
615
787
  const scopes = scope.split(" ").join(", ");
616
788
  if (mode === "cli") {
617
789
  return `
618
- \u2192 \uC774 \uBA85\uB839\uC5B4\uB294 ${scopes} scope\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. \`nworks login --user --scope "${scope}"\`\uB97C \uC2E4\uD589\uD558\uC138\uC694.`;
790
+ \u2192 This command requires the ${scopes} scope(s). Run \`nworks login --user --scope "${scope}"\`.`;
619
791
  }
620
792
  return `
621
793
  \u2192 \uC774 API\uB294 ${scopes} scope\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. nworks_login_user tool\uB85C \uB85C\uADF8\uC778\uD558\uC138\uC694 (scope\uB97C \uC9C0\uC815\uD558\uC9C0 \uC54A\uC73C\uBA74 \uC804\uCCB4 \uAD8C\uD55C\uC774 \uC790\uB3D9 \uD3EC\uD568\uB429\uB2C8\uB2E4).`;
@@ -652,6 +824,18 @@ function cliError(err, opts = {}, area) {
652
824
  var logoutCommand = new Command2("logout").description("Remove stored credentials and tokens").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
653
825
  try {
654
826
  const profile = opts.profile;
827
+ try {
828
+ const creds = await loadCredentials(profile);
829
+ const token = await loadToken(profile);
830
+ const userToken = await loadUserToken(profile);
831
+ if (token?.accessToken) {
832
+ await revokeToken(token.accessToken, creds.clientId, creds.clientSecret);
833
+ }
834
+ if (userToken?.refreshToken) {
835
+ await revokeToken(userToken.refreshToken, creds.clientId, creds.clientSecret);
836
+ }
837
+ } catch {
838
+ }
655
839
  await clearCredentials(profile);
656
840
  output({ success: true, message: `Logged out (profile: ${profile})` }, opts);
657
841
  } catch (err) {
@@ -696,7 +880,7 @@ import { Command as Command4 } from "commander";
696
880
  var BASE_URL = "https://www.worksapis.com/v1.0";
697
881
  var MAX_RETRIES = 3;
698
882
  function sleep(ms) {
699
- return new Promise((resolve) => setTimeout(resolve, ms));
883
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
700
884
  }
701
885
  async function request(opts, _retryCount = 0) {
702
886
  const { method, path, body, profile = "default" } = opts;
@@ -718,7 +902,9 @@ async function request(opts, _retryCount = 0) {
718
902
  return request(opts, _retryCount + 1);
719
903
  }
720
904
  if (res.status === 429 && _retryCount < MAX_RETRIES) {
721
- const retryAfter = parseInt(res.headers.get("Retry-After") ?? "5", 10);
905
+ const MAX_RETRY_AFTER = 60;
906
+ const rawRetry = parseInt(res.headers.get("Retry-After") ?? "5", 10);
907
+ const retryAfter = Math.min(Number.isNaN(rawRetry) ? 5 : rawRetry, MAX_RETRY_AFTER);
722
908
  await sleep(retryAfter * 1e3);
723
909
  return request(opts, _retryCount + 1);
724
910
  }
@@ -743,6 +929,59 @@ async function request(opts, _retryCount = 0) {
743
929
  return JSON.parse(text);
744
930
  }
745
931
 
932
+ // src/utils/sanitize.ts
933
+ import { resolve, sep } from "path";
934
+ import { randomBytes as randomBytes2 } from "crypto";
935
+ function sanitizePathSegment(value) {
936
+ if (!value || typeof value !== "string") {
937
+ throw new Error("Path segment must be a non-empty string");
938
+ }
939
+ if (value === "me") return value;
940
+ if (/[/\\]/.test(value) || value.includes("..")) {
941
+ throw new Error(`Invalid path segment: "${value}"`);
942
+ }
943
+ return encodeURIComponent(value);
944
+ }
945
+ function sanitizeFileName(name) {
946
+ if (!name || typeof name !== "string") {
947
+ throw new Error("File name must be a non-empty string");
948
+ }
949
+ const segments = name.split(/[/\\]/).filter((s) => s.length > 0);
950
+ const base = segments[segments.length - 1] ?? "";
951
+ return base.replace(/[\r\n"]/g, "_");
952
+ }
953
+ function validateLocalPath(filePath, allowedBase) {
954
+ const resolved = resolve(filePath);
955
+ if (allowedBase) {
956
+ const resolvedBase = resolve(allowedBase);
957
+ if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + sep)) {
958
+ throw new Error(`Path "${filePath}" escapes the allowed directory "${allowedBase}"`);
959
+ }
960
+ }
961
+ return resolved;
962
+ }
963
+ function validateRedirectUrl(location, allowedHosts) {
964
+ let parsed;
965
+ try {
966
+ parsed = new URL(location);
967
+ } catch {
968
+ throw new Error(`Invalid redirect URL: "${location}"`);
969
+ }
970
+ if (parsed.protocol !== "https:") {
971
+ throw new Error(`Redirect URL must use HTTPS: "${location}"`);
972
+ }
973
+ const isAllowed = allowedHosts.some(
974
+ (host) => parsed.hostname === host || parsed.hostname.endsWith("." + host)
975
+ );
976
+ if (!isAllowed) {
977
+ throw new Error(`Redirect to untrusted host: "${parsed.hostname}"`);
978
+ }
979
+ return location;
980
+ }
981
+ function generateSecureState() {
982
+ return randomBytes2(32).toString("hex");
983
+ }
984
+
746
985
  // src/api/message.ts
747
986
  function buildContent(opts) {
748
987
  const type = opts.type ?? "text";
@@ -750,7 +989,11 @@ function buildContent(opts) {
750
989
  return { type: "text", text: opts.text };
751
990
  }
752
991
  if (type === "button") {
753
- const actions = opts.actions ? JSON.parse(opts.actions) : [];
992
+ const actions = opts.actions ? (() => {
993
+ const parsed = JSON.parse(opts.actions);
994
+ if (!Array.isArray(parsed)) throw new Error("actions must be a JSON array");
995
+ return parsed;
996
+ })() : [];
754
997
  return {
755
998
  type: "button_template",
756
999
  contentText: opts.text,
@@ -758,7 +1001,11 @@ function buildContent(opts) {
758
1001
  };
759
1002
  }
760
1003
  if (type === "list") {
761
- const elements = opts.elements ? JSON.parse(opts.elements) : [];
1004
+ const elements = opts.elements ? (() => {
1005
+ const parsed = JSON.parse(opts.elements);
1006
+ if (!Array.isArray(parsed)) throw new Error("elements must be a JSON array");
1007
+ return parsed;
1008
+ })() : [];
762
1009
  return {
763
1010
  type: "list_template",
764
1011
  coverData: { text: opts.text },
@@ -780,7 +1027,7 @@ async function send(opts) {
780
1027
  if (opts.to) {
781
1028
  const result = await request({
782
1029
  method: "POST",
783
- path: `/bots/${creds.botId}/users/${opts.to}/messages`,
1030
+ path: `/bots/${sanitizePathSegment(creds.botId)}/users/${sanitizePathSegment(opts.to)}/messages`,
784
1031
  body,
785
1032
  profile
786
1033
  });
@@ -789,7 +1036,7 @@ async function send(opts) {
789
1036
  if (opts.channel) {
790
1037
  const result = await request({
791
1038
  method: "POST",
792
- path: `/bots/${creds.botId}/channels/${opts.channel}/messages`,
1039
+ path: `/bots/${sanitizePathSegment(creds.botId)}/channels/${sanitizePathSegment(opts.channel)}/messages`,
793
1040
  body,
794
1041
  profile
795
1042
  });
@@ -806,7 +1053,7 @@ async function listMembers(channelId, profile = "default") {
806
1053
  }
807
1054
  const result = await request({
808
1055
  method: "GET",
809
- path: `/bots/${creds.botId}/channels/${channelId}/members`,
1056
+ path: `/bots/${sanitizePathSegment(creds.botId)}/channels/${sanitizePathSegment(channelId)}/members`,
810
1057
  profile
811
1058
  });
812
1059
  return { members: result.members ?? [], responseMetaData: result.responseMetaData };
@@ -854,20 +1101,25 @@ var messageCommand = new Command4("message").description("Bot message operations
854
1101
  import { Command as Command5 } from "commander";
855
1102
 
856
1103
  // src/api/directory.ts
857
- async function listUsers(profile = "default") {
1104
+ async function listUsers(count = 100, cursor, profile = "default") {
1105
+ const params = new URLSearchParams();
1106
+ params.set("count", String(count));
1107
+ if (cursor) params.set("cursor", cursor);
858
1108
  const result = await request({
859
1109
  method: "GET",
860
- path: "/users",
1110
+ path: `/users?${params.toString()}`,
861
1111
  profile
862
1112
  });
863
1113
  return { users: result.users ?? [], responseMetaData: result.responseMetaData };
864
1114
  }
865
1115
 
866
1116
  // src/commands/directory.ts
867
- var membersCommand2 = new Command5("members").description("List organization members (requires directory.read scope)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
1117
+ var membersCommand2 = new Command5("members").description("List organization members (requires directory.read scope)").option("--count <n>", "Items per page (default: 100)").option("--cursor <cursor>", "Pagination cursor from a previous response").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
868
1118
  try {
869
- const result = await listUsers(opts.profile);
1119
+ const count = opts.count ? Number(opts.count) : 100;
1120
+ const result = await listUsers(count, opts.cursor, opts.profile);
870
1121
  const formatted = {
1122
+ nextCursor: result.responseMetaData?.nextCursor ?? null,
871
1123
  users: result.users.map((u) => ({
872
1124
  userId: u.userId,
873
1125
  userName: [u.userName?.lastName, u.userName?.firstName].filter(Boolean).join(" ") || "",
@@ -890,6 +1142,7 @@ import { Command as Command6 } from "commander";
890
1142
  import { randomUUID } from "crypto";
891
1143
 
892
1144
  // src/auth/token-user.ts
1145
+ var inFlightRefresh = /* @__PURE__ */ new Map();
893
1146
  async function getValidUserToken(profile = "default") {
894
1147
  const cached = await loadUserToken(profile);
895
1148
  if (!cached) {
@@ -900,22 +1153,106 @@ async function getValidUserToken(profile = "default") {
900
1153
  if (cached.expiresAt > Date.now() / 1e3 + 300) {
901
1154
  return cached.accessToken;
902
1155
  }
1156
+ return refreshValidUserToken(profile);
1157
+ }
1158
+ function refreshValidUserToken(profile = "default") {
1159
+ const existing = inFlightRefresh.get(profile);
1160
+ if (existing) return existing;
1161
+ const run = doRefresh(profile);
1162
+ inFlightRefresh.set(profile, run);
1163
+ void run.finally(() => inFlightRefresh.delete(profile)).catch(() => {
1164
+ });
1165
+ return run;
1166
+ }
1167
+ async function doRefresh(profile) {
1168
+ const cached = await loadUserToken(profile);
1169
+ if (!cached) {
1170
+ throw new AuthError(
1171
+ "User OAuth token not found. Run `nworks login --user` first."
1172
+ );
1173
+ }
903
1174
  const refreshed = await refreshUserToken(cached.refreshToken, profile);
904
1175
  await saveUserToken(refreshed, profile);
905
1176
  return refreshed.accessToken;
906
1177
  }
907
1178
 
908
- // src/api/calendar.ts
909
- var BASE_URL2 = "https://www.worksapis.com/v1.0";
910
- async function authedFetch(url, init, profile) {
911
- const token = await getValidUserToken(profile);
912
- const headers = new Headers(init.headers);
913
- headers.set("Authorization", `Bearer ${token}`);
914
- return fetch(url, { ...init, headers });
1179
+ // src/api/user-client.ts
1180
+ var MAX_RETRIES2 = 3;
1181
+ var MAX_RETRY_AFTER_SECONDS = 60;
1182
+ var MAX_CONCURRENT_PER_HOST = 5;
1183
+ function sleep2(ms) {
1184
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1185
+ }
1186
+ var HostSemaphore = class {
1187
+ constructor(max) {
1188
+ this.max = max;
1189
+ }
1190
+ active = 0;
1191
+ waiters = [];
1192
+ async acquire() {
1193
+ if (this.active < this.max) {
1194
+ this.active++;
1195
+ return;
1196
+ }
1197
+ await new Promise((resolve3) => this.waiters.push(resolve3));
1198
+ }
1199
+ release() {
1200
+ const next = this.waiters.shift();
1201
+ if (next) {
1202
+ next();
1203
+ } else {
1204
+ this.active--;
1205
+ }
1206
+ }
1207
+ };
1208
+ var semaphores = /* @__PURE__ */ new Map();
1209
+ function semaphoreFor(host) {
1210
+ let semaphore = semaphores.get(host);
1211
+ if (!semaphore) {
1212
+ semaphore = new HostSemaphore(MAX_CONCURRENT_PER_HOST);
1213
+ semaphores.set(host, semaphore);
1214
+ }
1215
+ return semaphore;
915
1216
  }
916
- async function handleError(res) {
1217
+ async function userFetch(url, init, profile) {
1218
+ let refreshedOn401 = false;
1219
+ for (let attempt = 0; ; attempt++) {
1220
+ const res = await fetchOnce(url, init, profile);
1221
+ if (res.status === 401 && !refreshedOn401) {
1222
+ refreshedOn401 = true;
1223
+ await refreshValidUserToken(profile);
1224
+ continue;
1225
+ }
1226
+ if (res.status === 429 && attempt < MAX_RETRIES2) {
1227
+ await sleep2(retryAfterMs(res));
1228
+ continue;
1229
+ }
1230
+ return res;
1231
+ }
1232
+ }
1233
+ async function fetchOnce(url, init, profile) {
1234
+ const host = new URL(url).host;
1235
+ const semaphore = semaphoreFor(host);
1236
+ await semaphore.acquire();
1237
+ try {
1238
+ const token = await getValidUserToken(profile);
1239
+ const headers = new Headers(init.headers);
1240
+ headers.set("Authorization", `Bearer ${token}`);
1241
+ return await fetch(url, { ...init, headers });
1242
+ } finally {
1243
+ semaphore.release();
1244
+ }
1245
+ }
1246
+ function retryAfterMs(res) {
1247
+ const raw = parseInt(res.headers.get("Retry-After") ?? "5", 10);
1248
+ const seconds = Math.min(Number.isNaN(raw) ? 5 : raw, MAX_RETRY_AFTER_SECONDS);
1249
+ return seconds * 1e3;
1250
+ }
1251
+ async function handleUserApiError(res) {
917
1252
  if (res.status === 401) {
918
- throw new AuthError("User token expired. Run `nworks login --user --scope calendar` again.");
1253
+ throw new AuthError(
1254
+ "User token expired or revoked. Run `nworks login --user` again."
1255
+ );
919
1256
  }
920
1257
  let code = "UNKNOWN";
921
1258
  let description = `HTTP ${res.status}`;
@@ -927,6 +1264,9 @@ async function handleError(res) {
927
1264
  }
928
1265
  throw new ApiError(code, description, res.status);
929
1266
  }
1267
+
1268
+ // src/api/calendar.ts
1269
+ var BASE_URL2 = "https://www.worksapis.com/v1.0";
930
1270
  function generateEventId() {
931
1271
  return `event-${randomUUID()}`;
932
1272
  }
@@ -940,12 +1280,12 @@ function normalizeDateTime(dt) {
940
1280
  async function listEvents(fromDateTime, untilDateTime, userId = "me", profile = "default") {
941
1281
  const from = encodeURIComponent(fromDateTime);
942
1282
  const until = encodeURIComponent(untilDateTime);
943
- const url = `${BASE_URL2}/users/${userId}/calendar/events?fromDateTime=${from}&untilDateTime=${until}`;
1283
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events?fromDateTime=${from}&untilDateTime=${until}`;
944
1284
  if (process.env["NWORKS_VERBOSE"] === "1") {
945
1285
  console.error(`[nworks] GET ${url}`);
946
1286
  }
947
- const res = await authedFetch(url, { method: "GET" }, profile);
948
- if (!res.ok) return handleError(res);
1287
+ const res = await userFetch(url, { method: "GET" }, profile);
1288
+ if (!res.ok) return handleUserApiError(res);
949
1289
  const data = await res.json();
950
1290
  return { events: data.events ?? [] };
951
1291
  }
@@ -977,12 +1317,12 @@ async function createEvent(opts) {
977
1317
  eventComponents: [eventComponent],
978
1318
  sendNotification: opts.sendNotification ?? false
979
1319
  };
980
- const url = `${BASE_URL2}/users/${userId}/calendar/events`;
1320
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events`;
981
1321
  if (process.env["NWORKS_VERBOSE"] === "1") {
982
1322
  console.error(`[nworks] POST ${url}`);
983
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1323
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
984
1324
  }
985
- const res = await authedFetch(
1325
+ const res = await userFetch(
986
1326
  url,
987
1327
  {
988
1328
  method: "POST",
@@ -994,16 +1334,16 @@ async function createEvent(opts) {
994
1334
  if (res.status === 201) {
995
1335
  return await res.json();
996
1336
  }
997
- if (!res.ok) return handleError(res);
1337
+ if (!res.ok) return handleUserApiError(res);
998
1338
  return await res.json();
999
1339
  }
1000
1340
  async function getEvent(eventId, userId = "me", profile = "default") {
1001
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${eventId}`;
1341
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(eventId)}`;
1002
1342
  if (process.env["NWORKS_VERBOSE"] === "1") {
1003
1343
  console.error(`[nworks] GET ${url}`);
1004
1344
  }
1005
- const res = await authedFetch(url, { method: "GET" }, profile);
1006
- if (!res.ok) return handleError(res);
1345
+ const res = await userFetch(url, { method: "GET" }, profile);
1346
+ if (!res.ok) return handleUserApiError(res);
1007
1347
  const data = await res.json();
1008
1348
  const event = data.eventComponents[0];
1009
1349
  if (!event) throw new ApiError("NOT_FOUND", "Event not found", 404);
@@ -1041,12 +1381,12 @@ async function updateEvent(opts) {
1041
1381
  eventComponents: [eventComponent],
1042
1382
  sendNotification: opts.sendNotification ?? false
1043
1383
  };
1044
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${opts.eventId}`;
1384
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(opts.eventId)}`;
1045
1385
  if (process.env["NWORKS_VERBOSE"] === "1") {
1046
1386
  console.error(`[nworks] PUT ${url}`);
1047
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
1387
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1048
1388
  }
1049
- const res = await authedFetch(
1389
+ const res = await userFetch(
1050
1390
  url,
1051
1391
  {
1052
1392
  method: "PUT",
@@ -1055,18 +1395,18 @@ async function updateEvent(opts) {
1055
1395
  },
1056
1396
  profile
1057
1397
  );
1058
- if (!res.ok) return handleError(res);
1398
+ if (!res.ok) return handleUserApiError(res);
1059
1399
  }
1060
1400
  async function deleteEvent(eventId, userId = "me", sendNotification = false, profile = "default") {
1061
1401
  const params = new URLSearchParams();
1062
1402
  params.set("sendNotification", String(sendNotification));
1063
- const url = `${BASE_URL2}/users/${userId}/calendar/events/${eventId}?${params.toString()}`;
1403
+ const url = `${BASE_URL2}/users/${sanitizePathSegment(userId)}/calendar/events/${sanitizePathSegment(eventId)}?${params.toString()}`;
1064
1404
  if (process.env["NWORKS_VERBOSE"] === "1") {
1065
1405
  console.error(`[nworks] DELETE ${url}`);
1066
1406
  }
1067
- const res = await authedFetch(url, { method: "DELETE" }, profile);
1407
+ const res = await userFetch(url, { method: "DELETE" }, profile);
1068
1408
  if (res.status === 204) return;
1069
- if (!res.ok) return handleError(res);
1409
+ if (!res.ok) return handleUserApiError(res);
1070
1410
  }
1071
1411
 
1072
1412
  // src/commands/calendar.ts
@@ -1186,32 +1526,19 @@ import { join as join2 } from "path";
1186
1526
  import { Command as Command7 } from "commander";
1187
1527
 
1188
1528
  // src/api/drive.ts
1189
- import { readFile as readFile4, stat } from "fs/promises";
1529
+ import { readFile as readFile4, stat as stat2 } from "fs/promises";
1190
1530
  import { basename } from "path";
1191
1531
  var BASE_URL3 = "https://www.worksapis.com/v1.0";
1192
- async function authedFetch2(url, init, profile) {
1193
- const token = await getValidUserToken(profile);
1194
- const headers = new Headers(init.headers);
1195
- headers.set("Authorization", `Bearer ${token}`);
1196
- return fetch(url, { ...init, headers });
1197
- }
1198
- async function handleError2(res) {
1199
- if (res.status === 401) {
1200
- throw new AuthError("User token expired. Run `nworks login --user --scope file` again.");
1201
- }
1202
- let code = "UNKNOWN";
1203
- let description = `HTTP ${res.status}`;
1204
- try {
1205
- const body = await res.json();
1206
- code = body.code ?? code;
1207
- description = body.description ?? description;
1208
- } catch {
1209
- }
1210
- throw new ApiError(code, description, res.status);
1211
- }
1532
+ var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
1533
+ var ALLOWED_HOSTS = [
1534
+ "storage.worksmobile.com",
1535
+ "apis-storage.worksmobile.com",
1536
+ "www.worksapis.com",
1537
+ "worksapis.com"
1538
+ ];
1212
1539
  async function listFiles(userId = "me", folderId, count = 20, cursor, profile = "default") {
1213
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1214
- const path = folderId ? `${base}/${folderId}/children` : base;
1540
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
1541
+ const path = folderId ? `${base}/${sanitizePathSegment(folderId)}/children` : base;
1215
1542
  const params = new URLSearchParams();
1216
1543
  params.set("count", String(count));
1217
1544
  if (cursor) params.set("cursor", cursor);
@@ -1219,21 +1546,25 @@ async function listFiles(userId = "me", folderId, count = 20, cursor, profile =
1219
1546
  if (process.env["NWORKS_VERBOSE"] === "1") {
1220
1547
  console.error(`[nworks] GET ${url}`);
1221
1548
  }
1222
- const res = await authedFetch2(url, { method: "GET" }, profile);
1223
- if (!res.ok) return handleError2(res);
1549
+ const res = await userFetch(url, { method: "GET" }, profile);
1550
+ if (!res.ok) return handleUserApiError(res);
1224
1551
  const data = await res.json();
1225
1552
  return { files: data.files ?? [], responseMetaData: data.responseMetaData };
1226
1553
  }
1227
1554
  async function uploadFile(localPath, userId = "me", folderId, overwrite = false, profile = "default") {
1228
1555
  const fileName = basename(localPath);
1229
- const fileStat = await stat(localPath);
1556
+ const safeName = sanitizeFileName(fileName);
1557
+ const fileStat = await stat2(localPath);
1230
1558
  const fileSize = fileStat.size;
1231
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1232
- const createUrl = folderId ? `${base}/${folderId}` : base;
1559
+ if (fileSize > MAX_UPLOAD_SIZE) {
1560
+ throw new ApiError("FILE_TOO_LARGE", `File size (${fileSize} bytes) exceeds maximum allowed (${MAX_UPLOAD_SIZE} bytes)`, 413);
1561
+ }
1562
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
1563
+ const createUrl = folderId ? `${base}/${sanitizePathSegment(folderId)}` : base;
1233
1564
  if (process.env["NWORKS_VERBOSE"] === "1") {
1234
1565
  console.error(`[nworks] POST ${createUrl} (create upload URL)`);
1235
1566
  }
1236
- const createRes = await authedFetch2(
1567
+ const createRes = await userFetch(
1237
1568
  createUrl,
1238
1569
  {
1239
1570
  method: "POST",
@@ -1242,13 +1573,14 @@ async function uploadFile(localPath, userId = "me", folderId, overwrite = false,
1242
1573
  },
1243
1574
  profile
1244
1575
  );
1245
- if (!createRes.ok) return handleError2(createRes);
1576
+ if (!createRes.ok) return handleUserApiError(createRes);
1246
1577
  const { uploadUrl } = await createRes.json();
1578
+ validateRedirectUrl(uploadUrl, ALLOWED_HOSTS);
1247
1579
  const fileBuffer = await readFile4(localPath);
1248
1580
  const boundary = `----nworks${Date.now()}`;
1249
1581
  const header = Buffer.from(
1250
1582
  `--${boundary}\r
1251
- Content-Disposition: form-data; name="Filedata"; filename="${fileName}"\r
1583
+ Content-Disposition: form-data; name="Filedata"; filename="${safeName}"\r
1252
1584
  Content-Type: application/octet-stream\r
1253
1585
  \r
1254
1586
  `
@@ -1260,7 +1592,7 @@ Content-Type: application/octet-stream\r
1260
1592
  if (process.env["NWORKS_VERBOSE"] === "1") {
1261
1593
  console.error(`[nworks] POST ${uploadUrl} (upload content, ${fileSize} bytes)`);
1262
1594
  }
1263
- const uploadRes = await authedFetch2(
1595
+ const uploadRes = await userFetch(
1264
1596
  uploadUrl,
1265
1597
  {
1266
1598
  method: "POST",
@@ -1269,17 +1601,20 @@ Content-Type: application/octet-stream\r
1269
1601
  },
1270
1602
  profile
1271
1603
  );
1272
- if (!uploadRes.ok) return handleError2(uploadRes);
1604
+ if (!uploadRes.ok) return handleUserApiError(uploadRes);
1273
1605
  return await uploadRes.json();
1274
1606
  }
1275
1607
  async function uploadBuffer(fileBuffer, fileName, userId = "me", folderId, overwrite = false, profile = "default") {
1276
1608
  const fileSize = fileBuffer.length;
1277
- const base = `${BASE_URL3}/users/${userId}/drive/files`;
1278
- const createUrl = folderId ? `${base}/${folderId}` : base;
1609
+ if (fileSize > MAX_UPLOAD_SIZE) {
1610
+ throw new ApiError("FILE_TOO_LARGE", `File size (${fileSize} bytes) exceeds maximum allowed (${MAX_UPLOAD_SIZE} bytes)`, 413);
1611
+ }
1612
+ const base = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files`;
1613
+ const createUrl = folderId ? `${base}/${sanitizePathSegment(folderId)}` : base;
1279
1614
  if (process.env["NWORKS_VERBOSE"] === "1") {
1280
1615
  console.error(`[nworks] POST ${createUrl} (create upload URL for buffer)`);
1281
1616
  }
1282
- const createRes = await authedFetch2(
1617
+ const createRes = await userFetch(
1283
1618
  createUrl,
1284
1619
  {
1285
1620
  method: "POST",
@@ -1288,12 +1623,13 @@ async function uploadBuffer(fileBuffer, fileName, userId = "me", folderId, overw
1288
1623
  },
1289
1624
  profile
1290
1625
  );
1291
- if (!createRes.ok) return handleError2(createRes);
1626
+ if (!createRes.ok) return handleUserApiError(createRes);
1292
1627
  const { uploadUrl } = await createRes.json();
1628
+ validateRedirectUrl(uploadUrl, ALLOWED_HOSTS);
1293
1629
  const boundary = `----nworks${Date.now()}`;
1294
1630
  const header = Buffer.from(
1295
1631
  `--${boundary}\r
1296
- Content-Disposition: form-data; name="Filedata"; filename="${fileName}"\r
1632
+ Content-Disposition: form-data; name="Filedata"; filename="${sanitizeFileName(fileName)}"\r
1297
1633
  Content-Type: application/octet-stream\r
1298
1634
  \r
1299
1635
  `
@@ -1305,7 +1641,7 @@ Content-Type: application/octet-stream\r
1305
1641
  if (process.env["NWORKS_VERBOSE"] === "1") {
1306
1642
  console.error(`[nworks] POST ${uploadUrl} (upload buffer, ${fileSize} bytes)`);
1307
1643
  }
1308
- const uploadRes = await authedFetch2(
1644
+ const uploadRes = await userFetch(
1309
1645
  uploadUrl,
1310
1646
  {
1311
1647
  method: "POST",
@@ -1314,32 +1650,29 @@ Content-Type: application/octet-stream\r
1314
1650
  },
1315
1651
  profile
1316
1652
  );
1317
- if (!uploadRes.ok) return handleError2(uploadRes);
1653
+ if (!uploadRes.ok) return handleUserApiError(uploadRes);
1318
1654
  return await uploadRes.json();
1319
1655
  }
1320
- async function downloadFile(fileId, userId = "me", profile = "default") {
1321
- const url = `${BASE_URL3}/users/${userId}/drive/files/${fileId}/download`;
1656
+ async function downloadViaRedirect(url, profile) {
1322
1657
  if (process.env["NWORKS_VERBOSE"] === "1") {
1323
1658
  console.error(`[nworks] GET ${url} (get download URL)`);
1324
1659
  }
1325
- const redirectRes = await authedFetch2(
1660
+ const redirectRes = await userFetch(
1326
1661
  url,
1327
1662
  { method: "GET", redirect: "manual" },
1328
1663
  profile
1329
1664
  );
1330
- if (redirectRes.status === 401) {
1331
- throw new AuthError("User token expired. Run `nworks login --user --scope file` again.");
1332
- }
1333
1665
  const location = redirectRes.headers.get("location");
1334
1666
  if (!location) {
1335
- if (!redirectRes.ok) return handleError2(redirectRes);
1667
+ if (!redirectRes.ok) return handleUserApiError(redirectRes);
1336
1668
  throw new ApiError("NO_REDIRECT", "No download URL returned", redirectRes.status);
1337
1669
  }
1670
+ const safeLocation = validateRedirectUrl(location, ALLOWED_HOSTS);
1338
1671
  if (process.env["NWORKS_VERBOSE"] === "1") {
1339
- console.error(`[nworks] GET ${location} (download content)`);
1672
+ console.error(`[nworks] GET ${safeLocation} (download content)`);
1340
1673
  }
1341
- const downloadRes = await authedFetch2(location, { method: "GET" }, profile);
1342
- if (!downloadRes.ok) return handleError2(downloadRes);
1674
+ const downloadRes = await userFetch(safeLocation, { method: "GET" }, profile);
1675
+ if (!downloadRes.ok) return handleUserApiError(downloadRes);
1343
1676
  const arrayBuffer = await downloadRes.arrayBuffer();
1344
1677
  const buffer = Buffer.from(arrayBuffer);
1345
1678
  const disposition = downloadRes.headers.get("content-disposition");
@@ -1352,6 +1685,42 @@ async function downloadFile(fileId, userId = "me", profile = "default") {
1352
1685
  }
1353
1686
  return { buffer, fileName };
1354
1687
  }
1688
+ async function downloadFile(fileId, userId = "me", profile = "default") {
1689
+ const url = `${BASE_URL3}/users/${sanitizePathSegment(userId)}/drive/files/${sanitizePathSegment(fileId)}/download`;
1690
+ return downloadViaRedirect(url, profile);
1691
+ }
1692
+ async function listSharedDrives(count = 20, cursor, profile = "default") {
1693
+ const params = new URLSearchParams();
1694
+ params.set("count", String(count));
1695
+ if (cursor) params.set("cursor", cursor);
1696
+ const url = `${BASE_URL3}/sharedrives?${params.toString()}`;
1697
+ if (process.env["NWORKS_VERBOSE"] === "1") {
1698
+ console.error(`[nworks] GET ${url}`);
1699
+ }
1700
+ const res = await userFetch(url, { method: "GET" }, profile);
1701
+ if (!res.ok) return handleUserApiError(res);
1702
+ const data = await res.json();
1703
+ return { sharedDrives: data.sharedrives ?? [], responseMetaData: data.responseMetaData };
1704
+ }
1705
+ async function listSharedDriveFiles(sharedriveId, folderId, count = 20, cursor, profile = "default") {
1706
+ const base = `${BASE_URL3}/sharedrives/${sanitizePathSegment(sharedriveId)}/files`;
1707
+ const path = folderId ? `${base}/${sanitizePathSegment(folderId)}/children` : base;
1708
+ const params = new URLSearchParams();
1709
+ params.set("count", String(count));
1710
+ if (cursor) params.set("cursor", cursor);
1711
+ const url = `${path}?${params.toString()}`;
1712
+ if (process.env["NWORKS_VERBOSE"] === "1") {
1713
+ console.error(`[nworks] GET ${url}`);
1714
+ }
1715
+ const res = await userFetch(url, { method: "GET" }, profile);
1716
+ if (!res.ok) return handleUserApiError(res);
1717
+ const data = await res.json();
1718
+ return { files: data.files ?? [], responseMetaData: data.responseMetaData };
1719
+ }
1720
+ async function downloadSharedDriveFile(sharedriveId, fileId, profile = "default") {
1721
+ const url = `${BASE_URL3}/sharedrives/${sanitizePathSegment(sharedriveId)}/files/${sanitizePathSegment(fileId)}/download`;
1722
+ return downloadViaRedirect(url, profile);
1723
+ }
1355
1724
 
1356
1725
  // src/commands/drive.ts
1357
1726
  var listCommand2 = new Command7("list").description("List files in Drive (requires User OAuth with file or file.read scope)").option("--user <userId>", "Target user ID (default: me)").option("--folder <folderId>", "Folder ID to list (default: root)").option("--count <n>", "Items per page (default: 20)", "20").option("--cursor <cursor>", "Pagination cursor").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
@@ -1445,37 +1814,90 @@ var downloadCommand = new Command7("download").description("Download a file from
1445
1814
  cliError(err, opts, "drive");
1446
1815
  }
1447
1816
  });
1448
- var driveCommand = new Command7("drive").description("Drive operations (requires User OAuth with file scope)").addCommand(listCommand2).addCommand(uploadCommand).addCommand(downloadCommand);
1817
+ var sharedriveListCommand = new Command7("sharedrive-list").description("List shared drives (requires User OAuth with file or file.read scope)").option("--count <n>", "Items per page (default: 20)", "20").option("--cursor <cursor>", "Pagination cursor").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
1818
+ try {
1819
+ const result = await listSharedDrives(
1820
+ parseInt(opts.count, 10),
1821
+ opts.cursor,
1822
+ opts.profile
1823
+ );
1824
+ output(
1825
+ {
1826
+ sharedDrives: result.sharedDrives,
1827
+ count: result.sharedDrives.length,
1828
+ nextCursor: result.responseMetaData?.nextCursor ?? null
1829
+ },
1830
+ opts
1831
+ );
1832
+ } catch (err) {
1833
+ cliError(err, opts, "drive");
1834
+ }
1835
+ });
1836
+ var sharedriveFilesCommand = new Command7("sharedrive-files").description("List files in a shared drive (requires User OAuth with file or file.read scope)").requiredOption("--sharedrive <sharedriveId>", "Shared drive ID (from sharedrive-list)").option("--folder <fileId>", "Folder ID to list (default: root)").option("--count <n>", "Items per page (default: 20)", "20").option("--cursor <cursor>", "Pagination cursor").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
1837
+ try {
1838
+ const result = await listSharedDriveFiles(
1839
+ opts.sharedrive,
1840
+ opts.folder,
1841
+ parseInt(opts.count, 10),
1842
+ opts.cursor,
1843
+ opts.profile
1844
+ );
1845
+ const files = result.files.map((f) => ({
1846
+ name: f.fileName,
1847
+ type: f.fileType,
1848
+ size: f.fileSize,
1849
+ modified: f.modifiedTime,
1850
+ fileId: f.fileId
1851
+ }));
1852
+ output(
1853
+ {
1854
+ files,
1855
+ count: files.length,
1856
+ nextCursor: result.responseMetaData?.nextCursor ?? null
1857
+ },
1858
+ opts
1859
+ );
1860
+ } catch (err) {
1861
+ cliError(err, opts, "drive");
1862
+ }
1863
+ });
1864
+ var sharedriveDownloadCommand = new Command7("sharedrive-download").description("Download a file from a shared drive (requires User OAuth with file or file.read scope)").requiredOption("--sharedrive <sharedriveId>", "Shared drive ID (from sharedrive-list)").requiredOption("--file-id <fileId>", "File ID to download").option("--out <path>", "Output directory (default: current directory)").option("--name <filename>", "Output filename (default: original name)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
1865
+ try {
1866
+ const result = await downloadSharedDriveFile(
1867
+ opts.sharedrive,
1868
+ opts.fileId,
1869
+ opts.profile
1870
+ );
1871
+ const fileName = opts.name ?? result.fileName ?? opts.fileId;
1872
+ const outDir = opts.out ?? process.cwd();
1873
+ const outPath = join2(outDir, fileName);
1874
+ await writeFile2(outPath, result.buffer);
1875
+ output(
1876
+ {
1877
+ success: true,
1878
+ fileName,
1879
+ path: outPath,
1880
+ size: result.buffer.length
1881
+ },
1882
+ opts
1883
+ );
1884
+ } catch (err) {
1885
+ cliError(err, opts, "drive");
1886
+ }
1887
+ });
1888
+ var driveCommand = new Command7("drive").description("Drive operations (requires User OAuth with file scope)").addCommand(listCommand2).addCommand(uploadCommand).addCommand(downloadCommand).addCommand(sharedriveListCommand).addCommand(sharedriveFilesCommand).addCommand(sharedriveDownloadCommand);
1449
1889
 
1450
1890
  // src/commands/mail.ts
1891
+ import { writeFile as writeFile3 } from "fs/promises";
1892
+ import { join as join3 } from "path";
1451
1893
  import { Command as Command8 } from "commander";
1452
1894
 
1453
1895
  // src/api/mail.ts
1454
1896
  var BASE_URL4 = "https://www.worksapis.com/v1.0";
1455
- async function authedFetch3(url, init, profile) {
1456
- const token = await getValidUserToken(profile);
1457
- const headers = new Headers(init.headers);
1458
- headers.set("Authorization", `Bearer ${token}`);
1459
- return fetch(url, { ...init, headers });
1460
- }
1461
- async function handleError3(res) {
1462
- if (res.status === 401) {
1463
- throw new AuthError("User token expired. Run `nworks login --user --scope mail` again.");
1464
- }
1465
- let code = "UNKNOWN";
1466
- let description = `HTTP ${res.status}`;
1467
- try {
1468
- const body = await res.json();
1469
- code = body.code ?? code;
1470
- description = body.description ?? description;
1471
- } catch {
1472
- }
1473
- throw new ApiError(code, description, res.status);
1474
- }
1475
1897
  async function sendMail(opts) {
1476
1898
  const userId = opts.userId ?? "me";
1477
1899
  const profile = opts.profile ?? "default";
1478
- const url = `${BASE_URL4}/users/${userId}/mail`;
1900
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail`;
1479
1901
  if (process.env["NWORKS_VERBOSE"] === "1") {
1480
1902
  console.error(`[nworks] POST ${url}`);
1481
1903
  }
@@ -1487,7 +1909,7 @@ async function sendMail(opts) {
1487
1909
  if (opts.cc) body.cc = opts.cc;
1488
1910
  if (opts.bcc) body.bcc = opts.bcc;
1489
1911
  if (opts.contentType) body.contentType = opts.contentType;
1490
- const res = await authedFetch3(
1912
+ const res = await userFetch(
1491
1913
  url,
1492
1914
  {
1493
1915
  method: "POST",
@@ -1497,19 +1919,19 @@ async function sendMail(opts) {
1497
1919
  profile
1498
1920
  );
1499
1921
  if (res.status === 202) return;
1500
- if (!res.ok) return handleError3(res);
1922
+ if (!res.ok) return handleUserApiError(res);
1501
1923
  }
1502
1924
  async function listMails(folderId = 0, userId = "me", count = 30, cursor, isUnread, profile = "default") {
1503
1925
  const params = new URLSearchParams();
1504
1926
  params.set("count", String(count));
1505
1927
  if (cursor) params.set("cursor", cursor);
1506
1928
  if (isUnread) params.set("isUnread", "true");
1507
- const url = `${BASE_URL4}/users/${userId}/mail/mailfolders/${folderId}/children?${params.toString()}`;
1929
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail/mailfolders/${sanitizePathSegment(String(folderId))}/children?${params.toString()}`;
1508
1930
  if (process.env["NWORKS_VERBOSE"] === "1") {
1509
1931
  console.error(`[nworks] GET ${url}`);
1510
1932
  }
1511
- const res = await authedFetch3(url, { method: "GET" }, profile);
1512
- if (!res.ok) return handleError3(res);
1933
+ const res = await userFetch(url, { method: "GET" }, profile);
1934
+ if (!res.ok) return handleUserApiError(res);
1513
1935
  const data = await res.json();
1514
1936
  return {
1515
1937
  mails: data.mails ?? [],
@@ -1520,14 +1942,26 @@ async function listMails(folderId = 0, userId = "me", count = 30, cursor, isUnre
1520
1942
  };
1521
1943
  }
1522
1944
  async function readMail(mailId, userId = "me", profile = "default") {
1523
- const url = `${BASE_URL4}/users/${userId}/mail/${mailId}`;
1945
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail/${sanitizePathSegment(String(mailId))}`;
1524
1946
  if (process.env["NWORKS_VERBOSE"] === "1") {
1525
1947
  console.error(`[nworks] GET ${url}`);
1526
1948
  }
1527
- const res = await authedFetch3(url, { method: "GET" }, profile);
1528
- if (!res.ok) return handleError3(res);
1949
+ const res = await userFetch(url, { method: "GET" }, profile);
1950
+ if (!res.ok) return handleUserApiError(res);
1529
1951
  return await res.json();
1530
1952
  }
1953
+ async function downloadAttachment(mailId, attachmentId, userId = "me", profile = "default") {
1954
+ const url = `${BASE_URL4}/users/${sanitizePathSegment(userId)}/mail/${sanitizePathSegment(
1955
+ String(mailId)
1956
+ )}/attachments/${sanitizePathSegment(String(attachmentId))}`;
1957
+ if (process.env["NWORKS_VERBOSE"] === "1") {
1958
+ console.error(`[nworks] GET ${url}`);
1959
+ }
1960
+ const res = await userFetch(url, { method: "GET" }, profile);
1961
+ if (!res.ok) return handleUserApiError(res);
1962
+ const data = await res.json();
1963
+ return { buffer: Buffer.from(data.data, "base64"), filename: data.filename };
1964
+ }
1531
1965
 
1532
1966
  // src/commands/mail.ts
1533
1967
  var sendCommand2 = new Command8("send").description("Send a mail (requires User OAuth with mail scope)").requiredOption("--to <emails>", "Recipient email addresses (separate with ;)").requiredOption("--subject <subject>", "Mail subject").option("--body <body>", "Mail body content").option("--cc <emails>", "CC email addresses (separate with ;)").option("--bcc <emails>", "BCC email addresses (separate with ;)").option("--content-type <type>", "Body format: html or text", "html").option("--user <userId>", "Sender user ID (default: me)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").option("--dry-run", "Print request without sending").action(async (opts) => {
@@ -1615,41 +2049,46 @@ var readCommand = new Command8("read").description("Read a specific mail (requir
1615
2049
  cliError(err, opts, "mail");
1616
2050
  }
1617
2051
  });
1618
- var mailCommand = new Command8("mail").description("Mail operations (requires User OAuth with mail scope)").addCommand(sendCommand2).addCommand(listCommand3).addCommand(readCommand);
2052
+ var downloadAttachmentCommand = new Command8("download-attachment").description("Download a mail attachment (requires User OAuth with mail or mail.read scope)").requiredOption("--id <mailId>", "Mail ID (from mail list)").requiredOption("--attachment-id <attachmentId>", "Attachment ID (from mail read)").option("--out <path>", "Output directory (default: current directory)").option("--name <filename>", "Output filename (default: original name)").option("--user <userId>", "Target user ID (default: me)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
2053
+ try {
2054
+ const result = await downloadAttachment(
2055
+ parseInt(opts.id, 10),
2056
+ parseInt(opts.attachmentId, 10),
2057
+ opts.user ?? "me",
2058
+ opts.profile
2059
+ );
2060
+ const fileName = opts.name ?? result.filename;
2061
+ const outDir = opts.out ?? process.cwd();
2062
+ const outPath = join3(outDir, fileName);
2063
+ await writeFile3(outPath, result.buffer);
2064
+ output(
2065
+ {
2066
+ success: true,
2067
+ fileName,
2068
+ path: outPath,
2069
+ size: result.buffer.length
2070
+ },
2071
+ opts
2072
+ );
2073
+ } catch (err) {
2074
+ cliError(err, opts, "mail");
2075
+ }
2076
+ });
2077
+ var mailCommand = new Command8("mail").description("Mail operations (requires User OAuth with mail scope)").addCommand(sendCommand2).addCommand(listCommand3).addCommand(readCommand).addCommand(downloadAttachmentCommand);
1619
2078
 
1620
2079
  // src/commands/task.ts
1621
2080
  import { Command as Command9 } from "commander";
1622
2081
 
1623
2082
  // src/api/task.ts
1624
2083
  var BASE_URL5 = "https://www.worksapis.com/v1.0";
1625
- async function authedFetch4(url, init, profile) {
1626
- const token = await getValidUserToken(profile);
1627
- const headers = new Headers(init.headers);
1628
- headers.set("Authorization", `Bearer ${token}`);
1629
- return fetch(url, { ...init, headers });
1630
- }
1631
- async function handleError4(res) {
1632
- if (res.status === 401) {
1633
- throw new AuthError("User token expired. Run `nworks login --user --scope task` again.");
1634
- }
1635
- let code = "UNKNOWN";
1636
- let description = `HTTP ${res.status}`;
1637
- try {
1638
- const body = await res.json();
1639
- code = body.code ?? code;
1640
- description = body.description ?? description;
1641
- } catch {
1642
- }
1643
- throw new ApiError(code, description, res.status);
1644
- }
1645
- async function resolveUserId(userId, profile) {
1646
- if (userId !== "me") return userId;
1647
- const url = `${BASE_URL5}/users/me`;
1648
- const res = await authedFetch4(url, { method: "GET" }, profile);
1649
- if (!res.ok) return handleError4(res);
1650
- const data = await res.json();
1651
- if (process.env["NWORKS_VERBOSE"] === "1") {
1652
- console.error(`[nworks] Resolved "me" \u2192 ${data.userId}`);
2084
+ async function resolveUserId(userId, profile) {
2085
+ if (userId !== "me") return userId;
2086
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}`;
2087
+ const res = await userFetch(url, { method: "GET" }, profile);
2088
+ if (!res.ok) return handleUserApiError(res);
2089
+ const data = await res.json();
2090
+ if (process.env["NWORKS_VERBOSE"] === "1") {
2091
+ console.error(`[nworks] Resolved "me" \u2192 ${data.userId}`);
1653
2092
  }
1654
2093
  return data.userId;
1655
2094
  }
@@ -1659,12 +2098,12 @@ async function listTasks(categoryId = "default", userId = "me", count = 50, curs
1659
2098
  params.set("count", String(count));
1660
2099
  params.set("status", status);
1661
2100
  if (cursor) params.set("cursor", cursor);
1662
- const url = `${BASE_URL5}/users/${userId}/tasks?${params.toString()}`;
2101
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}/tasks?${params.toString()}`;
1663
2102
  if (process.env["NWORKS_VERBOSE"] === "1") {
1664
2103
  console.error(`[nworks] GET ${url}`);
1665
2104
  }
1666
- const res = await authedFetch4(url, { method: "GET" }, profile);
1667
- if (!res.ok) return handleError4(res);
2105
+ const res = await userFetch(url, { method: "GET" }, profile);
2106
+ if (!res.ok) return handleUserApiError(res);
1668
2107
  const data = await res.json();
1669
2108
  return { tasks: data.tasks ?? [], responseMetaData: data.responseMetaData };
1670
2109
  }
@@ -1683,12 +2122,12 @@ async function createTask(opts) {
1683
2122
  };
1684
2123
  if (opts.dueDate) body.dueDate = opts.dueDate;
1685
2124
  if (opts.categoryId) body.categoryId = opts.categoryId;
1686
- const url = `${BASE_URL5}/users/${userId}/tasks`;
2125
+ const url = `${BASE_URL5}/users/${sanitizePathSegment(userId)}/tasks`;
1687
2126
  if (process.env["NWORKS_VERBOSE"] === "1") {
1688
2127
  console.error(`[nworks] POST ${url}`);
1689
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
2128
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1690
2129
  }
1691
- const res = await authedFetch4(
2130
+ const res = await userFetch(
1692
2131
  url,
1693
2132
  {
1694
2133
  method: "POST",
@@ -1700,7 +2139,7 @@ async function createTask(opts) {
1700
2139
  if (res.status === 201) {
1701
2140
  return await res.json();
1702
2141
  }
1703
- if (!res.ok) return handleError4(res);
2142
+ if (!res.ok) return handleUserApiError(res);
1704
2143
  return await res.json();
1705
2144
  }
1706
2145
  async function updateTask(opts) {
@@ -1709,11 +2148,11 @@ async function updateTask(opts) {
1709
2148
  if (opts.title !== void 0) body.title = opts.title;
1710
2149
  if (opts.content !== void 0) body.content = opts.content;
1711
2150
  if (opts.dueDate !== void 0) body.dueDate = opts.dueDate;
1712
- const url = `${BASE_URL5}/tasks/${opts.taskId}`;
2151
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(opts.taskId)}`;
1713
2152
  if (process.env["NWORKS_VERBOSE"] === "1") {
1714
2153
  console.error(`[nworks] PATCH ${url}`);
1715
2154
  }
1716
- const res = await authedFetch4(
2155
+ const res = await userFetch(
1717
2156
  url,
1718
2157
  {
1719
2158
  method: "PATCH",
@@ -1722,47 +2161,47 @@ async function updateTask(opts) {
1722
2161
  },
1723
2162
  profile
1724
2163
  );
1725
- if (!res.ok) return handleError4(res);
2164
+ if (!res.ok) return handleUserApiError(res);
1726
2165
  return await res.json();
1727
2166
  }
1728
2167
  async function completeTask(taskId, profile = "default") {
1729
- const url = `${BASE_URL5}/tasks/${taskId}/complete`;
2168
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}/complete`;
1730
2169
  if (process.env["NWORKS_VERBOSE"] === "1") {
1731
2170
  console.error(`[nworks] POST ${url}`);
1732
2171
  }
1733
- const res = await authedFetch4(
2172
+ const res = await userFetch(
1734
2173
  url,
1735
2174
  { method: "POST" },
1736
2175
  profile
1737
2176
  );
1738
2177
  if (res.status === 204) return;
1739
- if (!res.ok) return handleError4(res);
2178
+ if (!res.ok) return handleUserApiError(res);
1740
2179
  }
1741
2180
  async function incompleteTask(taskId, profile = "default") {
1742
- const url = `${BASE_URL5}/tasks/${taskId}/incomplete`;
2181
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}/incomplete`;
1743
2182
  if (process.env["NWORKS_VERBOSE"] === "1") {
1744
2183
  console.error(`[nworks] POST ${url}`);
1745
2184
  }
1746
- const res = await authedFetch4(
2185
+ const res = await userFetch(
1747
2186
  url,
1748
2187
  { method: "POST" },
1749
2188
  profile
1750
2189
  );
1751
2190
  if (res.status === 204) return;
1752
- if (!res.ok) return handleError4(res);
2191
+ if (!res.ok) return handleUserApiError(res);
1753
2192
  }
1754
2193
  async function deleteTask(taskId, profile = "default") {
1755
- const url = `${BASE_URL5}/tasks/${taskId}`;
2194
+ const url = `${BASE_URL5}/tasks/${sanitizePathSegment(taskId)}`;
1756
2195
  if (process.env["NWORKS_VERBOSE"] === "1") {
1757
2196
  console.error(`[nworks] DELETE ${url}`);
1758
2197
  }
1759
- const res = await authedFetch4(
2198
+ const res = await userFetch(
1760
2199
  url,
1761
2200
  { method: "DELETE" },
1762
2201
  profile
1763
2202
  );
1764
2203
  if (res.status === 204) return;
1765
- if (!res.ok) return handleError4(res);
2204
+ if (!res.ok) return handleUserApiError(res);
1766
2205
  }
1767
2206
 
1768
2207
  // src/commands/task.ts
@@ -1885,26 +2324,6 @@ import { Command as Command10 } from "commander";
1885
2324
 
1886
2325
  // src/api/board.ts
1887
2326
  var BASE_URL6 = "https://www.worksapis.com/v1.0";
1888
- async function authedFetch5(url, init, profile) {
1889
- const token = await getValidUserToken(profile);
1890
- const headers = new Headers(init.headers);
1891
- headers.set("Authorization", `Bearer ${token}`);
1892
- return fetch(url, { ...init, headers });
1893
- }
1894
- async function handleError5(res) {
1895
- if (res.status === 401) {
1896
- throw new AuthError("User token expired. Run `nworks login --user --scope board` again.");
1897
- }
1898
- let code = "UNKNOWN";
1899
- let description = `HTTP ${res.status}`;
1900
- try {
1901
- const body = await res.json();
1902
- code = body.code ?? code;
1903
- description = body.description ?? description;
1904
- } catch {
1905
- }
1906
- throw new ApiError(code, description, res.status);
1907
- }
1908
2327
  function safeParseJson(text) {
1909
2328
  const safe = text.replace(
1910
2329
  /"((?:board|post|domain|user)Id)"\s*:\s*(\d{16,})/g,
@@ -1920,11 +2339,11 @@ async function listBoards(count = 20, cursor, profile = "default") {
1920
2339
  if (process.env["NWORKS_VERBOSE"] === "1") {
1921
2340
  console.error(`[nworks] GET ${url}`);
1922
2341
  }
1923
- const res = await authedFetch5(url, { method: "GET" }, profile);
1924
- if (!res.ok) return handleError5(res);
2342
+ const res = await userFetch(url, { method: "GET" }, profile);
2343
+ if (!res.ok) return handleUserApiError(res);
1925
2344
  const text = await res.text();
1926
2345
  if (process.env["NWORKS_VERBOSE"] === "1") {
1927
- console.error(`[nworks] Response: ${text}`);
2346
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1928
2347
  }
1929
2348
  const data = safeParseJson(text);
1930
2349
  return { boards: data.boards ?? [], responseMetaData: data.responseMetaData };
@@ -1933,29 +2352,29 @@ async function listPosts(boardId, count = 20, cursor, profile = "default") {
1933
2352
  const params = new URLSearchParams();
1934
2353
  params.set("count", String(count));
1935
2354
  if (cursor) params.set("cursor", cursor);
1936
- const url = `${BASE_URL6}/boards/${boardId}/posts?${params.toString()}`;
2355
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(boardId)}/posts?${params.toString()}`;
1937
2356
  if (process.env["NWORKS_VERBOSE"] === "1") {
1938
2357
  console.error(`[nworks] GET ${url}`);
1939
2358
  }
1940
- const res = await authedFetch5(url, { method: "GET" }, profile);
1941
- if (!res.ok) return handleError5(res);
2359
+ const res = await userFetch(url, { method: "GET" }, profile);
2360
+ if (!res.ok) return handleUserApiError(res);
1942
2361
  const text = await res.text();
1943
2362
  if (process.env["NWORKS_VERBOSE"] === "1") {
1944
- console.error(`[nworks] Response: ${text}`);
2363
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1945
2364
  }
1946
2365
  const data = safeParseJson(text);
1947
2366
  return { posts: data.posts ?? [], responseMetaData: data.responseMetaData };
1948
2367
  }
1949
2368
  async function readPost(boardId, postId, profile = "default") {
1950
- const url = `${BASE_URL6}/boards/${boardId}/posts/${postId}`;
2369
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(boardId)}/posts/${sanitizePathSegment(postId)}`;
1951
2370
  if (process.env["NWORKS_VERBOSE"] === "1") {
1952
2371
  console.error(`[nworks] GET ${url}`);
1953
2372
  }
1954
- const res = await authedFetch5(url, { method: "GET" }, profile);
1955
- if (!res.ok) return handleError5(res);
2373
+ const res = await userFetch(url, { method: "GET" }, profile);
2374
+ if (!res.ok) return handleUserApiError(res);
1956
2375
  const text = await res.text();
1957
2376
  if (process.env["NWORKS_VERBOSE"] === "1") {
1958
- console.error(`[nworks] Response: ${text}`);
2377
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1959
2378
  }
1960
2379
  return safeParseJson(text);
1961
2380
  }
@@ -1967,12 +2386,12 @@ async function createPost(opts) {
1967
2386
  };
1968
2387
  if (opts.enableComment !== void 0) body.enableComment = opts.enableComment;
1969
2388
  if (opts.sendNotifications !== void 0) body.sendNotifications = opts.sendNotifications;
1970
- const url = `${BASE_URL6}/boards/${opts.boardId}/posts`;
2389
+ const url = `${BASE_URL6}/boards/${sanitizePathSegment(opts.boardId)}/posts`;
1971
2390
  if (process.env["NWORKS_VERBOSE"] === "1") {
1972
2391
  console.error(`[nworks] POST ${url}`);
1973
- console.error(`[nworks] Body: ${JSON.stringify(body, null, 2)}`);
2392
+ console.error(`[nworks] Body: ${JSON.stringify(body).length} bytes`);
1974
2393
  }
1975
- const res = await authedFetch5(
2394
+ const res = await userFetch(
1976
2395
  url,
1977
2396
  {
1978
2397
  method: "POST",
@@ -1984,11 +2403,11 @@ async function createPost(opts) {
1984
2403
  if (res.status === 201 || res.ok) {
1985
2404
  const text = await res.text();
1986
2405
  if (process.env["NWORKS_VERBOSE"] === "1") {
1987
- console.error(`[nworks] Response: ${text}`);
2406
+ console.error(`[nworks] Response: ${res.status} (${text.length} bytes)`);
1988
2407
  }
1989
2408
  return safeParseJson(text);
1990
2409
  }
1991
- return handleError5(res);
2410
+ return handleUserApiError(res);
1992
2411
  }
1993
2412
 
1994
2413
  // src/commands/board.ts
@@ -2086,128 +2505,256 @@ var createCommand3 = new Command10("create").description("Create a post in a boa
2086
2505
  });
2087
2506
  var boardCommand = new Command10("board").description("Board operations (requires User OAuth)").addCommand(listCommand5).addCommand(postsCommand).addCommand(readCommand2).addCommand(createCommand3);
2088
2507
 
2089
- // src/commands/mcp-cmd.ts
2090
- import { Command as Command12 } from "commander";
2091
-
2092
- // src/mcp/server.ts
2093
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2094
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2095
-
2096
- // src/mcp/tools.ts
2097
- import { z } from "zod";
2098
-
2099
- // src/commands/doctor.ts
2508
+ // src/commands/contact.ts
2100
2509
  import { Command as Command11 } from "commander";
2101
- import { existsSync as existsSync3 } from "fs";
2102
- import { readFile as readFile5 } from "fs/promises";
2103
- async function runChecks(profile) {
2104
- const results = [];
2105
- let creds;
2106
- try {
2107
- creds = await loadCredentials(profile);
2108
- results.push({ check: "credentials", status: "OK", detail: `clientId: ${creds.clientId}` });
2109
- } catch {
2110
- results.push({ check: "credentials", status: "FAIL", detail: "\uC778\uC99D \uC815\uBCF4 \uC5C6\uC74C. CLI: `nworks login --user` / MCP: nworks_setup tool \uC0AC\uC6A9 (\uD658\uACBD\uBCC0\uC218 NWORKS_CLIENT_SECRET \uD544\uC694)" });
2111
- return results;
2510
+
2511
+ // src/api/contact.ts
2512
+ var BASE_URL7 = "https://www.worksapis.com/v1.0";
2513
+ async function listContacts(opts = {}) {
2514
+ const userId = opts.userId ?? "me";
2515
+ const profile = opts.profile ?? "default";
2516
+ const params = new URLSearchParams();
2517
+ params.set("count", String(opts.count ?? 20));
2518
+ if (opts.cursor) params.set("cursor", opts.cursor);
2519
+ if (opts.accessibleRange) params.set("accessibleRange", opts.accessibleRange);
2520
+ if (opts.contactTagId) params.set("contactTagId", opts.contactTagId);
2521
+ if (opts.email) params.set("email", opts.email);
2522
+ if (opts.telephone) params.set("telephone", opts.telephone);
2523
+ if (opts.orderBy) params.set("orderBy", opts.orderBy);
2524
+ if (opts.searchDateType) params.set("searchDateType", opts.searchDateType);
2525
+ if (opts.startDateTime) params.set("startDateTime", opts.startDateTime);
2526
+ if (opts.endDateTime) params.set("endDateTime", opts.endDateTime);
2527
+ const url = `${BASE_URL7}/users/${sanitizePathSegment(userId)}/contacts?${params.toString()}`;
2528
+ if (process.env["NWORKS_VERBOSE"] === "1") {
2529
+ console.error(`[nworks] GET ${url}`);
2112
2530
  }
2113
- if (hasServiceAccountCreds(creds)) {
2114
- results.push({ check: "serviceAccount", status: "OK", detail: creds.serviceAccount });
2115
- } else {
2116
- results.push({ check: "serviceAccount", status: "SKIP", detail: "\uBBF8\uC124\uC815 (\uBD07 \uBA54\uC2DC\uC9C0 \uC0AC\uC6A9 \uC2DC \uD544\uC694)" });
2531
+ const res = await userFetch(url, { method: "GET" }, profile);
2532
+ if (!res.ok) return handleUserApiError(res);
2533
+ const data = await res.json();
2534
+ return { contacts: data.contacts ?? [], responseMetaData: data.responseMetaData };
2535
+ }
2536
+ async function getContact(contactId, profile = "default") {
2537
+ const url = `${BASE_URL7}/contacts/${sanitizePathSegment(contactId)}`;
2538
+ if (process.env["NWORKS_VERBOSE"] === "1") {
2539
+ console.error(`[nworks] GET ${url}`);
2117
2540
  }
2118
- if (creds.privateKeyPath) {
2119
- if (existsSync3(creds.privateKeyPath)) {
2120
- try {
2121
- await readFile5(creds.privateKeyPath, "utf-8");
2122
- results.push({ check: "privateKey", status: "OK", detail: creds.privateKeyPath });
2123
- } catch {
2124
- results.push({ check: "privateKey", status: "FAIL", detail: `\uC77D\uAE30 \uBD88\uAC00: ${creds.privateKeyPath}` });
2125
- }
2126
- } else {
2127
- results.push({ check: "privateKey", status: "FAIL", detail: `\uD30C\uC77C \uC5C6\uC74C: ${creds.privateKeyPath}` });
2128
- }
2129
- } else {
2130
- results.push({ check: "privateKey", status: "SKIP", detail: "\uBBF8\uC124\uC815" });
2541
+ const res = await userFetch(url, { method: "GET" }, profile);
2542
+ if (!res.ok) return handleUserApiError(res);
2543
+ return await res.json();
2544
+ }
2545
+ async function createContact(payload, profile = "default") {
2546
+ const url = `${BASE_URL7}/contacts`;
2547
+ if (process.env["NWORKS_VERBOSE"] === "1") {
2548
+ console.error(`[nworks] POST ${url}`);
2131
2549
  }
2132
- if (creds.botId) {
2133
- results.push({ check: "botId", status: "OK", detail: creds.botId });
2134
- } else {
2135
- results.push({ check: "botId", status: "SKIP", detail: "\uBBF8\uC124\uC815 (\uBA54\uC2DC\uC9C0 \uC804\uC1A1 \uC2DC \uD544\uC694)" });
2550
+ const res = await userFetch(
2551
+ url,
2552
+ {
2553
+ method: "POST",
2554
+ headers: { "Content-Type": "application/json" },
2555
+ body: JSON.stringify(payload)
2556
+ },
2557
+ profile
2558
+ );
2559
+ if (!res.ok) return handleUserApiError(res);
2560
+ return await res.json();
2561
+ }
2562
+ async function updateContact(contactId, payload, partial = true, profile = "default") {
2563
+ const url = `${BASE_URL7}/contacts/${sanitizePathSegment(contactId)}`;
2564
+ if (process.env["NWORKS_VERBOSE"] === "1") {
2565
+ console.error(`[nworks] ${partial ? "PATCH" : "PUT"} ${url}`);
2136
2566
  }
2137
- const token = await loadToken(profile);
2138
- if (token) {
2139
- const valid = token.expiresAt > Date.now() / 1e3;
2140
- results.push({
2141
- check: "serviceToken",
2142
- status: valid ? "OK" : "EXPIRED",
2143
- detail: valid ? `\uB9CC\uB8CC: ${new Date(token.expiresAt * 1e3).toISOString()}` : `\uB9CC\uB8CC\uB428: ${new Date(token.expiresAt * 1e3).toISOString()}`
2144
- });
2145
- } else {
2146
- results.push({ check: "serviceToken", status: "SKIP", detail: "\uD1A0\uD070 \uC5C6\uC74C" });
2567
+ const res = await userFetch(
2568
+ url,
2569
+ {
2570
+ method: partial ? "PATCH" : "PUT",
2571
+ headers: { "Content-Type": "application/json" },
2572
+ body: JSON.stringify(payload)
2573
+ },
2574
+ profile
2575
+ );
2576
+ if (!res.ok) return handleUserApiError(res);
2577
+ return await res.json();
2578
+ }
2579
+ async function deleteContact(contactId, profile = "default") {
2580
+ const url = `${BASE_URL7}/contacts/${sanitizePathSegment(contactId)}`;
2581
+ if (process.env["NWORKS_VERBOSE"] === "1") {
2582
+ console.error(`[nworks] DELETE ${url}`);
2147
2583
  }
2148
- const userToken = await loadUserToken(profile);
2149
- if (userToken) {
2150
- const valid = userToken.expiresAt > Date.now() / 1e3;
2151
- results.push({
2152
- check: "userOAuth",
2153
- status: valid ? "OK" : "EXPIRED",
2154
- detail: valid ? `scope: ${userToken.scope} | \uB9CC\uB8CC: ${new Date(userToken.expiresAt * 1e3).toISOString()}` : `\uB9CC\uB8CC\uB428 | scope: ${userToken.scope}`
2584
+ const res = await userFetch(url, { method: "DELETE" }, profile);
2585
+ if (res.status === 204) return;
2586
+ if (!res.ok) return handleUserApiError(res);
2587
+ }
2588
+ async function listContactTags(opts = {}) {
2589
+ const userId = opts.userId ?? "me";
2590
+ const profile = opts.profile ?? "default";
2591
+ const params = new URLSearchParams();
2592
+ params.set("count", String(opts.count ?? 20));
2593
+ if (opts.cursor) params.set("cursor", opts.cursor);
2594
+ const url = `${BASE_URL7}/users/${sanitizePathSegment(userId)}/contact-tags?${params.toString()}`;
2595
+ if (process.env["NWORKS_VERBOSE"] === "1") {
2596
+ console.error(`[nworks] GET ${url}`);
2597
+ }
2598
+ const res = await userFetch(url, { method: "GET" }, profile);
2599
+ if (!res.ok) return handleUserApiError(res);
2600
+ const data = await res.json();
2601
+ return { contactTags: data.contactTags ?? [], responseMetaData: data.responseMetaData };
2602
+ }
2603
+
2604
+ // src/commands/contact.ts
2605
+ var listCommand6 = new Command11("list").description("List contacts (requires User OAuth with contact or contact.read scope)").option("--user <userId>", "Target user ID (default: me)").option("--count <n>", "Items per page (default: 20)", "20").option("--cursor <cursor>", "Pagination cursor").option("--tag <contactTagId>", "Filter by contact tag ID").option("--email <email>", "Filter by email").option("--tel <telephone>", "Filter by phone number").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
2606
+ try {
2607
+ const result = await listContacts({
2608
+ userId: opts.user ?? "me",
2609
+ count: parseInt(opts.count, 10),
2610
+ cursor: opts.cursor,
2611
+ contactTagId: opts.tag,
2612
+ email: opts.email,
2613
+ telephone: opts.tel,
2614
+ profile: opts.profile
2155
2615
  });
2156
- } else {
2157
- results.push({ check: "userOAuth", status: "SKIP", detail: "\uD1A0\uD070 \uC5C6\uC74C. `nworks login --user` \uD544\uC694" });
2616
+ output(
2617
+ {
2618
+ contacts: result.contacts,
2619
+ count: result.contacts.length,
2620
+ nextCursor: result.responseMetaData?.nextCursor ?? null
2621
+ },
2622
+ opts
2623
+ );
2624
+ } catch (err) {
2625
+ cliError(err, opts, "contact");
2158
2626
  }
2159
- if (hasServiceAccountCreds(creds) && token && token.expiresAt > Date.now() / 1e3) {
2160
- try {
2161
- const res = await fetch("https://www.worksapis.com/v1.0/users/me", {
2162
- headers: { Authorization: `Bearer ${token.accessToken}` }
2163
- });
2164
- if (res.ok) {
2165
- results.push({ check: "apiConnection", status: "OK", detail: "NAVER WORKS API \uC5F0\uACB0 \uC131\uACF5" });
2166
- } else {
2167
- results.push({ check: "apiConnection", status: "FAIL", detail: `HTTP ${res.status}` });
2168
- }
2169
- } catch (e) {
2170
- results.push({ check: "apiConnection", status: "FAIL", detail: `\uC5F0\uACB0 \uC2E4\uD328: ${e.message}` });
2171
- }
2172
- } else if (userToken && userToken.expiresAt > Date.now() / 1e3) {
2173
- try {
2174
- const res = await fetch("https://www.worksapis.com/v1.0/users/me", {
2175
- headers: { Authorization: `Bearer ${userToken.accessToken}` }
2176
- });
2177
- if (res.ok) {
2178
- results.push({ check: "apiConnection", status: "OK", detail: "NAVER WORKS API \uC5F0\uACB0 \uC131\uACF5" });
2179
- } else {
2180
- results.push({ check: "apiConnection", status: "FAIL", detail: `HTTP ${res.status}` });
2181
- }
2182
- } catch (e) {
2183
- results.push({ check: "apiConnection", status: "FAIL", detail: `\uC5F0\uACB0 \uC2E4\uD328: ${e.message}` });
2184
- }
2185
- } else {
2186
- results.push({ check: "apiConnection", status: "SKIP", detail: "\uC720\uD6A8\uD55C \uD1A0\uD070 \uC5C6\uC74C \u2014 API \uD14C\uC2A4\uD2B8 \uAC74\uB108\uB700" });
2627
+ });
2628
+ var getCommand = new Command11("get").description("Get a contact by ID (requires User OAuth with contact or contact.read scope)").requiredOption("--id <contactId>", "Contact ID (from contact list)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
2629
+ try {
2630
+ const result = await getContact(opts.id, opts.profile);
2631
+ output(result, opts);
2632
+ } catch (err) {
2633
+ cliError(err, opts, "contact");
2187
2634
  }
2188
- return results;
2189
- }
2190
- var doctorCommand = new Command11("doctor").description("Check nworks configuration and connectivity").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
2635
+ });
2636
+ var createCommand4 = new Command11("create").description("Create a contact (requires User OAuth with contact scope)").requiredOption("--payload <json>", "Contact fields as JSON (passed through to the NAVER WORKS API)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").option("--dry-run", "Print request without creating").action(async (opts) => {
2191
2637
  try {
2192
- const results = await runChecks(opts.profile);
2193
- if (opts.json || !process.stdout.isTTY) {
2194
- output(results, opts);
2195
- } else {
2196
- console.log("\n nworks doctor\n");
2197
- for (const r of results) {
2198
- const icon = r.status === "OK" ? "\u2705" : r.status === "SKIP" ? "\u2796" : "\u274C";
2199
- console.log(` ${icon} ${r.check.padEnd(16)} ${r.detail}`);
2200
- }
2201
- console.log();
2638
+ const payload = JSON.parse(opts.payload);
2639
+ if (opts.dryRun) {
2640
+ output({ dryRun: true, request: payload }, opts);
2641
+ return;
2202
2642
  }
2643
+ const result = await createContact(payload, opts.profile);
2644
+ output({ success: true, ...result }, opts);
2203
2645
  } catch (err) {
2204
- cliError(err, opts);
2646
+ cliError(err, opts, "contact");
2647
+ }
2648
+ });
2649
+ var updateCommand3 = new Command11("update").description("Update a contact (requires User OAuth with contact scope)").requiredOption("--id <contactId>", "Contact ID (from contact list)").requiredOption("--payload <json>", "Fields to update as JSON (passed through to the NAVER WORKS API)").option("--replace", "Use PUT instead of PATCH (replace entire contact)", false).option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
2650
+ try {
2651
+ const payload = JSON.parse(opts.payload);
2652
+ const result = await updateContact(
2653
+ opts.id,
2654
+ payload,
2655
+ !opts.replace,
2656
+ opts.profile
2657
+ );
2658
+ output({ success: true, ...result }, opts);
2659
+ } catch (err) {
2660
+ cliError(err, opts, "contact");
2661
+ }
2662
+ });
2663
+ var deleteCommand3 = new Command11("delete").description("Delete a contact (requires User OAuth with contact scope)").requiredOption("--id <contactId>", "Contact ID (from contact list)").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
2664
+ try {
2665
+ await deleteContact(opts.id, opts.profile);
2666
+ output({ success: true, contactId: opts.id, message: "Contact deleted" }, opts);
2667
+ } catch (err) {
2668
+ cliError(err, opts, "contact");
2669
+ }
2670
+ });
2671
+ var listTagsCommand = new Command11("list-tags").description("List contact tags (requires User OAuth with contact or contact.read scope)").option("--user <userId>", "Target user ID (default: me)").option("--count <n>", "Items per page (default: 20)", "20").option("--cursor <cursor>", "Pagination cursor").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
2672
+ try {
2673
+ const result = await listContactTags({
2674
+ userId: opts.user ?? "me",
2675
+ count: parseInt(opts.count, 10),
2676
+ cursor: opts.cursor,
2677
+ profile: opts.profile
2678
+ });
2679
+ output(
2680
+ {
2681
+ contactTags: result.contactTags,
2682
+ count: result.contactTags.length,
2683
+ nextCursor: result.responseMetaData?.nextCursor ?? null
2684
+ },
2685
+ opts
2686
+ );
2687
+ } catch (err) {
2688
+ cliError(err, opts, "contact");
2205
2689
  }
2206
2690
  });
2691
+ var contactCommand = new Command11("contact").description("Contact operations (requires User OAuth with contact scope)").addCommand(listCommand6).addCommand(getCommand).addCommand(createCommand4).addCommand(updateCommand3).addCommand(deleteCommand3).addCommand(listTagsCommand);
2207
2692
 
2208
- // src/mcp/tools.ts
2209
- function registerTools(server) {
2210
- server.tool(
2693
+ // src/commands/mcp-cmd.ts
2694
+ import { Command as Command13 } from "commander";
2695
+ import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
2696
+
2697
+ // src/mcp/server.ts
2698
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2699
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2700
+
2701
+ // src/mcp/tools/setup.ts
2702
+ import { z as z2 } from "zod";
2703
+
2704
+ // src/mcp/tool-registry.ts
2705
+ var TOOL_ANNOTATIONS = {
2706
+ nworks_setup: { title: "Save credentials", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2707
+ nworks_message_send: { title: "Send message", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2708
+ nworks_message_members: { title: "List channel members", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2709
+ nworks_directory_members: { title: "List organization members", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2710
+ nworks_calendar_list: { title: "List calendar events", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2711
+ nworks_calendar_create: { title: "Create calendar event", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2712
+ nworks_calendar_update: { title: "Update calendar event", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
2713
+ nworks_calendar_delete: { title: "Delete calendar event", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
2714
+ nworks_drive_list: { title: "List drive files", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2715
+ nworks_drive_upload: { title: "Upload file to drive", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2716
+ // download는 outputDir 지정 시 로컬 파일을 쓰므로 순수 읽기가 아니다(readOnlyHint=false).
2717
+ nworks_drive_download: { title: "Download drive file", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2718
+ nworks_sharedrive_list: { title: "List shared drives", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2719
+ nworks_sharedrive_files: { title: "List shared drive files", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2720
+ nworks_sharedrive_download: { title: "Download shared drive file", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2721
+ nworks_mail_send: { title: "Send mail", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2722
+ nworks_mail_list: { title: "List mail", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2723
+ nworks_mail_read: { title: "Read mail", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2724
+ // drive_download와 같은 이유로 outputDir 지정 시 로컬 파일을 쓴다(readOnlyHint=false).
2725
+ nworks_mail_download_attachment: { title: "Download mail attachment", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2726
+ nworks_task_list: { title: "List tasks", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2727
+ nworks_task_create: { title: "Create task", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2728
+ nworks_task_update: { title: "Update task", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
2729
+ nworks_task_delete: { title: "Delete task", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
2730
+ nworks_board_list: { title: "List boards", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2731
+ nworks_board_posts: { title: "List board posts", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2732
+ nworks_board_read: { title: "Read board post", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2733
+ nworks_board_create: { title: "Create board post", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2734
+ nworks_contact_list: { title: "List contacts", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2735
+ nworks_contact_get: { title: "Get contact", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2736
+ nworks_contact_create: { title: "Create contact", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2737
+ nworks_contact_update: { title: "Update contact", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
2738
+ nworks_contact_delete: { title: "Delete contact", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
2739
+ nworks_contact_list_tags: { title: "List contact tags", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2740
+ nworks_login_user: { title: "Log in with User OAuth", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2741
+ nworks_whoami: { title: "Show auth status", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2742
+ nworks_logout: { title: "Log out", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
2743
+ nworks_doctor: { title: "Diagnose connection", readOnlyHint: true, idempotentHint: true, openWorldHint: true }
2744
+ };
2745
+ function defineTool(server, name, description, inputSchema, handler) {
2746
+ const annotations = TOOL_ANNOTATIONS[name];
2747
+ server.registerTool(
2748
+ name,
2749
+ { description, inputSchema, ...annotations ? { annotations } : {} },
2750
+ handler
2751
+ );
2752
+ }
2753
+
2754
+ // src/mcp/tools/setup.ts
2755
+ function registerSetupTools(server) {
2756
+ defineTool(
2757
+ server,
2211
2758
  "nworks_setup",
2212
2759
  `NAVER WORKS API \uC778\uC99D \uC815\uBCF4\uB97C \uC124\uC815\uD569\uB2C8\uB2E4.
2213
2760
 
@@ -2230,10 +2777,10 @@ function registerTools(server) {
2230
2777
 
2231
2778
  OAuth Redirect URI: http://localhost:9876/callback`,
2232
2779
  {
2233
- clientId: z.string().describe("Client ID (Developer Console\uC5D0\uC11C \uBC1C\uAE09)"),
2234
- serviceAccount: z.string().optional().describe("Service Account ID (\uC608: xxxxx.serviceaccount@domain)"),
2235
- botId: z.string().optional().describe("Bot ID (\uBA54\uC2DC\uC9C0 \uC804\uC1A1 \uC2DC \uD544\uC694)"),
2236
- domainId: z.string().optional().describe("Domain ID")
2780
+ clientId: z2.string().describe("Client ID (Developer Console\uC5D0\uC11C \uBC1C\uAE09)"),
2781
+ serviceAccount: z2.string().optional().describe("Service Account ID (\uC608: xxxxx.serviceaccount@domain)"),
2782
+ botId: z2.string().optional().describe("Bot ID (\uBA54\uC2DC\uC9C0 \uC804\uC1A1 \uC2DC \uD544\uC694)"),
2783
+ domainId: z2.string().optional().describe("Domain ID")
2237
2784
  },
2238
2785
  async ({ clientId, serviceAccount, botId, domainId }) => {
2239
2786
  try {
@@ -2271,7 +2818,7 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2271
2818
  nextSteps.push('NWORKS_PRIVATE_KEY_PATH \uD658\uACBD\uBCC0\uC218\uAC00 \uC124\uC815\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. Service Account \uC778\uC99D\uC5D0\uB294 Private Key \uD30C\uC77C \uACBD\uB85C\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. \uC0AC\uC6A9\uC790\uC5D0\uAC8C \uC548\uB0B4\uD558\uC138\uC694: (1) Developer Console(https://dev.worksmobile.com)\uC5D0\uC11C Private Key\uB97C \uB2E4\uC6B4\uB85C\uB4DC (2) MCP \uC124\uC815 \uD30C\uC77C\uC758 env\uC5D0 NWORKS_PRIVATE_KEY_PATH\uB97C \uCD94\uAC00 (\uC608: "NWORKS_PRIVATE_KEY_PATH": "C:/keys/private.key") (3) MCP \uD074\uB77C\uC774\uC5B8\uD2B8 \uC7AC\uC2DC\uC791');
2272
2819
  }
2273
2820
  nextSteps.push("User OAuth\uAC00 \uD544\uC694\uD55C API\uB294 nworks_login_user tool\uB85C \uBE0C\uB77C\uC6B0\uC800 \uB85C\uADF8\uC778\uC744 \uC9C4\uD589\uD558\uC138\uC694");
2274
- const mask = (s) => s.length <= 4 ? "****" : `****${s.slice(-Math.min(4, Math.floor(s.length / 3)))}`;
2821
+ const mask2 = (s) => s.length <= 4 ? "****" : `****${s.slice(-Math.min(4, Math.floor(s.length / 3)))}`;
2275
2822
  return {
2276
2823
  content: [
2277
2824
  {
@@ -2280,10 +2827,10 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2280
2827
  success: true,
2281
2828
  message: "\uC778\uC99D \uC815\uBCF4\uAC00 \uC800\uC7A5\uB418\uC5C8\uC2B5\uB2C8\uB2E4.",
2282
2829
  nextSteps,
2283
- clientId,
2284
- clientSecret: `${mask(resolvedSecret)} (\uD658\uACBD\uBCC0\uC218)`,
2830
+ clientId: mask2(clientId),
2831
+ clientSecret: `${mask2(resolvedSecret)} (\uD658\uACBD\uBCC0\uC218)`,
2285
2832
  serviceAccount: serviceAccount ?? null,
2286
- privateKeyPath: resolvedPrivateKeyPath ? `${mask(resolvedPrivateKeyPath)} (\uD658\uACBD\uBCC0\uC218)` : null,
2833
+ privateKeyPath: resolvedPrivateKeyPath ? `${mask2(resolvedPrivateKeyPath)} (\uD658\uACBD\uBCC0\uC218)` : null,
2287
2834
  botId: botId ?? null
2288
2835
  })
2289
2836
  }
@@ -2297,16 +2844,22 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2297
2844
  }
2298
2845
  }
2299
2846
  );
2300
- server.tool(
2847
+ }
2848
+
2849
+ // src/mcp/tools/message.ts
2850
+ import { z as z3 } from "zod";
2851
+ function registerMessageTools(server) {
2852
+ defineTool(
2853
+ server,
2301
2854
  "nworks_message_send",
2302
2855
  "NAVER WORKS \uBA54\uC2DC\uC9C0\uB97C \uC804\uC1A1\uD569\uB2C8\uB2E4 (\uBD07\uC774 \uC0AC\uC6A9\uC790 \uB610\uB294 \uCC44\uB110\uC5D0 \uBC1C\uC1A1). Service Account \uC778\uC99D \uC0AC\uC6A9 (nworks_setup\uC5D0\uC11C serviceAccount, botId \uC124\uC815 + \uD658\uACBD\uBCC0\uC218 NWORKS_PRIVATE_KEY_PATH \uD544\uC694. User OAuth \uBD88\uD544\uC694)",
2303
2856
  {
2304
- to: z.string().optional().describe("\uC218\uC2E0\uC790 userId (channel\uACFC \uD0DD 1). nworks_directory_members\uB85C userId \uC870\uD68C \uAC00\uB2A5"),
2305
- channel: z.string().optional().describe("\uCC44\uB110 channelId (to\uC640 \uD0DD 1). nworks_message_members\uB85C \uCC44\uB110 \uAD6C\uC131\uC6D0 \uD655\uC778 \uAC00\uB2A5"),
2306
- text: z.string().describe("\uBA54\uC2DC\uC9C0 \uBCF8\uBB38"),
2307
- type: z.enum(["text", "button", "list"]).optional().describe("\uBA54\uC2DC\uC9C0 \uD0C0\uC785 (\uAE30\uBCF8: text)"),
2308
- actions: z.string().optional().describe("\uBC84\uD2BC \uC561\uC158 JSON (type=button\uC77C \uB54C)"),
2309
- elements: z.string().optional().describe("\uB9AC\uC2A4\uD2B8 \uD56D\uBAA9 JSON (type=list\uC77C \uB54C)")
2857
+ to: z3.string().optional().describe("\uC218\uC2E0\uC790 userId (channel\uACFC \uD0DD 1). nworks_directory_members\uB85C userId \uC870\uD68C \uAC00\uB2A5"),
2858
+ channel: z3.string().optional().describe("\uCC44\uB110 channelId (to\uC640 \uD0DD 1). nworks_message_members\uB85C \uCC44\uB110 \uAD6C\uC131\uC6D0 \uD655\uC778 \uAC00\uB2A5"),
2859
+ text: z3.string().describe("\uBA54\uC2DC\uC9C0 \uBCF8\uBB38"),
2860
+ type: z3.enum(["text", "button", "list"]).optional().describe("\uBA54\uC2DC\uC9C0 \uD0C0\uC785 (\uAE30\uBCF8: text)"),
2861
+ actions: z3.string().optional().describe("\uBC84\uD2BC \uC561\uC158 JSON (type=button\uC77C \uB54C)"),
2862
+ elements: z3.string().optional().describe("\uB9AC\uC2A4\uD2B8 \uD56D\uBAA9 JSON (type=list\uC77C \uB54C)")
2310
2863
  },
2311
2864
  async ({ to, channel, text, type, actions, elements }) => {
2312
2865
  try {
@@ -2329,11 +2882,12 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2329
2882
  }
2330
2883
  }
2331
2884
  );
2332
- server.tool(
2885
+ defineTool(
2886
+ server,
2333
2887
  "nworks_message_members",
2334
2888
  "\uD2B9\uC815 \uCC44\uB110\uC758 \uAD6C\uC131\uC6D0 \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uC774 \uCC44\uB110\uC5D0 \uB204\uAC00 \uC788\uC5B4?' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. Service Account \uC778\uC99D \uC0AC\uC6A9 (nworks_setup \uD544\uC694)",
2335
2889
  {
2336
- channel: z.string().describe("\uCC44\uB110 channelId")
2890
+ channel: z3.string().describe("\uCC44\uB110 channelId")
2337
2891
  },
2338
2892
  async ({ channel }) => {
2339
2893
  try {
@@ -2349,15 +2903,29 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2349
2903
  }
2350
2904
  }
2351
2905
  );
2352
- server.tool(
2906
+ }
2907
+
2908
+ // src/mcp/tools/directory.ts
2909
+ import { z as z4 } from "zod";
2910
+ function registerDirectoryTools(server) {
2911
+ defineTool(
2912
+ server,
2353
2913
  "nworks_directory_members",
2354
- "NAVER WORKS \uC870\uC9C1 \uAD6C\uC131\uC6D0(\uC9C1\uC6D0) \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uAD6C\uC131\uC6D0 \uBAA9\uB85D \uBCF4\uC5EC\uC918', '\uD300\uC6D0 \uCC3E\uC544\uC918', '\uB204\uAD6C\uD55C\uD14C \uBA54\uC2DC\uC9C0 \uBCF4\uB0BC\uC9C0 userId \uCC3E\uAE30' \uB4F1\uC5D0 \uC0AC\uC6A9. Service Account \uC778\uC99D \uC0AC\uC6A9 (nworks_setup \uD544\uC694). \uBA54\uC2DC\uC9C0 \uC804\uC1A1 \uC2DC \uC218\uC2E0\uC790 userId\uB97C \uC5EC\uAE30\uC11C \uC870\uD68C \uAC00\uB2A5",
2355
- {},
2356
- async () => {
2914
+ "NAVER WORKS \uC870\uC9C1 \uAD6C\uC131\uC6D0(\uC9C1\uC6D0) \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uAD6C\uC131\uC6D0 \uBAA9\uB85D \uBCF4\uC5EC\uC918', '\uD300\uC6D0 \uCC3E\uC544\uC918', '\uB204\uAD6C\uD55C\uD14C \uBA54\uC2DC\uC9C0 \uBCF4\uB0BC\uC9C0 userId \uCC3E\uAE30' \uB4F1\uC5D0 \uC0AC\uC6A9. Service Account \uC778\uC99D \uC0AC\uC6A9 (nworks_setup \uD544\uC694). \uBA54\uC2DC\uC9C0 \uC804\uC1A1 \uC2DC \uC218\uC2E0\uC790 userId\uB97C \uC5EC\uAE30\uC11C \uC870\uD68C \uAC00\uB2A5. \uAD6C\uC131\uC6D0\uC774 \uB9CE\uC73C\uBA74 nextCursor\uB85C \uB2E4\uC74C \uD398\uC774\uC9C0\uB97C \uC774\uC5B4\uC11C \uC870\uD68C",
2915
+ {
2916
+ count: z4.number().int().min(1).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 100)"),
2917
+ cursor: z4.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C (\uC774\uC804 \uC751\uB2F5\uC758 nextCursor \uAC12)")
2918
+ },
2919
+ async ({ count, cursor }) => {
2357
2920
  try {
2358
- const result = await listUsers();
2921
+ const result = await listUsers(count ?? 100, cursor);
2359
2922
  return {
2360
- content: [{ type: "text", text: JSON.stringify(result) }]
2923
+ content: [{ type: "text", text: JSON.stringify({
2924
+ users: result.users,
2925
+ count: result.users.length,
2926
+ hasMore: !!result.responseMetaData?.nextCursor,
2927
+ nextCursor: result.responseMetaData?.nextCursor ?? null
2928
+ }) }]
2361
2929
  };
2362
2930
  } catch (err) {
2363
2931
  return {
@@ -2367,13 +2935,19 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2367
2935
  }
2368
2936
  }
2369
2937
  );
2370
- server.tool(
2938
+ }
2939
+
2940
+ // src/mcp/tools/calendar.ts
2941
+ import { z as z5 } from "zod";
2942
+ function registerCalendarTools(server) {
2943
+ defineTool(
2944
+ server,
2371
2945
  "nworks_calendar_list",
2372
2946
  "\uC0AC\uC6A9\uC790\uC758 \uCE98\uB9B0\uB354 \uC77C\uC815/\uC2A4\uCF00\uC904\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uC624\uB298 \uC77C\uC815 \uC54C\uB824\uC918', '\uC774\uBC88 \uC8FC \uC2A4\uCF00\uC904 \uD655\uC778' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (calendar.read scope). \uBBF8\uB85C\uADF8\uC778 \uC2DC nworks_login_user\uB85C \uB85C\uADF8\uC778 \uD544\uC694",
2373
2947
  {
2374
- fromDateTime: z.string().describe("\uC2DC\uC791 \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss+09:00)"),
2375
- untilDateTime: z.string().describe("\uC885\uB8CC \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss+09:00)"),
2376
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2948
+ fromDateTime: z5.string().describe("\uC2DC\uC791 \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss+09:00)"),
2949
+ untilDateTime: z5.string().describe("\uC885\uB8CC \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss+09:00)"),
2950
+ userId: z5.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2377
2951
  },
2378
2952
  async ({ fromDateTime, untilDateTime, userId }) => {
2379
2953
  try {
@@ -2402,21 +2976,32 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2402
2976
  }
2403
2977
  }
2404
2978
  );
2405
- server.tool(
2979
+ defineTool(
2980
+ server,
2406
2981
  "nworks_calendar_create",
2407
2982
  "\uCE98\uB9B0\uB354 \uC77C\uC815\uC744 \uC0C8\uB85C \uB9CC\uB4ED\uB2C8\uB2E4. '\uD68C\uC758 \uC7A1\uC544\uC918', '\uC77C\uC815 \uB4F1\uB85D\uD574\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (calendar + calendar.read scope)",
2408
2983
  {
2409
- summary: z.string().describe("\uC77C\uC815 \uC81C\uBAA9"),
2410
- start: z.string().describe("\uC2DC\uC791 \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss)"),
2411
- end: z.string().describe("\uC885\uB8CC \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss)"),
2412
- timeZone: z.string().optional().describe("\uD0C0\uC784\uC874 (\uAE30\uBCF8: Asia/Seoul)"),
2413
- description: z.string().optional().describe("\uC77C\uC815 \uC124\uBA85"),
2414
- location: z.string().optional().describe("\uC7A5\uC18C"),
2415
- attendees: z.array(z.object({ email: z.string(), displayName: z.string().optional() })).optional().describe("\uCC38\uC11D\uC790 \uBAA9\uB85D"),
2416
- sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
2417
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2984
+ summary: z5.string().describe("\uC77C\uC815 \uC81C\uBAA9"),
2985
+ start: z5.string().describe("\uC2DC\uC791 \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss)"),
2986
+ end: z5.string().describe("\uC885\uB8CC \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss)"),
2987
+ timeZone: z5.string().optional().describe("\uD0C0\uC784\uC874 (\uAE30\uBCF8: Asia/Seoul)"),
2988
+ description: z5.string().optional().describe("\uC77C\uC815 \uC124\uBA85"),
2989
+ location: z5.string().optional().describe("\uC7A5\uC18C"),
2990
+ attendees: z5.array(z5.object({ email: z5.string(), displayName: z5.string().optional() })).optional().describe("\uCC38\uC11D\uC790 \uBAA9\uB85D"),
2991
+ sendNotification: z5.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
2992
+ userId: z5.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2418
2993
  },
2419
- async ({ summary, start, end, timeZone, description, location, attendees, sendNotification, userId }) => {
2994
+ async ({
2995
+ summary,
2996
+ start,
2997
+ end,
2998
+ timeZone,
2999
+ description,
3000
+ location,
3001
+ attendees,
3002
+ sendNotification,
3003
+ userId
3004
+ }) => {
2420
3005
  try {
2421
3006
  const result = await createEvent({
2422
3007
  summary,
@@ -2441,21 +3026,32 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2441
3026
  }
2442
3027
  }
2443
3028
  );
2444
- server.tool(
3029
+ defineTool(
3030
+ server,
2445
3031
  "nworks_calendar_update",
2446
3032
  "\uAE30\uC874 \uCE98\uB9B0\uB354 \uC77C\uC815\uC744 \uC218\uC815\uD569\uB2C8\uB2E4. '\uC77C\uC815 \uC2DC\uAC04 \uBCC0\uACBD\uD574\uC918', '\uD68C\uC758 \uC81C\uBAA9 \uBC14\uAFD4\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (calendar + calendar.read scope). eventId\uB294 nworks_calendar_list\uB85C \uC870\uD68C \uAC00\uB2A5",
2447
3033
  {
2448
- eventId: z.string().describe("\uC77C\uC815 ID (nworks_calendar_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
2449
- summary: z.string().optional().describe("\uC0C8 \uC81C\uBAA9"),
2450
- start: z.string().optional().describe("\uC0C8 \uC2DC\uC791 \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss)"),
2451
- end: z.string().optional().describe("\uC0C8 \uC885\uB8CC \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss)"),
2452
- timeZone: z.string().optional().describe("\uD0C0\uC784\uC874 (\uAE30\uBCF8: Asia/Seoul)"),
2453
- description: z.string().optional().describe("\uC0C8 \uC124\uBA85"),
2454
- location: z.string().optional().describe("\uC0C8 \uC7A5\uC18C"),
2455
- sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
2456
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3034
+ eventId: z5.string().describe("\uC77C\uC815 ID (nworks_calendar_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3035
+ summary: z5.string().optional().describe("\uC0C8 \uC81C\uBAA9"),
3036
+ start: z5.string().optional().describe("\uC0C8 \uC2DC\uC791 \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss)"),
3037
+ end: z5.string().optional().describe("\uC0C8 \uC885\uB8CC \uC77C\uC2DC (YYYY-MM-DDThh:mm:ss)"),
3038
+ timeZone: z5.string().optional().describe("\uD0C0\uC784\uC874 (\uAE30\uBCF8: Asia/Seoul)"),
3039
+ description: z5.string().optional().describe("\uC0C8 \uC124\uBA85"),
3040
+ location: z5.string().optional().describe("\uC0C8 \uC7A5\uC18C"),
3041
+ sendNotification: z5.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
3042
+ userId: z5.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2457
3043
  },
2458
- async ({ eventId, summary, start, end, timeZone, description, location, sendNotification, userId }) => {
3044
+ async ({
3045
+ eventId,
3046
+ summary,
3047
+ start,
3048
+ end,
3049
+ timeZone,
3050
+ description,
3051
+ location,
3052
+ sendNotification,
3053
+ userId
3054
+ }) => {
2459
3055
  try {
2460
3056
  await updateEvent({
2461
3057
  eventId,
@@ -2479,13 +3075,14 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2479
3075
  }
2480
3076
  }
2481
3077
  );
2482
- server.tool(
3078
+ defineTool(
3079
+ server,
2483
3080
  "nworks_calendar_delete",
2484
3081
  "\uCE98\uB9B0\uB354 \uC77C\uC815\uC744 \uC0AD\uC81C\uD569\uB2C8\uB2E4. '\uC77C\uC815 \uCDE8\uC18C\uD574\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (calendar + calendar.read scope). eventId\uB294 nworks_calendar_list\uB85C \uC870\uD68C \uAC00\uB2A5",
2485
3082
  {
2486
- eventId: z.string().describe("\uC0AD\uC81C\uD560 \uC77C\uC815 ID (nworks_calendar_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
2487
- sendNotification: z.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
2488
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3083
+ eventId: z5.string().describe("\uC0AD\uC81C\uD560 \uC77C\uC815 ID (nworks_calendar_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3084
+ sendNotification: z5.boolean().optional().describe("\uCC38\uC11D\uC790\uC5D0\uAC8C \uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)"),
3085
+ userId: z5.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2489
3086
  },
2490
3087
  async ({ eventId, sendNotification, userId }) => {
2491
3088
  try {
@@ -2505,14 +3102,20 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2505
3102
  }
2506
3103
  }
2507
3104
  );
2508
- server.tool(
3105
+ }
3106
+
3107
+ // src/mcp/tools/drive.ts
3108
+ import { z as z6 } from "zod";
3109
+ function registerDriveTools(server) {
3110
+ defineTool(
3111
+ server,
2509
3112
  "nworks_drive_list",
2510
3113
  "NAVER WORKS \uB4DC\uB77C\uC774\uBE0C\uC758 \uD30C\uC77C/\uD3F4\uB354 \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uB4DC\uB77C\uC774\uBE0C \uD30C\uC77C \uBCF4\uC5EC\uC918', '\uB0B4 \uD30C\uC77C \uBAA9\uB85D' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (file.read scope)",
2511
3114
  {
2512
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)"),
2513
- folderId: z.string().optional().describe("\uD3F4\uB354 ID (\uBBF8\uC9C0\uC815 \uC2DC \uB8E8\uD2B8)"),
2514
- count: z.number().optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20, \uCD5C\uB300: 200)"),
2515
- cursor: z.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
3115
+ userId: z6.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)"),
3116
+ folderId: z6.string().optional().describe("\uD3F4\uB354 ID (\uBBF8\uC9C0\uC815 \uC2DC \uB8E8\uD2B8)"),
3117
+ count: z6.number().int().min(1).max(200).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20, \uCD5C\uB300: 200)"),
3118
+ cursor: z6.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
2516
3119
  },
2517
3120
  async ({ userId, folderId, count, cursor }) => {
2518
3121
  try {
@@ -2541,16 +3144,17 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2541
3144
  }
2542
3145
  }
2543
3146
  );
2544
- server.tool(
3147
+ defineTool(
3148
+ server,
2545
3149
  "nworks_drive_upload",
2546
3150
  "\uD30C\uC77C\uC744 \uB4DC\uB77C\uC774\uBE0C\uC5D0 \uC5C5\uB85C\uB4DC\uD569\uB2C8\uB2E4 (User OAuth file scope \uD544\uC694). content(base64)\uC640 fileName\uC73C\uB85C \uC804\uB2EC\uD558\uAC70\uB098, filePath\uB85C \uB85C\uCEEC \uD30C\uC77C \uACBD\uB85C\uB97C \uC9C0\uC815\uD569\uB2C8\uB2E4. MCP \uD074\uB77C\uC774\uC5B8\uD2B8\uC5D0\uC11C\uB294 content+fileName \uBC29\uC2DD\uC744 \uAD8C\uC7A5\uD569\uB2C8\uB2E4.",
2547
3151
  {
2548
- content: z.string().optional().describe("\uC5C5\uB85C\uB4DC\uD560 \uD30C\uC77C \uB0B4\uC6A9 (base64 \uC778\uCF54\uB529). filePath \uB300\uC2E0 \uC0AC\uC6A9"),
2549
- fileName: z.string().optional().describe("\uD30C\uC77C\uBA85 (content \uC0AC\uC6A9 \uC2DC \uD544\uC218)"),
2550
- filePath: z.string().optional().describe("\uC5C5\uB85C\uB4DC\uD560 \uB85C\uCEEC \uD30C\uC77C \uACBD\uB85C (content \uB300\uC2E0 \uC0AC\uC6A9, \uB85C\uCEEC \uD658\uACBD\uC5D0\uC11C\uB9CC \uB3D9\uC791)"),
2551
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)"),
2552
- folderId: z.string().optional().describe("\uC5C5\uB85C\uB4DC\uD560 \uD3F4\uB354 ID (\uBBF8\uC9C0\uC815 \uC2DC \uB8E8\uD2B8)"),
2553
- overwrite: z.boolean().optional().describe("\uB3D9\uC77C \uD30C\uC77C\uBA85 \uB36E\uC5B4\uC4F0\uAE30 (\uAE30\uBCF8: false)")
3152
+ content: z6.string().optional().describe("\uC5C5\uB85C\uB4DC\uD560 \uD30C\uC77C \uB0B4\uC6A9 (base64 \uC778\uCF54\uB529). filePath \uB300\uC2E0 \uC0AC\uC6A9"),
3153
+ fileName: z6.string().optional().describe("\uD30C\uC77C\uBA85 (content \uC0AC\uC6A9 \uC2DC \uD544\uC218)"),
3154
+ filePath: z6.string().optional().describe("\uC5C5\uB85C\uB4DC\uD560 \uB85C\uCEEC \uD30C\uC77C \uACBD\uB85C (content \uB300\uC2E0 \uC0AC\uC6A9, \uB85C\uCEEC \uD658\uACBD\uC5D0\uC11C\uB9CC \uB3D9\uC791)"),
3155
+ userId: z6.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)"),
3156
+ folderId: z6.string().optional().describe("\uC5C5\uB85C\uB4DC\uD560 \uD3F4\uB354 ID (\uBBF8\uC9C0\uC815 \uC2DC \uB8E8\uD2B8)"),
3157
+ overwrite: z6.boolean().optional().describe("\uB3D9\uC77C \uD30C\uC77C\uBA85 \uB36E\uC5B4\uC4F0\uAE30 (\uAE30\uBCF8: false)")
2554
3158
  },
2555
3159
  async ({ content, fileName, filePath, userId, folderId, overwrite }) => {
2556
3160
  try {
@@ -2568,11 +3172,12 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2568
3172
  overwrite ?? false
2569
3173
  );
2570
3174
  } else if (filePath) {
3175
+ const safePath = validateLocalPath(filePath);
2571
3176
  if (process.env["NWORKS_VERBOSE"] === "1") {
2572
- console.error(`[nworks] MCP upload: filePath=${filePath}`);
3177
+ console.error(`[nworks] MCP upload: filePath=${safePath}`);
2573
3178
  }
2574
3179
  result = await uploadFile(
2575
- filePath,
3180
+ safePath,
2576
3181
  userId ?? "me",
2577
3182
  folderId,
2578
3183
  overwrite ?? false
@@ -2587,23 +3192,25 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2587
3192
  content: [{ type: "text", text: JSON.stringify({ success: true, ...result }) }]
2588
3193
  };
2589
3194
  } catch (err) {
2590
- const error = err;
2591
- const detail = process.env["NWORKS_VERBOSE"] === "1" ? ` | stack: ${error.stack}` : "";
3195
+ if (process.env["NWORKS_VERBOSE"] === "1") {
3196
+ console.error(`[nworks] drive upload error: ${err.stack}`);
3197
+ }
2592
3198
  return {
2593
- content: [{ type: "text", text: `${mcpErrorHint(err, "drive.upload")}${detail}` }],
3199
+ content: [{ type: "text", text: mcpErrorHint(err, "drive.upload") }],
2594
3200
  isError: true
2595
3201
  };
2596
3202
  }
2597
3203
  }
2598
3204
  );
2599
- server.tool(
3205
+ defineTool(
3206
+ server,
2600
3207
  "nworks_drive_download",
2601
3208
  "\uB4DC\uB77C\uC774\uBE0C \uD30C\uC77C\uC744 \uB2E4\uC6B4\uB85C\uB4DC\uD569\uB2C8\uB2E4. User OAuth \uC778\uC99D \uD544\uC694 (file.read scope). outputDir\uC744 \uC9C0\uC815\uD558\uBA74 \uB85C\uCEEC\uC5D0 \uD30C\uC77C\uB85C \uC800\uC7A5\uD558\uACE0, \uBBF8\uC9C0\uC815 \uC2DC \uD30C\uC77C \uB0B4\uC6A9\uC744 \uC9C1\uC811 \uBC18\uD658\uD569\uB2C8\uB2E4 (\uD14D\uC2A4\uD2B8\uB294 text, \uBC14\uC774\uB108\uB9AC\uB294 base64). 5MB \uCD08\uACFC \uD30C\uC77C\uC740 \uBC18\uB4DC\uC2DC outputDir\uB97C \uC9C0\uC815\uD574\uC57C \uD569\uB2C8\uB2E4.",
2602
3209
  {
2603
- fileId: z.string().describe("\uB2E4\uC6B4\uB85C\uB4DC\uD560 \uD30C\uC77C ID (nworks_drive_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
2604
- outputDir: z.string().optional().describe("\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC (\uC9C0\uC815 \uC2DC \uD30C\uC77C\uB85C \uC800\uC7A5, \uBBF8\uC9C0\uC815 \uC2DC \uB0B4\uC6A9\uC744 \uC9C1\uC811 \uBC18\uD658)"),
2605
- outputName: z.string().optional().describe("\uC800\uC7A5 \uD30C\uC77C\uBA85 (\uBBF8\uC9C0\uC815 \uC2DC \uC6D0\uBCF8 \uD30C\uC77C\uBA85)"),
2606
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3210
+ fileId: z6.string().describe("\uB2E4\uC6B4\uB85C\uB4DC\uD560 \uD30C\uC77C ID (nworks_drive_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3211
+ outputDir: z6.string().optional().describe("\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC (\uC9C0\uC815 \uC2DC \uD30C\uC77C\uB85C \uC800\uC7A5, \uBBF8\uC9C0\uC815 \uC2DC \uB0B4\uC6A9\uC744 \uC9C1\uC811 \uBC18\uD658)"),
3212
+ outputName: z6.string().optional().describe("\uC800\uC7A5 \uD30C\uC77C\uBA85 (\uBBF8\uC9C0\uC815 \uC2DC \uC6D0\uBCF8 \uD30C\uC77C\uBA85)"),
3213
+ userId: z6.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2607
3214
  },
2608
3215
  async ({ fileId, outputDir, outputName, userId }) => {
2609
3216
  try {
@@ -2613,10 +3220,13 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2613
3220
  );
2614
3221
  const fileName = outputName ?? result.fileName ?? fileId;
2615
3222
  if (outputDir) {
2616
- const { writeFile: writeFile3 } = await import("fs/promises");
2617
- const { join: join3 } = await import("path");
2618
- const outPath = join3(outputDir, fileName);
2619
- await writeFile3(outPath, result.buffer);
3223
+ const { writeFile: writeFile4 } = await import("fs/promises");
3224
+ const { join: join5 } = await import("path");
3225
+ const safeDir = validateLocalPath(outputDir);
3226
+ const safeName = sanitizeFileName(fileName);
3227
+ const outPath = join5(safeDir, safeName);
3228
+ validateLocalPath(outPath, safeDir);
3229
+ await writeFile4(outPath, result.buffer);
2620
3230
  return {
2621
3231
  content: [{ type: "text", text: JSON.stringify({ success: true, fileName, path: outPath, size: result.buffer.length }) }]
2622
3232
  };
@@ -2649,60 +3259,180 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2649
3259
  }
2650
3260
  }
2651
3261
  );
2652
- server.tool(
2653
- "nworks_mail_send",
2654
- "NAVER WORKS \uBA54\uC77C\uC744 \uC804\uC1A1\uD569\uB2C8\uB2E4. '\uBA54\uC77C \uBCF4\uB0B4\uC918', '\uC774\uBA54\uC77C \uC791\uC131\uD574\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. \uBE44\uB3D9\uAE30 \uC804\uC1A1(\uC131\uACF5 \uC2DC 202). User OAuth \uC778\uC99D \uD544\uC694 (mail scope)",
3262
+ defineTool(
3263
+ server,
3264
+ "nworks_sharedrive_list",
3265
+ "NAVER WORKS \uACF5\uC720 \uB4DC\uB77C\uC774\uBE0C \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uACF5\uC720 \uB4DC\uB77C\uC774\uBE0C \uBB50 \uC788\uC5B4?', '\uD300 \uACF5\uC720 \uB4DC\uB77C\uC774\uBE0C \uCC3E\uC544\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (file.read scope)",
2655
3266
  {
2656
- to: z.string().describe("\uC218\uC2E0\uC790 \uC774\uBA54\uC77C (\uC5EC\uB7EC \uBA85\uC740 ; \uB85C \uAD6C\uBD84)"),
2657
- subject: z.string().describe("\uBA54\uC77C \uC81C\uBAA9"),
2658
- body: z.string().optional().describe("\uBA54\uC77C \uBCF8\uBB38"),
2659
- cc: z.string().optional().describe("\uCC38\uC870 \uC774\uBA54\uC77C (\uC5EC\uB7EC \uBA85\uC740 ; \uB85C \uAD6C\uBD84)"),
2660
- bcc: z.string().optional().describe("\uC228\uC740\uCC38\uC870 \uC774\uBA54\uC77C (\uC5EC\uB7EC \uBA85\uC740 ; \uB85C \uAD6C\uBD84)"),
2661
- contentType: z.enum(["html", "text"]).optional().describe("\uBCF8\uBB38 \uD615\uC2DD (\uAE30\uBCF8: html)"),
2662
- userId: z.string().optional().describe("\uBC1C\uC2E0\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3267
+ count: z6.number().int().min(1).max(200).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20, \uCD5C\uB300: 200)"),
3268
+ cursor: z6.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
2663
3269
  },
2664
- async ({ to, subject, body, cc, bcc, contentType, userId }) => {
3270
+ async ({ count, cursor }) => {
2665
3271
  try {
2666
- await sendMail({
2667
- to,
2668
- subject,
2669
- body,
2670
- cc,
2671
- bcc,
2672
- contentType,
2673
- userId: userId ?? "me"
2674
- });
3272
+ const result = await listSharedDrives(count ?? 20, cursor);
2675
3273
  return {
2676
- content: [{ type: "text", text: JSON.stringify({ success: true, message: "\uBA54\uC77C\uC774 \uC804\uC1A1\uB418\uC5C8\uC2B5\uB2C8\uB2E4 (\uBE44\uB3D9\uAE30 \uCC98\uB9AC)" }) }]
3274
+ content: [{ type: "text", text: JSON.stringify({ sharedDrives: result.sharedDrives, count: result.sharedDrives.length, hasMore: !!result.responseMetaData?.nextCursor, nextCursor: result.responseMetaData?.nextCursor ?? null }) }]
2677
3275
  };
2678
3276
  } catch (err) {
2679
3277
  return {
2680
- content: [{ type: "text", text: mcpErrorHint(err, "mail.send") }],
3278
+ content: [{ type: "text", text: mcpErrorHint(err, "drive.sharedrive_list") }],
2681
3279
  isError: true
2682
3280
  };
2683
3281
  }
2684
3282
  }
2685
3283
  );
2686
- server.tool(
2687
- "nworks_mail_list",
2688
- "\uBC1B\uC740 \uBA54\uC77C \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uBA54\uC77C \uD655\uC778\uD574\uC918', '\uBC1B\uC740\uD3B8\uC9C0\uD568 \uBCF4\uC5EC\uC918', '\uC548 \uC77D\uC740 \uBA54\uC77C \uC788\uC5B4?' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (mail.read scope)",
3284
+ defineTool(
3285
+ server,
3286
+ "nworks_sharedrive_files",
3287
+ "\uACF5\uC720 \uB4DC\uB77C\uC774\uBE0C\uC758 \uD30C\uC77C/\uD3F4\uB354 \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. sharedriveId\uB294 nworks_sharedrive_list\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (file.read scope)",
2689
3288
  {
2690
- folderId: z.number().optional().describe("\uBA54\uC77C \uD3F4\uB354 ID (\uAE30\uBCF8: 0 = \uBC1B\uC740\uD3B8\uC9C0\uD568)"),
2691
- count: z.number().optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 30, \uCD5C\uB300: 200)"),
2692
- cursor: z.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C"),
2693
- isUnread: z.boolean().optional().describe("\uC77D\uC9C0 \uC54A\uC740 \uBA54\uC77C\uB9CC \uC870\uD68C"),
2694
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3289
+ sharedriveId: z6.string().describe("\uACF5\uC720 \uB4DC\uB77C\uC774\uBE0C ID (nworks_sharedrive_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3290
+ folderId: z6.string().optional().describe("\uD3F4\uB354 ID (\uBBF8\uC9C0\uC815 \uC2DC \uB8E8\uD2B8)"),
3291
+ count: z6.number().int().min(1).max(200).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20, \uCD5C\uB300: 200)"),
3292
+ cursor: z6.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
2695
3293
  },
2696
- async ({ folderId, count, cursor, isUnread, userId }) => {
3294
+ async ({ sharedriveId, folderId, count, cursor }) => {
2697
3295
  try {
2698
- const result = await listMails(
2699
- folderId ?? 0,
2700
- userId ?? "me",
2701
- count ?? 30,
2702
- cursor,
2703
- isUnread
2704
- );
2705
- const mails = result.mails.map((m) => ({
3296
+ const result = await listSharedDriveFiles(
3297
+ sharedriveId,
3298
+ folderId,
3299
+ count ?? 20,
3300
+ cursor
3301
+ );
3302
+ const files = result.files.map((f) => ({
3303
+ fileId: f.fileId,
3304
+ name: f.fileName,
3305
+ type: f.fileType,
3306
+ size: f.fileSize,
3307
+ modified: f.modifiedTime,
3308
+ path: f.filePath
3309
+ }));
3310
+ return {
3311
+ content: [{ type: "text", text: JSON.stringify({ files, count: files.length, hasMore: !!result.responseMetaData?.nextCursor, nextCursor: result.responseMetaData?.nextCursor ?? null }) }]
3312
+ };
3313
+ } catch (err) {
3314
+ return {
3315
+ content: [{ type: "text", text: mcpErrorHint(err, "drive.sharedrive_files") }],
3316
+ isError: true
3317
+ };
3318
+ }
3319
+ }
3320
+ );
3321
+ defineTool(
3322
+ server,
3323
+ "nworks_sharedrive_download",
3324
+ "\uACF5\uC720 \uB4DC\uB77C\uC774\uBE0C \uD30C\uC77C\uC744 \uB2E4\uC6B4\uB85C\uB4DC\uD569\uB2C8\uB2E4. User OAuth \uC778\uC99D \uD544\uC694 (file.read scope). outputDir\uC744 \uC9C0\uC815\uD558\uBA74 \uB85C\uCEEC\uC5D0 \uD30C\uC77C\uB85C \uC800\uC7A5\uD558\uACE0, \uBBF8\uC9C0\uC815 \uC2DC \uD30C\uC77C \uB0B4\uC6A9\uC744 \uC9C1\uC811 \uBC18\uD658\uD569\uB2C8\uB2E4 (\uD14D\uC2A4\uD2B8\uB294 text, \uBC14\uC774\uB108\uB9AC\uB294 base64). 5MB \uCD08\uACFC \uD30C\uC77C\uC740 \uBC18\uB4DC\uC2DC outputDir\uB97C \uC9C0\uC815\uD574\uC57C \uD569\uB2C8\uB2E4.",
3325
+ {
3326
+ sharedriveId: z6.string().describe("\uACF5\uC720 \uB4DC\uB77C\uC774\uBE0C ID (nworks_sharedrive_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3327
+ fileId: z6.string().describe("\uB2E4\uC6B4\uB85C\uB4DC\uD560 \uD30C\uC77C ID (nworks_sharedrive_files\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3328
+ outputDir: z6.string().optional().describe("\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC (\uC9C0\uC815 \uC2DC \uD30C\uC77C\uB85C \uC800\uC7A5, \uBBF8\uC9C0\uC815 \uC2DC \uB0B4\uC6A9\uC744 \uC9C1\uC811 \uBC18\uD658)"),
3329
+ outputName: z6.string().optional().describe("\uC800\uC7A5 \uD30C\uC77C\uBA85 (\uBBF8\uC9C0\uC815 \uC2DC \uC6D0\uBCF8 \uD30C\uC77C\uBA85)")
3330
+ },
3331
+ async ({ sharedriveId, fileId, outputDir, outputName }) => {
3332
+ try {
3333
+ const result = await downloadSharedDriveFile(sharedriveId, fileId);
3334
+ const fileName = outputName ?? result.fileName ?? fileId;
3335
+ if (outputDir) {
3336
+ const { writeFile: writeFile4 } = await import("fs/promises");
3337
+ const { join: join5 } = await import("path");
3338
+ const safeDir = validateLocalPath(outputDir);
3339
+ const safeName = sanitizeFileName(fileName);
3340
+ const outPath = join5(safeDir, safeName);
3341
+ validateLocalPath(outPath, safeDir);
3342
+ await writeFile4(outPath, result.buffer);
3343
+ return {
3344
+ content: [{ type: "text", text: JSON.stringify({ success: true, fileName, path: outPath, size: result.buffer.length }) }]
3345
+ };
3346
+ }
3347
+ const MAX_INLINE_SIZE = 5 * 1024 * 1024;
3348
+ if (result.buffer.length > MAX_INLINE_SIZE) {
3349
+ const sizeMB = (result.buffer.length / (1024 * 1024)).toFixed(1);
3350
+ return {
3351
+ content: [{ type: "text", text: JSON.stringify({ error: true, message: `\uD30C\uC77C\uC774 \uB108\uBB34 \uD07D\uB2C8\uB2E4 (${sizeMB}MB). outputDir\uB97C \uC9C0\uC815\uD574\uC11C \uB85C\uCEEC\uC5D0 \uC800\uC7A5\uD558\uC138\uC694.`, fileName, size: result.buffer.length }) }],
3352
+ isError: true
3353
+ };
3354
+ }
3355
+ const textExtensions = /\.(txt|md|csv|json|xml|html|htm|css|js|ts|jsx|tsx|yaml|yml|toml|ini|cfg|conf|log|sh|bash|zsh|py|rb|java|go|rs|c|cpp|h|hpp|sql|graphql|env|gitignore|dockerignore|editorconfig)$/i;
3356
+ const isText = textExtensions.test(fileName);
3357
+ if (isText) {
3358
+ const text = result.buffer.toString("utf-8");
3359
+ return {
3360
+ content: [{ type: "text", text: JSON.stringify({ success: true, fileName, size: result.buffer.length, encoding: "text", content: text }) }]
3361
+ };
3362
+ }
3363
+ const base64 = result.buffer.toString("base64");
3364
+ return {
3365
+ content: [{ type: "text", text: JSON.stringify({ success: true, fileName, size: result.buffer.length, encoding: "base64", content: base64 }) }]
3366
+ };
3367
+ } catch (err) {
3368
+ return {
3369
+ content: [{ type: "text", text: mcpErrorHint(err, "drive.sharedrive_download") }],
3370
+ isError: true
3371
+ };
3372
+ }
3373
+ }
3374
+ );
3375
+ }
3376
+
3377
+ // src/mcp/tools/mail.ts
3378
+ import { z as z7 } from "zod";
3379
+ function registerMailTools(server) {
3380
+ defineTool(
3381
+ server,
3382
+ "nworks_mail_send",
3383
+ "NAVER WORKS \uBA54\uC77C\uC744 \uC804\uC1A1\uD569\uB2C8\uB2E4. '\uBA54\uC77C \uBCF4\uB0B4\uC918', '\uC774\uBA54\uC77C \uC791\uC131\uD574\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. \uBE44\uB3D9\uAE30 \uC804\uC1A1(\uC131\uACF5 \uC2DC 202). User OAuth \uC778\uC99D \uD544\uC694 (mail scope)",
3384
+ {
3385
+ to: z7.string().describe("\uC218\uC2E0\uC790 \uC774\uBA54\uC77C (\uC5EC\uB7EC \uBA85\uC740 ; \uB85C \uAD6C\uBD84)"),
3386
+ subject: z7.string().describe("\uBA54\uC77C \uC81C\uBAA9"),
3387
+ body: z7.string().optional().describe("\uBA54\uC77C \uBCF8\uBB38"),
3388
+ cc: z7.string().optional().describe("\uCC38\uC870 \uC774\uBA54\uC77C (\uC5EC\uB7EC \uBA85\uC740 ; \uB85C \uAD6C\uBD84)"),
3389
+ bcc: z7.string().optional().describe("\uC228\uC740\uCC38\uC870 \uC774\uBA54\uC77C (\uC5EC\uB7EC \uBA85\uC740 ; \uB85C \uAD6C\uBD84)"),
3390
+ contentType: z7.enum(["html", "text"]).optional().describe("\uBCF8\uBB38 \uD615\uC2DD (\uAE30\uBCF8: html)"),
3391
+ userId: z7.string().optional().describe("\uBC1C\uC2E0\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3392
+ },
3393
+ async ({ to, subject, body, cc, bcc, contentType, userId }) => {
3394
+ try {
3395
+ await sendMail({
3396
+ to,
3397
+ subject,
3398
+ body,
3399
+ cc,
3400
+ bcc,
3401
+ contentType,
3402
+ userId: userId ?? "me"
3403
+ });
3404
+ return {
3405
+ content: [{ type: "text", text: JSON.stringify({ success: true, message: "\uBA54\uC77C\uC774 \uC804\uC1A1\uB418\uC5C8\uC2B5\uB2C8\uB2E4 (\uBE44\uB3D9\uAE30 \uCC98\uB9AC)" }) }]
3406
+ };
3407
+ } catch (err) {
3408
+ return {
3409
+ content: [{ type: "text", text: mcpErrorHint(err, "mail.send") }],
3410
+ isError: true
3411
+ };
3412
+ }
3413
+ }
3414
+ );
3415
+ defineTool(
3416
+ server,
3417
+ "nworks_mail_list",
3418
+ "\uBC1B\uC740 \uBA54\uC77C \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uBA54\uC77C \uD655\uC778\uD574\uC918', '\uBC1B\uC740\uD3B8\uC9C0\uD568 \uBCF4\uC5EC\uC918', '\uC548 \uC77D\uC740 \uBA54\uC77C \uC788\uC5B4?' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (mail.read scope)",
3419
+ {
3420
+ folderId: z7.number().int().min(0).optional().describe("\uBA54\uC77C \uD3F4\uB354 ID (\uAE30\uBCF8: 0 = \uBC1B\uC740\uD3B8\uC9C0\uD568)"),
3421
+ count: z7.number().int().min(1).max(200).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 30, \uCD5C\uB300: 200)"),
3422
+ cursor: z7.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C"),
3423
+ isUnread: z7.boolean().optional().describe("\uC77D\uC9C0 \uC54A\uC740 \uBA54\uC77C\uB9CC \uC870\uD68C"),
3424
+ userId: z7.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3425
+ },
3426
+ async ({ folderId, count, cursor, isUnread, userId }) => {
3427
+ try {
3428
+ const result = await listMails(
3429
+ folderId ?? 0,
3430
+ userId ?? "me",
3431
+ count ?? 30,
3432
+ cursor,
3433
+ isUnread
3434
+ );
3435
+ const mails = result.mails.map((m) => ({
2706
3436
  mailId: m.mailId,
2707
3437
  from: m.from.email,
2708
3438
  subject: m.subject,
@@ -2721,12 +3451,13 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2721
3451
  }
2722
3452
  }
2723
3453
  );
2724
- server.tool(
3454
+ defineTool(
3455
+ server,
2725
3456
  "nworks_mail_read",
2726
3457
  "\uD2B9\uC815 \uBA54\uC77C\uC758 \uC0C1\uC138 \uB0B4\uC6A9(\uBCF8\uBB38, \uCCA8\uBD80\uD30C\uC77C \uB4F1)\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uC774 \uBA54\uC77C \uB0B4\uC6A9 \uBCF4\uC5EC\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. mailId\uB294 nworks_mail_list\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (mail.read scope)",
2727
3458
  {
2728
- mailId: z.number().describe("\uBA54\uC77C ID (nworks_mail_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
2729
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3459
+ mailId: z7.number().int().min(0).describe("\uBA54\uC77C ID (nworks_mail_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3460
+ userId: z7.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2730
3461
  },
2731
3462
  async ({ mailId, userId }) => {
2732
3463
  try {
@@ -2760,15 +3491,80 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2760
3491
  }
2761
3492
  }
2762
3493
  );
2763
- server.tool(
3494
+ defineTool(
3495
+ server,
3496
+ "nworks_mail_download_attachment",
3497
+ "\uBA54\uC77C \uCCA8\uBD80\uD30C\uC77C\uC744 \uB2E4\uC6B4\uB85C\uB4DC\uD569\uB2C8\uB2E4. mailId\uC640 attachmentId\uB294 nworks_mail_read\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (mail.read scope). outputDir\uC744 \uC9C0\uC815\uD558\uBA74 \uB85C\uCEEC\uC5D0 \uD30C\uC77C\uB85C \uC800\uC7A5\uD558\uACE0, \uBBF8\uC9C0\uC815 \uC2DC \uD30C\uC77C \uB0B4\uC6A9\uC744 \uC9C1\uC811 \uBC18\uD658\uD569\uB2C8\uB2E4 (\uD14D\uC2A4\uD2B8\uB294 text, \uBC14\uC774\uB108\uB9AC\uB294 base64). 5MB \uCD08\uACFC \uD30C\uC77C\uC740 \uBC18\uB4DC\uC2DC outputDir\uB97C \uC9C0\uC815\uD574\uC57C \uD569\uB2C8\uB2E4.",
3498
+ {
3499
+ mailId: z7.number().int().min(0).describe("\uBA54\uC77C ID (nworks_mail_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3500
+ attachmentId: z7.number().int().min(0).describe("\uCCA8\uBD80\uD30C\uC77C ID (nworks_mail_read\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3501
+ outputDir: z7.string().optional().describe("\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC (\uC9C0\uC815 \uC2DC \uD30C\uC77C\uB85C \uC800\uC7A5, \uBBF8\uC9C0\uC815 \uC2DC \uB0B4\uC6A9\uC744 \uC9C1\uC811 \uBC18\uD658)"),
3502
+ outputName: z7.string().optional().describe("\uC800\uC7A5 \uD30C\uC77C\uBA85 (\uBBF8\uC9C0\uC815 \uC2DC \uC6D0\uBCF8 \uD30C\uC77C\uBA85)"),
3503
+ userId: z7.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3504
+ },
3505
+ async ({ mailId, attachmentId, outputDir, outputName, userId }) => {
3506
+ try {
3507
+ const result = await downloadAttachment(
3508
+ mailId,
3509
+ attachmentId,
3510
+ userId ?? "me"
3511
+ );
3512
+ const fileName = outputName ?? result.filename;
3513
+ if (outputDir) {
3514
+ const { writeFile: writeFile4 } = await import("fs/promises");
3515
+ const { join: join5 } = await import("path");
3516
+ const safeDir = validateLocalPath(outputDir);
3517
+ const safeName = sanitizeFileName(fileName);
3518
+ const outPath = join5(safeDir, safeName);
3519
+ validateLocalPath(outPath, safeDir);
3520
+ await writeFile4(outPath, result.buffer);
3521
+ return {
3522
+ content: [{ type: "text", text: JSON.stringify({ success: true, fileName, path: outPath, size: result.buffer.length }) }]
3523
+ };
3524
+ }
3525
+ const MAX_INLINE_SIZE = 5 * 1024 * 1024;
3526
+ if (result.buffer.length > MAX_INLINE_SIZE) {
3527
+ const sizeMB = (result.buffer.length / (1024 * 1024)).toFixed(1);
3528
+ return {
3529
+ content: [{ type: "text", text: JSON.stringify({ error: true, message: `\uD30C\uC77C\uC774 \uB108\uBB34 \uD07D\uB2C8\uB2E4 (${sizeMB}MB). outputDir\uB97C \uC9C0\uC815\uD574\uC11C \uB85C\uCEEC\uC5D0 \uC800\uC7A5\uD558\uC138\uC694.`, fileName, size: result.buffer.length }) }],
3530
+ isError: true
3531
+ };
3532
+ }
3533
+ const textExtensions = /\.(txt|md|csv|json|xml|html|htm|css|js|ts|jsx|tsx|yaml|yml|toml|ini|cfg|conf|log|sh|bash|zsh|py|rb|java|go|rs|c|cpp|h|hpp|sql|graphql|env|gitignore|dockerignore|editorconfig)$/i;
3534
+ const isText = textExtensions.test(fileName);
3535
+ if (isText) {
3536
+ const text = result.buffer.toString("utf-8");
3537
+ return {
3538
+ content: [{ type: "text", text: JSON.stringify({ success: true, fileName, size: result.buffer.length, encoding: "text", content: text }) }]
3539
+ };
3540
+ }
3541
+ const base64 = result.buffer.toString("base64");
3542
+ return {
3543
+ content: [{ type: "text", text: JSON.stringify({ success: true, fileName, size: result.buffer.length, encoding: "base64", content: base64 }) }]
3544
+ };
3545
+ } catch (err) {
3546
+ return {
3547
+ content: [{ type: "text", text: mcpErrorHint(err, "mail.download_attachment") }],
3548
+ isError: true
3549
+ };
3550
+ }
3551
+ }
3552
+ );
3553
+ }
3554
+
3555
+ // src/mcp/tools/task.ts
3556
+ import { z as z8 } from "zod";
3557
+ function registerTaskTools(server) {
3558
+ defineTool(
3559
+ server,
2764
3560
  "nworks_task_list",
2765
3561
  "\uD560 \uC77C(TODO) \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uD560 \uC77C \uD655\uC778\uD574\uC918', 'TODO \uBAA9\uB85D \uBCF4\uC5EC\uC918', '\uB0A8\uC740 \uC5C5\uBB34 \uBB50 \uC788\uC5B4?' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (task.read scope)",
2766
3562
  {
2767
- categoryId: z.string().optional().describe("\uCE74\uD14C\uACE0\uB9AC ID (\uAE30\uBCF8: default)"),
2768
- status: z.enum(["TODO", "ALL"]).optional().describe("\uD544\uD130: TODO \uB610\uB294 ALL (\uAE30\uBCF8: ALL)"),
2769
- count: z.number().optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 50, \uCD5C\uB300: 100)"),
2770
- cursor: z.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C"),
2771
- userId: z.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3563
+ categoryId: z8.string().optional().describe("\uCE74\uD14C\uACE0\uB9AC ID (\uAE30\uBCF8: default)"),
3564
+ status: z8.enum(["TODO", "ALL"]).optional().describe("\uD544\uD130: TODO \uB610\uB294 ALL (\uAE30\uBCF8: ALL)"),
3565
+ count: z8.number().int().min(1).max(100).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 50, \uCD5C\uB300: 100)"),
3566
+ cursor: z8.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C"),
3567
+ userId: z8.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2772
3568
  },
2773
3569
  async ({ categoryId, status, count, cursor, userId }) => {
2774
3570
  try {
@@ -2798,16 +3594,17 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2798
3594
  }
2799
3595
  }
2800
3596
  );
2801
- server.tool(
3597
+ defineTool(
3598
+ server,
2802
3599
  "nworks_task_create",
2803
3600
  "\uD560 \uC77C(TODO)\uC744 \uC0C8\uB85C \uB9CC\uB4ED\uB2C8\uB2E4. '\uD560 \uC77C \uCD94\uAC00\uD574\uC918', 'TODO \uB4F1\uB85D\uD574\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. \uAE30\uBCF8\uC801\uC73C\uB85C \uC790\uAE30 \uC790\uC2E0\uC5D0\uAC8C \uD560\uB2F9. User OAuth \uC778\uC99D \uD544\uC694 (task + user.read scope)",
2804
3601
  {
2805
- title: z.string().describe("\uD560 \uC77C \uC81C\uBAA9"),
2806
- content: z.string().optional().describe("\uD560 \uC77C \uB0B4\uC6A9"),
2807
- dueDate: z.string().optional().describe("\uB9C8\uAC10\uC77C (YYYY-MM-DD)"),
2808
- categoryId: z.string().optional().describe("\uCE74\uD14C\uACE0\uB9AC ID"),
2809
- assigneeIds: z.array(z.string()).optional().describe("\uB2F4\uB2F9\uC790 user ID \uBAA9\uB85D (\uBBF8\uC9C0\uC815 \uC2DC \uC790\uAE30 \uC790\uC2E0)"),
2810
- userId: z.string().optional().describe("\uC0DD\uC131\uC790 user ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
3602
+ title: z8.string().describe("\uD560 \uC77C \uC81C\uBAA9"),
3603
+ content: z8.string().optional().describe("\uD560 \uC77C \uB0B4\uC6A9"),
3604
+ dueDate: z8.string().optional().describe("\uB9C8\uAC10\uC77C (YYYY-MM-DD)"),
3605
+ categoryId: z8.string().optional().describe("\uCE74\uD14C\uACE0\uB9AC ID"),
3606
+ assigneeIds: z8.array(z8.string()).optional().describe("\uB2F4\uB2F9\uC790 user ID \uBAA9\uB85D (\uBBF8\uC9C0\uC815 \uC2DC \uC790\uAE30 \uC790\uC2E0)"),
3607
+ userId: z8.string().optional().describe("\uC0DD\uC131\uC790 user ID (\uBBF8\uC9C0\uC815 \uC2DC me)")
2811
3608
  },
2812
3609
  async ({ title, content, dueDate, categoryId, assigneeIds, userId }) => {
2813
3610
  try {
@@ -2830,15 +3627,16 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2830
3627
  }
2831
3628
  }
2832
3629
  );
2833
- server.tool(
3630
+ defineTool(
3631
+ server,
2834
3632
  "nworks_task_update",
2835
3633
  "\uD560 \uC77C\uC744 \uC218\uC815\uD558\uAC70\uB098 \uC644\uB8CC \uCC98\uB9AC\uD569\uB2C8\uB2E4. '\uD560 \uC77C \uC644\uB8CC \uCC98\uB9AC\uD574\uC918', '\uB9C8\uAC10\uC77C \uBCC0\uACBD\uD574\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. taskId\uB294 nworks_task_list\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (task + user.read scope)",
2836
3634
  {
2837
- taskId: z.string().describe("\uD560 \uC77C ID (nworks_task_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
2838
- status: z.enum(["done", "todo"]).optional().describe("\uC0C1\uD0DC \uBCC0\uACBD: done(\uC644\uB8CC) \uB610\uB294 todo(\uBBF8\uC644\uB8CC)"),
2839
- title: z.string().optional().describe("\uC0C8 \uC81C\uBAA9"),
2840
- content: z.string().optional().describe("\uC0C8 \uB0B4\uC6A9"),
2841
- dueDate: z.string().optional().describe("\uC0C8 \uB9C8\uAC10\uC77C (YYYY-MM-DD)")
3635
+ taskId: z8.string().describe("\uD560 \uC77C ID (nworks_task_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3636
+ status: z8.enum(["done", "todo"]).optional().describe("\uC0C1\uD0DC \uBCC0\uACBD: done(\uC644\uB8CC) \uB610\uB294 todo(\uBBF8\uC644\uB8CC)"),
3637
+ title: z8.string().optional().describe("\uC0C8 \uC81C\uBAA9"),
3638
+ content: z8.string().optional().describe("\uC0C8 \uB0B4\uC6A9"),
3639
+ dueDate: z8.string().optional().describe("\uC0C8 \uB9C8\uAC10\uC77C (YYYY-MM-DD)")
2842
3640
  },
2843
3641
  async ({ taskId, status, title, content, dueDate }) => {
2844
3642
  try {
@@ -2872,11 +3670,12 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2872
3670
  }
2873
3671
  }
2874
3672
  );
2875
- server.tool(
3673
+ defineTool(
3674
+ server,
2876
3675
  "nworks_task_delete",
2877
3676
  "\uD560 \uC77C\uC744 \uC0AD\uC81C\uD569\uB2C8\uB2E4. taskId\uB294 nworks_task_list\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (task + user.read scope)",
2878
3677
  {
2879
- taskId: z.string().describe("\uC0AD\uC81C\uD560 \uD560 \uC77C ID (nworks_task_list\uB85C \uC870\uD68C \uAC00\uB2A5)")
3678
+ taskId: z8.string().describe("\uC0AD\uC81C\uD560 \uD560 \uC77C ID (nworks_task_list\uB85C \uC870\uD68C \uAC00\uB2A5)")
2880
3679
  },
2881
3680
  async ({ taskId }) => {
2882
3681
  try {
@@ -2892,12 +3691,18 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2892
3691
  }
2893
3692
  }
2894
3693
  );
2895
- server.tool(
3694
+ }
3695
+
3696
+ // src/mcp/tools/board.ts
3697
+ import { z as z9 } from "zod";
3698
+ function registerBoardTools(server) {
3699
+ defineTool(
3700
+ server,
2896
3701
  "nworks_board_list",
2897
3702
  "NAVER WORKS \uAC8C\uC2DC\uD310 \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uAC8C\uC2DC\uD310 \uBB50 \uC788\uC5B4?', '\uACF5\uC9C0\uC0AC\uD56D \uAC8C\uC2DC\uD310 \uCC3E\uC544\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (board.read scope)",
2898
3703
  {
2899
- count: z.number().optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20)"),
2900
- cursor: z.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
3704
+ count: z9.number().int().min(1).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20)"),
3705
+ cursor: z9.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
2901
3706
  },
2902
3707
  async ({ count, cursor }) => {
2903
3708
  try {
@@ -2918,13 +3723,14 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2918
3723
  }
2919
3724
  }
2920
3725
  );
2921
- server.tool(
3726
+ defineTool(
3727
+ server,
2922
3728
  "nworks_board_posts",
2923
3729
  "\uAC8C\uC2DC\uD310\uC758 \uAE00 \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uAC8C\uC2DC\uD310 \uAE00 \uBCF4\uC5EC\uC918', '\uACF5\uC9C0\uC0AC\uD56D \uD655\uC778' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. boardId\uB294 nworks_board_list\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (board.read scope)",
2924
3730
  {
2925
- boardId: z.string().describe("\uAC8C\uC2DC\uD310 ID (nworks_board_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
2926
- count: z.number().optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20, \uCD5C\uB300: 40)"),
2927
- cursor: z.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
3731
+ boardId: z9.string().describe("\uAC8C\uC2DC\uD310 ID (nworks_board_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3732
+ count: z9.number().int().min(1).max(40).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20, \uCD5C\uB300: 40)"),
3733
+ cursor: z9.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
2928
3734
  },
2929
3735
  async ({ boardId, count, cursor }) => {
2930
3736
  try {
@@ -2948,12 +3754,13 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2948
3754
  }
2949
3755
  }
2950
3756
  );
2951
- server.tool(
3757
+ defineTool(
3758
+ server,
2952
3759
  "nworks_board_read",
2953
3760
  "\uAC8C\uC2DC\uD310 \uAE00\uC758 \uC0C1\uC138 \uB0B4\uC6A9\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. postId\uB294 nworks_board_posts\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (board.read scope)",
2954
3761
  {
2955
- boardId: z.string().describe("\uAC8C\uC2DC\uD310 ID (nworks_board_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
2956
- postId: z.string().describe("\uAE00 ID (nworks_board_posts\uB85C \uC870\uD68C \uAC00\uB2A5)")
3762
+ boardId: z9.string().describe("\uAC8C\uC2DC\uD310 ID (nworks_board_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3763
+ postId: z9.string().describe("\uAE00 ID (nworks_board_posts\uB85C \uC870\uD68C \uAC00\uB2A5)")
2957
3764
  },
2958
3765
  async ({ boardId, postId }) => {
2959
3766
  try {
@@ -2979,15 +3786,16 @@ OAuth Redirect URI: http://localhost:9876/callback`,
2979
3786
  }
2980
3787
  }
2981
3788
  );
2982
- server.tool(
3789
+ defineTool(
3790
+ server,
2983
3791
  "nworks_board_create",
2984
3792
  "\uAC8C\uC2DC\uD310\uC5D0 \uAE00\uC744 \uC791\uC131\uD569\uB2C8\uB2E4. '\uAC8C\uC2DC\uD310\uC5D0 \uAE00 \uC62C\uB824\uC918', '\uACF5\uC9C0 \uC791\uC131\uD574\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. boardId\uB294 nworks_board_list\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (board scope)",
2985
3793
  {
2986
- boardId: z.string().describe("\uAC8C\uC2DC\uD310 ID (nworks_board_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
2987
- title: z.string().describe("\uAE00 \uC81C\uBAA9"),
2988
- body: z.string().optional().describe("\uAE00 \uBCF8\uBB38"),
2989
- enableComment: z.boolean().optional().describe("\uB313\uAE00 \uD5C8\uC6A9 (\uAE30\uBCF8: true)"),
2990
- sendNotifications: z.boolean().optional().describe("\uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)")
3794
+ boardId: z9.string().describe("\uAC8C\uC2DC\uD310 ID (nworks_board_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3795
+ title: z9.string().describe("\uAE00 \uC81C\uBAA9"),
3796
+ body: z9.string().optional().describe("\uAE00 \uBCF8\uBB38"),
3797
+ enableComment: z9.boolean().optional().describe("\uB313\uAE00 \uD5C8\uC6A9 (\uAE30\uBCF8: true)"),
3798
+ sendNotifications: z9.boolean().optional().describe("\uC54C\uB9BC \uBC1C\uC1A1 (\uAE30\uBCF8: false)")
2991
3799
  },
2992
3800
  async ({ boardId, title, body, enableComment, sendNotifications }) => {
2993
3801
  try {
@@ -3009,36 +3817,176 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3009
3817
  }
3010
3818
  }
3011
3819
  );
3012
- server.tool(
3820
+ }
3821
+
3822
+ // src/mcp/tools/contact.ts
3823
+ import { z as z10 } from "zod";
3824
+ function registerContactTools(server) {
3825
+ defineTool(
3826
+ server,
3827
+ "nworks_contact_list",
3828
+ "NAVER WORKS \uAC1C\uC778 \uC5F0\uB77D\uCC98 \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uC5F0\uB77D\uCC98 \uBAA9\uB85D \uBCF4\uC5EC\uC918', '\uB0B4 \uBA85\uD568\uCCA9 \uD655\uC778' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (contact.read scope)",
3829
+ {
3830
+ userId: z10.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)"),
3831
+ count: z10.number().int().min(1).max(500).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20, \uCD5C\uB300: 500)"),
3832
+ cursor: z10.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C"),
3833
+ contactTagId: z10.string().optional().describe("\uC5F0\uB77D\uCC98 \uD0DC\uADF8 ID\uB85C \uD544\uD130\uB9C1 (nworks_contact_list_tags\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3834
+ email: z10.string().optional().describe("\uC774\uBA54\uC77C\uB85C \uD544\uD130\uB9C1"),
3835
+ telephone: z10.string().optional().describe("\uC804\uD654\uBC88\uD638\uB85C \uD544\uD130\uB9C1")
3836
+ },
3837
+ async ({ userId, count, cursor, contactTagId, email, telephone }) => {
3838
+ try {
3839
+ const result = await listContacts({
3840
+ userId: userId ?? "me",
3841
+ count: count ?? 20,
3842
+ cursor,
3843
+ contactTagId,
3844
+ email,
3845
+ telephone
3846
+ });
3847
+ return {
3848
+ content: [{ type: "text", text: JSON.stringify({ contacts: result.contacts, count: result.contacts.length, hasMore: !!result.responseMetaData?.nextCursor, nextCursor: result.responseMetaData?.nextCursor ?? null }) }]
3849
+ };
3850
+ } catch (err) {
3851
+ return {
3852
+ content: [{ type: "text", text: mcpErrorHint(err, "contact.list") }],
3853
+ isError: true
3854
+ };
3855
+ }
3856
+ }
3857
+ );
3858
+ defineTool(
3859
+ server,
3860
+ "nworks_contact_get",
3861
+ "\uD2B9\uC815 \uC5F0\uB77D\uCC98\uC758 \uC0C1\uC138 \uC815\uBCF4\uB97C \uC870\uD68C\uD569\uB2C8\uB2E4. contactId\uB294 nworks_contact_list\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (contact.read scope)",
3862
+ {
3863
+ contactId: z10.string().describe("\uC5F0\uB77D\uCC98 ID (nworks_contact_list\uB85C \uC870\uD68C \uAC00\uB2A5)")
3864
+ },
3865
+ async ({ contactId }) => {
3866
+ try {
3867
+ const result = await getContact(contactId);
3868
+ return {
3869
+ content: [{ type: "text", text: JSON.stringify(result) }]
3870
+ };
3871
+ } catch (err) {
3872
+ return {
3873
+ content: [{ type: "text", text: mcpErrorHint(err, "contact.get") }],
3874
+ isError: true
3875
+ };
3876
+ }
3877
+ }
3878
+ );
3879
+ defineTool(
3880
+ server,
3881
+ "nworks_contact_create",
3882
+ `\uC0C8 \uC5F0\uB77D\uCC98\uB97C \uB9CC\uB4ED\uB2C8\uB2E4. '\uC5F0\uB77D\uCC98 \uCD94\uAC00\uD574\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. payload\uB294 NAVER WORKS \uC5F0\uB77D\uCC98 API \uC2A4\uD399\uC5D0 \uB9DE\uB294 JSON \uAC1D\uCCB4\uB97C \uADF8\uB300\uB85C \uC804\uB2EC\uD569\uB2C8\uB2E4. contactName\uACFC permission\uC740 \uD544\uC218\uC774\uBA70, permission.accessibleMembers\uC5D0\uB294 \uCD5C\uC18C 1\uBA85\uC774 \uC788\uC5B4\uC57C \uD569\uB2C8\uB2E4(\uBCF4\uD1B5 \uBCF8\uC778). \uC608: {"contactName":{"lastName":"\uAE40","firstName":"\uCCA0\uC218"},"emails":[{"email":"a@b.com","primary":true}],"permission":{"accessibleRange":"MEMBER","isCoEditing":false,"accessibleMembers":[{"id":"<userId>","type":"USER"}]}}. User OAuth \uC778\uC99D \uD544\uC694 (contact scope)`,
3883
+ {
3884
+ payload: z10.record(z10.string(), z10.unknown()).describe("\uC5F0\uB77D\uCC98 \uD544\uB4DC. \uD544\uC218: contactName, permission(accessibleRange\xB7isCoEditing\xB7accessibleMembers \uCD5C\uC18C 1\uBA85). \uC120\uD0DD: emails[], telephones[], organizations[], memo")
3885
+ },
3886
+ async ({ payload }) => {
3887
+ try {
3888
+ const result = await createContact(payload);
3889
+ return {
3890
+ content: [{ type: "text", text: JSON.stringify({ success: true, ...result }) }]
3891
+ };
3892
+ } catch (err) {
3893
+ return {
3894
+ content: [{ type: "text", text: mcpErrorHint(err, "contact.create") }],
3895
+ isError: true
3896
+ };
3897
+ }
3898
+ }
3899
+ );
3900
+ defineTool(
3901
+ server,
3902
+ "nworks_contact_update",
3903
+ "\uAE30\uC874 \uC5F0\uB77D\uCC98\uB97C \uC218\uC815\uD569\uB2C8\uB2E4. contactId\uB294 nworks_contact_list\uB85C \uC870\uD68C \uAC00\uB2A5. payload\uB294 \uC218\uC815\uD560 \uD544\uB4DC\uB9CC \uB2F4\uC740 JSON \uAC1D\uCCB4\uC785\uB2C8\uB2E4. User OAuth \uC778\uC99D \uD544\uC694 (contact scope)",
3904
+ {
3905
+ contactId: z10.string().describe("\uC5F0\uB77D\uCC98 ID (nworks_contact_list\uB85C \uC870\uD68C \uAC00\uB2A5)"),
3906
+ payload: z10.record(z10.string(), z10.unknown()).describe("\uC218\uC815\uD560 \uD544\uB4DC (name, email, tel, company \uB4F1)"),
3907
+ replace: z10.boolean().optional().describe("\uC804\uCCB4 \uAD50\uCCB4(PUT) \uC5EC\uBD80 (\uAE30\uBCF8: false, \uBD80\uBD84 \uC218\uC815 PATCH)")
3908
+ },
3909
+ async ({ contactId, payload, replace }) => {
3910
+ try {
3911
+ const result = await updateContact(contactId, payload, !(replace ?? false));
3912
+ return {
3913
+ content: [{ type: "text", text: JSON.stringify({ success: true, ...result }) }]
3914
+ };
3915
+ } catch (err) {
3916
+ return {
3917
+ content: [{ type: "text", text: mcpErrorHint(err, "contact.update") }],
3918
+ isError: true
3919
+ };
3920
+ }
3921
+ }
3922
+ );
3923
+ defineTool(
3924
+ server,
3925
+ "nworks_contact_delete",
3926
+ "\uC5F0\uB77D\uCC98\uB97C \uC0AD\uC81C\uD569\uB2C8\uB2E4. contactId\uB294 nworks_contact_list\uB85C \uC870\uD68C \uAC00\uB2A5. User OAuth \uC778\uC99D \uD544\uC694 (contact scope)",
3927
+ {
3928
+ contactId: z10.string().describe("\uC0AD\uC81C\uD560 \uC5F0\uB77D\uCC98 ID (nworks_contact_list\uB85C \uC870\uD68C \uAC00\uB2A5)")
3929
+ },
3930
+ async ({ contactId }) => {
3931
+ try {
3932
+ await deleteContact(contactId);
3933
+ return {
3934
+ content: [{ type: "text", text: JSON.stringify({ success: true, contactId, message: "\uC5F0\uB77D\uCC98\uAC00 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4" }) }]
3935
+ };
3936
+ } catch (err) {
3937
+ return {
3938
+ content: [{ type: "text", text: mcpErrorHint(err, "contact.delete") }],
3939
+ isError: true
3940
+ };
3941
+ }
3942
+ }
3943
+ );
3944
+ defineTool(
3945
+ server,
3946
+ "nworks_contact_list_tags",
3947
+ "\uC5F0\uB77D\uCC98 \uD0DC\uADF8(\uADF8\uB8F9) \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4. '\uC5F0\uB77D\uCC98 \uD0DC\uADF8 \uBAA9\uB85D \uBCF4\uC5EC\uC918' \uB4F1\uC758 \uC694\uCCAD\uC5D0 \uC0AC\uC6A9. User OAuth \uC778\uC99D \uD544\uC694 (contact.read scope)",
3948
+ {
3949
+ userId: z10.string().optional().describe("\uB300\uC0C1 \uC0AC\uC6A9\uC790 ID (\uBBF8\uC9C0\uC815 \uC2DC me)"),
3950
+ count: z10.number().int().min(1).max(500).optional().describe("\uD398\uC774\uC9C0\uB2F9 \uD56D\uBAA9 \uC218 (\uAE30\uBCF8: 20, \uCD5C\uB300: 500)"),
3951
+ cursor: z10.string().optional().describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uCEE4\uC11C")
3952
+ },
3953
+ async ({ userId, count, cursor }) => {
3954
+ try {
3955
+ const result = await listContactTags({ userId: userId ?? "me", count: count ?? 20, cursor });
3956
+ return {
3957
+ content: [{ type: "text", text: JSON.stringify({ contactTags: result.contactTags, count: result.contactTags.length, hasMore: !!result.responseMetaData?.nextCursor, nextCursor: result.responseMetaData?.nextCursor ?? null }) }]
3958
+ };
3959
+ } catch (err) {
3960
+ return {
3961
+ content: [{ type: "text", text: mcpErrorHint(err, "contact.list_tags") }],
3962
+ isError: true
3963
+ };
3964
+ }
3965
+ }
3966
+ );
3967
+ }
3968
+
3969
+ // src/mcp/tools/auth.ts
3970
+ import { z as z11 } from "zod";
3971
+ function registerAuthTools(server) {
3972
+ defineTool(
3973
+ server,
3013
3974
  "nworks_login_user",
3014
- "User OAuth \uB85C\uADF8\uC778\uC744 \uC2DC\uC791\uD569\uB2C8\uB2E4. \uBC18\uD658\uB41C URL\uC744 \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uC5F4\uC5B4 NAVER WORKS\uC5D0 \uB85C\uADF8\uC778\uD558\uC138\uC694. \uB85C\uADF8\uC778 \uC644\uB8CC \uD6C4 \uC790\uB3D9\uC73C\uB85C \uD1A0\uD070\uC774 \uC800\uC7A5\uB429\uB2C8\uB2E4. \uC911\uC694: scope\uB97C \uC9C0\uC815\uD558\uC9C0 \uB9C8\uC138\uC694. \uAE30\uBCF8\uAC12\uC774 \uBAA8\uB4E0 API(\uCE98\uB9B0\uB354, \uBA54\uC77C, \uD560\uC77C, \uB4DC\uB77C\uC774\uBE0C, \uAC8C\uC2DC\uD310)\uB97C \uD3EC\uD568\uD558\uBBC0\uB85C \uD55C \uBC88 \uB85C\uADF8\uC778\uC73C\uB85C \uC804\uCCB4 \uAE30\uB2A5\uC744 \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. scope\uB97C \uC881\uAC8C \uC9C0\uC815\uD558\uBA74 \uB2E4\uB978 \uAE30\uB2A5 \uC0AC\uC6A9 \uC2DC \uC7AC\uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4.",
3975
+ "User OAuth \uB85C\uADF8\uC778\uC744 \uC2DC\uC791\uD569\uB2C8\uB2E4. \uBC18\uD658\uB41C URL\uC744 \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uC5F4\uC5B4 NAVER WORKS\uC5D0 \uB85C\uADF8\uC778\uD558\uC138\uC694. \uB85C\uADF8\uC778 \uC644\uB8CC \uD6C4 \uC790\uB3D9\uC73C\uB85C \uD1A0\uD070\uC774 \uC800\uC7A5\uB429\uB2C8\uB2E4. \uC911\uC694: preset\uC744 \uC9C0\uC815\uD558\uC9C0 \uB9C8\uC138\uC694. \uAE30\uBCF8\uAC12(default=all)\uC774 \uBAA8\uB4E0 API(\uCE98\uB9B0\uB354, \uBA54\uC77C, \uD560\uC77C, \uB4DC\uB77C\uC774\uBE0C, \uAC8C\uC2DC\uD310)\uB97C \uD3EC\uD568\uD558\uBBC0\uB85C \uD55C \uBC88 \uB85C\uADF8\uC778\uC73C\uB85C \uC804\uCCB4 \uAE30\uB2A5\uC744 \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uC77D\uAE30 \uC804\uC6A9\uC73C\uB85C \uC81C\uD55C\uD558\uB824\uBA74 preset='readonly'\uB97C \uC9C0\uC815\uD558\uC138\uC694(\uC774 \uACBD\uC6B0 \uC0DD\uC131/\uC218\uC815/\uC0AD\uC81C \uB3C4\uAD6C\uB294 \uC7AC\uB85C\uADF8\uC778 \uD544\uC694).",
3015
3976
  {
3016
- scope: z.string().optional().describe("\uC9C0\uC815\uD558\uC9C0 \uB9C8\uC138\uC694 (\uAE30\uBCF8\uAC12\uC774 \uC804\uCCB4 scope \uD3EC\uD568). \uD2B9\uC218\uD55C \uACBD\uC6B0\uC5D0\uB9CC \uC0AC\uC6A9")
3977
+ preset: z11.enum(["readonly", "default", "all"]).optional().describe("scope \uD504\uB9AC\uC14B. \uAE30\uBCF8 default(=all, \uC804\uCCB4 \uAE30\uB2A5). readonly\uB294 \uC77D\uAE30 \uC804\uC6A9."),
3978
+ scope: z11.string().optional().describe("\uACE0\uAE09: \uACF5\uBC31 \uAD6C\uBD84 raw scope \uC9C1\uC811 \uC9C0\uC815(preset \uB300\uC2E0). \uD2B9\uC218\uD55C \uACBD\uC6B0\uC5D0\uB9CC \uC0AC\uC6A9")
3017
3979
  },
3018
- async ({ scope }) => {
3019
- const DEFAULT_SCOPE = "calendar calendar.read file file.read mail mail.read task task.read user.read board board.read";
3980
+ async ({ preset, scope }) => {
3020
3981
  try {
3021
3982
  const creds = await loadCredentials();
3022
- const SCOPE_DEPS = {
3023
- calendar: ["calendar.read"],
3024
- task: ["user.read"],
3025
- "task.read": ["user.read"]
3026
- };
3027
- const expandScopes = (scopes) => {
3028
- const expanded = new Set(scopes);
3029
- for (const s of scopes) {
3030
- const deps = SCOPE_DEPS[s];
3031
- if (deps) deps.forEach((d) => expanded.add(d));
3032
- }
3033
- return [...expanded];
3034
- };
3035
3983
  const existingToken = await loadUserToken();
3036
3984
  const existingScopes = existingToken?.scope?.split(" ").filter(Boolean) ?? [];
3037
- const requestedScopes = expandScopes((scope ?? DEFAULT_SCOPE).split(" ").filter(Boolean));
3038
- const mergedScopes = [.../* @__PURE__ */ new Set([...existingScopes, ...requestedScopes])].join(" ");
3039
- const state = Math.random().toString(36).substring(2);
3985
+ const requestedScopes = resolveScopes(preset ?? scope);
3986
+ const mergedScopes = mergeScopes(existingScopes, requestedScopes);
3987
+ const state = generateSecureState();
3040
3988
  const authorizeUrl = buildAuthorizeUrl(creds.clientId, mergedScopes, state);
3041
- startOAuthCallbackServer(creds.clientId, creds.clientSecret).then(
3989
+ startOAuthCallbackServer(creds.clientId, creds.clientSecret, state).then(
3042
3990
  (token) => saveUserToken({
3043
3991
  accessToken: token.accessToken,
3044
3992
  refreshToken: token.refreshToken,
@@ -3072,7 +4020,8 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3072
4020
  }
3073
4021
  }
3074
4022
  );
3075
- server.tool(
4023
+ defineTool(
4024
+ server,
3076
4025
  "nworks_whoami",
3077
4026
  "\uD604\uC7AC \uC778\uC99D\uB41C NAVER WORKS \uACC4\uC815 \uC815\uBCF4\uC640 \uD1A0\uD070 \uC720\uD6A8 \uC0C1\uD0DC\uB97C \uD655\uC778\uD569\uB2C8\uB2E4. \uC778\uC99D \uBB38\uC81C \uC9C4\uB2E8 \uC2DC \uBA3C\uC800 \uD638\uCD9C",
3078
4027
  {},
@@ -3083,9 +4032,10 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3083
4032
  const userToken = await loadUserToken();
3084
4033
  const isValid = token ? token.expiresAt > Date.now() / 1e3 : false;
3085
4034
  const userTokenValid = userToken ? userToken.expiresAt > Date.now() / 1e3 : false;
4035
+ const mask2 = (s) => s.length <= 4 ? "****" : `****${s.slice(-4)}`;
3086
4036
  const info = {
3087
4037
  serviceAccount: creds.serviceAccount ?? null,
3088
- clientId: creds.clientId,
4038
+ clientId: mask2(creds.clientId),
3089
4039
  botId: creds.botId ?? null,
3090
4040
  tokenValid: isValid,
3091
4041
  userOAuth: userToken ? { valid: userTokenValid, scope: userToken.scope } : null
@@ -3101,12 +4051,25 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3101
4051
  }
3102
4052
  }
3103
4053
  );
3104
- server.tool(
4054
+ defineTool(
4055
+ server,
3105
4056
  "nworks_logout",
3106
4057
  "\uC800\uC7A5\uB41C NAVER WORKS \uC778\uC99D \uC815\uBCF4\uC640 \uD1A0\uD070\uC744 \uBAA8\uB450 \uC0AD\uC81C\uD569\uB2C8\uB2E4",
3107
4058
  {},
3108
4059
  async () => {
3109
4060
  try {
4061
+ try {
4062
+ const creds = await loadCredentials();
4063
+ const token = await loadToken();
4064
+ const userToken = await loadUserToken();
4065
+ if (token?.accessToken) {
4066
+ await revokeToken(token.accessToken, creds.clientId, creds.clientSecret);
4067
+ }
4068
+ if (userToken?.refreshToken) {
4069
+ await revokeToken(userToken.refreshToken, creds.clientId, creds.clientSecret);
4070
+ }
4071
+ } catch {
4072
+ }
3110
4073
  await clearCredentials();
3111
4074
  return {
3112
4075
  content: [
@@ -3127,15 +4090,162 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3127
4090
  }
3128
4091
  }
3129
4092
  );
3130
- server.tool(
4093
+ }
4094
+
4095
+ // src/commands/doctor.ts
4096
+ import { Command as Command12 } from "commander";
4097
+ import { existsSync as existsSync3 } from "fs";
4098
+ import { readFile as readFile5 } from "fs/promises";
4099
+ import { resolve as resolve2, join as join4, sep as sep2 } from "path";
4100
+ function mask(value) {
4101
+ return value.length <= 4 ? "****" : `****${value.slice(-4)}`;
4102
+ }
4103
+ async function runChecks(profile) {
4104
+ const results = [];
4105
+ let creds;
4106
+ try {
4107
+ creds = await loadCredentials(profile);
4108
+ results.push({ check: "credentials", status: "OK", detail: `clientId: ${mask(creds.clientId)}` });
4109
+ } catch {
4110
+ results.push({ check: "credentials", status: "FAIL", detail: "No credentials found. CLI: run `nworks login --user`. MCP: use the nworks_setup tool (env NWORKS_CLIENT_SECRET required)." });
4111
+ return results;
4112
+ }
4113
+ if (hasServiceAccountCreds(creds)) {
4114
+ results.push({ check: "serviceAccount", status: "OK", detail: creds.serviceAccount });
4115
+ } else {
4116
+ results.push({ check: "serviceAccount", status: "SKIP", detail: "Not set (required for bot messaging)" });
4117
+ }
4118
+ if (creds.privateKeyPath) {
4119
+ if (existsSync3(creds.privateKeyPath)) {
4120
+ try {
4121
+ await readFile5(creds.privateKeyPath, "utf-8");
4122
+ results.push({ check: "privateKey", status: "OK", detail: creds.privateKeyPath });
4123
+ } catch {
4124
+ results.push({ check: "privateKey", status: "FAIL", detail: `Cannot read: ${creds.privateKeyPath}` });
4125
+ }
4126
+ } else {
4127
+ results.push({ check: "privateKey", status: "FAIL", detail: `File not found: ${creds.privateKeyPath}` });
4128
+ }
4129
+ } else {
4130
+ results.push({ check: "privateKey", status: "SKIP", detail: "Not set" });
4131
+ }
4132
+ if (creds.privateKeyPath && existsSync3(creds.privateKeyPath)) {
4133
+ const resolvedKey = resolve2(creds.privateKeyPath);
4134
+ const cwd = resolve2(process.cwd());
4135
+ const insideCwd = resolvedKey === cwd || resolvedKey.startsWith(cwd + sep2);
4136
+ const looksLikeProject = existsSync3(join4(cwd, ".git")) || existsSync3(join4(cwd, "package.json"));
4137
+ if (insideCwd && looksLikeProject) {
4138
+ results.push({
4139
+ check: "keyLocation",
4140
+ status: "WARN",
4141
+ detail: "Private key is inside a project directory. Risk of exposure via editor plugins, AI tools, or `npm pack` \u2014 move it outside (e.g. your home directory) and update the path."
4142
+ });
4143
+ }
4144
+ }
4145
+ if (creds.botId) {
4146
+ results.push({ check: "botId", status: "OK", detail: creds.botId });
4147
+ } else {
4148
+ results.push({ check: "botId", status: "SKIP", detail: "Not set (required to send messages)" });
4149
+ }
4150
+ const token = await loadToken(profile);
4151
+ if (token) {
4152
+ const valid = token.expiresAt > Date.now() / 1e3;
4153
+ results.push({
4154
+ check: "serviceToken",
4155
+ status: valid ? "OK" : "EXPIRED",
4156
+ detail: valid ? `expires: ${new Date(token.expiresAt * 1e3).toISOString()}` : `expired: ${new Date(token.expiresAt * 1e3).toISOString()}`
4157
+ });
4158
+ } else {
4159
+ results.push({ check: "serviceToken", status: "SKIP", detail: "No token" });
4160
+ }
4161
+ const userToken = await loadUserToken(profile);
4162
+ if (userToken) {
4163
+ const valid = userToken.expiresAt > Date.now() / 1e3;
4164
+ results.push({
4165
+ check: "userOAuth",
4166
+ status: valid ? "OK" : "EXPIRED",
4167
+ detail: valid ? `scope: ${userToken.scope} | expires: ${new Date(userToken.expiresAt * 1e3).toISOString()}` : `expired | scope: ${userToken.scope}`
4168
+ });
4169
+ } else {
4170
+ results.push({ check: "userOAuth", status: "SKIP", detail: "No token. Run `nworks login --user`." });
4171
+ }
4172
+ if (hasServiceAccountCreds(creds) && token && token.expiresAt > Date.now() / 1e3) {
4173
+ try {
4174
+ const res = await fetch("https://www.worksapis.com/v1.0/users/me", {
4175
+ headers: { Authorization: `Bearer ${token.accessToken}` }
4176
+ });
4177
+ if (res.ok) {
4178
+ results.push({ check: "apiConnection", status: "OK", detail: "Connected to NAVER WORKS API" });
4179
+ } else {
4180
+ results.push({ check: "apiConnection", status: "FAIL", detail: `HTTP ${res.status}` });
4181
+ }
4182
+ } catch (e) {
4183
+ results.push({ check: "apiConnection", status: "FAIL", detail: `Connection failed: ${e.message}` });
4184
+ }
4185
+ } else if (userToken && userToken.expiresAt > Date.now() / 1e3) {
4186
+ try {
4187
+ const res = await fetch("https://www.worksapis.com/v1.0/users/me", {
4188
+ headers: { Authorization: `Bearer ${userToken.accessToken}` }
4189
+ });
4190
+ if (res.ok) {
4191
+ results.push({ check: "apiConnection", status: "OK", detail: "Connected to NAVER WORKS API" });
4192
+ } else {
4193
+ results.push({ check: "apiConnection", status: "FAIL", detail: `HTTP ${res.status}` });
4194
+ }
4195
+ } catch (e) {
4196
+ results.push({ check: "apiConnection", status: "FAIL", detail: `Connection failed: ${e.message}` });
4197
+ }
4198
+ } else {
4199
+ results.push({ check: "apiConnection", status: "SKIP", detail: "No valid token \u2014 skipping API test" });
4200
+ }
4201
+ return results;
4202
+ }
4203
+ var doctorCommand = new Command12("doctor").description("Check nworks configuration and connectivity").option("--profile <name>", "Profile name", "default").option("--json", "JSON output").action(async (opts) => {
4204
+ try {
4205
+ const results = await runChecks(opts.profile);
4206
+ if (opts.json || !process.stdout.isTTY) {
4207
+ output(results, opts);
4208
+ } else {
4209
+ console.log("\n nworks doctor\n");
4210
+ for (const r of results) {
4211
+ const icon = r.status === "OK" ? "\u2705" : r.status === "SKIP" ? "\u2796" : r.status === "WARN" ? "\u26A0\uFE0F" : "\u274C";
4212
+ console.log(` ${icon} ${r.check.padEnd(16)} ${r.detail}`);
4213
+ }
4214
+ console.log();
4215
+ }
4216
+ if (results.some((r) => r.status === "FAIL")) {
4217
+ process.exitCode = 1;
4218
+ }
4219
+ } catch (err) {
4220
+ cliError(err, opts);
4221
+ }
4222
+ });
4223
+
4224
+ // src/mcp/tools/diagnostics.ts
4225
+ function registerDiagnosticsTools(server) {
4226
+ defineTool(
4227
+ server,
3131
4228
  "nworks_doctor",
3132
4229
  "NAVER WORKS \uC5F0\uACB0 \uC0C1\uD0DC\uB97C \uC9C4\uB2E8\uD569\uB2C8\uB2E4. \uC778\uC99D \uC815\uBCF4, \uD1A0\uD070, Private Key, API \uC5F0\uACB0\uC744 \uC810\uAC80\uD569\uB2C8\uB2E4.",
3133
4230
  {},
3134
4231
  async () => {
3135
4232
  try {
3136
4233
  const results = await runChecks("default");
4234
+ const maskedResults = results.map((r) => {
4235
+ if (r.check === "credentials" && r.status === "OK") {
4236
+ return { ...r, detail: r.detail.replace(/clientId: .+/, "clientId: ****") };
4237
+ }
4238
+ if (r.check === "privateKey" && r.status === "OK") {
4239
+ return { ...r, detail: "OK (path hidden)" };
4240
+ }
4241
+ if (r.check === "serviceAccount" && r.status === "OK") {
4242
+ const masked = r.detail.length <= 4 ? "****" : `****${r.detail.slice(-4)}`;
4243
+ return { ...r, detail: masked };
4244
+ }
4245
+ return r;
4246
+ });
3137
4247
  return {
3138
- content: [{ type: "text", text: JSON.stringify(results) }]
4248
+ content: [{ type: "text", text: JSON.stringify(maskedResults) }]
3139
4249
  };
3140
4250
  } catch (err) {
3141
4251
  return {
@@ -3147,6 +4257,21 @@ OAuth Redirect URI: http://localhost:9876/callback`,
3147
4257
  );
3148
4258
  }
3149
4259
 
4260
+ // src/mcp/tools.ts
4261
+ function registerTools(server) {
4262
+ registerSetupTools(server);
4263
+ registerMessageTools(server);
4264
+ registerDirectoryTools(server);
4265
+ registerCalendarTools(server);
4266
+ registerDriveTools(server);
4267
+ registerMailTools(server);
4268
+ registerTaskTools(server);
4269
+ registerBoardTools(server);
4270
+ registerContactTools(server);
4271
+ registerAuthTools(server);
4272
+ registerDiagnosticsTools(server);
4273
+ }
4274
+
3150
4275
  // src/mcp/server.ts
3151
4276
  async function startMcpServer() {
3152
4277
  try {
@@ -3167,7 +4292,7 @@ async function startMcpServer() {
3167
4292
  }
3168
4293
  const server = new McpServer({
3169
4294
  name: "nworks",
3170
- version: "1.0.0"
4295
+ version: VERSION
3171
4296
  });
3172
4297
  registerTools(server);
3173
4298
  const transport = new StdioServerTransport();
@@ -3175,22 +4300,31 @@ async function startMcpServer() {
3175
4300
  }
3176
4301
 
3177
4302
  // src/commands/mcp-cmd.ts
3178
- var mcpCommand = new Command12("mcp").description("Start MCP server (stdio transport)").option("--list-tools", "List available MCP tools").action(async (opts) => {
4303
+ function listRegisteredTools() {
4304
+ const server = new McpServer2({ name: "nworks", version: "0.0.0" });
4305
+ registerTools(server);
4306
+ const registered = server._registeredTools;
4307
+ return Object.entries(registered).map(([name, tool]) => ({
4308
+ name,
4309
+ summary: (tool.description ?? "").split("\n")[0]?.trim() ?? ""
4310
+ }));
4311
+ }
4312
+ var mcpCommand = new Command13("mcp").description("Start MCP server (stdio transport)").option("--list-tools", "List available MCP tools").action(async (opts) => {
3179
4313
  if (opts.listTools) {
3180
- console.log("nworks_message_send \u2014 Send message to user or channel");
3181
- console.log("nworks_message_members \u2014 List channel members");
3182
- console.log("nworks_directory_members \u2014 List organization members");
3183
- console.log("nworks_calendar_list \u2014 List calendar events (User OAuth)");
3184
- console.log("nworks_whoami \u2014 Show auth status");
4314
+ const tools = listRegisteredTools();
4315
+ const width = tools.length ? Math.max(...tools.map((t) => t.name.length)) : 0;
4316
+ for (const { name, summary } of tools) {
4317
+ console.log(`${name.padEnd(width)} ${summary}`);
4318
+ }
4319
+ console.log(`
4320
+ ${tools.length} tools`);
3185
4321
  return;
3186
4322
  }
3187
4323
  await startMcpServer();
3188
4324
  });
3189
4325
 
3190
4326
  // src/index.ts
3191
- var require2 = createRequire(import.meta.url);
3192
- var { version } = require2("../package.json");
3193
- var program = new Command13().name("nworks").description("NAVER WORKS CLI \u2014 built for humans and AI agents").version(version).option("--json", "Always output JSON").option("-v, --verbose", "Debug logging").option("--dry-run", "Print request without calling API").option("-p, --profile <name>", "Profile name", "default");
4327
+ var program = new Command14().name("nworks").description("NAVER WORKS CLI \u2014 built for humans and AI agents").version(VERSION);
3194
4328
  program.addCommand(loginCommand);
3195
4329
  program.addCommand(logoutCommand);
3196
4330
  program.addCommand(whoamiCommand);
@@ -3201,6 +4335,7 @@ program.addCommand(driveCommand);
3201
4335
  program.addCommand(mailCommand);
3202
4336
  program.addCommand(taskCommand);
3203
4337
  program.addCommand(boardCommand);
4338
+ program.addCommand(contactCommand);
3204
4339
  program.addCommand(mcpCommand);
3205
4340
  program.addCommand(doctorCommand);
3206
4341
  program.parse();