kintai-plus-mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -9
  2. package/dist/cli.mjs +137 -36
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  「出勤して」「今月の残業はどれくらい?」「昨日の工数を設計 2 時間・実装 5 時間で入れて」のように話しかけるだけで、打刻・勤務実績の確認・工数入力ができます。
9
9
 
10
10
  - 手元の PC で動くローカルの stdio サーバーです(トークンを第三者のサーバーに預けません)
11
- - トークンは OS のキーチェーンに保存し、有効期限が近づくと自動で再発行します
11
+ - トークンは OS のキーチェーンに保存し、有効期限が近づくと自動で再発行します(環境変数で渡したトークンは、明示しない限り保存も再発行もしません)
12
12
  - 打刻の位置情報・IP アドレスは、既定では AI に渡しません
13
13
 
14
14
  公開 API の仕様(公式): [マネーフォワード クラウド勤怠Plus 公開API 仕様](https://akashi.zendesk.com/hc/ja/articles/115000475854)
@@ -26,7 +26,7 @@
26
26
  | `get_working_records` | 勤務実績(労働区分・時刻・実働・休憩。31 日ごとに自動で分割) |
27
27
  | `summarize_working_hours` | 実働合計・出勤日数・休日出勤日数・法定時間外の目安(月ごとの内訳つき) |
28
28
  | `get_alerts` | アラート(打刻忘れ、遅刻、36協定 など) |
29
- | `check_attendance_issues` | 打刻漏れチェック(退勤・出勤・休憩戻の打刻忘れ、12 時間超の日、勤務間インターバル 11 時間未満、休日出勤) |
29
+ | `check_attendance_issues` | 打刻漏れチェック(退勤・出勤・休憩戻の打刻忘れ、12 時間超の日、勤務間インターバル 11 時間未満、休日出勤)。勤務実績にその時刻がある日は打刻漏れにしない。休憩が不明な経過時間は、12 時間超を情報にとどめる |
30
30
  | `list_projects` / `list_tasks` / `get_my_projects` | プロジェクトとタスク |
31
31
  | `get_manhours` | 工数(プロジェクト名・タスク名つき) |
32
32
  | `save_manhours` 【書き込み】 / `delete_manhours` 【書き込み】 | 工数の登録・更新・削除 |
@@ -63,9 +63,9 @@
63
63
 
64
64
  **管理者が行うこと**(一度だけ): 従業員設定 > 対象の従業員の「変更」 > 利用機能設定 で、「公開API利用可否」を「利用する」にします。
65
65
 
66
- **本人が行うこと**: マイページ > APIトークン で発行します。トークンの有効期限は 1 ヶ月と 1 日です(このツールが自動で再発行します)。
66
+ **本人が行うこと**: マイページ > APIトークン で発行します。トークンの有効期限は 1 ヶ月と 1 日です。`login` で登録したトークンは、登録時と、期限が近づいたときに再発行します。
67
67
 
68
- > このツール専用のトークンを発行してください。初回の利用時と期限が近づいたときに再発行するため、同じトークンを他のツールで使っていると、そちらが使えなくなることがあります。
68
+ > `login` で登録するトークンは、このツール専用にしてください。再発行すると、それまで使っていたトークンは使えなくなります。環境変数で渡すだけの場合は、既定では再発行しません(下の「環境変数でトークンを渡す」)。
69
69
 
70
70
  ### 2. ログイン
71
71
 
@@ -165,24 +165,29 @@ args = ["-y", "kintai-plus-mcp"]
165
165
  | 変数 | 既定 | 内容 |
166
166
  |---|---|---|
167
167
  | `KINTAI_PLUS_READ_ONLY` | `false` | `true` で書き込みツール(打刻・工数)を出さない |
168
- | `CONFIRM_WRITES` | `false` | `true` で書き込みの前に確認ダイアログを出す(Elicitation に対応したクライアントのみ。未対応のクライアントでは書き込みが失敗します) |
168
+ | `KINTAI_PLUS_CONFIRM_WRITES` | `false` | `true` で書き込みの前に確認ダイアログを出す(Elicitation に対応したクライアントのみ。未対応のクライアントでは書き込みが失敗します)。旧名の `CONFIRM_WRITES` も当面は読みます |
169
169
  | `KINTAI_PLUS_INCLUDE_LOCATION` | `false` | `true` で打刻の位置情報・IP を返す |
170
170
  | `KINTAI_PLUS_REFRESH_DAYS` | `15` | 有効期限の残りがこの日数を切ったら再発行する(1〜30) |
171
171
  | `KINTAI_PLUS_AUTO_REFRESH` | `true` | `false` で自動の再発行を止める |
172
+ | `KINTAI_PLUS_PERSIST_ENV_TOKEN` | `false` | `true` で、環境変数のトークンを保存し、期限が不明なら再発行する。環境変数の値は使えなくなることがある |
172
173
  | `KINTAI_PLUS_CREDENTIAL_STORE` | `auto` | 保存先: `auto`(キーチェーン → ファイル)/ `keychain` / `file` |
173
174
  | `KINTAI_PLUS_CREDENTIALS_FILE` | `~/.config/kintai-plus-mcp/credentials.json` | ファイル保存のパス(指定するとファイル保存になる) |
174
175
  | `KINTAI_PLUS_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` / `silent`(ログは stderr のみ) |
175
176
  | `KINTAI_PLUS_MIN_INTERVAL_MS` | `300` | API リクエストの最小間隔(ミリ秒) |
176
177
 
177
- ### キーチェーンのない環境(サーバー上のボットなど)
178
+ ### 環境変数でトークンを渡す(ボットなど)
178
179
 
179
- キーチェーンが使えない環境では、本人だけが読み書きできるファイル(パーミッション 600)に保存します。`login` を対話で行えない場合は、環境変数で初回だけ取り込めます。
180
+ `login` が使えないときは、環境変数だけで起動できます。**既定では保存せず、再発行 API も呼びません。**
180
181
 
181
182
  ```sh
182
183
  KINTAI_PLUS_TOKEN=... KINTAI_PLUS_COMPANY_ID=... KINTAI_PLUS_HOST=atnd.ak4.jp npx kintai-plus-mcp
183
184
  ```
184
185
 
185
- 保存先が空のときだけ取り込み、以降は保存先の値(自動で再発行されたもの)を使います。
186
+ 再発行 API は、渡したトークンを無効にして新しいトークンを返します。環境変数の値を自動で再発行すると、その値を共有している他のプロセスが突然使えなくなります。このサーバーは、明示しない限りそれをしません。
187
+
188
+ 自動更新(期限が近づいたら再発行して保存)が必要なときは、このサーバー専用のトークンを `npx kintai-plus-mcp login` で登録してください。キーチェーンが使えない環境では、本人だけが読み書きできるファイル(パーミッション 600)に保存します。
189
+
190
+ 環境変数のトークンを保存して再発行する動作は、`KINTAI_PLUS_PERSIST_ENV_TOKEN=true` を付けたときだけです。有効にすると、環境変数に書いたトークンは使えなくなることがあります。保存先にすでに認証情報があるときは、環境変数より保存先を優先します。
186
191
 
187
192
  ## 制約
188
193
 
@@ -227,7 +232,7 @@ This project is not affiliated with, endorsed by, or sponsored by Money Forward,
227
232
  - Punch in/out, check today's status, view punch history, working records, alerts, and man-hours, and record man-hours
228
233
  - Admin tools (staff list, team punches, working statuses, project members) are shown only to admins
229
234
  - Runs locally over stdio: `npx kintai-plus-mcp`. Register your token first with `npx kintai-plus-mcp login`
230
- - The token is stored in the OS keychain (or a 600-permission file) and reissued automatically before it expires
235
+ - The token is stored in the OS keychain (or a 600-permission file) and reissued automatically before it expires. A token passed only via `KINTAI_PLUS_TOKEN` is not stored or reissued unless `KINTAI_PLUS_PERSIST_ENV_TOKEN=true` (reissue invalidates the previous token)
231
236
  - Location and IP address of punches are not sent to the AI by default
232
237
  - Not supported: requests (申請) and schedules, because the public API does not provide them. Punches are recorded at the time the server receives them and cannot be edited via the API
233
238
  - Requires Node.js 22+ and an API token issued from your Kintai Plus "My Page" (an admin must enable "公開API利用可否" for you)
package/dist/cli.mjs CHANGED
@@ -621,6 +621,7 @@ var TokenManager = class {
621
621
  this.#o = {
622
622
  refreshBeforeDays: 15,
623
623
  autoRefresh: true,
624
+ reissueUnknownExpiry: true,
624
625
  now: () => /* @__PURE__ */ new Date(),
625
626
  warn: () => {},
626
627
  cacheTtlMs: 6e4,
@@ -653,7 +654,7 @@ var TokenManager = class {
653
654
  return credentials;
654
655
  }
655
656
  #needsRefresh(credentials) {
656
- if (credentials.expiresAt === void 0) return true;
657
+ if (credentials.expiresAt === void 0) return this.#o.reissueUnknownExpiry;
657
658
  return Date.parse(credentials.expiresAt) - this.#o.now().getTime() <= this.#o.refreshBeforeDays * DAY_MS$1;
658
659
  }
659
660
  #isExpired(credentials) {
@@ -752,7 +753,7 @@ const silentLogger = createLogger({
752
753
  // package.json
753
754
  var name = "kintai-plus-mcp";
754
755
  // src/version.ts
755
- const VERSION = "0.1.0";
756
+ const VERSION = "0.1.2";
756
757
  const PACKAGE_NAME = name;
757
758
  // src/api/throttle.ts
758
759
  const systemClock = {
@@ -906,10 +907,11 @@ function loadConfig(env = process.env) {
906
907
  refreshBeforeDays: int(env, "KINTAI_PLUS_REFRESH_DAYS", 15, 1, 30),
907
908
  autoRefresh: bool(env, "KINTAI_PLUS_AUTO_REFRESH", true),
908
909
  readOnly: bool(env, "KINTAI_PLUS_READ_ONLY", false),
909
- confirmWrites: bool(env, "CONFIRM_WRITES", false),
910
+ confirmWrites: read(env, "KINTAI_PLUS_CONFIRM_WRITES") !== void 0 ? bool(env, "KINTAI_PLUS_CONFIRM_WRITES", false) : bool(env, "CONFIRM_WRITES", false),
910
911
  includeLocation: bool(env, "KINTAI_PLUS_INCLUDE_LOCATION", false),
911
912
  logLevel: oneOf(env, "KINTAI_PLUS_LOG_LEVEL", LOG_LEVELS, "info"),
912
- minIntervalMs: int(env, "KINTAI_PLUS_MIN_INTERVAL_MS", 300, 100, 6e4)
913
+ minIntervalMs: int(env, "KINTAI_PLUS_MIN_INTERVAL_MS", 300, 100, 6e4),
914
+ persistEnvToken: bool(env, "KINTAI_PLUS_PERSIST_ENV_TOKEN", false)
913
915
  };
914
916
  }
915
917
  function read(env, name) {
@@ -1167,10 +1169,11 @@ async function selectCredentialStore(options = {}) {
1167
1169
  return fallback;
1168
1170
  }
1169
1171
  /**
1170
- * 保存先が空のときだけ、環境変数から認証情報を取り込む。以降は保存先を正とする。
1171
- * 戻り値は保存先にある(または取り込んだ)認証情報。どちらもなければ undefined。
1172
+ * 保存先が空のとき、環境変数から認証情報を読む。
1173
+ * 既定では保存しない。`persist: true` のときだけ保存し、以降は保存先を正とする。
1174
+ * 保存先に既にあれば、環境変数は無視して保存先を返す。
1172
1175
  */
1173
- async function importFromEnv(store, env = process.env, now = /* @__PURE__ */ new Date()) {
1176
+ async function importFromEnv(store, env = process.env, now = /* @__PURE__ */ new Date(), options = {}) {
1174
1177
  const existing = await store.load();
1175
1178
  if (existing) return existing;
1176
1179
  const token = env[TOKEN_ENV]?.trim();
@@ -1186,9 +1189,41 @@ async function importFromEnv(store, env = process.env, now = /* @__PURE__ */ new
1186
1189
  token,
1187
1190
  updatedAt: now.toISOString()
1188
1191
  };
1189
- await store.save(credentials);
1192
+ if (options.persist) await store.save(credentials);
1190
1193
  return credentials;
1191
1194
  }
1195
+ /**
1196
+ * 保存先が空のあいだ、環境変数のトークンをメモリ上だけで返す。
1197
+ * 保存先に認証情報が入ったあと(login など)は保存先を読む。書き出しは保存先に既にあるときだけ。
1198
+ */
1199
+ var EnvFallbackStore = class {
1200
+ #inner;
1201
+ #fallback;
1202
+ constructor(inner, fallback) {
1203
+ this.#inner = inner;
1204
+ this.#fallback = fallback;
1205
+ }
1206
+ get kind() {
1207
+ return this.#inner.kind;
1208
+ }
1209
+ get location() {
1210
+ return this.#inner.location;
1211
+ }
1212
+ async load() {
1213
+ return await this.#inner.load() ?? this.#fallback;
1214
+ }
1215
+ async save(credentials) {
1216
+ if (await this.#inner.load()) {
1217
+ await this.#inner.save(credentials);
1218
+ return;
1219
+ }
1220
+ this.#fallback = credentials;
1221
+ }
1222
+ async clear() {
1223
+ this.#fallback = void 0;
1224
+ return this.#inner.clear();
1225
+ }
1226
+ };
1192
1227
  // src/runtime.ts
1193
1228
  /** 設定・ログ・保存先・トークン管理・API クライアントを組み立てる。 */
1194
1229
  async function createRuntime(options = {}) {
@@ -1213,10 +1248,25 @@ async function createRuntime(options = {}) {
1213
1248
  ...options.onResponse ? { onResponse: options.onResponse } : {}
1214
1249
  });
1215
1250
  const lockPath = join(dirname(defaultCredentialsPath(env)), "refresh.lock");
1251
+ let tokenStore = store;
1252
+ let usingEnvToken = false;
1253
+ try {
1254
+ const before = await store.load();
1255
+ const imported = await importFromEnv(store, env, options.now?.() ?? /* @__PURE__ */ new Date(), { persist: config.persistEnvToken });
1256
+ if (imported) logger.addSecret(imported.token);
1257
+ if (!before && imported && !config.persistEnvToken) {
1258
+ usingEnvToken = true;
1259
+ tokenStore = new EnvFallbackStore(store, imported);
1260
+ }
1261
+ } catch (error) {
1262
+ if (!(error instanceof CredentialsFormatError)) throw error;
1263
+ logger.warn(error.message);
1264
+ }
1216
1265
  const tokens = new TokenManager({
1217
- store,
1266
+ store: tokenStore,
1218
1267
  refreshBeforeDays: config.refreshBeforeDays,
1219
1268
  autoRefresh: config.autoRefresh,
1269
+ reissueUnknownExpiry: !usingEnvToken,
1220
1270
  warn: (m) => logger.warn(m),
1221
1271
  acquireLock: () => acquireFileLock(lockPath),
1222
1272
  reissue: async (credentials) => {
@@ -1228,18 +1278,12 @@ async function createRuntime(options = {}) {
1228
1278
  },
1229
1279
  ...options.now ? { now: options.now } : {}
1230
1280
  });
1231
- try {
1232
- const imported = await importFromEnv(store, env, options.now?.() ?? /* @__PURE__ */ new Date());
1233
- if (imported) logger.addSecret(imported.token);
1234
- } catch (error) {
1235
- if (!(error instanceof CredentialsFormatError)) throw error;
1236
- logger.warn(error.message);
1237
- }
1238
1281
  return {
1239
1282
  config,
1240
1283
  logger,
1241
1284
  store,
1242
1285
  tokens,
1286
+ usingEnvToken,
1243
1287
  createClient,
1244
1288
  async getClient() {
1245
1289
  const credentials = await tokens.getCredentials();
@@ -1389,20 +1433,28 @@ async function logout(deps) {
1389
1433
  const removed = await runtime.store.clear();
1390
1434
  io.stdout(removed ? `認証情報を削除しました: ${runtime.store.location}\nトークン自体はまだ有効です。不要ならマイページで無効化してください。
1391
1435
  ` : `保存された認証情報はありません: ${runtime.store.location}\n`);
1392
- if (deps.runtimeOptions?.env?.KINTAI_PLUS_TOKEN ?? process.env.KINTAI_PLUS_TOKEN) io.stderr("注意: KINTAI_PLUS_TOKEN が設定されているため、次回の起動時に再び取り込まれます。\n");
1436
+ if ((deps.runtimeOptions?.env ?? process.env).KINTAI_PLUS_TOKEN) io.stderr(runtime.config.persistEnvToken ? "注意: KINTAI_PLUS_TOKEN が設定されているため、次回の起動時に再び保存されます。再発行すると、環境変数の値は使えなくなることがあります。\n" : "注意: KINTAI_PLUS_TOKEN が設定されています。保存した認証情報が無くても、次回の起動はこの環境変数のトークンを使います。保存しません。再発行もしません。\n");
1393
1437
  return 0;
1394
1438
  }
1395
1439
  async function status(deps) {
1396
1440
  const { io } = deps;
1397
1441
  const runtime = await createRuntime(deps.runtimeOptions);
1398
1442
  const now = deps.now?.() ?? /* @__PURE__ */ new Date();
1399
- const lines = [`保存先: ${runtime.store.location}`];
1400
- const credentials = await runtime.store.load();
1443
+ const stored = await runtime.store.load();
1444
+ const fromEnv = !stored && runtime.usingEnvToken;
1445
+ const credentials = stored ?? (fromEnv ? await runtime.tokens.getCredentials() : void 0);
1401
1446
  if (!credentials) {
1402
- io.stdout(`${lines.join("\n")}\n未ログインです。\`npx kintai-plus-mcp login\` で登録してください。\n`);
1447
+ io.stdout(`保存先: ${runtime.store.location}\n未ログインです。\`npx kintai-plus-mcp login\` で登録してください。\n`);
1403
1448
  return 1;
1404
1449
  }
1405
- lines.push(`接続先: ${credentials.host}`, `ログイン企業ID: ${credentials.companyId}`, `従業員ID: ${credentials.staffId ?? "不明"}`, `有効期限: ${formatExpiry(credentials.expiresAt, now)}`, `自動更新: ${runtime.config.autoRefresh ? `有効(残り ${runtime.config.refreshBeforeDays} 日を切ったら再発行)` : "無効"}`);
1450
+ const lines = [
1451
+ fromEnv ? "保存先: なし(環境変数のトークンを、このプロセスの中だけで使います)" : `保存先: ${runtime.store.location}`,
1452
+ `接続先: ${credentials.host}`,
1453
+ `ログイン企業ID: ${credentials.companyId}`,
1454
+ `従業員ID: ${credentials.staffId ?? "不明"}`,
1455
+ `有効期限: ${fromEnv ? "不明(環境変数のトークンは自動では再発行しません)" : formatExpiry(credentials.expiresAt, now)}`,
1456
+ `自動更新: ${fromEnv ? "無効(環境変数のトークンは再発行しません。自動更新が必要なら login で登録してください)" : runtime.config.autoRefresh ? `有効(残り ${runtime.config.refreshBeforeDays} 日を切ったら再発行)` : "無効"}`
1457
+ ];
1406
1458
  let code = 0;
1407
1459
  try {
1408
1460
  const staff = await fetchMyStaff(await runtime.getClient(), { staffId: credentials.staffId });
@@ -2512,7 +2564,9 @@ function apiHint(error) {
2512
2564
  case "unknown_company": return "The company ID (ログイン企業ID) was not found. Check the company ID and that the host matches the domain after login (atnd.ak4.jp or atnd-awj.ak4.jp), then run `npx kintai-plus-mcp login` again.";
2513
2565
  case "forbidden": return "Kintai Plus denied this operation. The user may not have permission for it (admin rights may be required), or the public API (公開API利用可否) may be disabled for this user.";
2514
2566
  case "not_found": return "The requested item was not found. Check the IDs (staff, project, task) and the date.";
2515
- case "invalid_request": return "Kintai Plus rejected the request. Check the parameters (dates, IDs, period length).";
2567
+ case "invalid_request":
2568
+ if (error.apiErrors.some((e) => e.message?.includes("従業員IDは必ず入力してください"))) return "Kintai Plus also returns 「従業員IDは必ず入力してください」 when a staff ID does not exist or is outside the staff you manage. It does not always mean staffIds was omitted. Check the IDs with list_staff.";
2569
+ return "Kintai Plus rejected the request. Check the parameters (dates, IDs, period length).";
2516
2570
  case "rate_limited": return "Too many requests. Wait a minute before trying again.";
2517
2571
  case "server":
2518
2572
  case "network":
@@ -2522,6 +2576,11 @@ function apiHint(error) {
2522
2576
  }
2523
2577
  // src/tools/common.ts
2524
2578
  const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD").describe("Date in YYYY-MM-DD (Japan time)");
2579
+ /** 月。01–12 以外(2026-13 など)は拒否する */
2580
+ const monthSchema = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Use YYYY-MM (month 01-12)");
2581
+ function assertMonth(month) {
2582
+ if (!monthSchema.safeParse(month).success) throw new ToolInputError(`Invalid month: ${month} (use YYYY-MM, month 01-12)`);
2583
+ }
2525
2584
  /** ツール入力の期間を検証して DateRange にする */
2526
2585
  function resolveRange(input, now, defaults) {
2527
2586
  const from = input.from ?? defaults.from ?? todayInTokyo(now);
@@ -2542,9 +2601,12 @@ function formatMinutes(minutes) {
2542
2601
  return `${sign}${Math.floor(abs / 60)}:${String(abs % 60).padStart(2, "0")}`;
2543
2602
  }
2544
2603
  // src/tools/registry.ts
2545
- /** 型推論のためのヘルパー */
2604
+ /** 型推論のためのヘルパー。未知の引数は捨てずに拒否する */
2546
2605
  function defineTool(tool) {
2547
- return tool;
2606
+ return {
2607
+ ...tool,
2608
+ inputSchema: tool.inputSchema.strict()
2609
+ };
2548
2610
  }
2549
2611
  /** 権限・バックエンドの機能・読み取り専用モードに応じて、公開するツールを選ぶ(ADR 0004, 0005) */
2550
2612
  function selectTools(tools, options) {
@@ -2649,7 +2711,21 @@ async function staffIndex(ctx) {
2649
2711
  }
2650
2712
  }
2651
2713
  async function resolveStaffIds(input, ctx) {
2652
- if (input) return [...new Set(input)];
2714
+ if (input) {
2715
+ const unique = [...new Set(input)];
2716
+ try {
2717
+ const listed = await ctx.backend.listStaff();
2718
+ if (!listed.truncated) {
2719
+ const known = new Set(listed.staff.map((s) => s.staffId));
2720
+ const unknown = unique.filter((id) => !known.has(id));
2721
+ if (unknown.length > 0) throw new ToolInputError(`Unknown staff ID(s): ${unknown.join(", ")}. They are not in the staff you manage. Use list_staff to get valid IDs. Kintai Plus reports an unknown ID as 「従業員IDは必ず入力してください」, which does not mean staffIds was omitted.`);
2722
+ }
2723
+ } catch (error) {
2724
+ if (error instanceof ToolInputError) throw error;
2725
+ ctx.logger.warn(`Could not check staff IDs (${error instanceof Error ? error.name : "error"})`);
2726
+ }
2727
+ return unique;
2728
+ }
2653
2729
  const ids = (await managedStaff(ctx)).map((s) => s.staffId);
2654
2730
  if (ids.length > 500) throw new ToolInputError(`You manage ${ids.length} staff; pass staffIds (up to 500) to narrow down.`);
2655
2731
  return ids;
@@ -2795,11 +2871,12 @@ const getAlertsTool = defineTool({
2795
2871
  name: "get_alerts",
2796
2872
  title: "Get my attendance alerts",
2797
2873
  description: "Get your own attendance alerts (アラート) from Kintai Plus, such as missing punches (打刻忘れ), suspected absence (欠勤疑い), late arrival (遅刻), early leave (早退), overtime threshold (残業時間閾値超え), 36 agreement (36協定), and missing man-hours (工数未入力). Optionally filter by month.",
2798
- inputSchema: z.object({ month: z.string().regex(/^\d{4}-\d{2}$/, "Use YYYY-MM").optional().describe("Only alerts for this month (YYYY-MM).") }),
2874
+ inputSchema: z.object({ month: monthSchema.optional().describe("Only alerts for this month (YYYY-MM).") }),
2799
2875
  capability: "alerts.read",
2800
2876
  audience: "everyone",
2801
2877
  kind: "read",
2802
2878
  handler: async (input, ctx) => {
2879
+ if (input.month) assertMonth(input.month);
2803
2880
  const all = await ctx.backend.getAlerts();
2804
2881
  const alerts = input.month ? all.filter((a) => (a.month ?? a.date?.slice(0, 7)) === input.month) : all;
2805
2882
  return {
@@ -2832,15 +2909,21 @@ const isOut = (s) => s.type === "clock_out" || s.type === "direct_out";
2832
2909
  * 日をまたぐ勤務(前日に出勤し、翌日の最初の打刻が退勤)は 1 つの勤務として扱い、打刻漏れにしない。
2833
2910
  * 範囲の初日の朝に終わる夜勤のため、stamps には範囲の前日の分も含めてよい。
2834
2911
  * 休暇・申請は API で見えないため、「労働日に打刻なし」は休暇の可能性を添えて warning にとどめる。
2912
+ * 勤務実績に開始・終了(または出勤・退勤の打刻時刻)がある日は、打刻修正などで打刻が欠けていても打刻漏れにしない。
2913
+ * 休憩が不明な経過時間は 12 時間超を info にし、1 時間引いても超えるときだけ warning にする。
2835
2914
  */
2836
2915
  function findAttendanceIssues(input) {
2837
2916
  const issues = [];
2838
- const add = (date, type, message) => issues.push({
2917
+ const add = (date, type, message, severity = SEVERITY[type]) => issues.push({
2839
2918
  date,
2840
2919
  type,
2841
- severity: SEVERITY[type],
2920
+ severity,
2842
2921
  message
2843
2922
  });
2923
+ const reportOpenPunch = (open) => {
2924
+ if (recordHasClockOut(recordByDate.get(open.date))) return;
2925
+ add(open.date, open.type, open.type === "missing_clock_out" ? "退勤の打刻がありません(打刻忘れの可能性)" : "休憩戻の打刻がありません(休憩中のまま)");
2926
+ };
2844
2927
  const stampsByDate = /* @__PURE__ */ new Map();
2845
2928
  for (const stamp of [...input.stamps].sort((a, b) => a.stampedAt.getTime() - b.stampedAt.getTime())) {
2846
2929
  const date = toLocalDate(stamp.stampedAt);
@@ -2858,9 +2941,9 @@ function findAttendanceIssues(input) {
2858
2941
  const firstInOrOut = stamps.find((s) => isIn(s) || isOut(s));
2859
2942
  const startsWithOut = firstInOrOut !== void 0 && isOut(firstInOrOut);
2860
2943
  const overnight = pending?.type === "missing_clock_out" && pending.lastInAt !== void 0 && diffDays(pending.date, date) === 1 && startsWithOut && firstInOrOut.stampedAt.getTime() - pending.lastInAt.getTime() <= MAX_OVERNIGHT_SHIFT_MINUTES * 6e4;
2861
- if (pending && !overnight) add(pending.date, pending.type, pending.type === "missing_clock_out" ? "退勤の打刻がありません(打刻忘れの可能性)" : "休憩戻の打刻がありません(休憩中のまま)");
2944
+ if (pending && !overnight) reportOpenPunch(pending);
2862
2945
  pending = void 0;
2863
- if (startsWithOut && !overnight) add(date, "missing_clock_in", "出勤の打刻がないまま退勤しています");
2946
+ if (startsWithOut && !overnight && !recordHasClockIn(record)) add(date, "missing_clock_in", "出勤の打刻がないまま退勤しています");
2864
2947
  const status = computeDayStatus(stamps, endOfDay(date));
2865
2948
  const lastIn = [...stamps].reverse().find(isIn);
2866
2949
  if (status.state === "working") pending = {
@@ -2873,9 +2956,8 @@ function findAttendanceIssues(input) {
2873
2956
  type: "missing_break_end"
2874
2957
  };
2875
2958
  const minutes = record?.actualWorkingMinutes ?? 0;
2876
- const worked = record?.workingMinutesAreGross ? "開始から終了までの経過(休憩は不明)" : "実働";
2877
2959
  if (record?.dayCategory === "workday" && stamps.length === 0 && minutes === 0 && !overnight) add(date, "no_punches_on_workday", "労働日ですが打刻がありません(休暇などの場合は問題ありません)");
2878
- if (minutes > LONG_DAY_MINUTES) add(date, "long_working_day", `${worked}が ${formatHm(minutes)} で、12 時間を超えています`);
2960
+ if (minutes > LONG_DAY_MINUTES) addLongDay(date, minutes, record?.workingMinutesAreGross === true, add);
2879
2961
  const firstIn = stamps.find(isIn);
2880
2962
  if (previousOut && firstIn && diffDays(previousOut.date, date) === 1) {
2881
2963
  const gap = Math.round((firstIn.stampedAt.getTime() - previousOut.at.getTime()) / 6e4);
@@ -2889,9 +2971,27 @@ function findAttendanceIssues(input) {
2889
2971
  if (minutes > 0 && record?.dayCategory === "legal_holiday") add(date, "holiday_work", holidayWorkMessage("法定休日", minutes, record.workingMinutesAreGross));
2890
2972
  else if (minutes > 0 && record?.dayCategory === "scheduled_holiday") add(date, "holiday_work", holidayWorkMessage("所定休日", minutes, record.workingMinutesAreGross));
2891
2973
  }
2892
- if (pending) add(pending.date, pending.type, pending.type === "missing_clock_out" ? "退勤の打刻がありません(打刻忘れの可能性)" : "休憩戻の打刻がありません(休憩中のまま)");
2974
+ if (pending) reportOpenPunch(pending);
2893
2975
  return issues.filter((issue) => issue.date >= input.range.from).sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
2894
2976
  }
2977
+ function recordHasClockIn(record) {
2978
+ return record?.startTime !== void 0 || record?.clockInAt !== void 0;
2979
+ }
2980
+ function recordHasClockOut(record) {
2981
+ return record?.endTime !== void 0 || record?.clockOutAt !== void 0;
2982
+ }
2983
+ function addLongDay(date, minutes, gross, add) {
2984
+ if (!gross) {
2985
+ add(date, "long_working_day", `実働が ${formatHm(minutes)} で、12 時間を超えています`);
2986
+ return;
2987
+ }
2988
+ const elapsed = `開始から終了までの経過が ${formatHm(minutes)}`;
2989
+ if (minutes > 780) {
2990
+ add(date, "long_working_day", `${elapsed} です。1 時間の休憩を差し引いても 12 時間を超えます(実際の休憩時間は不明)`);
2991
+ return;
2992
+ }
2993
+ add(date, "long_working_day", `${elapsed} で 12 時間を超えています。休憩時間が不明なため、実働はこれより短い可能性があります`, "info");
2994
+ }
2895
2995
  function holidayWorkMessage(kind, minutes, gross) {
2896
2996
  const base = `${kind}に ${formatHm(minutes)} 働いています`;
2897
2997
  return gross ? `${base}(開始から終了までの経過。休憩は不明)` : base;
@@ -2904,7 +3004,7 @@ const MAX_DAYS = 93;
2904
3004
  const checkAttendanceIssuesTool = defineTool({
2905
3005
  name: "check_attendance_issues",
2906
3006
  title: "Check my attendance for issues",
2907
- description: `Check your own punches and working records for issues (打刻漏れチェック): missing clock out/in (退勤・出勤の打刻忘れ), break not ended (休憩戻なし), no punches on a workday, days over 12 hours, rest interval under 11 hours (勤務間インターバル), and holiday work. Defaults to the 1st of this month through yesterday (on the 1st, the previous month). Max ${MAX_DAYS} days. Overnight shifts are recognized. Leave and requests are not visible via the API, so "no punches" may simply be a day off.`,
3007
+ description: `Check your own punches and working records for issues (打刻漏れチェック): missing clock out/in (退勤・出勤の打刻忘れ), break not ended (休憩戻なし), no punches on a workday, days over 12 hours, rest interval under 11 hours (勤務間インターバル), and holiday work. A missing punch is not reported when the working record already has that start or end time (for example after a correction in the web UI). When break time is unknown, elapsed time over 12 hours is info unless it still exceeds 12 hours after a 1-hour break. Defaults to the 1st of this month through yesterday (on the 1st, the previous month). Max ${MAX_DAYS} days. Overnight shifts are recognized. Leave and requests are not visible via the API, so "no punches" may simply be a day off.`,
2908
3008
  inputSchema: z.object({
2909
3009
  from: dateSchema.optional().describe("Start date (YYYY-MM-DD)."),
2910
3010
  to: dateSchema.optional().describe("End date, inclusive (YYYY-MM-DD). Defaults to yesterday.")
@@ -2959,7 +3059,7 @@ const saveManhoursTool = defineTool({
2959
3059
  description: "Create or update your own man-hours (工数) for one day: minutes per project task. Only the listed tasks are overwritten; other tasks on that day are kept. To remove an entry, use delete_manhours. Use list_projects / get_my_projects and list_tasks to find IDs. Days that are closed (締め) or fixed (確定) cannot be changed.",
2960
3060
  inputSchema: z.object({
2961
3061
  date: dateSchema.describe("Date (YYYY-MM-DD)."),
2962
- entries: z.array(z.object({
3062
+ entries: z.array(z.strictObject({
2963
3063
  projectId: z.number().int().positive(),
2964
3064
  taskId: z.number().int().positive(),
2965
3065
  minutes: z.number().int().min(1).max(DAY_MINUTES).describe("Minutes worked on this task (e.g. 90 = 1.5 hours).")
@@ -3156,7 +3256,7 @@ const findOvertimeRisksTool = defineTool({
3156
3256
  title: "Find overtime risks in my team",
3157
3257
  description: "Admin only. For staff you manage, estimate statutory overtime (法定時間外の目安) so far this month and project it to the month end (月末の着地予測), then list who is over or likely to exceed the threshold (default 45 hours, the standard monthly limit under a 36 agreement). Levels: over (already exceeded), likely (projected to exceed), watch (projected ≥ 80%). Always present results as estimates.",
3158
3258
  inputSchema: z.object({
3159
- month: z.string().regex(/^\d{4}-\d{2}$/, "Use YYYY-MM").optional().describe("Month (YYYY-MM). Defaults to the current month."),
3259
+ month: monthSchema.optional().describe("Month (YYYY-MM). Defaults to the current month."),
3160
3260
  staffIds: z.array(z.number().int().positive()).min(1).max(500).optional().describe("Defaults to all staff you manage."),
3161
3261
  thresholdHours: z.number().int().min(1).max(200).optional().describe("Monthly overtime threshold in hours. Default 45."),
3162
3262
  includeAll: z.boolean().optional().describe("Include staff with no risk (level ok). Default false.")
@@ -3166,6 +3266,7 @@ const findOvertimeRisksTool = defineTool({
3166
3266
  kind: "read",
3167
3267
  handler: async (input, ctx) => {
3168
3268
  const today = todayInTokyo(ctx.now());
3269
+ if (input.month) assertMonth(input.month);
3169
3270
  const month = input.month ?? today.slice(0, 7);
3170
3271
  const monthStart = `${month}-01`;
3171
3272
  if (monthStart > today) throw new ToolInputError(`${month} has not started yet.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kintai-plus-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Unofficial MCP server for マネーフォワード クラウド勤怠Plus (formerly AKASHI) public API",
5
5
  "mcpName": "io.github.atzzCokeK/kintai-plus-mcp",
6
6
  "keywords": [
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "type": "module",
21
21
  "bin": {
22
- "kintai-plus-mcp": "./dist/cli.mjs"
22
+ "kintai-plus-mcp": "dist/cli.mjs"
23
23
  },
24
24
  "files": [
25
25
  "dist"