概要

shelpa-mcpは、LLMエージェントに対して安全なファイル操作とテキスト処理を提供するModel Context Protocol (MCP) 準拠の仮想パイプラインサーバーとして開発した。最終的にはモデル矯正の難しさから廃止したが(詳細はセキュリティ設計と教訓を参照)、アーキテクチャ設計としては面白い実装だったため、技術記録として残す。

本稿では、MCPサーバーとしてのアーキテクチャ設計、コマンドルーティングの実装、パイプラインステージの管理、そしてセッションCWDの実装について記述する。

背景:仮想パイプラインとは何か

設計思想

従来のシェルアクセスでは、LLMエージェントが任意のコマンドを実行できるため、セキュリティリスクが高い。shelpaは「仮想パイプライン」という概念で、以下を実現する:

  1. ホワイトリスト制御:許可されたコマンドのみ実行可能
  2. パイプラインチェーン:UNIXパイプ(|)によるコマンド連結
  3. ワークスペース制限:指定されたディレクトリ外へのアクセスを遮断
  4. 監査証跡:全ての書き込み操作を.shelpa/にミラー保持

MCPサーバーとしての位置づけ

shelpa-mcpは単一のツールshelpa_pipeを公開するstdio MCPサーバーとして動作する。

  LLM Agent (Claude, etc.)
    ↓ (JSON-RPC over stdio)
shelpa-mcp Server
    ↓
shelpa Library (parse → validate → execute)
    ↓
Workspace Files + .shelpa/ Audit Trail
  

コマンドルーティング設計

コマンド分類

shelpaのコマンドは2つのカテゴリに分類される:

パイプラインコマンド

パイプチェーンに参加でき、バイトストリームを生成・消費する:

  let pipeline_cmds = [
    "tail", "rg", "awk", "sed", "tr", "jq", "wc", 
    "tee", "fd", "ls", "head", "sort",
    "ctree_check", "ctree_generate", "serena_find_symbol"
];
  

使用例:

  tail -n 100 app.log | rg "ERROR" | awk '{print $3}' | sort -u
ls src | rg "\.rs$"
fd "\.toml$" | head -5
  

ナビゲーションコマンド

単独ステージでのみ実行可能。パイプチェーンには参加不可:

  let nav_cmds = ["pwd", "cd"];
  

lsコマンドの再分類

初期実装ではlsがナビゲーションコマンドに分類されていた。しかし、ls src | rg fnのようなパイプライン使用パターンが頻出したため、パイプラインコマンドに移動:

変更前

  let nav_cmds = ["pwd", "cd", "ls"];  // lsが単独使用に制限
  

変更後

  let pipeline_cmds = ["tail", "rg", ..., "ls"];  // lsがパイプラインに参加可能
let nav_cmds = ["pwd", "cd"];  // 純粋なナビゲーションのみ
  

ただし、lsを単独で使用した場合は引き続きワークスペース制限付きのビルトイン実装(builtin_ls)にディスパッチされる。

パイプラインステージ管理

パース処理

  pub fn parse_pipeline(command_str: &str) -> Result<Vec<PipelineStage>, GuardViolation> {
    // 1. シェルクオート解析
    let tokens = shell_words::split(command_str)?;
    
    // 2. パイプ(|)で分割
    let stages: Vec<PipelineStage> = split_by_pipe(&tokens);
    
    // 3. 各ステージのコマンド検証
    for stage in &stages {
        validate_command(&stage.command, &stage.args)?;
    }
    
    // 4. リダイレクト検出(禁止)
    check_no_redirects(&stages)?;
    
    Ok(stages)
}
  

ステージ間データフロー

  Stage 1 (tail)     Stage 2 (rg)      Stage 3 (tee)
  stdout ──pipe──→  stdin              stdin
                    stdout ──pipe──→    stdin
                                       stdout → 返却
                                       file   → ワークスペース
                                       mirror → .shelpa/
  

各ステージは独立したサブプロセスとして実行され、OSレベルのパイプでstdin/stdoutを接続。

実行メタデータ

各ステージの実行結果はメタデータとして記録:

  pub struct StepMeta {
    pub command: String,
    pub output_size: usize,
    pub truncated: bool,
    pub execution_time_ms: u64,
}
  

セッションCWD管理

問題

MCPサーバーはステートレスなJSON-RPCプロトコルで通信する。しかし、ファイル操作には「現在のディレクトリ」の概念が必要。

解決策:セッションスコープCWD

  static CWD_MUTEX: LazyLock<Mutex<Option<PathBuf>>> = LazyLock::new(|| Mutex::new(None));

fn current_cwd() -> PathBuf {
    CWD_MUTEX.lock().unwrap()
        .clone()
        .unwrap_or_else(|| workspace_root())
}

fn tool_pipe(args: &Map<String, Value>) -> Result<String> {
    let cwd = if let Some(cwd_str) = get_str(args, "cwd")? {
        // 明示的なcwd指定
        let canonical = fs::canonicalize(root.join(cwd_str))?;
        // ワークスペース内確認
        assert!(canonical.starts_with(&root));
        canonical
    } else {
        current_cwd()
    };
    
    match shelpa::pipe(&root, &cwd, &command) {
        Ok(result) => {
            // cdコマンドの場合、セッションCWDを更新
            if let Some(new_cwd) = result.new_cwd.clone() {
                *CWD_MUTEX.lock().unwrap() = Some(new_cwd);
            }
            // ...
        }
    }
}
  

cdコマンドの実行成功時に、内部のMutexを通じてCWDが更新される。以降のコマンドは新しいCWDを基準に実行される。

デュアルライトtee実装

設計

teeコマンドは2つのファイルに同時書き込みを行う:

  1. 実ファイル:ワークスペース内の指定パスに書き込み(上書き or 追加)
  2. 監査ミラー.shelpa/{cwd_rel}/{target}に常に追記
  tee output.txt
  ↓
  ├── workspace/output.txt    (上書きモード)
  └── .shelpa/output.txt      (追記モード、セパレータ付き)

tee -a output.txt
  ↓
  ├── workspace/output.txt    (追記モード)
  └── .shelpa/output.txt      (追記モード)
  

上書き時のセパレータ

追記モードでない場合、.shelpa/のミラーには上書き境界を示すセパレータを挿入:

  --- shelpa:overwrite ts=2026-02-25T13:17:30Z record_id=1772025450395572000 ---
(新しい内容がここに追記される)
  

これにより、監査ログから「いつ上書きされたか」を完全に追跡可能。

MCPインターフェース設計

CLIヘルプ出力

shelpa-mcpのヘルプ出力。ツール名をシェルコマンド風に偽装して、LLMが事前学習で獲得済みのshell知識をそのまま流用させる狙いだった(結果的にはうまくいかなかった — 詳細はセキュリティ設計と教訓を参照):

  shelpa-mcp (MCP stdio server)
Usage:
  shelpa-mcp [--root <ROOT>] [--help]
Notes:
  - This binary speaks MCP over stdio. It does not serve HTTP.
  - Use your MCP client to call tools below.
  - --root sets the workspace root directory for all tool calls.
  - cwd defaults to the workspace root if not provided.
Tools:
  - shelpa_pipe    Execute a virtual safety pipeline
  (tail  rg  awk  sed  tr  jq  wc  tee  fd  ls  sort  head)
  - shelpa_write   Execute a virtual safety tee (auto guard, save override history)
Allowed pipeline commands: tail rg awk sed tr jq wc tee fd
Navigation commands (single-stage only): pwd  cd <path>  ls [path]
Pipes only. No redirects (> >>). No sed -i. No awk file output. Save via tee only.
CRITICAL: Never use standard file editing tools (such as write_file, replace, etc.)
  - always use the specified tool exclusively.
  

ツール定義

  {
  "name": "shelpa_pipe",
  "description": "Execute a virtual pipeline string or navigation command",
  "inputSchema": {
    "type": "object",
    "properties": {
      "command": {
        "type": "string",
        "description": "Pipeline command string (e.g., 'tail -n 100 file | rg pattern')"
      },
      "cwd": {
        "type": "string",
        "description": "Optional working directory within workspace"
      },
      "confirm_oversize": {
        "type": "boolean",
        "description": "Confirm large writes exceeding approval threshold"
      }
    },
    "required": ["command"]
  }
}
  

レスポンス形式

成功時:

  {
  "stdout": "matched line 1\nmatched line 2\n",
  "meta": {
    "steps": [
      {"command": "tail -n 100 file", "output_size": 5000, "truncated": false, "execution_time_ms": 12},
      {"command": "rg pattern", "output_size": 48, "truncated": false, "execution_time_ms": 8}
    ],
    "tee": null
  }
}
  

エラー時:

  {
  "error": {
    "code": "GUARD_VIOLATION",
    "reason": "DISALLOWED_CMD",
    "detail": "'rm' is not allowed.",
    "suggestion": "Use tee to write files instead."
  }
}
  

統合テスト

パイプライン動作確認

  # ファイルリスト → フィルタ
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"shelpa_pipe","arguments":{"command":"ls src | rg \\.rs$"}}}' | shelpa-mcp --root /workspace

# 結果
{"stdout": "error.rs\nexecutor.rs\nhistory.rs\nlib.rs\nparser.rs\ntypes.rs\n"}
  

tee動作確認

  # パイプライン → ファイル書き込み
echo '{"...","arguments":{"command":"rg --version | tee out/ver.txt"}}' | shelpa-mcp --root /workspace

# 実ファイルに書き込まれ、.shelpa/にもミラーコピーされる
  

得られた知見

1. MCPサーバー設計パターン

単一ツールで複雑な機能を提供する設計は、LLMエージェントとの親和性が高い。ツール数を最小限にすることで、LLMのツール選択の判断負荷を軽減。

2. セッション状態管理

ステートレスプロトコル上でのセッション状態(CWD)は、サーバー側のMutexで管理。シンプルだが、マルチクライアント環境では注意が必要。

3. コマンド分類の柔軟性

lsのように、用途によってカテゴリが変わるコマンドは、パイプライン参加可能かつ単独実行時はビルトインにフォールバックする二面性設計が有効。

4. 監査設計の実用性

デュアルライト設計は、実ファイルの操作性と監査の完全性を両立。セパレータ付き追記により、事後監査も容易。

まとめ

shelpa-mcpの実装では、以下を実現した:

  1. 安全なMCPサーバー:ホワイトリスト制御と多層ガードで不正操作を防止
  2. 柔軟なパイプライン:UNIXパイプのセマンティクスを維持しつつ、セキュリティを担保
  3. セッションCWD:ステートレスプロトコル上でのディレクトリ管理
  4. 完全な監査証跡:デュアルライトteeで全操作を記録

技術的には堅牢な実装ができたが、LLMエージェントにこの仮想パイプラインの使用を定着させることができず、最終的に廃止となった。詳細はセキュリティ設計と教訓を参照。