Skip to content

Release 0.1.2 — make geo-mcp actually work end-to-end - #3

Merged
maflot merged 3 commits into
mainfrom
dev
Apr 20, 2026
Merged

maflot merged 3 commits into
mainfrom
dev

Conversation

@maflot

@maflot maflot commented Apr 20, 2026

Copy link
Copy Markdown
Member

Three categories of bugs kept this server from being usable in practice:

  1. Every MCP tool call crashed. A misuse of @server.call_tool() meant
    every call routed to cleanup_downloads_tool and blew up on an
    arg-count mismatch.
  2. Every download silently failed since NCBI deprecated FTP in 2024.
    The URL regex only matched ftp://.
  3. Importing the package errored if no config.json existed —
    broke pytest, mypy, and any non-server consumer.

This PR fixes all three, migrates the rest of the I/O path to non-blocking
aiohttp, lands the test infra to keep them fixed, and tags 0.1.2.

Commits

Hash Subject
359c43b fix(downloader): accept https:// URLs from NCBI and lazy-load config
65c49b9 chore: adopt org-wide tooling baseline
e44c7b1 release: prep 0.1.2
b895362 fix(mcp): route every tool call through one dispatcher

+1601 / -1627 across 20 files.

What this fixes

Ordered by user-visible severity, not by file.

Show-stoppers

  • Every MCP tool call routed to cleanup_downloads_tool and crashed
    with "takes 1 positional argument but 2 were given".

    GEOMCPServer._setup_tools called @self.server.call_tool() once per
    tool with a one-argument handler. The MCP SDK registers one handler
    per CallToolRequest — each decorator call replaced the previous one,
    so only the last-defined function survived — and invokes it as
    handler(name, arguments), which the single-arg handlers refused.
    Replaced the 12 per-tool registrations with a single dispatcher that
    delegates to handle_call_tool(name, arguments). Pinned by
    tests/test_mcp_server.py (3 tests including a closure-walking
    signature check).

  • Downloads broken for every accession since mid-2024. NCBI retired
    the anonymous FTP endpoint; E-utilities XML now returns
    https://ftp.ncbi.nlm.nih.gov/... URLs. The old regex only matched
    ftp:// and silently dropped every response, surfacing as
    "no downloadable SOFT file exposed". URL extraction now accepts both
    schemes and normalises to HTTPS. Pinned by 8 of the
    tests/test_downloader.py cases (HTTPS, FTP, GSE-vs-GSM path
    construction, deduplication, empty-XML).

  • Package could not be imported without a pre-existing config.json.
    geo_downloader ran _load_config() at module scope and called
    sys.exit(1) if email was missing. Broke pytest, mypy, and every
    consumer that merely imported the package. Config is now lazy through
    _config() with four precedence layers: runtime configure()
    overrides → GEOMCP_* env vars → config.json (at $CONFIG_PATH,
    $XDG_CONFIG_HOME/geomcp/config.json, or legacy
    ~/.geo-mcp/config.json) → built-in defaults.

Stdio-loop blocking

  • geo_profiles was still sync. The downloader migrated to
    aiohttp in an earlier round, but every search_* tool was still
    using blocking requests + time.sleep(0.1) inside the MCP stdio
    loop. Migrated to aiohttp + asyncio.sleep, dropped the duplicate
    load_config(), now shares geo_downloader._config() and
    _require_email(). Every public search_* is now a coroutine. Tool
    call-sites in mcp_server.py updated to await.

  • asyncio.Semaphore bound to the wrong loop. The module-scope
    semaphore captured whichever loop happened to be live at import time
    and then raised "attached to a different loop" under MCP stdio. Now
    created per-loop in _get_semaphore().

Bootstrap / cleanup

  • First-run bootstrap was broken. main.setup_environment() tried
    to copy a config_template.json that doesn't exist in the package,
    then sys.exit(1)'d. Symbol kept as a no-op shim for backward compat.

  • main.py no longer os.chdir's the process into the install
    directory or prepends a fictional .venv/bin to PATH.

  • pytest.ini testpaths pointed at geo_mcp_server/test, a
    directory that no longer exists. pytest was silently collecting zero
    tests. Repointed to tests/. Without this fix the entire test suite
    below would have been collected-but-not-run on CI.

  • README usage examples referenced the long-gone geo_mcp_server/
    layout, the pre-CLI python main.py --mode stdio invocation, port
    8000 (the HTTP server runs on 8001), and the wrong clone dir
    (GEO_MCP vs GEOmcp). All fixed.

What this changes

Config surface

  • configure(**overrides) runtime API. The CLI flags (--email,
    --api-key, --download-dir, --max-file-size-mb,
    --max-total-downloads-mb, --max-concurrent-downloads,
    --download-timeout-seconds, --allowed-download-paths) now actually
    apply through this path.
  • GEOMCP_EMAIL, GEOMCP_API_KEY, GEOMCP_BASE_URL,
    GEOMCP_DOWNLOAD_DIR env vars as a layer between config.json and
    configure() overrides.
  • geomcp.__version__ and geo-mcp --version now read from installed
    package metadata via importlib.metadata, so they stay in sync with
    the wheel/tag instead of drifting from a hand-maintained constant.

