poker-mcp 1.9.9 → 1.10.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/README.md CHANGED
@@ -4,14 +4,92 @@ YAML-based input file management tool for radiation-shielding calculation code P
4
4
 
5
5
  ## 📋 クイック情報
6
6
 
7
- - **バージョン**: 1.9.9
8
- - **ツール数**: 36メソッド
7
+ - **バージョン**: 1.10.0
8
+ - **ツール数**: 36メソッド(非同期ジョブを有効にすると 39、GUI を外すと 1 減る)
9
9
  - **プロトコル**: MCP (Model Context Protocol) 1.0.0 完全準拠
10
10
  - **メインサーバー**: `src/mcp_server_stdio_v4.js`
11
11
  - **データ保存**: `~/.poker-mcp/`(`POKER_MCP_HOME`環境変数で変更可)
12
12
  - **核種データ**: `${POKER_INSTALL_PATH}/LIB/ICRP-07.NDX` を直接参照
13
13
  - **実行方式**: STDIO通信(MCPプロトコル標準)
14
14
 
15
+ ## 🆕 バージョン1.10.0の新機能
16
+
17
+ ### ⏳ 計算の非同期実行(既定では無効)
18
+
19
+ 時間のかかる計算を「投入して、あとで結果を取りに行く」形で扱えるようにしました。
20
+ 1 回のツール呼び出しは数秒で終わるので、MCP クライアントの待ち時間の上限や、
21
+ リモート公開時の中継(Cloudflare Tunnel の約 100 秒など)に切られずに、
22
+ 何時間かかる計算でも扱えます。
23
+
24
+ ```javascript
25
+ poker_submitCalculation({ yaml_file: "poker.yaml" }) // → { job_id: "20261013-053012-a1b2c3", status: "queued" }
26
+ poker_getJobStatus({ job_id: "20261013-053012-a1b2c3" }) // → queued / running / succeeded / failed
27
+ poker_getJobResult({ job_id: "20261013-053012-a1b2c3" }) // → executeCalculation と同じ形式の結果
28
+ poker_getDoseMap({ detector_name: "D_map", job_id: "20261013-053012-a1b2c3" })
29
+ ```
30
+
31
+ **既定では無効です。** 環境変数 `POKER_MCP_ASYNC=1` を設定したときだけ、上の 3 ツールが
32
+ 現れ、`poker_getDoseMap` に `job_id` 引数が付きます。設定しなければ、ツール一覧も動作も
33
+ v1.9.9 と同じです。主にリモート公開したサーバで使うことを想定しています。
34
+
35
+ ```json
36
+ "env": {
37
+ "POKER_MCP_HOME": "C:\\Users\\<username>\\poker_mcp_workspace",
38
+ "POKER_INSTALL_PATH": "C:/Poker",
39
+ "POKER_MCP_ASYNC": "1"
40
+ }
41
+ ```
42
+
43
+ | 環境変数 | 既定 | 内容 |
44
+ |---|---|---|
45
+ | `POKER_MCP_ASYNC` | 無効 | `1` で非同期ジョブを有効にする |
46
+ | `POKER_MCP_ASYNC_CONCURRENCY` | 1 | 同時に実行する計算の数 |
47
+ | `POKER_MCP_JOB_RETENTION_DAYS` | 7 | 終わったジョブを残す日数 |
48
+ | `POKER_MCP_JOB_TIMEOUT_MIN` | 1440 | 1 件の計算の打ち切り時間 [分] |
49
+
50
+ **計算の中身は同期と同じです。** ジョブも `poker_executeCalculation` と同じ処理で POKER を
51
+ 呼びます。`npm run test:jobs` で、同期と非同期の `.summary`・`.dose`・線量マップが
52
+ 全行一致することを確認しています(生成日時・計算時間・入力ファイルのパスの行を除く)。
53
+
54
+ **投入した時点の入力で計算します。** YAML(と `.paths`)を
55
+ `POKER_MCP_HOME/jobs/<ジョブID>/` にコピーしてから計算し、出力もそこに書きます。
56
+ 投入後に作業中の YAML を編集しても、投入済みのジョブには混ざらず、
57
+ 作業フォルダの `poker.yaml.summary` なども上書きしません。保留中の変更は含まれないので、
58
+ 必要なら先に `poker_applyChanges` で確定してください。
59
+
60
+ **サーバを再起動してもジョブは残ります。** 状態は `job.json` に保存しています。
61
+ 実行中に再起動した場合、その計算は失われているため `failed`(`INTERRUPTED_BY_RESTART`)
62
+ になります。待機中だったジョブは再び並び、実行されます。
63
+
64
+ ### 🖥️ GUI(poker_openGui)を使える環境でだけ登録
65
+
66
+ `poker_openGui` は、MCP サーバが動いている機械の画面に POKER.exe を開きます。次のどれかに
67
+ 当たるときは、ツール一覧から外し、処理も登録しません(名前で直接呼ばれても実行しません)。
68
+
69
+ | 条件 | 想定する場面 |
70
+ |---|---|
71
+ | `POKER_MCP_GUI=0` | **リモート公開したサーバ。** サーバに POKER が入っていても、GUI は利用者の手元ではなくサーバの画面に開き、利用者からは見えないため |
72
+ | Windows 以外 | POKER.exe は Windows 専用 |
73
+ | `POKER_INSTALL_PATH` に POKER.exe が無い | POKER の GUI を入れていない環境 |
74
+
75
+ 手元の Windows に POKER を入れて Claude Desktop から使う普段の環境では、従来どおり
76
+ 36 ツールが登録されます。判定はサーバの起動時に 1 回だけ行い、結果は起動ログに
77
+ `poker_openGui: 有効(…)` / `無効(…)` と理由付きで出ます。
78
+
79
+ GUI を外しても、計算(POKER_CUI.exe)・検証・CAD 連携・非同期ジョブには影響しません。
80
+ POKER.exe を使うのは `poker_openGui` だけです。
81
+
82
+ リモート公開するサーバの設定例(非同期ジョブを有効にし、GUI を外す):
83
+
84
+ ```json
85
+ "env": {
86
+ "POKER_MCP_HOME": "C:\\poker_mcp_workspace",
87
+ "POKER_INSTALL_PATH": "C:/Poker",
88
+ "POKER_MCP_ASYNC": "1",
89
+ "POKER_MCP_GUI": "0"
90
+ }
91
+ ```
92
+
15
93
  ## 🐛 バージョン1.9.9の修正
16
94
 
17
95
  npm で入れた環境(`npx poker-mcp`)でだけ現れる不具合と、失敗の伝え方の不具合を
@@ -406,10 +484,27 @@ npx poker-mcp
406
484
  POKERのインストールディレクトリを指定します。以下の2つの用途で参照されます。
407
485
 
408
486
  - **ICRP-07.NDX の参照先**: `{POKER_INSTALL_PATH}/LIB/ICRP-07.NDX` を直接参照(v1.4.0以降、コピーなし)
409
- - **POKER.exe の場所**: `{POKER_INSTALL_PATH}/POKER.exe`(`poker_openGui` 使用時)
487
+ - **POKER.exe の場所**: `{POKER_INSTALL_PATH}/POKER.exe`(`poker_openGui` 使用時)。
488
+ POKER.exe が見つからなければ `poker_openGui` は登録されません(v1.10.0)
410
489
 
