diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 763352e..d8403f6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -61,7 +61,7 @@ All server code lives under `server/`. The system has two main phases: **ingesti ### MCP interface (`server/main.py`) -FastMCP serves tools and prompts over stdio, SSE, or HTTP. HTTP indexing endpoints (`POST /reindex`, +`MCPServer` (`mcp` 2.x) serves tools and prompts over stdio, SSE, or HTTP. HTTP indexing endpoints (`POST /reindex`, `POST /reindex-history`) return streaming NDJSON for CI/CD consumption. ### Configuration diff --git a/.claude/rules/fastmcp-http.md b/.claude/rules/mcp-http.md similarity index 77% rename from .claude/rules/fastmcp-http.md rename to .claude/rules/mcp-http.md index 1064d75..4354519 100644 --- a/.claude/rules/fastmcp-http.md +++ b/.claude/rules/mcp-http.md @@ -4,30 +4,37 @@ paths: - "server/tools/**/*.py" --- -# FastMCP + Starlette HTTP conventions +# MCPServer + Starlette HTTP conventions -This project uses **FastMCP**. HTTP routes are registered on the -`FastMCP` instance via `@mcp.custom_route`. Never add routes to a separate -FastAPI or Starlette app. +This project uses **MCPServer** (the `mcp` 2.x successor to FastMCP). HTTP routes +are registered on the `MCPServer` instance via `@mcp.custom_route`. Never add +routes to a separate FastAPI or Starlette app. ## Route registration pattern -Wrap registration in a `register_*` function that accepts `mcp: FastMCP`. Call it +Wrap registration in a `register_*` function that accepts `mcp: MCPServer`. Call it from `server/main.py`: ```python -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from starlette.requests import Request from starlette.responses import StreamingResponse -def register_http_routes(mcp: FastMCP) -> None: +def register_http_routes(mcp: MCPServer) -> None: @mcp.custom_route("/reindex", methods=["POST"]) async def reindex(request: Request) -> StreamingResponse: ... ``` -Same pattern for MCP tools — `register_*_tools(mcp: FastMCP)` in `server/tools/`. +Same pattern for MCP tools — `register_*_tools(mcp: MCPServer)` in `server/tools/`. + +## Bind address + +`host`/`port` are not constructor kwargs — they belong to `run()` and to the app +factories. Always pass `host=settings.mcp_host` to `streamable_http_app()` / +`sse_app()`: those factories auto-enable DNS rebinding protection when `host` is +a loopback address, which rejects container traffic with `421 Invalid Host header`. ## Request body parsing diff --git a/pyproject.toml b/pyproject.toml index 6ee9ab6..1532f9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "semcode" -version = "1.0.0" +version = "1.1.0" description = "MCP server for semantic code search across microservices codebases" requires-python = ">=3.12" dependencies = [ - "mcp>=1.28.1", + "mcp>=2.0.0", "qdrant-client>=1.18.0", "tree-sitter>=0.26.0", "tree-sitter-go>=0.23.0", diff --git a/server/main.py b/server/main.py index 1c73807..3930c40 100644 --- a/server/main.py +++ b/server/main.py @@ -3,9 +3,10 @@ import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from importlib.metadata import PackageNotFoundError, version import uvicorn -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from starlette.applications import Starlette from server.config import settings @@ -29,7 +30,7 @@ @asynccontextmanager -async def lifespan(_: FastMCP) -> AsyncIterator[None]: +async def lifespan(_: MCPServer) -> AsyncIterator[None]: logger.info("Starting semcode MCP server...") embedder = get_embedding_provider() logger.info( @@ -70,8 +71,17 @@ async def lifespan(_: FastMCP) -> AsyncIterator[None]: # so the per-MCP-session lifespan would re-init the store on every client connect. _HTTP_TRANSPORTS = {"streamable-http", "sse"} -mcp = FastMCP( +try: + # Reported to clients as serverInfo.version; sourced from pyproject so there is + # no second place to bump. Absent only if the package isn't installed (e.g. a + # bare source checkout), which must not be fatal at import time. + _VERSION = version("semcode") +except PackageNotFoundError: # pragma: no cover + _VERSION = "0.0.0" + +mcp = MCPServer( "semcode", + version=_VERSION, instructions=( "Semantic code search across microservices codebases. Hybrid retrieval " "(dense embeddings + BM25) over symbols parsed with Tree-sitter. Supports " @@ -80,8 +90,6 @@ async def lifespan(_: FastMCP) -> AsyncIterator[None]: "Compose, Markdown, JSON, HTML, CSS, and XML." ), lifespan=lifespan if settings.mcp_transport not in _HTTP_TRANSPORTS else None, - host=settings.mcp_host, - port=settings.mcp_port, ) @@ -114,10 +122,13 @@ def main() -> None: register_http_routes(mcp) if settings.mcp_transport in _HTTP_TRANSPORTS: + # `host` must match the bind address: the app factories auto-enable DNS + # rebinding protection (allowed_hosts = localhost only) when host is a + # loopback address, which would reject container traffic on 0.0.0.0. app = ( - mcp.streamable_http_app() + mcp.streamable_http_app(host=settings.mcp_host) if settings.mcp_transport == "streamable-http" - else mcp.sse_app() + else mcp.sse_app(host=settings.mcp_host) ) _wrap_http_lifespan(app) uvicorn.run( diff --git a/server/prompts/service.py b/server/prompts/service.py index 46867ff..b8ee283 100644 --- a/server/prompts/service.py +++ b/server/prompts/service.py @@ -1,9 +1,9 @@ from __future__ import annotations -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -def register_service_prompts(mcp: FastMCP) -> None: +def register_service_prompts(mcp: MCPServer) -> None: @mcp.prompt( name="service_overview", description="Produce an architectural overview of a service: HTTP entry points, main domain types," diff --git a/server/prompts/system.py b/server/prompts/system.py index 2cae7d6..8270c5e 100644 --- a/server/prompts/system.py +++ b/server/prompts/system.py @@ -1,9 +1,9 @@ from __future__ import annotations -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -def register_system_prompts(mcp: FastMCP) -> None: +def register_system_prompts(mcp: MCPServer) -> None: @mcp.prompt( name="system_design_overview", description="Produce a complete architectural overview of the whole system: service inventory," diff --git a/server/routes/reindex.py b/server/routes/reindex.py index 6366371..ad04a69 100644 --- a/server/routes/reindex.py +++ b/server/routes/reindex.py @@ -5,7 +5,7 @@ import json import logging -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from starlette.requests import Request from starlette.responses import JSONResponse, StreamingResponse @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -def register_http_routes(mcp: FastMCP) -> None: +def register_http_routes(mcp: MCPServer) -> None: @mcp.custom_route("/reindex", methods=["POST"]) async def reindex(request: Request) -> StreamingResponse: diff --git a/server/tools/history.py b/server/tools/history.py index 696058f..4f51c3e 100644 --- a/server/tools/history.py +++ b/server/tools/history.py @@ -1,13 +1,13 @@ from __future__ import annotations -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from server.embeddings import get_embedding_provider from server.indexer.git_history import GitHistoryPipeline from server.state import get_commit_store -def register_history_tools(mcp: FastMCP) -> None: +def register_history_tools(mcp: MCPServer) -> None: @mcp.tool() async def search_commits( diff --git a/server/tools/index.py b/server/tools/index.py index ef63128..c2038a8 100644 --- a/server/tools/index.py +++ b/server/tools/index.py @@ -1,12 +1,12 @@ from __future__ import annotations -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from server.indexer.pipeline import IndexPipeline from server.state import get_store -def register_index_tools(mcp: FastMCP) -> None: +def register_index_tools(mcp: MCPServer) -> None: @mcp.tool() async def reindex( diff --git a/server/tools/search.py b/server/tools/search.py index e902a35..41167d8 100644 --- a/server/tools/search.py +++ b/server/tools/search.py @@ -3,7 +3,7 @@ import logging import httpx -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from server.config import settings from server.embeddings import get_embedding_provider @@ -19,7 +19,7 @@ USAGE_OVERFETCH = 5 -def register_search_tools(mcp: FastMCP) -> None: +def register_search_tools(mcp: MCPServer) -> None: @mcp.tool() async def search_code( diff --git a/server/tools/stats.py b/server/tools/stats.py index b65928e..25bdfd7 100644 --- a/server/tools/stats.py +++ b/server/tools/stats.py @@ -2,7 +2,7 @@ import logging -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from server.config import settings from server.state import get_service_registry, get_store @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -def register_stats_tools(mcp: FastMCP) -> None: +def register_stats_tools(mcp: MCPServer) -> None: @mcp.tool() async def list_indexed_services() -> str: diff --git a/tests/test_reindex_route.py b/tests/test_reindex_route.py index 08a04c3..2ec21ac 100644 --- a/tests/test_reindex_route.py +++ b/tests/test_reindex_route.py @@ -5,7 +5,7 @@ import httpx import pytest -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from server.indexer.pipeline import ProgressEvent from server.routes.reindex import register_http_routes @@ -19,7 +19,7 @@ @pytest.fixture def app(): - mcp = FastMCP("test") + mcp = MCPServer("test") register_http_routes(mcp) return mcp.streamable_http_app() diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py index 459c05e..bdf112d 100644 --- a/tests/tools/conftest.py +++ b/tests/tools/conftest.py @@ -4,13 +4,13 @@ from dataclasses import dataclass, field from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -def get_tool(register: Callable[[FastMCP], None], name: str) -> Callable: - """Register a tool module against a throwaway FastMCP instance and return +def get_tool(register: Callable[[MCPServer], None], name: str) -> Callable: + """Register a tool module against a throwaway MCPServer instance and return the underlying async function, bypassing MCP's request/response plumbing.""" - mcp = FastMCP("test") + mcp = MCPServer("test") register(mcp) return mcp._tool_manager._tools[name].fn diff --git a/uv.lock b/uv.lock index a30bab4..e691efb 100644 --- a/uv.lock +++ b/uv.lock @@ -424,6 +424,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -445,12 +458,29 @@ http2 = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -484,11 +514,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -554,15 +584,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -572,9 +602,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -761,6 +804,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/4e/1c9df57496409dc86b320bd38f29ad7a34b7115e4f35b8fca44a827568a7/onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212", size = 18021249, upload-time = "2026-04-27T22:00:18.954Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -1303,7 +1358,7 @@ wheels = [ [[package]] name = "semcode" -version = "1.0.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "fastembed" }, @@ -1351,7 +1406,7 @@ dev = [ requires-dist = [ { name = "fastembed", specifier = ">=0.4.0" }, { name = "httpx", specifier = ">=0.28.0" }, - { name = "mcp", specifier = ">=1.28.1" }, + { name = "mcp", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "qdrant-client", specifier = ">=1.18.0" }, @@ -1879,6 +1934,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/c7/dcf3ea1c4f5da9b10353b9af4455d756c92d728a8f58f03c480d3ef0ead5/tree_sitter_yaml-0.7.2-cp310-abi3-win_arm64.whl", hash = "sha256:f63c227b18e7ce7587bce124578f0bbf1f890ac63d3e3cd027417574273642c4", size = 44065, upload-time = "2025-10-07T14:40:35.337Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.25.1"