af-filesystem-mcp 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. af_filesystem_mcp-0.1.1/.github/dependabot.yml +26 -0
  2. af_filesystem_mcp-0.1.1/.github/release.yml +5 -0
  3. af_filesystem_mcp-0.1.1/.github/workflows/cd.yml +61 -0
  4. af_filesystem_mcp-0.1.1/.github/workflows/ci.yml +104 -0
  5. af_filesystem_mcp-0.1.1/.gitignore +52 -0
  6. af_filesystem_mcp-0.1.1/.pre-commit-config.yaml +67 -0
  7. af_filesystem_mcp-0.1.1/CLAUDE.md +279 -0
  8. af_filesystem_mcp-0.1.1/LICENSE +202 -0
  9. af_filesystem_mcp-0.1.1/PKG-INFO +115 -0
  10. af_filesystem_mcp-0.1.1/README.md +88 -0
  11. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/.helmignore +11 -0
  12. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/Chart.yaml +23 -0
  13. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/README.md +34 -0
  14. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/ci/broker-values.yaml +10 -0
  15. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/NOTES.txt +39 -0
  16. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/_helpers.tpl +114 -0
  17. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/configmap.yaml +23 -0
  18. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/deployment.yaml +161 -0
  19. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/ingress.yaml +36 -0
  20. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/service.yaml +15 -0
  21. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/serviceaccount.yaml +12 -0
  22. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/tests/test-healthz.yaml +18 -0
  23. af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/values.yaml +201 -0
  24. af_filesystem_mcp-0.1.1/pixi.lock +7611 -0
  25. af_filesystem_mcp-0.1.1/pixi.toml +95 -0
  26. af_filesystem_mcp-0.1.1/pyproject.toml +174 -0
  27. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/__init__.py +7 -0
  28. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/_version.py +24 -0
  29. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/_version.pyi +2 -0
  30. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/auth/__init__.py +3 -0
  31. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/auth/broker.py +114 -0
  32. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/auth/local.py +25 -0
  33. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/cli.py +148 -0
  34. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/helper/__init__.py +11 -0
  35. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/helper/__main__.py +103 -0
  36. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/helper/ops.py +440 -0
  37. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/identity.py +21 -0
  38. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/impersonate.py +129 -0
  39. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/paths.py +185 -0
  40. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/py.typed +0 -0
  41. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/roots.py +21 -0
  42. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/server.py +197 -0
  43. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/__init__.py +3 -0
  44. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/_helpers.py +162 -0
  45. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/grep_files.py +78 -0
  46. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/list_dir.py +78 -0
  47. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/read_file.py +73 -0
  48. af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/stat_path.py +63 -0
  49. af_filesystem_mcp-0.1.1/tbump.toml +62 -0
  50. af_filesystem_mcp-0.1.1/tests/auth/__init__.py +0 -0
  51. af_filesystem_mcp-0.1.1/tests/auth/test_broker.py +116 -0
  52. af_filesystem_mcp-0.1.1/tests/auth/test_local.py +15 -0
  53. af_filesystem_mcp-0.1.1/tests/conftest.py +64 -0
  54. af_filesystem_mcp-0.1.1/tests/helper/__init__.py +0 -0
  55. af_filesystem_mcp-0.1.1/tests/helper/test_main.py +121 -0
  56. af_filesystem_mcp-0.1.1/tests/helper/test_ops.py +247 -0
  57. af_filesystem_mcp-0.1.1/tests/test_cli.py +105 -0
  58. af_filesystem_mcp-0.1.1/tests/test_impersonate.py +185 -0
  59. af_filesystem_mcp-0.1.1/tests/test_paths.py +209 -0
  60. af_filesystem_mcp-0.1.1/tests/test_secure_open.py +145 -0
  61. af_filesystem_mcp-0.1.1/tests/test_server.py +92 -0
  62. af_filesystem_mcp-0.1.1/tests/tools/__init__.py +0 -0
  63. af_filesystem_mcp-0.1.1/tests/tools/test_grep_files.py +74 -0
  64. af_filesystem_mcp-0.1.1/tests/tools/test_helpers.py +149 -0
  65. af_filesystem_mcp-0.1.1/tests/tools/test_list_dir.py +101 -0
  66. af_filesystem_mcp-0.1.1/tests/tools/test_read_file.py +108 -0
  67. af_filesystem_mcp-0.1.1/tests/tools/test_stat_path.py +66 -0