411
490
  **デフォルト値**: `C:/Poker`(未設定時は `C:/Poker` を使用)
412
491
 
492
+ #### `POKER_MCP_ASYNC` ほか(オプション、v1.10.0)
493
+ 計算の非同期実行(`poker_submitCalculation` など)を有効にします。既定では無効で、設定しなければ
494
+ ツール一覧も動作も変わりません。詳しくは「[バージョン1.10.0の新機能](#-バージョン1100の新機能)」。
495
+
496
+ | 変数 | 既定 | 内容 |
497
+ |---|---|---|
498
+ | `POKER_MCP_ASYNC` | 無効 | `1` で非同期ジョブを有効にする |
499
+ | `POKER_MCP_ASYNC_CONCURRENCY` | 1 | 同時に実行する計算の数 |
500
+ | `POKER_MCP_JOB_RETENTION_DAYS` | 7 | 終わったジョブを残す日数 |
501
+ | `POKER_MCP_JOB_TIMEOUT_MIN` | 1440 | 1 件の計算の打ち切り時間 [分] |
502
+
503
+ #### `POKER_MCP_GUI`(オプション、v1.10.0)
504
+ `0` を設定すると `poker_openGui` を登録しません。リモート公開するサーバで使います
505
+ (GUI がサーバの画面に開き、利用者からは見えないため)。Windows 以外の環境や、
506
+ POKER.exe が見つからない環境では、設定しなくても登録されません。計算には影響しません。
507
+
413
508
  ```bash
414
509
  # Windows(コマンドプロンプト)
415
510
  set POKER_MCP_HOME=C:\Users\yoshi\poker_mcp_workspace
@@ -431,6 +526,7 @@ POKER_MCP_HOME/ # デフォルト: ~/.poker-mcp/
431
526
  ├── backups/ # 自動バックアップ(最大10世代)
432
527
  ├── data/ # ICRP-07.NDX 核種データベース
433
528
  ├── logs/ # error.log, combined.log
529
+ ├── jobs/ # 非同期ジョブ(POKER_MCP_ASYNC=1 のときだけ。既定 7 日で自動削除)
434
530
  └── config.json # ユーザー設定(任意)
435
531
  ```
436
532
 
@@ -466,6 +562,13 @@ POKER_MCP_HOME/ # デフォルト: ~/.poker-mcp/
466
562
  > **⚠️ 注意:** `cwd`(作業ディレクトリ)の指定は不要です。`POKER_MCP_HOME`
467
563
  > 環境変数で管理するため、`cwd` を設定すると v1.2.5 以前の問題が再発します。
468
564
 
565
+ > **版の固定:** `npx` は一度取得した版を使い続けることがあります。確実に特定の版を
566
+ > 使うには `"args": ["poker-mcp@1.9.9"]` のように版を書きます。v1.9.8 以前の npm 版では
567
+ > `poker_generateInput` が動きません(v1.9.9 で修正)。
568
+
569
+ リモート公開するサーバ向けの設定(非同期ジョブを有効にし、GUI を外す)は
570
+ 「[バージョン1.10.0の新機能](#-バージョン1100の新機能)」を参照してください。
571
+
469
572
  3. **Claude Desktopを再起動** してMCPサーバーを有効化
470
573
 
471
574
  ### 4. 動作確認
@@ -485,7 +588,9 @@ Claude Desktopで以下のようにテストできます:
485
588
  ## 🏆 主要機能
486
589
 
487
590
  ### ✅ **MCP完全対応**
488
- - **36メソッド完全実装**: 全ての放射線遮蔽計算入力管理機能
591
+ - **36メソッド完全実装**: 全ての放射線遮蔽計算入力管理機能(非同期ジョブ有効時は 39)
592
+ - **失敗の通知**: ツールが処理の結果として失敗(`success: false`)を返すと、MCP の `isError: true` が付く(v1.9.9)
593
+ - **計算の非同期実行**: 長い計算を投入して後から結果を取得(`POKER_MCP_ASYNC=1`、v1.10.0)
489
594
  - **JSON-RPC 2.0準拠**: 標準プロトコル完全対応
490
595
  - **STDIO通信**: MCPクライアントとの標準通信方式
491
596
  - **自動バックアップ・ロールバック**: 企業品質のデータ保護
@@ -519,6 +624,10 @@ Claude Desktopで以下のようにテストできます:
519
624
  | **📐 CAD** | 2個 | CAD から入力と経路を生成 | generateInput・generatePaths |
520
625
  | **⚙️ System** | 6個 | システム制御 | applyChanges・executeCalculation・resetYaml・confirmDaughterNuclides・openGui・getDoseMap |
521
626
  | **📜 Schema** | 1個 | ファイル書式の取得 | getSchema |
627
+ | **⏳ Job**(`POKER_MCP_ASYNC=1` のときだけ) | 3個 | 計算の非同期実行 | submitCalculation・getJobStatus・getJobResult |
628
+
629
+ - `poker_openGui` は、Windows で POKER.exe が見つかり、`POKER_MCP_GUI=0` でないときだけ登録されます。
630
+ - 非同期ジョブを有効にすると、`poker_getDoseMap` にジョブを指定する `job_id` 引数が付きます。
522
631
 
523
632
  ### 📋 **全36メソッド一覧**
524
633
  ```
@@ -537,6 +646,9 @@ CAD系 (2): poker_generateInput, poker_generatePaths
537
646
  System系 (6): poker_applyChanges, poker_executeCalculation, poker_resetYaml,
538
647
  poker_confirmDaughterNuclides, poker_openGui, poker_getDoseMap
539
648
  Schema系 (1): poker_getSchema
649
+
650
+ (POKER_MCP_ASYNC=1 のとき追加)
651
+ Job系 (3): poker_submitCalculation, poker_getJobStatus, poker_getJobResult
540
652
  ```
541
653
 
542
654
  ## 📁 プロジェクト構造
@@ -571,6 +683,8 @@ POKER_MCP_HOME/
571
683
  ├── 📁 logs/ # 📝 ログファイル
572
684
  │ ├── error.log
573
685
  │ └── combined.log
686
+ ├── 📁 jobs/ # ⏳ 非同期ジョブ(POKER_MCP_ASYNC=1 のときだけ)
687
+ │ └── <ジョブID>/ # job.json・入力のコピー・.summary・.dose・stdout/stderr
574
688
  └── config.json # ユーザー設定(任意)
575
689
  ```
576
690
 
@@ -664,6 +778,22 @@ POKER_MCP_HOME/
664
778
  - **メモリ**: 512MB以上推奨(大規模検出器使用時は1GB以上)
665
779
  - **POKER 本体**: v2.1.5 以降(`POKER_INSTALL_PATH` で場所を指定)
666
780
  - **FreeCAD**: 1.0 以降(CAD連携を使う場合。`FREECAD_PATH`、未設定なら自動探索)
781
+ - **POKER の GUI(POKER.exe)**: `poker_openGui` を使う場合のみ。Windows 専用
782
+
783
+ ## 🧪 開発者向けの確認
784
+
785
+ リポジトリで次を実行します。`check:manifest` 以外は POKER 本体が必要です(`POKER_INSTALL_PATH` 既定 `C:/Poker`)。
786
+
787
+ | コマンド | 内容 |
788
+ |---|---|
789
+ | `npm run check:manifest` | ツール定義とマニフェストの整合。既定・非同期有効・非同期有効かつ GUI 無効の 3 通り |
790
+ | `npm run test:jobs` | 非同期ジョブ。同期との全行一致、投入後の編集が混ざらないこと、待ち行列、再起動後の復元 |
791
+ | `npm run test:gui` | `poker_openGui` の登録条件(`POKER_MCP_GUI=0` で外れ、計算は従来どおり動く) |
792
+ | `npm run test:schema` | `poker_getSchema` と `.dose` の読み取り |
793
+ | `npm run test:pending` | 保留中の変更の表示 |
794
+ | `npm run test:cad-validate` など | CAD 連携(FreeCAD が必要) |
795
+
796
+ `test:jobs` と `test:gui` は、失敗があれば終了コード 1 で終わります。
667
797
 
668
798
  ## 🎯 実際の使用ワークフロー
669
799
 
@@ -688,6 +818,12 @@ POKER_MCP_HOME/
688
818
 
689
819
  ## 📝 更新履歴
690
820
 
821
+ ### v1.10.0 (2026-10)
822
+ - ✨ 計算の非同期実行(`poker_submitCalculation` / `poker_getJobStatus` / `poker_getJobResult`、`poker_getDoseMap` の `job_id`)。`POKER_MCP_ASYNC=1` のときだけ有効
823
+ - ✨ `poker_openGui` を GUI が使える環境でだけ登録(`POKER_MCP_GUI=0`、Windows 以外、POKER.exe なしで外す)
824
+ - ✅ `npm run test:jobs`(同期との全行一致、入力のスナップショット、再起動後の復元)と `npm run test:gui` を追加
825
+ - 🔧 `npm run check:manifest` が非同期ジョブ有効時・GUI 無効時のツール定義も照合
826
+
691
827
  ### v1.9.9 (2026-10)
692
828
  - 🐛 `tools/gen_input.py` と `tools/scatter.py` を npm パッケージに同梱(`poker_generateInput` が npm 版で動かなかった)
693
829
  - 🐛 ツールの失敗が `isError: true` として伝わるよう修正(応答の二重包みを解消、`success: false` に `isError` を付与)
@@ -783,6 +919,6 @@ obtained separately and licensed separately. poker-mcp は `poker_cui` を外部
783
919
 
784
920
  ---
785
921
 
786
- **🎯 Poker MCP Server v1.9.9**
922
+ **🎯 Poker MCP Server v1.10.0**
787
923
  **プロトコル**: MCP 1.0.0 完全準拠
788
924
  **作者**: Yoshihiro Hirao | **ライセンス**: MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poker-mcp",
3
- "version": "1.9.9",
3
+ "version": "1.10.0",
4
4
  "description": "YAML-based input file management tool for radiation shielding calculation code POKER with MCP support - Enhanced with collision detection, daughter nuclide auto-completion, and environment variable support",
5
5
  "main": "src/mcp_server_stdio_v4.js",
6
6
  "type": "module",
@@ -11,7 +11,9 @@
11
11
  "start": "node src/mcp_server_stdio_v4.js",
12
12
  "dev": "nodemon src/mcp_server_stdio_v4.js",
13
13
  "prepare": "echo 'NPX ready: npx poker-mcp'",
14
- "check:manifest": "node scripts/check-manifest-sync.mjs",
14
+ "check:manifest": "node scripts/check-manifest-sync.mjs && node scripts/check-manifest-sync.mjs --async && node scripts/check-manifest-sync.mjs --async --no-gui",
15
+ "test:jobs": "node tests/async-jobs.test.mjs",
16
+ "test:gui": "node tests/gui-switch.test.mjs",
15
17
  "test:pending": "node tests/pending-view.test.mjs",
16
18
  "test:cad": "node tests/cad-e2e.test.mjs",
17
19
  "test:cad-full": "node tests/cad-full.test.mjs",
@@ -7,7 +7,7 @@ import { TASKS_DIR } from '../../utils/paths.js';
7
7
  import { parseDoseSummary } from '../../utils/summaryParser.js';
8
8
  import { parseDoseMap } from '../../utils/doseMapParser.js';
9
9
 
10
- export function createCalculationHandlers(taskManager) {
10
+ export function createCalculationHandlers(taskManager, { jobManager = null } = {}) {
11
11
  const calculationService = new CalculationService();
12
12
 
13
13
  // server.js はハンドラを handler(args) の形でオブジェクトから切り離して呼ぶため、
@@ -18,10 +18,26 @@ export function createCalculationHandlers(taskManager) {
18
18
  if (!args.detector_name) {
19
19
  throw new ValidationError('detector_name is required', 'detector_name', args.detector_name);
20
20
  }
21
- const yamlFile = args.yaml_file || 'poker.yaml';
22
- const absolutePathPattern = /^([a-zA-Z]:[\\\/]|\/)/;
23
- const resolvedYamlFile = absolutePathPattern.test(yamlFile) ? yamlFile : path.join(TASKS_DIR, yamlFile);
24
- const dosePath = resolvedYamlFile + '.dose';
21
+ let dosePath;
22
+ if (args.job_id) {
23
+ // 非同期ジョブの結果を読む(ジョブ専用フォルダの .dose)
24
+ if (!jobManager) {
25
+ return { success: false, error: 'job_id は非同期ジョブが有効なとき(POKER_MCP_ASYNC=1)だけ使えます' };
26
+ }
27
+ const job = jobManager.get(args.job_id);
28
+ if (!job) {
29
+ return { success: false, error: `ジョブが見つかりません: ${args.job_id}`, error_type: 'JOB_NOT_FOUND' };
30
+ }
31
+ if (job.status !== 'succeeded') {
32
+ return { success: false, error: `ジョブ ${args.job_id} は完了していません(status: ${job.status})`, status: job.status };
33
+ }
34
+ dosePath = job.outputs.dose_file;
35
+ } else {
36
+ const yamlFile = args.yaml_file || 'poker.yaml';
37
+ const absolutePathPattern = /^([a-zA-Z]:[\\\/]|\/)/;
38
+ const resolvedYamlFile = absolutePathPattern.test(yamlFile) ? yamlFile : path.join(TASKS_DIR, yamlFile);
39
+ dosePath = resolvedYamlFile + '.dose';
40
+ }
25
41
  // .dose は poker_cui 完了直後に書き込まれるが、完了検知からディスク反映までに数秒の
26
42
  // ばらつきがある。実運用(executeCalculation とは別コール)では準備済みだが、直後呼び出しに
27
43
  // 備え、準備できるまで最大約10秒リトライする(未準備時のみ待機)。
@@ -5,16 +5,25 @@ import { createTransformHandlers } from './transformHandlers.js';
5
5
  import { createBuildupFactorHandlers } from './buildupFactorHandlers.js';
6
6
  import { createSourceHandlers } from './sourceHandlers.js';
7
7
  import { createDetectorHandlers } from './detectorHandlers.js';
8
- import { createUnitHandlers } from './unitHandlers.js';
9
- import { createThinnedIndicesHandlers } from './thinnedIndicesHandlers.js';
8
+ import { createUnitHandlers } from './unitHandlers.js';
9
+ import { createThinnedIndicesHandlers } from './thinnedIndicesHandlers.js';
10
10
  import { createCadHandlers } from './cadHandlers.js';
11
11
  import { createCalculationHandlers } from './calculationHandlers.js';
12
12
  import { resetHandlers } from './resetHandlers.js';
13
13
  import { createDaughterNuclideHandler } from './daughterNuclideHandler.js';
14
- import { createGuiHandlers } from './guiHandlers.js';
14
+ import { createGuiHandlers } from './guiHandlers.js';
15
15
  import { createSchemaHandlers } from './schemaHandlers.js';
16
+ import { createJobHandlers } from './jobHandlers.js';
16
17
 
17
- export function createAllHandlers(taskManager) {
18
+ /**
19
+ * @param {Object} taskManager
20
+ * @param {Object} [opts]
21
+ * @param {import('../../services/JobManager.js').JobManager|null} [opts.jobManager]
22
+ * 非同期ジョブが有効なときだけ渡される。null ならジョブ系のハンドラは登録しない。
23
+ * @param {boolean} [opts.guiEnabled=true]
24
+ * false なら poker_openGui のハンドラを登録しない(ツール名で直接呼ばれても実行しない)。
25
+ */
26
+ export function createAllHandlers(taskManager, { jobManager = null, guiEnabled = true } = {}) {
18
27
  return {
19
28
  // 立体操作
20
29
  ...createBodyHandlers(taskManager),
@@ -35,12 +44,15 @@ export function createAllHandlers(taskManager) {
35
44
  ...createDetectorHandlers(taskManager),
36
45
 
37
46
  // 単位操作
38
- ...createUnitHandlers(taskManager),
39
- ...createThinnedIndicesHandlers(taskManager),
47
+ ...createUnitHandlers(taskManager),
48
+ ...createThinnedIndicesHandlers(taskManager),
40
49
  ...createCadHandlers(taskManager),
41
50
 
42
51
  // 計算操作
43
- ...createCalculationHandlers(taskManager),
52
+ ...createCalculationHandlers(taskManager, { jobManager }),
53
+
54
+ // 非同期ジョブ(POKER_MCP_ASYNC=1 のときだけ)
55
+ ...(jobManager ? createJobHandlers(taskManager, jobManager) : {}),
44
56
 
45
57
  // 子孫核種確認操作
46
58
  confirmDaughterNuclides: createDaughterNuclideHandler(taskManager),
@@ -48,10 +60,10 @@ export function createAllHandlers(taskManager) {
48
60
  // リセット操作
49
61
  resetYaml: resetHandlers.resetYaml(taskManager),
50
62
 
51
- // GUI表示操作
52
- ...createGuiHandlers(taskManager),
53
-
54
- // スキーマ取得(poker_cui --schema)
63
+ // GUI表示操作(Windows で POKER.exe があり、POKER_MCP_GUI=0 でないときだけ)
64
+ ...(guiEnabled ? createGuiHandlers(taskManager) : {}),
65
+
66
+ // スキーマ取得(poker_cui --schema)
55
67
  ...createSchemaHandlers(),
56
68
 
57
69
  // 共通操作
@@ -0,0 +1,183 @@
1
+ // mcp/handlers/jobHandlers.js - 計算の非同期実行のハンドラ
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { ValidationError } from '../../utils/errors.js';
5
+ import { logger } from '../../utils/logger.js';
6
+ import { TASKS_DIR } from '../../utils/paths.js';
7
+ import { parseDoseSummary } from '../../utils/summaryParser.js';
8
+ import { TERMINAL_STATUSES } from '../../services/JobManager.js';
9
+
10
+ const ABSOLUTE = /^([a-zA-Z]:[\\\/]|\/)/;
11
+ const resolveTaskPath = (p) => (ABSOLUTE.test(p) ? p : path.join(TASKS_DIR, p));
12
+
13
+ export function createJobHandlers(taskManager, jobManager) {
14
+ const statusView = (job) => {
15
+ const now = Date.now();
16
+ const start = job.started_at ? Date.parse(job.started_at) : null;
17
+ const end = job.finished_at ? Date.parse(job.finished_at) : null;
18
+ return {
19
+ job_id: job.id,
20
+ status: job.status,
21
+ submitted_at: job.submitted_at,
22
+ started_at: job.started_at,
23
+ finished_at: job.finished_at,
24
+ queue_position: jobManager.queuePosition(job.id),
25
+ running_seconds: start ? Math.round(((end || now) - start) / 1000) : null,
26
+ input_file: job.input.yaml_source,
27
+ path_input: job.input.path_input_source,
28
+ ...(job.status === 'failed' ? { error: job.error } : {})
29
+ };
30
+ };
31
+
32
+ return {
33
+ async submitCalculation(args) {
34
+ if (!args.yaml_file) {
35
+ throw new ValidationError('YAML file is required', 'yaml_file', args.yaml_file);
36
+ }
37
+ const yamlFile = resolveTaskPath(args.yaml_file);
38
+ if (!/\.(yaml|yml)$/i.test(yamlFile)) {
39
+ throw new ValidationError('YAML file must have .yaml or .yml extension', 'yaml_file', args.yaml_file);
40
+ }
41
+ if (!fs.existsSync(yamlFile)) {
42
+ throw new ValidationError(`YAML file not found: ${yamlFile}`, 'yaml_file', args.yaml_file);
43
+ }
44
+ let pathInput = null;
45
+ if (args.path_input) {
46
+ pathInput = resolveTaskPath(args.path_input);
47
+ if (!fs.existsSync(pathInput)) {
48
+ throw new ValidationError(`path_input not found: ${pathInput}`, 'path_input', args.path_input);
49
+ }
50
+ }
51
+ const outputFiles = args.output_files || {};
52
+ for (const [key, name] of Object.entries(outputFiles)) {
53
+ if (name && !/^[a-zA-Z0-9_\-\.]+\.(yaml|yml)$/.test(name)) {
54
+ throw new ValidationError(`Output file ${key} must be a file name ending with .yaml or .yml`, `output_files.${key}`, name);
55
+ }
56
+ }
57
+
58
+ // 同期の executeCalculation と同じ事前検証を、投入の時点で行う。
59
+ // 重大エラー(立体干渉を除く)があれば受け付けない。
60
+ if (taskManager?.dataManager) {
61
+ const v = await taskManager.dataManager.performPreCalculationValidation();
62
+ const critical = (v.criticalErrors || []).filter((e) => e.type !== 'collision_detected');
63
+ if (critical.length > 0) {
64
+ return {
65
+ success: false,
66
+ error: 'CRITICAL_VALIDATION_ERRORS',
67
+ error_type: 'PRE_VALIDATION_FAILED',
68
+ message: '計算実行前に解決が必要な重大エラーがあるため、ジョブを受け付けませんでした',
69
+ critical_errors: critical
70
+ };
71
+ }
72
+ }
73
+
74
+ const job = await jobManager.submit({
75
+ yamlFile,
76
+ pathInput,
77
+ summaryOptions: args.summary_options || {},
78
+ outputFiles
79
+ });
80
+
81
+ return {
82
+ success: true,
83
+ message: '計算を受け付けました。poker_getJobStatus で進み具合を確認し、終わったら poker_getJobResult で結果を取得してください。',
84
+ job_id: job.id,
85
+ status: job.status,
86
+ queue_position: jobManager.queuePosition(job.id),
87
+ input: {
88
+ yaml_file: yamlFile,
89
+ yaml_sha256: job.input.yaml_sha256,
90
+ path_input: pathInput
91
+ }
92
+ };
93
+ },
94
+
95
+ async getJobStatus(args = {}) {
96
+ if (!args.job_id) {
97
+ const jobs = jobManager.list(20).map(statusView);
98
+ return {
99
+ success: true,
100
+ running: jobManager.running.size,
101
+ queued: jobManager.queue.length,
102
+ concurrency: jobManager.concurrency,
103
+ jobs
104
+ };
105
+ }
106
+ const job = jobManager.get(args.job_id);
107
+ if (!job) {
108
+ return { success: false, error: `ジョブが見つかりません: ${args.job_id}`, error_type: 'JOB_NOT_FOUND' };
109
+ }
110
+ return { success: true, ...statusView(job) };
111
+ },
112
+
113
+ async getJobResult(args) {
114
+ const job = jobManager.get(args.job_id);
115
+ if (!job) {
116
+ return { success: false, error: `ジョブが見つかりません: ${args.job_id}`, error_type: 'JOB_NOT_FOUND' };
117
+ }
118
+ if (!TERMINAL_STATUSES.has(job.status)) {
119
+ return {
120
+ success: true,
121
+ ready: false,
122
+ ...statusView(job),
123
+ message: 'まだ終わっていません。しばらくおいて poker_getJobStatus で確認してください。'
124
+ };
125
+ }
126
+ if (job.status === 'failed') {
127
+ return {
128
+ success: false,
129
+ ready: true,
130
+ ...statusView(job),
131
+ error: job.error?.message,
132
+ error_type: job.error?.code || 'JOB_FAILED',
133
+ details: job.error
134
+ };
135
+ }
136
+
137
+ const files = {};
138
+ for (const [key, p] of Object.entries(job.outputs)) {
139
+ try {
140
+ const st = fs.statSync(p);
141
+ files[key] = { exists: true, size: st.size, path: p };
142
+ } catch (e) {
143
+ files[key] = { exists: false, path: p };
144
+ }
145
+ }
146
+ const stdout = jobManager.readOutput(job.id, 'stdout.txt');
147
+ const stderr = jobManager.readOutput(job.id, 'stderr.txt');
148
+
149
+ const response = {
150
+ success: true,
151
+ ready: true,
152
+ ...statusView(job),
153
+ message: 'Radiation shielding calculation completed successfully',
154
+ calculation: {
155
+ input_file: job.input.yaml_source,
156
+ snapshot_file: job.input.yaml_file,
157
+ yaml_sha256: job.input.yaml_sha256,
158
+ execution_time_ms: job.execution_time_ms,
159
+ options: job.summary_options
160
+ },
161
+ outputs: {
162
+ console: { stdout_length: stdout.length, stderr_length: stderr.length },
163
+ files
164
+ },
165
+ details: { stdout, stderr }
166
+ };
167
+
168
+ try {
169
+ const parsed = parseDoseSummary(job.outputs.summary_file);
170
+ if (parsed) {
171
+ response.result_total = parsed.result_total;
172
+ response.dose_columns = parsed.columns;
173
+ if (parsed.notes && parsed.notes.length) response.calculation_notes = parsed.notes;
174
+ if (parsed.elapsed_time) response.calculation.elapsed_time = parsed.elapsed_time;
175
+ if (parsed.warnings && parsed.warnings.length) response.calculation_warnings = parsed.warnings;
176
+ }
177
+ } catch (e) {
178
+ logger.warn('summary 構造化パースをスキップ', { job: job.id, error: e.message });
179
+ }
180
+ return response;
181
+ }
182
+ };
183
+ }
package/src/mcp/server.js CHANGED
@@ -8,6 +8,9 @@ import { createAllHandlers } from './handlers/index.js';
8
8
  import { safeExecute } from './middleware/errorHandler.js';
9
9
  import { logger } from '../utils/logger.js';
10
10
  import { YAML_FILE, PENDING_FILE } from '../utils/paths.js';
11
+ import { isAsyncEnabled, getAsyncConfig } from '../utils/asyncConfig.js';
12
+ import { JobManager } from '../services/JobManager.js';
13
+ import { getGuiStatus } from '../utils/guiConfig.js';
11
14
  import { readFileSync } from 'fs';
12
15
 
13
16
  // initialize の serverInfo で返す版。クライアント(Claude Desktop 等)が
@@ -39,6 +42,7 @@ export class PokerMcpServer {
39
42
 
40
43
  this.taskManager = new TaskManager(yamlFile, pendingFile);
41
44
  this.handlers = null;
45
+ this.jobManager = null;
42
46
  }
43
47
 
44
48
  async initialize() {
@@ -46,8 +50,21 @@ export class PokerMcpServer {
46
50
  // TaskManager初期化
47
51
  await this.taskManager.initialize();
48
52
 
53
+ // 非同期ジョブ(POKER_MCP_ASYNC=1 のときだけ)
54
+ if (isAsyncEnabled()) {
55
+ this.jobManager = new JobManager(getAsyncConfig());
56
+ await this.jobManager.init();
57
+ }
58
+
59
+ // POKER の GUI(poker_openGui)を使えるか
60
+ const gui = getGuiStatus();
61
+ logger.info(`poker_openGui: ${gui.enabled ? '有効' : '無効'}(${gui.reason})`);
62
+
49
63
  // ハンドラー作成
50
- this.handlers = createAllHandlers(this.taskManager);
64
+ this.handlers = createAllHandlers(this.taskManager, {
65
+ jobManager: this.jobManager,
66
+ guiEnabled: gui.enabled
67
+ });
51
68
 
52
69
  // ツール一覧ハンドラー
53
70
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -14,8 +14,15 @@ import { resetTools } from './resetTools.js';
14
14
  import { daughterNuclideTools } from './daughterNuclideTools.js';
15
15
  import { guiTools } from './guiTools.js';
16
16
  import { schemaTools } from './schemaTools.js';
17
+ import { jobTools, withJobIdParam } from './jobTools.js';
18
+ import { isAsyncEnabled } from '../../utils/asyncConfig.js';
19
+ import { isGuiEnabled } from '../../utils/guiConfig.js';
17
20
 
18
- export const allTools = [
21
+ // poker_openGui は、Windows で POKER.exe が見つかり、POKER_MCP_GUI=0 でないときだけ載せる
22
+ // (utils/guiConfig.js)。手元の Windows で POKER を入れている普段の環境では従来どおり載る。
23
+ const guiAvailable = isGuiEnabled();
24
+
25
+ const baseTools = [
19
26
  ...bodyTools,
20
27
  ...zoneTools,
21
28
  ...transformTools,
@@ -29,11 +36,19 @@ export const allTools = [
29
36
  ...calculationTools,
30
37
  ...resetTools,
31
38
  ...daughterNuclideTools,
32
- ...guiTools,
39
+ ...(guiAvailable ? guiTools : []),
33
40
  ...schemaTools
34
41
  ];
35
42
 
43
+ // 非同期ジョブ(POKER_MCP_ASYNC=1)が有効なときだけ、ジョブ系の 3 ツールを加え、
44
+ // poker_getDoseMap に job_id 引数を足す。無効なときは baseTools をそのまま使い、
45
+ // ツール一覧は非同期ジョブを持たない版と変わらない。
46
+ export const allTools = isAsyncEnabled()
47
+ ? [...baseTools.map((t) => (t.name === 'poker_getDoseMap' ? withJobIdParam(t) : t)), ...jobTools]
48
+ : baseTools;
49
+
36
50
  export {
51
+ jobTools,
37
52
  bodyTools,
38
53
  zoneTools,
39
54
  transformTools,
@@ -0,0 +1,98 @@
1
+ // mcp/tools/jobTools.js - 計算の非同期実行(POKER_MCP_ASYNC=1 のときだけ登録される)
2
+ //
3
+ // 長い計算を 1 回のツール呼び出しで待つと、MCP クライアントの待ち時間の上限や、
4
+ // 途中の中継(Cloudflare Tunnel など)の上限で切られる。投入・状態確認・結果取得に
5
+ // 分ければ、1 回の呼び出しは数秒で終わり、何時間かかる計算でも扱える。
6
+
7
+ const summaryOptionsSchema = {
8
+ type: 'object',
9
+ description: 'サマリー出力オプション(poker_executeCalculation と同じ)',
10
+ properties: {
11
+ show_parameters: { type: 'boolean', description: '入力パラメータをサマリーに表示(-p)', default: false },
12
+ show_source_data: { type: 'boolean', description: '各線源による計算データ及び線量を表示(-s)', default: true },
13
+ show_total_dose: { type: 'boolean', description: '各検出器に対する全線源からの総和線量を表示(-t)', default: true }
14
+ },
15
+ additionalProperties: false
16
+ };
17
+
18
+ export const jobTools = [
19
+ {
20
+ name: 'poker_submitCalculation',
21
+ description:
22
+ '時間のかかる遮蔽計算を非同期で投入し、すぐにジョブ ID を返します。数十秒で終わる計算は ' +
23
+ 'poker_executeCalculation を使ってください。投入した時点の YAML(と .paths)をジョブ専用の' +
24
+ 'フォルダにコピーして計算するため、投入後に YAML を編集しても実行中の計算には影響しません。' +
25
+ '保留中の変更は含まれないので、必要なら先に poker_applyChanges で確定してください。' +
26
+ '進み具合は poker_getJobStatus、結果は poker_getJobResult で取得します。',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ yaml_file: {
31
+ type: 'string',
32
+ description: '計算に使う YAML ファイル名(例: poker.yaml)。ファイル名のみなら POKER_MCP_HOME/tasks/ 配下を参照。絶対パスも可。',
33
+ pattern: '^([a-zA-Z0-9_\\-\\.]+\\.(yaml|yml)|([a-zA-Z]:[\\\\\/]|\\/)[^\\\\]+\\.(yaml|yml))$'
34
+ },
35
+ path_input: {
36
+ type: 'string',
37
+ description: 'CAD から抽出した経路ファイル(.paths)。poker_executeCalculation の path_input と同じ。'
38
+ },
39
+ summary_options: summaryOptionsSchema,
40
+ output_files: {
41
+ type: 'object',
42
+ description: '出力ファイル名(ジョブ専用フォルダに置かれる)。省略時は <YAML名>.summary / <YAML名>.dose。',
43
+ properties: {
44
+ summary_file: { type: 'string', pattern: '^[a-zA-Z0-9_\\-\\.]+\\.(yaml|yml)$' },
45
+ dose_file: { type: 'string', pattern: '^[a-zA-Z0-9_\\-\\.]+\\.(yaml|yml)$' }
46
+ },
47
+ additionalProperties: false
48
+ }
49
+ },
50
+ required: ['yaml_file'],
51
+ additionalProperties: false
52
+ }
53
+ },
54
+ {
55
+ name: 'poker_getJobStatus',
56
+ description:
57
+ '非同期ジョブの状態を返します(queued: 待機中 / running: 実行中 / succeeded: 完了 / failed: 失敗)。' +
58
+ 'job_id を省略すると最近のジョブの一覧を返します。終わっていなければ、数秒〜数十秒おいて再度確認してください。',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ job_id: { type: 'string', description: 'poker_submitCalculation が返したジョブ ID' }
63
+ },
64
+ additionalProperties: false
65
+ }
66
+ },
67
+ {
68
+ name: 'poker_getJobResult',
69
+ description:
70
+ '終わったジョブの結果を返します。内容は poker_executeCalculation の応答と同じ形式です' +
71
+ '(検出器ごとの result_total、dose_columns、calculation_warnings など)。' +
72
+ 'まだ終わっていなければ ready: false を返します。グリッド検出器の線量マップは ' +
73
+ 'poker_getDoseMap に job_id を指定して取得します。',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: {
77
+ job_id: { type: 'string', description: 'poker_submitCalculation が返したジョブ ID' }
78
+ },
79
+ required: ['job_id'],
80
+ additionalProperties: false
81
+ }
82
+ }
83
+ ];
84
+
85
+ /** 非同期有効時に poker_getDoseMap へ job_id 引数を足した定義を返す(元の定義は変更しない) */
86
+ export function withJobIdParam(tool) {
87
+ return {
88
+ ...tool,
89
+ description: tool.description + ' 非同期ジョブの結果は job_id で指定します。',
90
+ inputSchema: {
91
+ ...tool.inputSchema,
92
+ properties: {
93
+ ...tool.inputSchema.properties,
94
+ job_id: { type: 'string', description: '非同期ジョブ ID。指定するとそのジョブの .dose を読みます(yaml_file は無視)。' }
95
+ }
96
+ }
97
+ };
98
+ }
@@ -0,0 +1,254 @@
1
+ // services/JobManager.js - 計算の非同期実行(ジョブ)の管理
2
+ //
3
+ // 方針
4
+ // - 計算そのものは同期の executeCalculation と同じ CalculationService.executeCalculation
5
+ // を呼ぶ。計算の中身を二通りに持たない。
6
+ // - 投入した時点の入力(YAML と .paths)をジョブ専用フォルダにコピーし、それで計算する。
7
+ // 投入後に作業中の YAML を編集しても、実行中・待機中のジョブには混ざらない。
8
+ // - 出力もジョブ専用フォルダに書く。作業フォルダの poker.yaml.summary 等は上書きしない。
9
+ // - ジョブの状態は job.json に保存する。サーバを再起動しても消えない。
10
+ // 実行中に再起動した場合、その計算プロセスは失われているので failed にする。
11
+ // 待機中だったものは再び並べる(入力は既にコピー済みなので、そのまま実行できる)。
12
+ // - 終わったジョブは retentionDays を過ぎたら削除する。
13
+
14
+ import fs from 'fs';
15
+ import fsp from 'fs/promises';
16
+ import path from 'path';
17
+ import crypto from 'crypto';
18
+ import { logger } from '../utils/logger.js';
19
+ import { CalculationService } from './CalculationService.js';
20
+
21
+ export const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
22
+
23
+ export class JobManager {
24
+ constructor({ jobsDir, concurrency = 1, retentionDays = 7, timeoutMs = 24 * 3600 * 1000, calculationService } = {}) {
25
+ if (!jobsDir) throw new Error('JobManager: jobsDir is required');
26
+ this.jobsDir = jobsDir;
27
+ this.concurrency = concurrency;
28
+ this.retentionDays = retentionDays;
29
+ this.timeoutMs = timeoutMs;
30
+ this.calculationService = calculationService || new CalculationService();
31
+ this.jobs = new Map(); // id -> job
32
+ this.queue = []; // 待機中のジョブ ID(投入順)
33
+ this.running = new Set(); // 実行中のジョブ ID
34
+ this.waiters = new Map(); // id -> [resolve](テスト・内部用の完了待ち)
35
+ }
36
+
37
+ async init() {
38
+ await fsp.mkdir(this.jobsDir, { recursive: true });
39
+ const entries = await fsp.readdir(this.jobsDir, { withFileTypes: true });
40
+ const requeue = [];
41
+ for (const e of entries) {
42
+ if (!e.isDirectory()) continue;
43
+ const file = path.join(this.jobsDir, e.name, 'job.json');
44
+ let job;
45
+ try {
46
+ job = JSON.parse(await fsp.readFile(file, 'utf8'));
47
+ } catch {
48
+ continue; // 作成途中で落ちたフォルダなど。触らない
49
+ }
50
+ if (job.status === 'running') {
51
+ job.status = 'failed';
52
+ job.finished_at = new Date().toISOString();
53
+ job.error = {
54
+ message: '計算の実行中にサーバが再起動したため、計算は完了していません。投入し直してください。',
55
+ code: 'INTERRUPTED_BY_RESTART'
56
+ };
57
+ await this._save(job);
58
+ } else if (job.status === 'queued') {
59
+ requeue.push(job);
60
+ }
61
+ this.jobs.set(job.id, job);
62
+ }
63
+ requeue.sort((a, b) => a.submitted_at.localeCompare(b.submitted_at));
64
+ for (const job of requeue) this.queue.push(job.id);
65
+ await this.cleanup();
66
+ logger.info('JobManager 初期化', {
67
+ jobsDir: this.jobsDir, jobs: this.jobs.size, requeued: requeue.length,
68
+ concurrency: this.concurrency, retentionDays: this.retentionDays
69
+ });
70
+ this._pump();
71
+ }
72
+
73
+ /** 保存期間を過ぎた、終わったジョブを削除する */
74
+ async cleanup(now = Date.now()) {
75
+ const limit = this.retentionDays * 24 * 3600 * 1000;
76
+ for (const job of [...this.jobs.values()]) {
77
+ if (!TERMINAL_STATUSES.has(job.status) || !job.finished_at) continue;
78
+ if (now - Date.parse(job.finished_at) <= limit) continue;
79
+ try {
80
+ await fsp.rm(this._dir(job.id), { recursive: true, force: true });
81
+ this.jobs.delete(job.id);
82
+ logger.info('期限切れのジョブを削除', { id: job.id });
83
+ } catch (e) {
84
+ logger.warn('ジョブの削除に失敗', { id: job.id, error: e.message });
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 計算を投入する。入力をジョブ専用フォルダへコピーしてから待ち行列に並べる。
91
+ * @param {Object} p
92
+ * @param {string} p.yamlFile 入力 YAML(絶対パス)
93
+ * @param {string|null} p.pathInput .paths(絶対パス)または null
94
+ * @param {Object} p.summaryOptions
95
+ * @param {Object} p.outputFiles { summary_file?, dose_file? }(ファイル名のみ)
96
+ */
97
+ async submit({ yamlFile, pathInput = null, summaryOptions = {}, outputFiles = {} }) {
98
+ const id = this._newId();
99
+ const dir = this._dir(id);
100
+ await fsp.mkdir(dir, { recursive: true });
101
+
102
+ const yamlName = path.basename(yamlFile);
103
+ const jobYaml = path.join(dir, yamlName);
104
+ await fsp.copyFile(yamlFile, jobYaml);
105
+
106
+ let jobPaths = null;
107
+ if (pathInput) {
108
+ jobPaths = path.join(dir, path.basename(pathInput));
109
+ await fsp.copyFile(pathInput, jobPaths);
110
+ }
111
+
112
+ const job = {
113
+ id,
114
+ status: 'queued',
115
+ submitted_at: new Date().toISOString(),
116
+ started_at: null,
117
+ finished_at: null,
118
+ input: {
119
+ yaml_source: yamlFile,
120
+ yaml_file: jobYaml,
121
+ yaml_sha256: await sha256(jobYaml),
122
+ path_input_source: pathInput,
123
+ path_input: jobPaths,
124
+ path_input_sha256: jobPaths ? await sha256(jobPaths) : null
125
+ },
126
+ summary_options: summaryOptions,
127
+ outputs: {
128
+ summary_file: path.join(dir, outputFiles.summary_file || `${yamlName}.summary`),
129
+ dose_file: path.join(dir, outputFiles.dose_file || `${yamlName}.dose`)
130
+ },
131
+ timeout_ms: this.timeoutMs,
132
+ execution_time_ms: null,
133
+ error: null
134
+ };
135
+
136
+ await this._save(job);
137
+ this.jobs.set(id, job);
138
+ this.queue.push(id);
139
+ logger.info('ジョブを受け付けました', { id, yaml: yamlFile, pathInput });
140
+ this._pump();
141
+ return job;
142
+ }
143
+
144
+ get(id) {
145
+ return this.jobs.get(id) || null;
146
+ }
147
+
148
+ /** 新しい順に返す */
149
+ list(limit = 20) {
150
+ return [...this.jobs.values()]
151
+ .sort((a, b) => b.submitted_at.localeCompare(a.submitted_at))
152
+ .slice(0, limit);
153
+ }
154
+
155
+ /** 待機中なら 1 始まりの順番、それ以外は null */
156
+ queuePosition(id) {
157
+ const i = this.queue.indexOf(id);
158
+ return i < 0 ? null : i + 1;
159
+ }
160
+
161
+ /** ジョブが終わるまで待つ(テストと内部用) */
162
+ waitFor(id, timeoutMs = 600000) {
163
+ const job = this.get(id);
164
+ if (!job) return Promise.reject(new Error(`job not found: ${id}`));
165
+ if (TERMINAL_STATUSES.has(job.status)) return Promise.resolve(job);
166
+ return new Promise((resolve, reject) => {
167
+ const t = setTimeout(() => reject(new Error(`timeout waiting for job ${id}`)), timeoutMs);
168
+ const list = this.waiters.get(id) || [];
169
+ list.push((j) => { clearTimeout(t); resolve(j); });
170
+ this.waiters.set(id, list);
171
+ });
172
+ }
173
+
174
+ readOutput(id, name) {
175
+ const file = path.join(this._dir(id), name);
176
+ try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
177
+ }
178
+
179
+ // ---------------------------------------------------------------- 内部
180
+
181
+ _pump() {
182
+ while (this.running.size < this.concurrency && this.queue.length > 0) {
183
+ const id = this.queue.shift();
184
+ this._run(id).catch((e) => logger.error('ジョブ実行の内部エラー', { id, error: e.message }));
185
+ }
186
+ }
187
+
188
+ async _run(id) {
189
+ const job = this.jobs.get(id);
190
+ if (!job) return;
191
+ this.running.add(id);
192
+ job.status = 'running';
193
+ job.started_at = new Date().toISOString();
194
+ await this._save(job);
195
+ logger.info('ジョブ開始', { id });
196
+
197
+ try {
198
+ const r = await this.calculationService.executeCalculation(
199
+ job.input.yaml_file,
200
+ job.summary_options,
201
+ job.outputs,
202
+ job.timeout_ms,
203
+ job.input.path_input
204
+ );
205
+ await fsp.writeFile(path.join(this._dir(id), 'stdout.txt'), r.outputs?.stdout || '', 'utf8');
206
+ await fsp.writeFile(path.join(this._dir(id), 'stderr.txt'), r.outputs?.stderr || '', 'utf8');
207
+ job.status = 'succeeded';
208
+ job.execution_time_ms = r.executionTime ?? null;
209
+ } catch (e) {
210
+ const ctx = e.context || {};
211
+ job.status = 'failed';
212
+ job.error = {
213
+ message: e.message,
214
+ code: e.code || null,
215
+ exit_code: ctx.exitCode ?? null,
216
+ stderr: ctx.stderr || null,
217
+ stdout: ctx.stdout || null
218
+ };
219
+ if (ctx.stderr) await fsp.writeFile(path.join(this._dir(id), 'stderr.txt'), ctx.stderr, 'utf8').catch(() => {});
220
+ logger.warn('ジョブ失敗', { id, error: e.message });
221
+ } finally {
222
+ job.finished_at = new Date().toISOString();
223
+ await this._save(job).catch((e) => logger.error('job.json の保存に失敗', { id, error: e.message }));
224
+ this.running.delete(id);
225
+ logger.info('ジョブ終了', { id, status: job.status });
226
+ for (const resolve of this.waiters.get(id) || []) resolve(job);
227
+ this.waiters.delete(id);
228
+ this._pump();
229
+ this.cleanup().catch(() => {});
230
+ }
231
+ }
232
+
233
+ _dir(id) {
234
+ return path.join(this.jobsDir, id);
235
+ }
236
+
237
+ _newId() {
238
+ // 例: 20261013-053012-a1b2c3(UTC)。並べると投入順になる
239
+ const s = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
240
+ return `${s}-${crypto.randomBytes(3).toString('hex')}`;
241
+ }
242
+
243
+ async _save(job) {
244
+ const file = path.join(this._dir(job.id), 'job.json');
245
+ const tmp = `${file}.tmp`;
246
+ await fsp.writeFile(tmp, JSON.stringify(job, null, 2), 'utf8');
247
+ await fsp.rename(tmp, file);
248
+ }
249
+ }
250
+
251
+ async function sha256(file) {
252
+ const buf = await fsp.readFile(file);
253
+ return crypto.createHash('sha256').update(buf).digest('hex');
254
+ }
@@ -0,0 +1,34 @@
1
+ // utils/asyncConfig.js - 非同期ジョブ(submitCalculation など)の設定
2
+ //
3
+ // 非同期ジョブは既定で無効。環境変数 POKER_MCP_ASYNC=1 のときだけ有効になり、
4
+ // ツール一覧に poker_submitCalculation / poker_getJobStatus / poker_getJobResult が
5
+ // 加わる。無効のときのツール一覧と動作は、非同期ジョブを持たない版と同じ。
6
+ //
7
+ // POKER_MCP_ASYNC 1 / true / yes / on で有効(既定: 無効)
8
+ // POKER_MCP_ASYNC_CONCURRENCY 同時に実行する計算の数(既定: 1)
9
+ // POKER_MCP_JOB_RETENTION_DAYS 終わったジョブを残す日数(既定: 7)
10
+ // POKER_MCP_JOB_TIMEOUT_MIN 1 件の計算の打ち切り時間 [分](既定: 1440 = 24 時間)
11
+ //
12
+ // ジョブは POKER_MCP_HOME/jobs/<ジョブID>/ に置く。
13
+
14
+ import path from 'path';
15
+ import { BASE_DIR } from './paths.js';
16
+
17
+ export function isAsyncEnabled() {
18
+ const v = String(process.env.POKER_MCP_ASYNC || '').trim().toLowerCase();
19
+ return ['1', 'true', 'yes', 'on'].includes(v);
20
+ }
21
+
22
+ function positive(value, fallback) {
23
+ const n = Number(value);
24
+ return Number.isFinite(n) && n > 0 ? n : fallback;
25
+ }
26
+
27
+ export function getAsyncConfig() {
28
+ return {
29
+ jobsDir: path.join(BASE_DIR, 'jobs'),
30
+ concurrency: Math.max(1, Math.floor(positive(process.env.POKER_MCP_ASYNC_CONCURRENCY, 1))),
31
+ retentionDays: positive(process.env.POKER_MCP_JOB_RETENTION_DAYS, 7),
32
+ timeoutMs: positive(process.env.POKER_MCP_JOB_TIMEOUT_MIN, 1440) * 60 * 1000
33
+ };
34
+ }
@@ -0,0 +1,40 @@
1
+ // utils/guiConfig.js - POKER の GUI(poker_openGui)を登録するかどうか
2
+ //
3
+ // poker_openGui は POKER.exe(GUI)を、MCP サーバが動いている機械の画面に開く。
4
+ // 次のどれかに当たるときは、ツール一覧から外し、処理も登録しない。
5
+ //
6
+ // 1. POKER_MCP_GUI が 0 / false / no / off
7
+ // リモート公開したサーバで使う。サーバに POKER が入っていても、GUI は
8
+ // 利用者の手元ではなくサーバの画面に開き、利用者からは見えないため。
9
+ // 2. Windows 以外で動いている(POKER.exe は Windows 専用)
10
+ // 3. POKER_INSTALL_PATH(既定 C:/Poker)に POKER.exe が無い
11
+ //
12
+ // 判定はサーバの起動時に 1 回だけ行う。後から POKER を入れた場合はサーバを再起動する。
13
+ // GUI を外しても、計算(POKER_CUI.exe)を使うツールには影響しない。
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+
18
+ export function resolvePokerExePath() {
19
+ const installPath = process.env.POKER_INSTALL_PATH || 'C:/Poker';
20
+ return path.win32.join(installPath.replace(/\//g, '\\'), 'POKER.exe');
21
+ }
22
+
23
+ export function getGuiStatus() {
24
+ const v = String(process.env.POKER_MCP_GUI ?? '').trim().toLowerCase();
25
+ if (['0', 'false', 'no', 'off'].includes(v)) {
26
+ return { enabled: false, reason: 'POKER_MCP_GUI=0 により無効' };
27
+ }
28
+ if (process.platform !== 'win32') {
29
+ return { enabled: false, reason: `Windows 以外のため無効(${process.platform})` };
30
+ }
31
+ const exe = resolvePokerExePath();
32
+ if (!fs.existsSync(exe)) {
33
+ return { enabled: false, reason: `POKER.exe が見つからないため無効: ${exe}` };
34
+ }
35
+ return { enabled: true, reason: `POKER.exe: ${exe}` };
36
+ }
37
+
38
+ export function isGuiEnabled() {
39
+ return getGuiStatus().enabled;
40
+ }