[project] name = "pvliesdonk-paperless-mcp" version = "2.1.0" description = "Paperless-NGX over MCP: search, read, upload and tag documents; manage correspondents and types." readme = "README.md" # PROJECT-LICENSE-START — the license you picked; replace the value below and # it is kept across copier update. The template ships a proprietary starter # because it cannot know your choice, and it does not re-render over yours. # See LICENSE for the other places the same choice has to be stated. license = "LicenseRef-Proprietary" # starter: TODO update when you pick a license (see LICENSE) # PROJECT-LICENSE-END requires-python = ">=3.11" authors = [{ name = "Peter van Liesdonk", email = "peter@liesdonk.nl" }] # The template knows the terms that are true of every server it generates and # the project's own names; it cannot know the domain terms, which go in the # seam. An empty list was not a decision, just nobody's job (#583). keywords = [ "mcp", "model-context-protocol", "fastmcp", "paperless-mcp", "pvliesdonk-paperless-mcp", # PROJECT-KEYWORDS-START — add this project's domain terms below (what it # is *about*: "vault", "ocr", "patents"); kept across copier update. # PROJECT-KEYWORDS-END ] classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", # PROJECT-LICENSE-CLASSIFIER-START — kept across copier update; the trove # name is not the SPDX id (Apache-2.0 is "Apache Software License"), which # is one reason this is a separate edit rather than a derived one. "License :: Other/Proprietary License", # starter: TODO update when you pick a license (see LICENSE) # PROJECT-LICENSE-CLASSIFIER-END ] dependencies = [ # >=7.1.0 for register_health_routes: the unauthenticated /health and # /health/ready routes make_server wires under HTTP, which compose.yml # now probes instead of opening a socket. # # 7.0.0 was the previous floor, for the FastMCP 4 adoption: # configure_task_backend takes the # server as its first argument and registers the SEP-2663 tasks extension # on it (the fastmcp.settings.docket global it previously mutated is gone), # and pvl-core itself moves to fastmcp[tasks]>=4,<5 (pvl-core#307). # server.py's call site moves after FastMCP construction with this floor. # # 6.0.0 was the floor before that, for semantic InstructionRole values, shaped # deployment identity, effective FastMCP tool visibility during instruction # finalization, and the split operator contract: # {PREFIX}_INSTANCE_DESCRIPTION supplies routing context while # {PREFIX}_INSTRUCTIONS_EXTRA supplies policy (#553). # # 5.0.0 came earlier, for the InstructionsBuilder # (instructions_for / finalize_instructions). It removed # build_instructions, retained {PREFIX}_INSTRUCTIONS as a deprecated full # replacement, and added {PREFIX}_INSTRUCTIONS_EXTRA as the additive path; # add_transfer_workflow contributes capability-link prose for path-2 tools. # # 4.11.3 was the floor before that, for ServerConfig.oidc_advertised_scopes, which # config-presentation.yml names: the config-surface generator rejects a # presentation entry for a var core does not declare, so generation fails # outright wherever an older core is resolved. The field is the operator # override for the scopes pvl-core advertises to MCP clients in # protected-resource metadata — `openid offline_access` by default # (pvl-core #280). Before it, the advertised set was derived from # oidc_required_scopes, which multi mode requires to be empty, so clients # requested no scopes at all, the grant carried no refresh token, and # every session ended at access-token expiry needing a human at a browser. # # 4.11.2 was the previous floor, for build_instructions without the # read_only keyword: 4.11.1 declares that argument required and 4.11.2 # rejects it, so the floor and the server.py call site had to move in one # change (#443). 4.11.2 also makes FetchResult.final_url mandatory, so a # consumer constructing one — a project's test fakes included — must pass # it from that floor on. # # 4.11.0 was the floor before that, for configure_task_backend + # ServerConfig.tasks_url (the background-task backend surface, ADR 0002 # §4), which server.py wires unconditionally. 4.11.0 also moves # fastmcp[tasks] into pvl-core's BASE dependencies, so pydocket arrives # transitively — no extra to declare here, and no packaging plumbing. # 4.10.1 was the floor for apply_tool_visibility, named in place of the # 4.10.0 that was tagged but never reached PyPI: the generator resolves # this whole constraint rather than the floor alone (#335), but a floor # naming a release nobody can install is still a trap for anyone pinning # to it. "fastmcp-pvl-core>=7.1.0,<8", "typer>=0.12", # PROJECT-DEPS-START — add domain dependencies below; kept across copier update "httpx>=0.27", # `models/` and the tool signatures import pydantic directly. It used to # arrive through `pydantic-settings`, which the domain config no longer # needs; declared here so the import has a bound of its own rather than # relying on fastmcp's. "pydantic>=2.7", # PROJECT-DEPS-END ] [project.optional-dependencies] # PROJECT-EXTRAS-START — add domain-specific optional-dependency groups below; kept across copier update # Pulls debugpy transitively via fastmcp-pvl-core's [debug] extra. The # version range is inherited from the [project] dependency on # fastmcp-pvl-core above; pip resolves a single, consistent version. debug = ["fastmcp-pvl-core[debug]"] # PROJECT-EXTRAS-END # Tooling lives under PEP 735 [dependency-groups] (not [project.optional-dependencies]) # so it never ships to PyPI consumers and never lands in the security-audit input. [dependency-groups] dev = [ "pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=5", "diff-cover>=9", "ruff>=0.6", "mypy>=1.11", "pre-commit>=3", "pip-audit>=2.7", "respx>=0.21", "jsonschema>=4.18", # scripts/gen_config_surface.py imports yaml. It arrived transitively via # mkdocs-material in the `docs` group, so a plain `uv sync` left it out # and the generator fell back to its network bootstrap — slower, offline- # hostile, and liable to resolve a different fastmcp-pvl-core than this # project pins (#262). Declared here so the requirement is stated. "pyyaml>=6", # PyYAML ships no inline types, so `import yaml` in tests/test_compose.py # is an untyped import that fails `mypy src/ tests/` without these stubs. "types-PyYAML>=6", ] docs = [ "mike>=2", "mkdocs-material>=9.7.6", "mkdocstrings[python]>=0.28", "mkdocs-llmstxt>=0.5", ] browser = [ "pytest-playwright>=0.5", ] [project.scripts] paperless-mcp = "paperless_mcp.cli:main" [project.urls] Repository = "https://github.com/pvliesdonk/paperless-mcp" Documentation = "https://pvliesdonk.github.io/paperless-mcp/" Issues = "https://github.com/pvliesdonk/paperless-mcp/issues" Changelog = "https://github.com/pvliesdonk/paperless-mcp/blob/main/CHANGELOG.md" [build-system] # Bounded on purpose, at the minor. hatchling emits the newest core-metadata # version it knows, and the publisher's twine refuses metadata it does not # know -- an invariant spanning this file and release.yml's # gh-action-pypi-publish pin. Unpinned, this side moves on hatchling's # release schedule rather than on anyone's decision, and the break lands at # release time in every project at once (#479). # # The ceiling has to bind at the minor, because that is where the metadata # version moves: 1.27 took it to 2.4 and 1.32 took it to 2.5. A `<2` ceiling # would have stopped neither, so it buys the look of a guard without one. # Patch releases stay open; a minor bump is a deliberate edit here, paired # with a publisher that accepts whatever the new minor emits. requires = ["hatchling>=1.32,<1.33"] build-backend = "hatchling.build" [tool.uv] # PROJECT-UV-START — project-owned uv settings; kept across copier update. The # template ships none, but it does ship the rules for what goes here. # # This is where a temporary dependency bound belongs. `constraint-dependencies` # and `override-dependencies` are invisible to Renovate, so its update-lockfile # fallback cannot rewrite a deliberate cap the way it can one expressed in # `[project]` (#508) — and `scripts/check_pins.py` fails CI unless every entry # carries `# until: `, and again once that issue closes, so a pin # here cannot quietly outlive its reason. Keep one bound per package: the same # script rejects a package bounded both here and in `[project]`. # # constraint-dependencies = [ # "numpy<2.5", # until: https://github.com///issues/123 # ] # PROJECT-UV-END [tool.hatch.build.targets.wheel] packages = ["src/paperless_mcp"] [tool.ruff] target-version = "py311" line-length = 88 src = ["src", "tests"] [tool.ruff.lint] select = [ "E", "W", "F", "I", "B", "C4", "UP", "ARG", "SIM", "TCH", "PTH", "RUF", ] ignore = ["E501"] [tool.ruff.lint.per-file-ignores] # Depends() and CurrentContext() calls in argument defaults are the standard # FastMCP dependency injection pattern — B008 is a false positive here. # Context/FastMCP/Service imports must stay runtime (not TYPE_CHECKING) for # DI resolution — TC001/TC002/TC003. # Template-managed: the structural diff-gate adds `S` (security) on the diff # pass, so the shipped structural-gate test would self-block: its asserts trip # S101, and its bare-name `git` call trips S607 (a partial executable path — # the `sys.executable -m ...` calls are NOT flagged). S603 (subprocess use) is # included defensively: inert in current ruff but emitted by some versions. # Ignoring all three in tests keeps the gate from self-blocking on its own test # module; all three are safe in test code. No-op for the base whole-repo select # (which excludes S). If your project needs its own tests/** ignores, EXTEND # this list in place — do not add a second "tests/**" key (duplicate TOML key). "tests/**" = ["S101", "S603", "S607"] # The config-surface generator's bootstrap re-execs itself under `uv run` # (os.execvpe with a `shutil.which`-resolved absolute path — see # ensure_core_available). S606 flags any no-shell process start for review; # here the no-shell form is the safer one, and a `# noqa` instead of this # entry would trip RUF100 on the whole-repo pass, where `S` isn't selected. "scripts/gen_config_surface.py" = ["S606"] # The release stamper stages its rewrites with a fixed-argv `git add` # (subprocess with a list, never a shell). S607 flags the bare `git` name # (a partial executable path) and S603 the subprocess call itself; both are # safe here and would otherwise trip every adopter's structural diff-gate # the first time an update touches the template-owned parts of the script. "scripts/stamp_manifests.py" = ["S603", "S607"] # The release-notes promoter stages only its computed repository-relative paths # from an explicit repository root. Its `git add -A --` uses fixed argv with no # shell interpolation, so the same subprocess and partial-path exceptions apply. "scripts/promote_release_notes.py" = ["S603", "S607"] # Package bookkeeping invokes gh with fixed argv and JSON on stdin, never a shell. "scripts/package_milestones.py" = ["S603", "S607"] "scripts/check_pins.py" = ["S310"] # urlopen on a validated api.github.com URL "scripts/report_seeded_changes.py" = ["S603", "S606", "S607"] # PROJECT-RUFF-IGNORES-START — replace, extend, or override the entries below # for your project's real source/test paths (split _server_apps.py into # per-domain modules, rename tests, add per-file overrides as needed); kept # across copier update. # PLR0913 (too-many-args) is ignored on the DI/wiring modules below: FastMCP # inflates their arg counts with `Context` + `Depends()` injections, so the # structural diff-gate would flag every new tool/resource/prompt and make # `# noqa: PLR0913` routine. It only bites when enable_structural_gate is on # (PLR0913 isn't in the base select); domain modules keep the check, and # C901/PLR0912/PLR0915 still catch a genuinely complex tool here. # Template-owned script whose `git show` call is a bare executable name, the # same shape the template ignores for every sibling script in scripts/ — but # it is missing from the shipped per-file-ignores, so the structural diff # gate fails on it the first time an update puts it in a PR diff. Remove this # entry once pvliesdonk/fastmcp-server-template#524 ships a fix. "scripts/migrate_agent_instructions.py" = ["S603", "S607"] # The Paperless token literals in these two test modules are values fed to a # mocked transport, never credentials; S105/S106 flag them on the name alone. # A `# noqa` would trip RUF100 on the whole-repo pass, where `S` is not # selected, so the exemption lives here. Declared as their own keys rather # than by extending the template's "tests/**" entry, which sits outside this # sentinel and would be drift (pvliesdonk/fastmcp-server-template#618). "tests/unit/test_project_config.py" = ["S105", "S106"] "tests/unit/test_domain_service.py" = ["S106"] "src/paperless_mcp/cli.py" = ["B008"] "src/paperless_mcp/server.py" = ["TC003"] "src/paperless_mcp/_server_deps.py" = ["B008", "TC002", "TC003"] "src/paperless_mcp/_server_apps.py" = ["B008", "TC001", "TC002"] "src/paperless_mcp/tools/__init__.py" = ["B008", "TC001", "TC002"] "src/paperless_mcp/tools/_registry.py" = ["TC002", "TC003"] "src/paperless_mcp/tools/_icons.py" = ["TC002"] "src/paperless_mcp/tools/*.py" = ["TC001", "TC002", "TC003"] "src/paperless_mcp/resources/__init__.py" = ["B008", "TC001", "TC002"] "src/paperless_mcp/resources/*.py" = ["TC001", "TC002", "TC003"] "src/paperless_mcp/prompts.py" = ["B008", "TC002", "ARG001"] "tests/**/*.py" = ["TC001", "TC002", "TC003"] # ARG001 is scoped to the legacy unit suite, whose fixtures are taken for # ordering rather than use. A blanket `tests/**` entry made the template's # own `# noqa: ARG001` in tests/test_config_wizard_smoke.py unused, so # RUF100 stripped it on every `ruff check --fix` and forked that file. "tests/unit/**/*.py" = ["ARG001"] "src/paperless_mcp/models/*.py" = ["TC001", "TC002", "TC003"] "src/paperless_mcp/client/_errors.py" = ["TC002"] "src/paperless_mcp/client/_http.py" = ["TC003"] "src/paperless_mcp/client/*.py" = ["TC001", "TC002", "TC003"] # The aggregator's documented structural suppressions are exercised only by the # diff gate, whose custom rule selection is not enabled in the normal lint run. "scripts/copier_update_aggregator.py" = ["RUF100"] # PROJECT-RUFF-IGNORES-END [tool.ruff.lint.isort] known-first-party = ["paperless_mcp"] [tool.ruff.format] quote-style = "double" indent-style = "space" # Structural-gate thresholds. These rules are NOT in the whole-repo `select` # above — they apply only on the diff via the structural gate's # `--extend-select` (see .pre-commit-config.yaml / ci.yml). Values are ruff # defaults, surfaced here as editable policy constants. [tool.ruff.lint.mccabe] max-complexity = 10 [tool.ruff.lint.pylint] max-args = 5 max-branches = 12 max-returns = 6 max-statements = 50 [tool.pytest.ini_options] testpaths = ["tests"] addopts = ["--strict-markers", "-ra"] asyncio_mode = "auto" markers = [ "integration: live Paperless-NGX integration tests (opt-in)", "browser: end-to-end test that drives a headless browser (requires the 'browser' dependency group + 'playwright install chromium')", ] [tool.coverage.run] source = ["src/paperless_mcp"] branch = true [tool.coverage.report] fail_under = 80 exclude_lines = [ "pragma: no cover", "def __repr__", "raise AssertionError", "raise NotImplementedError", "if TYPE_CHECKING:", "if __name__ == .__main__.:", ] [tool.mypy] python_version = "3.11" strict = true warn_return_any = true warn_unused_ignores = true disallow_untyped_defs = true disallow_incomplete_defs = true check_untyped_defs = true no_implicit_optional = true [[tool.mypy.overrides]] module = ["fastmcp_pvl_core", "fastmcp_pvl_core.*"] ignore_missing_imports = true [[tool.mypy.overrides]] module = ["jsonschema", "jsonschema.*"] ignore_missing_imports = true [tool.pyright] # Point Pyright at the uv-managed venv so editor diagnostics resolve # imports the same way as our CLI tools (mypy/ruff/pytest run via uv). venvPath = "." venv = ".venv" pythonVersion = "3.11" # src-layout editable installs use a .pth shim that Pyright does not execute; # point Pyright at src/ directly so IDE import resolution matches runtime. extraPaths = ["src"]