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 +141 -5
- package/package.json +4 -2
- package/src/mcp/handlers/calculationHandlers.js +21 -5
- package/src/mcp/handlers/index.js +23 -11
- package/src/mcp/handlers/jobHandlers.js +183 -0
- package/src/mcp/server.js +18 -1
- package/src/mcp/tools/index.js +17 -2
- package/src/mcp/tools/jobTools.js +98 -0
- package/src/services/JobManager.js +254 -0
- package/src/utils/asyncConfig.js +34 -0
- package/src/utils/guiConfig.js +40 -0
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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 () => {
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|