[project] name = "markdown-vault-mcp" version = "5.0.0" description = "Generic markdown vault MCP with hybrid search" 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 = "MIT" # PROJECT-LICENSE-END requires-python = ">=3.11" authors = [{ name = "Peter van Liesdonk", email = "pvliesdonk@users.noreply.github.com" }] # 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", "markdown-vault-mcp", # PROJECT-KEYWORDS-START — add this project's domain terms below (what it # is *about*: "vault", "ocr", "patents"); kept across copier update. "markdown", "search", "fts5", "embeddings", "obsidian", "vault", "mcp-server", "knowledge-base", "semantic-search", # PROJECT-KEYWORDS-END ] classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", # 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 :: OSI Approved :: MIT License", "Topic :: Text Processing :: Markup :: Markdown", "Topic :: Scientific/Engineering :: Artificial Intelligence", # 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", # fastmcp.apps and fastmcp.server.providers.addressing are imported # directly by _server_apps.py, so the floor is declared here even though # fastmcp-pvl-core>=7 already guarantees fastmcp>=4 transitively. "fastmcp>=4", # PROJECT-DEPS-START — add domain dependencies below; kept across copier update "python-frontmatter>=1.0", "requests>=2.33.0", # PROJECT-DEPS-END ] [project.optional-dependencies] # PROJECT-EXTRAS-START — add domain-specific optional-dependency groups below; kept across copier update mcp = ["fastmcp>=4,<5"] # numpy carries no bound here on purpose: the <2.5 ceiling lives in # [tool.uv] constraint-dependencies below (single bound per package, per # scripts/check_pins.py), where Renovate's range rewriting cannot reach it # (#1216) — an extras cap got rewritten to >=2.5 by rangeStrategy # update-lockfile's out-of-range fallback. embeddings-api = ["httpx>=0.25", "numpy", "openai>=1.45"] embeddings = ["fastembed>=0.3", "numpy"] file-watcher = ["watchdog>=4.0"] # LLM-backed summarize tool (OpenAI-compatible chat-completions backend: # OpenAI, Ollama, Anthropic's compat endpoint, vLLM, ...). summarize = ["openai>=1.45"] all = ["fastmcp>=4,<5", "httpx>=0.25", "fastembed>=0.3", "numpy", "watchdog>=4.0", "openai>=1.45"] # 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>=7.0", "pytest-asyncio>=0.21", "pytest-cov>=4.0", "diff-cover>=9.0", "ruff>=0.1", "mypy>=1.0,<3", "pre-commit>=3.0", "pip-audit>=2.7", "watchdog>=4.0", "jsonschema>=4.18", "copier>=9.4", # template-conformance gate renders the template pristinely (#899) # 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", "mike>=2.1", ] browser = [ "pytest-playwright>=0.5", ] [project.scripts] markdown-vault-mcp = "markdown_vault_mcp.cli:main" [project.urls] Repository = "https://github.com/pvliesdonk/markdown-vault-mcp" Documentation = "https://pvliesdonk.github.io/markdown-vault-mcp/" Issues = "https://github.com/pvliesdonk/markdown-vault-mcp/issues" Changelog = "https://github.com/pvliesdonk/markdown-vault-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]`. # # The single numpy bound (extras above are deliberately bare). numpy 2.5+ # is 3.12-only and its PEP 695 stubs abort mypy at this project's # python_version=3.11 (#727, #1214); without a ceiling, uv's forked # resolution locks 2.5.x for the 3.12+ interpreters CI type-checks on. It # lives here rather than in the extras because Renovate does not manage # [tool.uv], so its update-lockfile fallback cannot rewrite the range the # way it did to an extras cap (#1216) — while uv itself honors the # constraint in every re-lock, lockFileMaintenance included. check_pins.py # fails CI the moment the `until:` issue closes, so the pin cannot outlive # the 3.12 floor raise that removes it. constraint-dependencies = [ "numpy<2.5", # until: https://github.com/pvliesdonk/fastmcp-server-template/issues/547 ] # PROJECT-UV-END [tool.hatch.build.targets.wheel] packages = ["src/markdown_vault_mcp"] # app.src.html is the input scripts/vendor_spa.py builds app.html from, so the # distribution carries the build and not the source (#580). It is # `_skip_if_exists` precisely because it is yours to edit; that is not a reason # to ship it to installers. exclude = ["src/markdown_vault_mcp/static/app.src.html"] [tool.ruff] target-version = "py311" line-length = 88 src = ["src", "tests"] # Keep ruff >=0.16's markdown code-fence formatter away from all markdown: # tests/fixtures/*.md is byte-exact test data (one file is deliberately # invalid UTF-8, which aborts the formatter), docs carry hand-aligned # comment columns and non-Python fences it mangles, and CI only format- # checks src/ and tests/ anyway. extend-exclude = ["*.md"] [tool.ruff.lint] select = [ "E", "W", "F", "I", "B", "C4", "UP", "ARG", "SIM", "TCH", "PTH", "RUF", ] ignore = ["E501"] [tool.ruff.lint.per-file-ignores] # Phase 1 write stubs: method signatures define the public API but bodies are # not yet implemented. ARG002 fires because parameters are unused. "src/markdown_vault_mcp/vault.py" = ["ARG002"] # PLR0913: the facet protocols (#1229) restate the signatures of the methods # they describe, so their argument counts are fixed by GitWriteStrategy's # existing surface — shrinking them here would only make the protocol stop # matching the implementation. Gate-only rule, so a `# noqa` would be # stripped (RUF100). "src/markdown_vault_mcp/git/interfaces.py" = ["PLR0913"] # S105: GITLAB_SIGNING_TOKEN_PREFIX is the published `whsec_` format marker # GitLab puts in front of a signing token, not a credential — the secret is # the base64 that follows it. Gate-only rule, so a `# noqa` would be # stripped (RUF100). "src/markdown_vault_mcp/_webhooks.py" = ["S105"] # Depends() and CurrentContext() calls in argument defaults are the standard # FastMCP dependency injection pattern — B008 is a false positive here. # Context/FastMCP/Vault 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). # S105/S106: tests construct configs with fake literal tokens/secrets # (git_token="...", github_webhook_secret="...") — not real credentials. # S108: fixture paths under /tmp are inputs to pure config objects, never # actually opened. "tests/**" = ["S101", "S603", "S607", "S105", "S106", "S108"] # 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 # config.py needs Sequence importable at runtime: the config-surface # generator resolves ProjectConfig's field annotations via get_type_hints, # which fails if the name only exists under TYPE_CHECKING (matches the # indexing.py / content.py suppressions below). "src/markdown_vault_mcp/config.py" = ["TC003"] # 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. "src/markdown_vault_mcp/cli.py" = ["B008", "PLR0913"] "src/markdown_vault_mcp/server.py" = ["B008", "TC002", "PLR0913"] "src/markdown_vault_mcp/_server_deps.py" = ["B008", "TC002", "TC003", "PLR0913"] "src/markdown_vault_mcp/domain.py" = ["B008", "TC002"] "src/markdown_vault_mcp/_server_tools/*.py" = ["B008", "TC001", "TC002", "PLR0913"] "src/markdown_vault_mcp/_server_resources.py" = ["B008", "TC001", "TC002", "PLR0913"] "src/markdown_vault_mcp/_server_prompts.py" = ["B008", "TC002", "PLR0913"] "src/markdown_vault_mcp/_server_apps.py" = ["B008", "TC001", "TC002", "PLR0913"] "src/markdown_vault_mcp/_server_transfer.py" = ["B008", "TC001", "TC002", "PLR0913"] # IndexingConfig/ContentConfig need pathlib.Path and collections.abc.Sequence at # runtime so typing.get_type_hints() works on the public frozen dataclasses; keep # those imports top-level, not behind TYPE_CHECKING. "src/markdown_vault_mcp/config_sections/indexing.py" = ["TC003"] "src/markdown_vault_mcp/config_sections/content.py" = ["TC003"] # The git/ package exists to spawn `git` subprocesses: S603/S607 would flag # every call (fixed argv built from validated paths/refs; the `git` executable # is resolved from PATH by design), making `# noqa` routine on any diff that # touches these modules. Ignore both at package scope; the rest of src/ keeps # the checks. Only bites when the structural diff-gate adds `S` on the diff # pass (S isn't in the base select). "src/markdown_vault_mcp/git/*.py" = ["S603", "S607"] # query.py's module-private diff helpers mirror get_file_diff's wide surface # (two refs + two path forms + env + a binary flag); a param object for # single-caller private functions is pure ceremony. PLR0913 is gate-only # (not in the base select), so `# noqa` would be stripped as unused (RUF100) # — per-file ignore is the working mechanism, same as the DI modules above. "src/markdown_vault_mcp/git/query.py" = ["PLR0913"] # scanner.py's parse/scan surface is inherently config-heavy (paths + chunking # strategy + exclusion/frontmatter policy + skip callback + title field): # scan_directory predates the gate at 7 params and parse_note_categorized # mirrors that surface. Same gate-only mechanism as query.py above. "src/markdown_vault_mcp/scanner.py" = ["PLR0913"] # ConventionMaintainer is a DI collaborator: six injected dependencies # (doc_mgr + okf_migrate + detector + sync_index + write_lock + today), not # logic. Same gate-only mechanism as scanner.py above (PLR0913 isn't in the # base select, so an inline `# noqa` would be stripped as unused, RUF100). "src/markdown_vault_mcp/_okf_convention.py" = ["PLR0913"] # OkfMigrationManager is the same shape: six injected collaborators (doc/link/ # search/git-query managers + the readiness gate + the reserved-frontmatter # policy), no logic. Same gate-only mechanism as _okf_convention.py above. "src/markdown_vault_mcp/managers/okf_migrate.py" = ["PLR0913"] # EmbeddingsManager is the same shape: a DI collaborator whose constructor # takes the injected vector-lifecycle dependencies extracted from # IndexManager (#1157; callable injection per the #736 precedent). Same # gate-only mechanism as okf_migrate.py above. "src/markdown_vault_mcp/managers/embeddings.py" = ["PLR0913"] # fts_index.py is the single SQL owner: several queries are assembled from # code-owned fragments (folder LIKE clauses, `?`-placeholder lists, snippet # projections) with every value bound as a parameter — no user input ever # reaches the SQL text. S608 flags any string-built query on sight, so a # diff touching one of these lines would need a routine `# noqa` that the # base pass strips as unused (RUF100, S isn't in the base select) — the same # gate-only mechanism as the git/ package's S603/S607 entry above. "src/markdown_vault_mcp/fts_index.py" = ["S608"] # Test fixtures import vault/protocol types only used in annotations. "tests/conftest.py" = ["TC002", "TC003"] # _install_fake_openai's keyword-only scenario toggles (content/refusal/ # finish_reason/...) are a test-fake surface; a param object for a fixture # helper is pure ceremony. Same gate-only mechanism as scanner.py above. "tests/test_summarize.py" = ["PLR0913"] "tests/test_smoke.py" = ["TC002", "TC003"] "tests/test_server.py" = ["TC003"] # The v6.0.0 copier migration script runs `git show HEAD:CLAUDE.md` with a # fixed argv and no shell, the same call its sibling template-owned scripts # make. The template exempts those three (stamp_manifests, # promote_release_notes, report_seeded_changes) from S603/S607 but not this # one, so its single S607 reddens the structural diff-gate on the adoption # diff, where every line of the new file counts as changed. Carried here # rather than upstream because the template declined the fix # (fastmcp-server-template#524, closed not_planned; its PR #527 closed # unmerged), naming this local entry as the downstream resolution — so no # future template entry will collide with this key. "scripts/migrate_agent_instructions.py" = ["S603", "S607"] # PROJECT-RUFF-IGNORES-END [tool.ruff.lint.isort] known-first-party = ["markdown_vault_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 = [ "browser: end-to-end test that drives a headless browser (requires the 'browser' dependency group + 'playwright install chromium')", ] [tool.coverage.run] source = ["src/markdown_vault_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__.:", # Ellipsis-only bodies: Protocol members and typing stubs (interfaces.py, # git/interfaces.py, scanner.ChunkStrategy, the write-callback Protocols). # They are declarations, never executed — `isinstance` against a # runtime_checkable Protocol inspects member presence without calling # anything — so counting them as missed lines measures nothing. "^\\s*\\.\\.\\.$", ] [tool.mypy] # Keep this in lockstep with the floor of `requires-python` above (nothing # enforces it mechanically). Raising `requires-python` without raising this # re-creates the trap #727 spent two rounds diagnosing: # a dependency that drops the old floor ships stubs in newer syntax, and mypy # aborts parsing them while still targeting the lower version. numpy did # exactly that — 2.5.0 requires >=3.12 and its stubs use PEP 695 `type` # aliases, which mypy rejects at python_version=3.11 on every mypy version. # The `requires-python = ">=3.11"` floor alone does NOT keep numpy 2.5 out: # uv forks the resolution on `python_full_version` markers, so a plain # `uv lock --upgrade` locks 2.5.x for 3.12+ interpreters — the ones CI # type-checks on (#1214). The `numpy<2.5` entry in [tool.uv] # constraint-dependencies is what holds the single 2.4.x resolution; lift # it when this moves to "3.12". # Beware when re-verifying: an incremental `uv lock` (no --upgrade) keeps the # already-locked 2.4.x and hides the fork — that masking is how #1168 judged # the previous cap redundant and removed it. Test with --upgrade. 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 = ["frontmatter", "frontmatter.*", "yaml", "yaml.*"] ignore_missing_imports = true [[tool.mypy.overrides]] # Tests pre-date the template's `mypy src/ tests/` requirement (added in # template v1.2.0). Typing the full test suite is tracked separately. module = ["tests.*"] ignore_errors = 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"]