Tooling / packaging

  • .github/workflows/ci.yml delegating to the org-wide reusable Python
    CI workflow (Python 3.10/3.11/3.12, ruff, black, pytest, coverage).
  • .pre-commit-config.yaml (trailing whitespace, EOF fixer, ruff,
    ruff-format, black 24.10.0).
  • CHANGELOG.md, CONTRIBUTING.md, MIT/BSD license alignment.
  • Classifier bumped to Development Status :: 4 - Beta.
  • Cleaned pre-existing ruff (F401, F841, E401) and black drift so
    CI is green on the first run.

What this removes

  • Duplicate release workflow (python-publish.yml overlapped with
    pypi-publish.yml; both fired on release: published and would
    double-upload).
  • Broken test.yml workflow that ran pytest against a non-existent dir.
  • Stale .github/WORKFLOWS.md that documented a workflow layout the
    repo never actually had.
  • Dead code in geo_profiles: legacy-FTP-URL download_geo_data,
    list_downloaded_datasets, get_download_stats — superseded by their
    async equivalents in geo_downloader. Nothing imported them.

Breaking changes

For the uncommon case only. Default download_dir is now
$XDG_DATA_HOME/geomcp/downloads instead of ./downloads resolved
relative to the installed package directory. Users with an existing
~/.geo-mcp/config.json are unaffected — that location is still honored
as a fallback, and geo-mcp --init writes there if it already exists so
no orphaning.

The public Python API is unchanged in shape; the search_* functions in
geomcp.geo_profiles are now coroutines, but they were never documented
as sync-callable from external code.

Verification

  • ruff check . — clean
  • black --check . — 10 files unchanged
  • pytest -q — 18 passed in 0.36s
  • geo-mcp --version — renders from importlib.metadata
    (0.1.dev15+ge44c7b1e5 in the dev install; will become 0.1.2
    after the tag)
  • inspect.iscoroutinefunction(geomcp.geo_profiles.search_geo) —
    True
  • Tool/dispatch parity: every tool from get_tool_definitions()
    has a branch in handle_call_tool (verified by both the
    regression test and an out-of-band parity check)
  • Legacy ~/.geo-mcp/config.json still read by _config()
  • Live in Claude Desktop after a restart of the editable install at
    ~/.venvs/geomcp — tool calls dispatch correctly
  • End-to-end against real NCBI: download_geo_data against
    GSE10072, then get_download_status and cleanup_downloads_tool.
    Couldn't run from the venv used for prep; do this once the PR is
    merged and the published wheel is installed.

Test inventory

tests/test_downloader.py — 15 cases:

  • import-without-config (downloader, mcp_server)
  • _require_email raises only on use, not on import
  • URL extraction: ftp:// accepted, https:// accepted, direct
    SOFT-file URL passed through, GSM-vs-GSE soft path construction,
    deduplication, empty-XML returns []
  • backward-compat aliases (_extract_ftp_links, _load_config)
  • configure() overrides win, None overrides ignored
  • default download_dir is XDG (not site-packages)
  • legacy ~/.geo-mcp/config.json still honored
  • setup_environment() no-op shim still callable

