From 7921cfd32f7c21e57f829daaa9417b1eb2535eaa Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark (CryptoJones)" Date: Sat, 1 Aug 2026 04:37:23 -0500 Subject: [PATCH] feat!: adopt MCP revision 2026-07-28 via the mcp 2.x SDK Moves omind to the stateless MCP revision. FastMCP -> MCPServer; the tool decorators and every tool's behaviour are unchanged, and a v2 server still serves 2025-era clients from the same process, so existing clients keep working. The <2.0 cap on mcp (#131) did exactly what it was added for: it held the fleet on 1.x until this landed deliberately. Re-capped at <3.0. Fixes two real transport bugs surfaced by the upgrade: - `omind node` went SILENT over stdio. Our fd-readiness transport parsed lines with mcp.types.JSONRPCMessage.model_validate_json, but in 2.x JSONRPCMessage is a plain union alias with no such method. The AttributeError was swallowed into the read stream, so every request hung until timeout rather than erroring. It now parses through the SDK's jsonrpc_message_adapter, matching the SDK's own stdio transport. - Replies serialized with exclude_none=True; switched to exclude_unset=True (what the SDK transport uses), which is what keeps the 2026-07-28 envelope fields - resultType, ttlMs, cacheScope - on the wire. We keep our own fd-readiness transport: the 2.x stdio_server still uses anyio.wrap_file, which is the thing it was written to avoid. Tests: the in-process helper follows call_tool's new CallToolResult return (it was a (content, structured) tuple). 842 tests pass; ruff + mypy clean. mcp-conformance 0.2.0 reports 17 contracts passing, 1 skipped (injection, deliberately unset). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 20 ++++++++++++++++ pyproject.toml | 5 +++- src/omind/server.py | 21 ++++++++++------ tests/test_server.py | 57 +++++++++++++++++++++++--------------------- uv.lock | 50 +++++++++++++++++++++++++------------- 5 files changed, 101 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f0990..bed9f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Adopt MCP revision `2026-07-28` (the stateless revision) by moving to the + `mcp` 2.x SDK** (`mcp>=2.0.0,<3.0`; the previous `<2.0` cap did its job and + held the fleet at 1.x until this was reviewed). `FastMCP` is now `MCPServer`; + the tool decorators and every tool's behaviour are unchanged, and a v2 server + still serves 2025-era clients from the same process, so existing MCP clients + keep working. Verified green against `mcp-conformance` 0.2.0 (17 contracts). + +### Fixed +- **The `omind node` stdio transport went silent under the 2.x SDK.** Our + fd-readiness transport parsed lines with + `mcp.types.JSONRPCMessage.model_validate_json`, but in 2.x `JSONRPCMessage` is + a plain union alias with no such method; the resulting `AttributeError` was + swallowed into the read stream and every request hung until timeout. It now + parses through the SDK's `jsonrpc_message_adapter`, matching the SDK's own + stdio transport. +- The same transport serialized replies with `exclude_none=True`; switched to + `exclude_unset=True` (what the SDK's transport uses), which is what keeps the + 2026-07-28 envelope fields — `resultType`, `ttlMs`, `cacheScope` — on the wire. + ### Removed - Remove the deprecated `graph-path`, `graph-orphans`, `graph-dangling`, and `graph-stats` MCP compatibility aliases after the 5.0 bridge release. Use the diff --git a/pyproject.toml b/pyproject.toml index 5d9b875..a4f914d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,10 @@ dependencies = [ "fastapi>=0.110,<1.0", "uvicorn[standard]>=0.29,<1.0", "pyyaml>=6.0,<7.0", - "mcp>=1.28.1,<2.0", + # Deliberately stepped to the mcp 2.x major (the cap above did its job and + # held the fleet at 1.x until this was reviewed): 2.0 implements MCP + # revision 2026-07-28, the stateless revision. Re-capped at the next major. + "mcp>=2.0.0,<3.0", "tomlkit>=0.12,<1.0", # Security floors for runtime transitives pulled by fastapi/mcp. "cryptography>=48.0.1,<50.0", diff --git a/src/omind/server.py b/src/omind/server.py index 755a2f5..55bb5b0 100644 --- a/src/omind/server.py +++ b/src/omind/server.py @@ -26,7 +26,7 @@ import anyio import mcp.types as mcp_types from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.shared.message import SessionMessage from omind import graph @@ -63,7 +63,10 @@ async def _fd_stdio_server() -> AsyncIterator[ async def send_line(line: str) -> None: try: - message = mcp_types.JSONRPCMessage.model_validate_json(line) + # mcp 2.x: JSONRPCMessage is a plain union alias, not a RootModel — + # it has no .model_validate_json. Parse through the SDK's TypeAdapter, + # with the same flags the SDK's own stdio transport uses. + message = mcp_types.jsonrpc_message_adapter.validate_json(line, by_name=False) except Exception as exc: await read_stream_writer.send(exc) return @@ -121,8 +124,12 @@ async def stdout_writer() -> None: try: async with write_stream_reader: async for session_message in write_stream_reader: + # exclude_unset (not exclude_none) matches the SDK's own + # stdio transport. It is what keeps the 2026-07-28 envelope + # fields the server explicitly set — resultType, ttlMs, + # cacheScope — on the wire. payload = session_message.message.model_dump_json( - by_alias=True, exclude_none=True + by_alias=True, exclude_unset=True ) await write_all((payload + "\n").encode("utf-8")) except (anyio.ClosedResourceError, BrokenPipeError): # pragma: no cover @@ -171,7 +178,7 @@ def _parse_action_items(items: list[str]) -> list[ActionItem]: return parsed -def build_server(omi_dir: Path | str, node_id: str | None = None) -> FastMCP: +def build_server(omi_dir: Path | str, node_id: str | None = None) -> MCPServer: """Build the node MCP server over one OMI folder. ``node_id`` (from the mesh config, when initialized) turns on Lamport @@ -182,7 +189,7 @@ def build_server(omi_dir: Path | str, node_id: str | None = None) -> FastMCP: # the mesh daemon, not just this server's tools. store = OmiStore(omi_dir, node_id=node_id) - mcp = FastMCP(SERVER_NAME, instructions=_INSTRUCTIONS) + mcp = MCPServer(SERVER_NAME, instructions=_INSTRUCTIONS) # The five graph tools each rebuilt the whole [[wikilink]] graph from disk # (a full-vault read+parse) on every call. Cache it, invalidated by a cheap @@ -479,10 +486,10 @@ def run_node(omi_dir: Path, node_id: str | None = None) -> int: async def run_stdio() -> None: mcp = build_server(omi_dir, node_id=node_id) async with _fd_stdio_server() as (read_stream, write_stream): - await mcp._mcp_server.run( # noqa: SLF001 - FastMCP exposes no public lower-level runner. + await mcp._lowlevel_server.run( # noqa: SLF001 - MCPServer exposes no public lower-level runner. read_stream, write_stream, - mcp._mcp_server.create_initialization_options(), # noqa: SLF001 + mcp._lowlevel_server.create_initialization_options(), # noqa: SLF001 ) anyio.run(run_stdio) diff --git a/tests/test_server.py b/tests/test_server.py index cb97ab5..613a587 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,7 +2,7 @@ # Copyright 2026 Aaron K. Clark """Tests for omind.server: the `omind node` mesh-node MCP server. -In-process tests drive FastMCP's tool layer directly; one subprocess smoke +In-process tests drive MCPServer's tool layer directly; one subprocess smoke test does a real stdio handshake and asserts the clean-exit-on-EOF contract (the regression test for the obsidian-mcp hang class, issue #49). """ @@ -19,8 +19,8 @@ from typing import Any import pytest -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.exceptions import ToolError +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from omind.paths import sync_signal_path from omind.server import build_server @@ -50,23 +50,26 @@ def omi_dir(tmp_path: Path) -> Path: @pytest.fixture -def server(omi_dir: Path) -> FastMCP: +def server(omi_dir: Path) -> MCPServer: return build_server(omi_dir, node_id="testnode-abc123") -def call(server: FastMCP, name: str, args: dict[str, Any]) -> Any: - """Invoke a tool in-process and return its structured result.""" - _content, structured = asyncio.run(server.call_tool(name, args)) - return structured +def call(server: MCPServer, name: str, args: dict[str, Any]) -> Any: + """Invoke a tool in-process and return its structured result. + + v2's ``call_tool`` returns a ``CallToolResult`` rather than v1's + ``(content, structured)`` tuple. + """ + return asyncio.run(server.call_tool(name, args)).structured_content -def test_exposes_exactly_the_designed_tools(server: FastMCP) -> None: +def test_exposes_exactly_the_designed_tools(server: MCPServer) -> None: tools = asyncio.run(server.list_tools()) assert {t.name for t in tools} == EXPECTED_TOOLS assert all(t.description for t in tools) -def test_create_read_round_trip(server: FastMCP, omi_dir: Path) -> None: +def test_create_read_round_trip(server: MCPServer, omi_dir: Path) -> None: created = call( server, "create-note", @@ -98,7 +101,7 @@ def test_create_read_round_trip(server: FastMCP, omi_dir: Path) -> None: assert "fields" not in raw -def test_edit_note_partial_update(server: FastMCP) -> None: +def test_edit_note_partial_update(server: MCPServer) -> None: call(server, "create-note", {"title": "Partial", "summary": "old", "tags": ["keep"]}) edited = call(server, "edit-note", {"name": "Partial.md", "summary": "new"}) assert edited["filename"] == "Partial.md" @@ -107,7 +110,7 @@ def test_edit_note_partial_update(server: FastMCP) -> None: assert got["fields"]["tags"] == ["keep"] # omitted fields untouched -def test_edit_note_version_conflict(server: FastMCP) -> None: +def test_edit_note_version_conflict(server: MCPServer) -> None: call(server, "create-note", {"title": "Versioned", "summary": "v1"}) stale = call(server, "read-note", {"name": "Versioned.md"})["version"] call(server, "edit-note", {"name": "Versioned.md", "summary": "v2"}) @@ -119,7 +122,7 @@ def test_edit_note_version_conflict(server: FastMCP) -> None: ) -def test_delete_archives_and_restore(server: FastMCP, omi_dir: Path) -> None: +def test_delete_archives_and_restore(server: MCPServer, omi_dir: Path) -> None: call(server, "create-note", {"title": "Archived", "summary": "s"}) deleted = call(server, "delete-note", {"name": "Archived.md"}) assert deleted == {"filename": "Archived.md", "status": "archived"} @@ -136,7 +139,7 @@ def test_delete_archives_and_restore(server: FastMCP, omi_dir: Path) -> None: assert "Archived.md" in names -def test_search_vault(server: FastMCP) -> None: +def test_search_vault(server: MCPServer) -> None: call(server, "create-note", {"title": "Alpha", "summary": "quantum cats", "tags": ["pets"]}) call(server, "create-note", {"title": "Beta", "details": "classical dogs", "tags": ["pets"]}) hits = call(server, "search-vault", {"query": "quantum"})["result"] @@ -145,7 +148,7 @@ def test_search_vault(server: FastMCP) -> None: assert {h["filename"] for h in by_tag} == {"Alpha.md", "Beta.md"} -def test_search_vault_is_bounded_and_pageable(server: FastMCP) -> None: +def test_search_vault_is_bounded_and_pageable(server: MCPServer) -> None: for number in range(7): call(server, "create-note", {"title": f"Page {number}", "summary": "shared"}) first = call(server, "search-vault", {"query": "shared", "limit": 2}) @@ -161,7 +164,7 @@ def test_search_vault_is_bounded_and_pageable(server: FastMCP) -> None: ) -def test_every_list_tool_is_bounded(server: FastMCP) -> None: +def test_every_list_tool_is_bounded(server: MCPServer) -> None: """No tool may return the whole vault in one result. `list-notes` used to: ~348 KB / 87k tokens on a 744-note vault, in a single @@ -195,7 +198,7 @@ def test_every_list_tool_is_bounded(server: FastMCP) -> None: assert page["count"] <= 1, tool -def test_search_hits_carry_an_excerpt_of_the_matched_text(server: FastMCP) -> None: +def test_search_hits_carry_an_excerpt_of_the_matched_text(server: MCPServer) -> None: """The excerpt is why a search result is often enough on its own — it shows the matched text even when the match is in a section `summary` never shows.""" call( @@ -209,7 +212,7 @@ def test_search_hits_carry_an_excerpt_of_the_matched_text(server: FastMCP) -> No assert hit["score"] > 0 -def test_recall_note_returns_one_bounded_representation(server: FastMCP) -> None: +def test_recall_note_returns_one_bounded_representation(server: MCPServer) -> None: call( server, "create-note", @@ -243,7 +246,7 @@ def test_recall_note_returns_one_bounded_representation(server: FastMCP) -> None assert section["section"] == "Details" -def test_help_tool_is_generated_from_live_cli(server: FastMCP) -> None: +def test_help_tool_is_generated_from_live_cli(server: MCPServer) -> None: result = call(server, "help", {"command": "/omind help ai usage"}) assert result["ok"] is True assert result["command"] == "omind ai usage" @@ -253,7 +256,7 @@ def test_help_tool_is_generated_from_live_cli(server: FastMCP) -> None: assert "usage" in unknown["error"] -def test_backlinks_and_tags(server: FastMCP) -> None: +def test_backlinks_and_tags(server: MCPServer) -> None: call(server, "create-note", {"title": "Hub", "summary": "s", "tags": ["one"]}) call(server, "create-note", {"title": "Spoke", "summary": "see [[Hub]]", "tags": ["two"]}) links = call(server, "backlinks", {"name": "Hub.md"})["result"] @@ -261,7 +264,7 @@ def test_backlinks_and_tags(server: FastMCP) -> None: assert call(server, "list-tags", {})["result"] == ["one", "two"] -def test_graph_tools(server: FastMCP) -> None: +def test_graph_tools(server: MCPServer) -> None: call(server, "create-note", {"title": "A", "summary": "s", "connections": ["B"]}) call(server, "create-note", {"title": "B", "summary": "s", "connections": ["C"]}) call(server, "create-note", {"title": "C", "summary": "s"}) @@ -282,7 +285,7 @@ def test_graph_tools(server: FastMCP) -> None: assert call(server, "graph", {"op": "stats"})["notes"] == 4 -def test_unified_graph_validates_operation_and_path_arguments(server: FastMCP) -> None: +def test_unified_graph_validates_operation_and_path_arguments(server: MCPServer) -> None: with pytest.raises(ToolError, match="one of"): call(server, "graph", {"op": "unknown"}) with pytest.raises(ToolError, match="requires source and target"): @@ -312,22 +315,22 @@ def counting(omi: Path) -> Any: assert calls["n"] == 2 # cache busted, rebuilt once -def test_graph_neighbors_unknown_note_is_a_tool_error(server: FastMCP) -> None: +def test_graph_neighbors_unknown_note_is_a_tool_error(server: MCPServer) -> None: with pytest.raises(ToolError, match="not found"): call(server, "graph-neighbors", {"name": "Nope"}) -def test_missing_note_is_a_tool_error(server: FastMCP) -> None: +def test_missing_note_is_a_tool_error(server: MCPServer) -> None: with pytest.raises(ToolError, match="not found"): call(server, "read-note", {"name": "Nope.md"}) -def test_traversal_is_a_tool_error(server: FastMCP) -> None: +def test_traversal_is_a_tool_error(server: MCPServer) -> None: with pytest.raises(ToolError, match="path separators"): call(server, "read-note", {"name": "../escape.md"}) -def test_writes_touch_the_sync_signal(server: FastMCP, omi_dir: Path) -> None: +def test_writes_touch_the_sync_signal(server: MCPServer, omi_dir: Path) -> None: signal = sync_signal_path(omi_dir) assert not signal.exists() call(server, "create-note", {"title": "Trigger", "summary": "s"}) @@ -337,7 +340,7 @@ def test_writes_touch_the_sync_signal(server: FastMCP, omi_dir: Path) -> None: assert signal.stat().st_mtime_ns >= first -def test_reads_do_not_touch_the_sync_signal(server: FastMCP, omi_dir: Path) -> None: +def test_reads_do_not_touch_the_sync_signal(server: MCPServer, omi_dir: Path) -> None: call(server, "create-note", {"title": "Quiet", "summary": "s"}) signal = sync_signal_path(omi_dir) signal.unlink() diff --git a/uv.lock b/uv.lock index d968adc..7193a73 100644 --- a/uv.lock +++ b/uv.lock @@ -1054,7 +1054,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1511,15 +1511,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "httpx-sse" -version = "0.4.3" -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" } -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" }, -] - [[package]] name = "httpx2" version = "2.9.1" @@ -1861,15 +1852,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'" }, @@ -1879,9 +1870,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]] @@ -2392,7 +2396,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110,<1.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.0,<3.0" }, - { name = "mcp", specifier = ">=1.28.1,<2.0" }, + { name = "mcp", specifier = ">=2.0.0,<3.0" }, { name = "model2vec", marker = "extra == 'embed'", specifier = ">=0.3,<1.0" }, { name = "msgpack", marker = "extra == 'dev'", specifier = ">=1.2.1,<2.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.9" }, @@ -2412,6 +2416,18 @@ requires-dist = [ ] provides-extras = ["e2e", "embed", "dev"] +[[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 = "packageurl-python" version = "0.17.6"