@@ -0,0 +1,26 @@
1
+ version: 2
2
+ updates:
3
+ # Maintain dependencies for GitHub Actions
4
+ - package-ecosystem: "github-actions"
5
+ directory: "/"
6
+ schedule:
7
+ interval: "monthly"
8
+ groups:
9
+ github-actions:
10
+ patterns:
11
+ - "*"
12
+ labels:
13
+ - "github-actions"
14
+ - "dependencies"
15
+ cooldown:
16
+ default-days: 7
17
+
18
+ # Ignore all pip dependencies to avoid PRs to update tests/constraints.txt
19
+ - package-ecosystem: "pip"
20
+ directory: "/"
21
+ schedule:
22
+ interval: "weekly"
23
+ ignore:
24
+ - dependency-name: "*"
25
+ cooldown:
26
+ default-days: 7
@@ -0,0 +1,5 @@
1
+ changelog:
2
+ exclude:
3
+ authors:
4
+ - dependabot[bot]
5
+ - pre-commit-ci[bot]
@@ -0,0 +1,61 @@
1
+ name: CD
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ pull_request:
6
+ push:
7
+ branches:
8
+ - main
9
+ release:
10
+ types:
11
+ - published
12
+
13
+ concurrency:
14
+ group: ${{ github.workflow }}-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ permissions: {}
18
+
19
+ env:
20
+ # Many color libraries just need this to be set to any value, but at least
21
+ # one distinguishes color depth, where "3" -> "256-bit color".
22
+ FORCE_COLOR: 3
23
+
24
+ jobs:
25
+ dist:
26
+ name: Distribution build
27
+ runs-on: ubuntu-latest
28
+ permissions:
29
+ contents: read # checkout the repository
30
+
31
+ steps:
32
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
33
+ with:
34
+ fetch-depth: 0
35
+ persist-credentials: false
36
+
37
+ - uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1
38
+
39
+ publish:
40
+ needs: [dist]
41
+ name: Publish to PyPI
42
+ environment: pypi
43
+ permissions:
44
+ id-token: write # trusted publishing to PyPI
45
+ attestations: write # generate build provenance attestation
46
+ contents: read # required by attest-build-provenance to read repository metadata
47
+ runs-on: ubuntu-latest
48
+ if: github.event_name == 'release' && github.event.action == 'published'
49
+
50
+ steps:
51
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
52
+ with:
53
+ name: Packages
54
+ path: dist
55
+
56
+ - name: Generate artifact attestation for sdist and wheel
57
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
58
+ with:
59
+ subject-path: "dist/*"
60
+
61
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -0,0 +1,104 @@
1
+ name: CI
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ pull_request:
6
+ push:
7
+ branches:
8
+ - main
9
+
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ permissions: {}
15
+
16
+ env:
17
+ # Many color libraries just need this to be set to any value, but at least
18
+ # one distinguishes color depth, where "3" -> "256-bit color".
19
+ FORCE_COLOR: 3
20
+
21
+ jobs:
22
+ pre-commit:
23
+ name: Format
24
+ runs-on: ubuntu-latest
25
+ permissions:
26
+ contents: read # checkout the repository
27
+ steps:
28
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
+ with:
30
+ fetch-depth: 0
31
+ persist-credentials: false
32
+
33
+ - uses: prefix-dev/setup-pixi@f00437f565399d418b0acc85936d12c1fb668347 # v0.10.1
34
+ with:
35
+ pixi-version: v0.68.1
36
+ cache: true
37
+ cache-key: dev-helm
38
+ cache-write:
39
+ ${{ github.event_name == 'push' && github.ref_name == 'main' }}
40
+ environments: dev helm
41
+
42
+ - name: Run pre-commit
43
+ run: pixi run pre-commit --hook-stage manual
44
+
45
+ - name: Run PyLint
46
+ run: pixi run pylint --output-format=github
47
+
48
+ - name: Run helm lint
49
+ run: pixi run helm-lint
50
+
51
+ checks:
52
+ name: Check Python ${{ matrix.python-version }} on ${{ matrix.runs-on }}
53
+ runs-on: ${{ matrix.runs-on }}
54
+ needs: [pre-commit]
55
+ permissions:
56
+ contents: read # checkout the repository
57
+ strategy:
58
+ fail-fast: false
59
+ matrix:
60
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
61
+ runs-on: [ubuntu-latest, macos-14] # windows-latest
62
+ include:
63
+ - python-version: "3.10"
64
+ pixi-environment: py310
65
+ - python-version: "3.11"
66
+ pixi-environment: py311
67
+ - python-version: "3.12"
68
+ pixi-environment: py312
69
+ - python-version: "3.13"
70
+ pixi-environment: py313
71
+
72
+ steps:
73
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
74
+ with:
75
+ fetch-depth: 0
76
+ persist-credentials: false
77
+
78
+ - uses: prefix-dev/setup-pixi@f00437f565399d418b0acc85936d12c1fb668347 # v0.10.1
79
+ with:
80
+ pixi-version: v0.68.1
81
+ cache: true
82
+ cache-key: ${{ matrix.pixi-environment }}-${{ github.ref_name }}
83
+ cache-write:
84
+ ${{ github.event_name == 'push' && github.ref_name == 'main' }}
85
+ environments: ${{ matrix.pixi-environment }}
86
+
87
+ - name: Test package
88
+ run: >-
89
+ pixi run -e $PIXI_ENVIRONMENT test-all -ra --cov-branch
90
+ --cov-report=xml --cov-report=term --junitxml=junit.xml -o
91
+ junit_family=legacy --durations=20
92
+ env:
93
+ PIXI_ENVIRONMENT: ${{ matrix.pixi-environment }}
94
+
95
+ - name: Upload coverage report
96
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
97
+ with:
98
+ token: ${{ secrets.CODECOV_TOKEN }}
99
+
100
+ - name: Upload test results to Codecov
101
+ if: ${{ !cancelled() }}
102
+ uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
103
+ with:
104
+ token: ${{ secrets.CODECOV_TOKEN }}
@@ -0,0 +1,52 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ *.egg
11
+ MANIFEST
12
+
13
+ # hatch-vcs generated version file (see pyproject.toml [tool.hatch])
14
+ src/*/_version.py
15
+
16
+ # Unit test / coverage reports
17
+ htmlcov/
18
+ .tox/
19
+ .nox/
20
+ .coverage
21
+ .coverage.*
22
+ .cache
23
+ coverage.xml
24
+ *.cover
25
+ .hypothesis/
26
+ .pytest_cache/
27
+ junit.xml
28
+
29
+ # mypy / ruff caches
30
+ .mypy_cache/
31
+ .ruff_cache/
32
+ .dmypy.json
33
+ dmypy.json
34
+
35
+ # Environments
36
+ .env
37
+ .venv/
38
+ env/
39
+ venv/
40
+ ENV/
41
+
42
+ # pixi
43
+ .pixi/
44
+ pixi.lock.bak
45
+
46
+ # IDE
47
+ .vscode/
48
+ .idea/
49
+ *.swp
50
+
51
+ # macOS
52
+ .DS_Store
@@ -0,0 +1,67 @@
1
+ ci:
2
+ autoupdate_commit_msg: "chore(deps): update pre-commit hooks"
3
+ autofix_commit_msg: "style: pre-commit fixes"
4
+ autoupdate_schedule: "monthly"
5
+
6
+ exclude: ^pixi.lock$
7
+
8
+ repos:
9
+ - repo: https://github.com/pre-commit/pre-commit-hooks
10
+ rev: "3e8a8703264a2f4a69428a0aa4dcb512790b2c8c" # frozen: v6.0.0
11
+ hooks:
12
+ - id: check-added-large-files
13
+ - id: check-case-conflict
14
+ - id: check-merge-conflict
15
+ - id: check-symlinks
16
+ - id: check-yaml
17
+ exclude: ^charts/.*/templates/
18
+ - id: debug-statements
19
+ - id: end-of-file-fixer
20
+ - id: mixed-line-ending
21
+ - id: name-tests-test
22
+ args: ["--pytest-test-first"]
23
+ - id: trailing-whitespace
24
+
25
+ - repo: https://github.com/rbubley/mirrors-prettier
26
+ rev: "0ee178619d696787ca73d210cc191d720868c631" # frozen: v3.9.6
27
+ hooks:
28
+ - id: prettier
29
+ types_or: [yaml, markdown, html, css, scss, javascript, json]
30
+ args: [--prose-wrap=always]
31
+ exclude: ^charts/.*/templates/
32
+
33
+ - repo: https://github.com/astral-sh/ruff-pre-commit
34
+ rev: "39d9ac5938dadb73df0564a45f163e25ff9fa6e2" # frozen: v0.16.1
35
+ hooks:
36
+ - id: ruff-check
37
+ args: ["--fix", "--show-fixes"]
38
+ - id: ruff-format
39
+
40
+ - repo: https://github.com/pre-commit/mirrors-mypy
41
+ rev: "41e691678310dfd3833f7ab4e180ddb014310356" # frozen: v2.3.0
42
+ hooks:
43
+ - id: mypy
44
+ files: src|tests
45
+ args: []
46
+ additional_dependencies:
47
+ - pytest
48
+ - mcp==2.0.0
49
+ - af-credentials==0.1.1
50
+
51
+ - repo: https://github.com/codespell-project/codespell
52
+ rev: "57b21406f092110c18776e39b0bda50d37c945c8" # frozen: v2.4.3
53
+ hooks:
54
+ - id: codespell
55
+ additional_dependencies:
56
+ - tomli; python_version<'3.11'
57
+
58
+ - repo: https://github.com/shellcheck-py/shellcheck-py
59
+ rev: "745eface02aef23e168a8afb6b5737818efbea95" # frozen: v0.11.0.1
60
+ hooks:
61
+ - id: shellcheck
62
+
63
+ - repo: https://github.com/abravalheri/validate-pyproject
64
+ rev: "4b2e70d08cb2ccd26d1fba73588de41c7a5d50b7" # frozen: v0.25
65
+ hooks:
66
+ - id: validate-pyproject
67
+ additional_dependencies: ["validate-pyproject-schema-store[all]"]
@@ -0,0 +1,279 @@
1
+ # af-filesystem-mcp — Contributor Guide
2
+
3
+ MCP server that gives an AF (Analysis Facility) user browse/read access to their
4
+ own files on the AF's shared NFS home (`/home/<unixname>`) and Ceph data area
5
+ (`/data/<unixname>`) — nothing more. Read-only in v1: there is no write, delete,
6
+ rename, or command-execution tool anywhere in this package.
7
+
8
+ Companion backends in the same af-mcp-platform: [ami-mcp][ami-mcp] (AMI
9
+ metadata) and [rucio-mcp][rucio-mcp] (Rucio data management). This repo follows
10
+ their conventions (tool naming, broker-mode auth, Helm chart shape) wherever
11
+ they apply, and departs from them explicitly where this backend's job is
12
+ different — see "How this differs from ami-mcp/rucio-mcp" below.
13
+
14
+ [ami-mcp]: https://github.com/kratsg/ami-mcp
15
+ [rucio-mcp]: https://github.com/kratsg/rucio-mcp
16
+
17
+ ## Origin
18
+
19
+ Designed in [maniaclab/af-mcp-platform#188][188], which also has the full design
20
+ rationale and the (rejected) alternative — an off-the-shelf
21
+ `rust-mcp-filesystem` server — and why it was rejected: stdio-only (no per-user
22
+ identity), and a dangling-symlink write-escape via a validate-then-open TOCTOU.
23
+ This package exists specifically to not have either problem.
24
+
25
+ [188]: https://github.com/maniaclab/af-mcp-platform/issues/188
26
+
27
+ ## Security model
28
+
29
+ **The security boundary is impersonation, not path-string validation.** Every
30
+ filesystem operation for user _alice_ runs in a short-lived helper subprocess
31
+ that is started running AS alice's real uid/gid
32
+ (`asyncio.create_subprocess_exec(..., user=uid, group=gid)`), never inside the
33
+ long-lived async server process itself (which would mean a process-wide
34
+ `seteuid()` racing across concurrently in-flight requests for different users).
35
+ This means the kernel — and, for the NFS-mounted homes, the NFS server —
36
+ enforces every permission check against the real identity: even a bug in this
37
+ server's own path-pinning logic can only let alice reach what alice's real uid
38
+ could already reach. This mirrors [voms-token-service][voms-token-service]'s
39
+ `minting.py` impersonation pattern, adapted from `subprocess.run` to
40
+ `asyncio.create_subprocess_exec`.
41
+
42
+ [voms-token-service]: https://github.com/maniaclab/voms-token-service
43
+
44
+ On top of that boundary, two layers of path confinement exist as policy hygiene
45
+ / prompt-injection containment (not the boundary itself — see
46
+ `src/af_filesystem_mcp/paths.py`'s module docstring):
47
+
48
+ - `resolve_confined(root, relative)`: a string-level check, before any I/O, that
49
+ fully resolves symlinks (`Path.resolve(strict=False)`, so a dangling symlink's
50
+ literal target is what gets checked — never falling back to checking only the
51
+ symlink's own parent directory the way rust-mcp-filesystem's `validate_path`
52
+ did) and confirms the result is still under `root`.
53
+ - `secure_open_confined(root, relative, flags)`: closes the TOCTOU window a
54
+ string-level check alone leaves open. It walks `root` → target one path
55
+ component at a time via `os.open(part, ..., dir_fd=parent_fd, O_NOFOLLOW)` —
56
+ an openat-style walk where _every_ component, intermediate or final, fails
57
+ outright (`ELOOP`) if it turns out to be a symlink at the moment it is
58
+ actually opened, rather than being silently followed. This is the direct fix
59
+ for rust-mcp-filesystem's actual vulnerability class.
60
+
61
+ `fs_read`/`fs_grep` (content-touching ops) always use `secure_open_confined` and
62
+ refuse to follow **any** symlink, even one that points to another file within
63
+ the same confined root — a deliberately conservative v1 restriction (see
64
+ `helper/ops.py`'s module docstring for why, and what it would take to loosen
65
+ it). `fs_list`/`fs_stat` (metadata-only, `lstat`-based, never dereferenced) use
66
+ the more permissive `resolve_confined`, since reporting a symlink's name and
67
+ literal target string never discloses file contents.
68
+
69
+ Unlike voms-token-service, this server needs **no `CAP_DAC_READ_SEARCH`**:
70
+ voms-token-service has exactly one code path (reading `~/.globus/*.pem`) where
71
+ root reads a user's file directly, before impersonating, and that capability is
72
+ what makes that one read work. af-filesystem-mcp has no such path at all — every
73
+ single byte of user data this server ever touches goes through the impersonated
74
+ subprocess. The container's capability set is `SETUID`+`SETGID` only (see
75
+ `charts/af-filesystem-mcp/values.yaml`'s `containerSecurityContext`).
76
+
77
+ ## Project layout
78
+
79
+ ```
80
+ src/af_filesystem_mcp/
81
+ ├── __init__.py, _version.pyi, py.typed
82
+ ├── cli.py # argparse: `af-filesystem-mcp serve`
83
+ ├── server.py # stdio (local identity) + broker-mode HTTP transport
84
+ ├── identity.py # Identity(uid, gid, unixname) -- what every call impersonates
85
+ ├── roots.py # RootsConfig: the /home, /data prefix roots
86
+ ├── paths.py # resolve_confined, secure_open_confined, UserRoots
87
+ ├── impersonate.py # run_helper/run_helper_json: per-call impersonated subprocess
88
+ ├── auth/
89
+ │ ├── local.py # stdio mode: the server process's own uid/gid
90
+ │ └── broker.py # HTTP mode: broker-issued JWT -> Identity
91
+ ├── helper/
92
+ │ ├── __main__.py # `python -m af_filesystem_mcp.helper <op> <json>` CLI
93
+ │ └── ops.py # list_dir/stat_path/read_file/grep_files (pure functions)
94
+ └── tools/
95
+ ├── _helpers.py # call_fs_op, format_error, append_next_actions
96
+ ├── list_dir.py # fs_list
97
+ ├── stat_path.py # fs_stat
98
+ ├── read_file.py # fs_read
99
+ └── grep_files.py # fs_grep
100
+ tests/
101
+ ├── conftest.py # mock_ctx, fs_roots fixtures (real tmp_path roots)
102
+ ├── test_paths.py # path confinement: traversal, symlink escapes
103
+ ├── test_secure_open.py # secure_open_confined: the O_NOFOLLOW walk
104
+ ├── test_impersonate.py # impersonation subprocess wiring (mocked, like
105
+ │ # voms-token-service's own test pattern)
106
+ ├── test_server.py, test_cli.py
107
+ ├── auth/ # local + broker identity resolution
108
+ ├── helper/ # ops.py + the __main__ CLI
109
+ └── tools/ # the four MCP tools, end-to-end through the
110
+ # real (non-impersonating-in-this-sandbox)
111
+ # helper subprocess -- see test_helpers.py's
112
+ # module docstring
113
+ ```
114
+
115
+ ## How this differs from ami-mcp/rucio-mcp
116
+
117
+ - **No shared-secret HTTP mode.** ami-mcp/rucio-mcp's shared-secret mode serves
118
+ one pre-authenticated identity behind a static bearer. For this backend that
119
+ identity IS a uid/gid — a shared secret would mean every caller impersonates
120
+ the _same_ fixed user, which defeats the entire point of per-user filesystem
121
+ access. HTTP transport is broker-only.
122
+ - **No credential redeem step.** ami-mcp/rucio-mcp's broker mode verifies a
123
+ broker-issued JWT and then redeems a _separate_ credential (a VOMS proxy) at
124
+ the broker per call. This backend's bearer already carries everything it needs
125
+ — the `uid`/`gid`/`unixname` POSIX claims — so there is no redeem call. See
126
+ `auth/broker.py`'s module docstring for why `resolve_identity` re-verifies the
127
+ same bearer a second time (the mcp SDK's `TokenVerifier` adapter discards
128
+ POSIX claims by design).
129
+ - **No "client" object.** ami-mcp/rucio-mcp tools call a client method
130
+ (`pyAMI.client.Client`, `rucio.client.Client`). This backend's equivalent is
131
+ `af_filesystem_mcp.tools._helpers.call_fs_op`, which runs the impersonated
132
+ helper subprocess instead of a network call.
133
+
134
+ ## Tool registration pattern
135
+
136
+ Same shape as ami-mcp/rucio-mcp: each `tools/*.py` module exports
137
+ `register(mcp: MCPServer) -> None`; `server.py` calls `register(mcp)` for every
138
+ module in `_register_all`. Tools are closures inside `register()` using the
139
+ `@mcp.tool()` decorator.
140
+
141
+ ```python
142
+ # tools/mymodule.py
143
+ from __future__ import annotations
144
+
145
+ from typing import Any, Literal
146
+
147
+ from mcp.server.mcpserver import Context, MCPServer # noqa: TC002 (needed at runtime for eval_str signature introspection)
148
+
149
+ from af_filesystem_mcp.tools._helpers import append_next_actions, call_fs_op, format_error
150
+
151
+
152
+ def register(mcp: MCPServer) -> None:
153
+ @mcp.tool()
154
+ async def fs_my_tool(
155
+ root: Literal["home", "data"],
156
+ path: str = "",
157
+ *,
158
+ ctx: Context[Any, Any],
159
+ ) -> str:
160
+ """Tool description -- shown to the LLM as the tool's purpose."""
161
+ try:
162
+ result = await call_fs_op(ctx, "my_op", root, path)
163
+ except Exception as exc: # noqa: BLE001
164
+ return format_error(exc, hints=["..."])
165
+ return append_next_actions(str(result), ["..."])
166
+ ```
167
+
168
+ Key conventions:
169
+
170
+ - Tool names are prefixed with `fs_` to avoid collisions.
171
+ - `ctx` is keyword-only (after `*`).
172
+ - `Context`/`MCPServer` must be imported as **real, non-`TYPE_CHECKING`**
173
+ imports (with a `# noqa: TC002` to satisfy ruff's type-checking-import lint) —
174
+ the mcp SDK's `func_metadata()` calls
175
+ `inspect.signature(func, eval_str=True)`, which needs `Context` to actually
176
+ resolve in the function's module globals at _runtime_, not just for static
177
+ type checking. Getting this wrong raises
178
+ `InvalidSignature: Unable to evaluate type annotations` the moment the tool is
179
+ registered.
180
+ - Errors are returned via `format_error(exc, hints=[...])` — never raised, never
181
+ a bare `f"Error: {exc}"` string.
182
+ - `except Exception as exc:` lines carry `# noqa: BLE001` inline;
183
+ `broad-exception-caught` is disabled globally in pylint (`pyproject.toml`).
184
+ - Use `append_next_actions(output, [...])` to suggest follow-up tool calls.
185
+ - If adding a new _operation_ (not just a new tool wrapping existing ops), add
186
+ the pure function to `helper/ops.py`, wire it into `helper/__main__ .py`'s
187
+ `_OPS` dict, and give it its own `PathEscapeError`/`OSError` handling
188
+ consistent with the existing four.
189
+
190
+ Then wire it in `server.py`:
191
+
192
+ ```python
193
+ from af_filesystem_mcp.tools import mymodule
194
+
195
+ for _module in [..., mymodule]:
196
+ _module.register(mcp)
197
+ ```
198
+
199
+ ## Adding a new tool
200
+
201
+ 1. Decide whether it's a new _tool_ over an existing op, or needs a new op in
202
+ `helper/ops.py` (see "Tool registration pattern" above).
203
+ 2. Add a new `@mcp.tool()` function inside the module's `register()`.
204
+ 3. If creating a new module, add it to `_register_all` in `server.py`.
205
+ 4. Write unit tests: `helper/ops.py` logic directly (no privilege needed — see
206
+ `tests/helper/test_ops.py`); the tool itself via `mock_ctx`/`fs_roots` from
207
+ `tests/conftest.py` (real subprocess, not privileged in tests, so it runs
208
+ unimpersonated exactly like stdio mode — see `tests/tools/test_helpers.py`'s
209
+ module docstring for why that is a legitimate near-end-to-end test, not a
210
+ shortcut).
211
+ 5. Run `pixi run test` to verify.
212
+
213
+ ## Build and test commands
214
+
215
+ ```bash
216
+ pixi run test # quick tests (no privilege needed)
217
+ pixi run test-slow # all tests including any marked slow/root_only
218
+ pixi run lint # pre-commit + pylint
219
+ pixi run helm-lint # lint + smoke-render the Helm chart
220
+ pixi run build # build sdist + wheel
221
+ ```
222
+
223
+ ## Development setup
224
+
225
+ ```bash
226
+ pixi install
227
+ pixi run pre-commit-install
228
+ ```
229
+
230
+ ## Server transports
231
+
232
+ - **stdio** (default): single caller, the process's own uid/gid
233
+ (`auth/ local.py`). No impersonation happens or is needed. Confined to the
234
+ real `$HOME` and a `--data-root` you provide.
235
+ - **http, broker mode** (the only HTTP mode):
236
+ `af-filesystem-mcp serve --transport http --broker-url <url> --broker-audience af-filesystem-mcp`.
237
+ Bearers are broker-issued identity JWTs (see af-mcp-platform's
238
+ `identityProviders[].targetOptions.af-filesystem-mcp.includePosix: true`)
239
+ verified via `af-credentials` (the `broker` extra:
240
+ `pip install af-filesystem-mcp[broker]`).
241
+
242
+ ## Deployment (Helm chart)
243
+
244
+ `charts/af-filesystem-mcp/` mirrors ami-mcp's chart shape (pixi-install init
245
+ container, same label/helper conventions), with two AF-specific additions:
246
+
247
+ - Two read-only PVC mounts (`homes.existingClaim` at `/home`,
248
+ `data.existingClaim` at `/data`) — the chart does not create the underlying
249
+ PV; that is a sibling flux_apps manifest, exactly voms-token-service's
250
+ `pv-homes.yaml`/`pvc-homes.yaml` pattern. See `values.yaml`'s comments on
251
+ `homes`/`data` for what each PVC needs to bind to, and the **known open
252
+ question** on `data`: the only observed `/data` convention on the AF today (a
253
+ node-level hostPath mount used by htcondor execute pods, e.g.
254
+ `/data/projects`) is not confirmed to expose a real per-user
255
+ `/data/<unixname>` tree the way the NFS homes export does for
256
+ `/home/<unixname>` — verify before deploying.
257
+ - `containerSecurityContext` adds only `SETUID`/`SETGID` (see "Security model"
258
+ above for why not `DAC_READ_SEARCH` too).
259
+
260
+ ## Non-goals (v1)
261
+
262
+ - No write, delete, rename, chmod, or any mutating tool.
263
+ - No arbitrary command execution (no shell, no sandboxed exec) — the tool
264
+ surface is deliberately the smallest useful set for "browse/read your own
265
+ files": `fs_list`, `fs_stat`, `fs_read`, `fs_grep`. A general-purpose
266
+ sandboxed shell was considered and rejected (see #188): it would need the same
267
+ impersonation/path-confinement machinery this package already has, plus
268
+ resource-limiting and auditability work an explicit, capped tool API gets for
269
+ free from having a fixed, enumerable set of operations.
270
+ - No full-tree operations (directory size, duplicate-finder, recursive copy) —
271
+ `fs_grep` is the one recursive op, and it is capped in files scanned and
272
+ matches returned.
273
+
274
+ Phase 2 (writes) is deferred by design, not by a disabled flag: there is no
275
+ `fs_write`/`fs_edit` code in this repository at all yet. When it is built, it
276
+ should ship behind an explicit, separately gated capability (a Helm value and/or
277
+ a distinct broker capability), soak-tested against the read-only v1 in
278
+ production first, and reuse `secure_open_confined` with `O_CREAT`/`O_EXCL`
279
+ semantics rather than a new path-resolution mechanism.