tests/test_mcp_server.py — 3 cases:

  • every tool from get_tool_definitions dispatches cleanly through
    handle_call_tool (with stubbed backends so no network)
  • unknown tool name raises ValueError
  • registered CallToolRequest handler accepts two positional arguments
    (closure-walks through the SDK's wrapper to find the user fn)

Reviewer notes (non-obvious choices)

  • Why one dispatcher instead of a registry dict. I drafted a
    _DISPATCH dict at module scope but reverted it: a dict captures
    function references at import time, which makes the
    monkeypatch-based tests ineffective. The big if/elif does
    attribute-lookups at call time, so stubbing
    geomcp.geo_profiles.search_geo actually changes what runs.
  • handle_list_tools exists at module scope even though
    _register() already wires @server.list_tools(). Reason:
    mcp_http_server.py imports handle_list_tools directly and calls
    it. Removing the module-level symbol would break the HTTP gateway.
  • mcp_http_server.py still has an async def main() with a stale
    os.chdir.
    It is only reachable via python -m geomcp.mcp_http_server; the user-facing geo-mcp --http path goes
    through main.run_http_server() which does not chdir. Left in place
    rather than expand the diff further; tracked for a follow-up.
  • CONTRIBUTING.md mentions vcrpy for cassette-recorded network
    tests. vcrpy is not added to dev deps; this is aspirational
    guidance, not enforced. Fine for now since no current test needs it.
  • Development Status :: 4 - Beta is a deliberate step from
    "Alpha". The README "not production-ready" warning was softened to a
    Beta notice. We can move to "5 - Production/Stable" after a couple of
    bug-free release cycles.
  • CHANGELOG date is 2026-04-17 (when prep work landed). If the
    tag goes out on a different day, bump that line in the same commit
    as the tag — no other state to migrate.

maflot added 3 commits April 15, 2026 12:42
The downloader silently stopped working after NCBI retired its anonymous
FTP endpoint in 2024. _extract_ftp_links only matched ftp:// URLs, so
every E-utils XML response returning https://ftp.ncbi.nlm.nih.gov/... was
dropped, producing spurious "no downloadable SOFT file exposed" errors
for valid accessions. The URL extractor now accepts both schemes and
normalises everything to https://.

Two independent bugs blocked the package from importing at all when no
config.json existed:

- geo_downloader._load_config() ran at module scope and raised
  FileNotFoundError if no config was found, then sys.exit(1) if email
  was missing. pytest, mypy, and any `python -c "import geomcp"` died.
- main.setup_environment() tried to copy a config_template.json that
  does not exist in the package, taking sys.exit(1) on first run.

Config is now resolved lazily with four layers (hardcoded defaults,
config.json, GEOMCP_* env vars, configure() runtime overrides). The
legacy ~/.geo-mcp/config.json location remains a fallback so existing
Claude Desktop installs keep working. CLI flags (--email, --api-key,
--download-dir, etc) now actually apply via geo_downloader.configure()
instead of being parsed and ignored.

Other correctness fixes:

- E-utilities calls moved from blocking requests to aiohttp so the
  stdio event loop is not stalled during downloads.
- asyncio.Semaphore moved off module scope (was bound to the first loop
  that happened to import us) to a per-loop cache.
- Default download directory is XDG ($XDG_DATA_HOME/geomcp/downloads)
  instead of a package-relative ./downloads that resolved inside
  site-packages.
- main.py no longer os.chdir()s the process or prepends a fictional
  .venv/bin to PATH.

Backward compatibility shims preserved: _extract_ftp_links is an alias
for _extract_download_urls, _load_config() returns the new lazy config,
setup_environment() is a no-op, --retmax is still accepted (hidden).

Adds a tests/ package with 15 regression tests covering the import-time
crash, URL scheme handling for both ftp:// and https://, the GSE vs GSM
SOFT path convention, configure() override precedence, XDG defaults,
legacy config fallback, and the backward-compat shims.
Phase 1 tooling pass for GEOmcp:

- Replace the broken test.yml (which invoked pytest against a
  non-existent geo_mcp_server/test/) and the duplicate
  python-publish.yml with a one-line ci.yml that delegates to
  MCPmed/.github's reusable-python-ci.yml (ruff, black, pytest on
  3.10/3.11/3.12, codecov upload). Release workflows (pypi-publish,
  dev-release) kept as-is for now; migration to Trusted Publisher is
  a follow-up.
- Add .pre-commit-config.yaml with ruff, ruff-format, black, and the
  standard pre-commit-hooks suite.
- Add CHANGELOG.md in Keep a Changelog format covering the Tier A
  download-pipeline fixes and this tooling pass.
- Add CONTRIBUTING.md scoped to GEOmcp specifics; defers to the
  organization-wide contribution guide in MCPmed/.github.
- Align license declarations: pyproject.toml classifier and
  README.md badge now match the BSD-3-Clause LICENSE file (they
  previously claimed MIT).
- Clean up pre-existing lint so CI is green on first run:
  unused imports (F401) and an unused CallToolRequest assignment
  (F841) in mcp_server.py; E401 in mcp_http_server.py; black
  reformat of __init__.py, geo_profiles.py, mcp_http_server.py,
  mcp_server.py. No behavior changes in those files.
- Remove stale .github/WORKFLOWS.md documenting workflows that do
  not exist as named.
- migrate geo_profiles esearch/esummary to aiohttp and share
  geo_downloader._config() so GEOMCP_* env vars, configure() overrides,
  and the XDG config.json finally apply to the search path. Stops the
  MCP stdio event loop from blocking on every tool call.
- drive __version__ and --version from importlib.metadata; remove the
  hardcoded 0.1.1/0.1.2 constants that drifted from hatch-vcs.
- repoint pytest.ini testpaths at tests/ (the old geo_mcp_server/test
  path no longer exists; pytest was silently collecting zero tests).
- drop dead FTP-URL helpers from geo_profiles (download_geo_data,
  list_downloaded_datasets, get_download_stats); superseded by the
  async equivalents in geo_downloader.
- bump classifier to Beta, refresh README (clone dir, XDG config path,
  HTTP port 8001, remove stale python main.py --mode stdio examples,
  soften the dev warning), and finalize CHANGELOG for 0.1.2.
@maflot
maflot merged commit 37a5712 into main Apr 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant