diff --git a/poetry.lock b/poetry.lock index bcbe79e8..e5bc2ea7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -58,6 +58,26 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "attrs" +version = "25.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, +] + +[package.extras] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + [[package]] name = "certifi" version = "2025.4.26" @@ -299,6 +319,43 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jsonschema" +version = "4.24.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d"}, + {file = "jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, + {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "macholib" version = "1.16.3" @@ -342,29 +399,31 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "mcp" -version = "1.8.0" +version = "1.11.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "mcp-1.8.0-py3-none-any.whl", hash = "sha256:889d9d3b4f12b7da59e7a3933a0acadae1fce498bfcd220defb590aa291a1334"}, - {file = "mcp-1.8.0.tar.gz", hash = "sha256:263dfb700540b726c093f0c3e043f66aded0730d0b51f04eb0a3eb90055fe49b"}, + {file = "mcp-1.11.0-py3-none-any.whl", hash = "sha256:58deac37f7483e4b338524b98bc949b7c2b7c33d978f5fafab5bde041c5e2595"}, + {file = "mcp-1.11.0.tar.gz", hash = "sha256:49a213df56bb9472ff83b3132a4825f5c8f5b120a90246f08b0dac6bedac44c8"}, ] [package.dependencies] anyio = ">=4.5" httpx = ">=0.27" httpx-sse = ">=0.4" -pydantic = ">=2.7.2,<3.0.0" +jsonschema = ">=4.20.0" +pydantic = ">=2.8.0,<3.0.0" pydantic-settings = ">=2.5.2" python-multipart = ">=0.0.9" +pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} sse-starlette = ">=1.6.1" starlette = ">=0.27" uvicorn = {version = ">=0.23.1", markers = "sys_platform != \"emscripten\""} [package.extras] -cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"] +cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] rich = ["rich (>=13.9.4)"] ws = ["websockets (>=15.0.1)"] @@ -770,6 +829,37 @@ files = [ {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, ] +[[package]] +name = "pywin32" +version = "311" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, + {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, + {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, + {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, + {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, + {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, + {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, + {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, + {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, + {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, + {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, + {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, + {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, + {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, + {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, + {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, + {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, + {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, + {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, + {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -846,6 +936,23 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "referencing" +version = "0.36.2" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, + {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + [[package]] name = "requests" version = "2.32.3" @@ -887,6 +994,160 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "rpds-py" +version = "0.26.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rpds_py-0.26.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4c70c70f9169692b36307a95f3d8c0a9fcd79f7b4a383aad5eaa0e9718b79b37"}, + {file = "rpds_py-0.26.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:777c62479d12395bfb932944e61e915741e364c843afc3196b694db3d669fcd0"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec671691e72dff75817386aa02d81e708b5a7ec0dec6669ec05213ff6b77e1bd"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a1cb5d6ce81379401bbb7f6dbe3d56de537fb8235979843f0d53bc2e9815a79"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f789e32fa1fb6a7bf890e0124e7b42d1e60d28ebff57fe806719abb75f0e9a3"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c55b0a669976cf258afd718de3d9ad1b7d1fe0a91cd1ab36f38b03d4d4aeaaf"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70d9ec912802ecfd6cd390dadb34a9578b04f9bcb8e863d0a7598ba5e9e7ccc"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3021933c2cb7def39d927b9862292e0f4c75a13d7de70eb0ab06efed4c508c19"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a7898b6ca3b7d6659e55cdac825a2e58c638cbf335cde41f4619e290dd0ad11"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:12bff2ad9447188377f1b2794772f91fe68bb4bbfa5a39d7941fbebdbf8c500f"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:191aa858f7d4902e975d4cf2f2d9243816c91e9605070aeb09c0a800d187e323"}, + {file = "rpds_py-0.26.0-cp310-cp310-win32.whl", hash = "sha256:b37a04d9f52cb76b6b78f35109b513f6519efb481d8ca4c321f6a3b9580b3f45"}, + {file = "rpds_py-0.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:38721d4c9edd3eb6670437d8d5e2070063f305bfa2d5aa4278c51cedcd508a84"}, + {file = "rpds_py-0.26.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9e8cb77286025bdb21be2941d64ac6ca016130bfdcd228739e8ab137eb4406ed"}, + {file = "rpds_py-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e09330b21d98adc8ccb2dbb9fc6cb434e8908d4c119aeaa772cb1caab5440a0"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9c1b92b774b2e68d11193dc39620d62fd8ab33f0a3c77ecdabe19c179cdbc1"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:824e6d3503ab990d7090768e4dfd9e840837bae057f212ff9f4f05ec6d1975e7"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ad7fd2258228bf288f2331f0a6148ad0186b2e3643055ed0db30990e59817a6"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dc23bbb3e06ec1ea72d515fb572c1fea59695aefbffb106501138762e1e915e"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80bf832ac7b1920ee29a426cdca335f96a2b5caa839811803e999b41ba9030d"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0919f38f5542c0a87e7b4afcafab6fd2c15386632d249e9a087498571250abe3"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d422b945683e409000c888e384546dbab9009bb92f7c0b456e217988cf316107"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a7711fa562ba2da1aa757e11024ad6d93bad6ad7ede5afb9af144623e5f76a"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238e8c8610cb7c29460e37184f6799547f7e09e6a9bdbdab4e8edb90986a2318"}, + {file = "rpds_py-0.26.0-cp311-cp311-win32.whl", hash = "sha256:893b022bfbdf26d7bedb083efeea624e8550ca6eb98bf7fea30211ce95b9201a"}, + {file = "rpds_py-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:87a5531de9f71aceb8af041d72fc4cab4943648d91875ed56d2e629bef6d4c03"}, + {file = "rpds_py-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:de2713f48c1ad57f89ac25b3cb7daed2156d8e822cf0eca9b96a6f990718cc41"}, + {file = "rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d"}, + {file = "rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2"}, + {file = "rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44"}, + {file = "rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c"}, + {file = "rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8"}, + {file = "rpds_py-0.26.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:696764a5be111b036256c0b18cd29783fab22154690fc698062fc1b0084b511d"}, + {file = "rpds_py-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6c15d2080a63aaed876e228efe4f814bc7889c63b1e112ad46fdc8b368b9e1"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390e3170babf42462739a93321e657444f0862c6d722a291accc46f9d21ed04e"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7da84c2c74c0f5bc97d853d9e17bb83e2dcafcff0dc48286916001cc114379a1"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c5fe114a6dd480a510b6d3661d09d67d1622c4bf20660a474507aaee7eeeee9"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3100b3090269f3a7ea727b06a6080d4eb7439dca4c0e91a07c5d133bb1727ea7"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c03c9b0c64afd0320ae57de4c982801271c0c211aa2d37f3003ff5feb75bb04"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5963b72ccd199ade6ee493723d18a3f21ba7d5b957017607f815788cef50eaf1"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da4e873860ad5bab3291438525cae80169daecbfafe5657f7f5fb4d6b3f96b9"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5afaddaa8e8c7f1f7b4c5c725c0070b6eed0228f705b90a1732a48e84350f4e9"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4916dc96489616a6f9667e7526af8fa693c0fdb4f3acb0e5d9f4400eb06a47ba"}, + {file = "rpds_py-0.26.0-cp313-cp313-win32.whl", hash = "sha256:2a343f91b17097c546b93f7999976fd6c9d5900617aa848c81d794e062ab302b"}, + {file = "rpds_py-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a0b60701f2300c81b2ac88a5fb893ccfa408e1c4a555a77f908a2596eb875a5"}, + {file = "rpds_py-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:257d011919f133a4746958257f2c75238e3ff54255acd5e3e11f3ff41fd14256"}, + {file = "rpds_py-0.26.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:529c8156d7506fba5740e05da8795688f87119cce330c244519cf706a4a3d618"}, + {file = "rpds_py-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f53ec51f9d24e9638a40cabb95078ade8c99251945dad8d57bf4aabe86ecee35"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab504c4d654e4a29558eaa5bb8cea5fdc1703ea60a8099ffd9c758472cf913f"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd0641abca296bc1a00183fe44f7fced8807ed49d501f188faa642d0e4975b83"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b312fecc1d017b5327afa81d4da1480f51c68810963a7336d92203dbb3d4f1"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c741107203954f6fc34d3066d213d0a0c40f7bb5aafd698fb39888af277c70d8"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3e55a7db08dc9a6ed5fb7103019d2c1a38a349ac41901f9f66d7f95750942f"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e851920caab2dbcae311fd28f4313c6953993893eb5c1bb367ec69d9a39e7ed"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dfbf280da5f876d0b00c81f26bedce274e72a678c28845453885a9b3c22ae632"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1cc81d14ddfa53d7f3906694d35d54d9d3f850ef8e4e99ee68bc0d1e5fed9a9c"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dca83c498b4650a91efcf7b88d669b170256bf8017a5db6f3e06c2bf031f57e0"}, + {file = "rpds_py-0.26.0-cp313-cp313t-win32.whl", hash = "sha256:4d11382bcaf12f80b51d790dee295c56a159633a8e81e6323b16e55d81ae37e9"}, + {file = "rpds_py-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff110acded3c22c033e637dd8896e411c7d3a11289b2edf041f86663dbc791e9"}, + {file = "rpds_py-0.26.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:da619979df60a940cd434084355c514c25cf8eb4cf9a508510682f6c851a4f7a"}, + {file = "rpds_py-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea89a2458a1a75f87caabefe789c87539ea4e43b40f18cff526052e35bbb4fdf"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feac1045b3327a45944e7dcbeb57530339f6b17baff154df51ef8b0da34c8c12"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b818a592bd69bfe437ee8368603d4a2d928c34cffcdf77c2e761a759ffd17d20"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a8b0dd8648709b62d9372fc00a57466f5fdeefed666afe3fea5a6c9539a0331"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d3498ad0df07d81112aa6ec6c95a7e7b1ae00929fb73e7ebee0f3faaeabad2f"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4146ccb15be237fdef10f331c568e1b0e505f8c8c9ed5d67759dac58ac246"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9a63785467b2d73635957d32a4f6e73d5e4df497a16a6392fa066b753e87387"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de4ed93a8c91debfd5a047be327b7cc8b0cc6afe32a716bbbc4aedca9e2a83af"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:caf51943715b12af827696ec395bfa68f090a4c1a1d2509eb4e2cb69abbbdb33"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a59e5bc386de021f56337f757301b337d7ab58baa40174fb150accd480bc953"}, + {file = "rpds_py-0.26.0-cp314-cp314-win32.whl", hash = "sha256:92c8db839367ef16a662478f0a2fe13e15f2227da3c1430a782ad0f6ee009ec9"}, + {file = "rpds_py-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:b0afb8cdd034150d4d9f53926226ed27ad15b7f465e93d7468caaf5eafae0d37"}, + {file = "rpds_py-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:ca3f059f4ba485d90c8dc75cb5ca897e15325e4e609812ce57f896607c1c0867"}, + {file = "rpds_py-0.26.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5afea17ab3a126006dc2f293b14ffc7ef3c85336cf451564a0515ed7648033da"}, + {file = "rpds_py-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69f0c0a3df7fd3a7eec50a00396104bb9a843ea6d45fcc31c2d5243446ffd7a7"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:801a71f70f9813e82d2513c9a96532551fce1e278ec0c64610992c49c04c2dad"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df52098cde6d5e02fa75c1f6244f07971773adb4a26625edd5c18fee906fa84d"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bc596b30f86dc6f0929499c9e574601679d0341a0108c25b9b358a042f51bca"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9dfbe56b299cf5875b68eb6f0ebaadc9cac520a1989cac0db0765abfb3709c19"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac64f4b2bdb4ea622175c9ab7cf09444e412e22c0e02e906978b3b488af5fde8"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:181ef9b6bbf9845a264f9aa45c31836e9f3c1f13be565d0d010e964c661d1e2b"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:49028aa684c144ea502a8e847d23aed5e4c2ef7cadfa7d5eaafcb40864844b7a"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e5d524d68a474a9688336045bbf76cb0def88549c1b2ad9dbfec1fb7cfbe9170"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1851f429b822831bd2edcbe0cfd12ee9ea77868f8d3daf267b189371671c80e"}, + {file = "rpds_py-0.26.0-cp314-cp314t-win32.whl", hash = "sha256:7bdb17009696214c3b66bb3590c6d62e14ac5935e53e929bcdbc5a495987a84f"}, + {file = "rpds_py-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f14440b9573a6f76b4ee4770c13f0b5921f71dde3b6fcb8dabbefd13b7fe05d7"}, + {file = "rpds_py-0.26.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7a48af25d9b3c15684059d0d1fc0bc30e8eee5ca521030e2bffddcab5be40226"}, + {file = "rpds_py-0.26.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c71c2f6bf36e61ee5c47b2b9b5d47e4d1baad6426bfed9eea3e858fc6ee8806"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d815d48b1804ed7867b539236b6dd62997850ca1c91cad187f2ddb1b7bbef19"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84cfbd4d4d2cdeb2be61a057a258d26b22877266dd905809e94172dff01a42ae"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbaa70553ca116c77717f513e08815aec458e6b69a028d4028d403b3bc84ff37"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39bfea47c375f379d8e87ab4bb9eb2c836e4f2069f0f65731d85e55d74666387"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1533b7eb683fb5f38c1d68a3c78f5fdd8f1412fa6b9bf03b40f450785a0ab915"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c5ab0ee51f560d179b057555b4f601b7df909ed31312d301b99f8b9fc6028284"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e5162afc9e0d1f9cae3b577d9c29ddbab3505ab39012cb794d94a005825bde21"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:43f10b007033f359bc3fa9cd5e6c1e76723f056ffa9a6b5c117cc35720a80292"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e3730a48e5622e598293eee0762b09cff34dd3f271530f47b0894891281f051d"}, + {file = "rpds_py-0.26.0-cp39-cp39-win32.whl", hash = "sha256:4b1f66eb81eab2e0ff5775a3a312e5e2e16bf758f7b06be82fb0d04078c7ac51"}, + {file = "rpds_py-0.26.0-cp39-cp39-win_amd64.whl", hash = "sha256:519067e29f67b5c90e64fb1a6b6e9d2ec0ba28705c51956637bac23a2f4ddae1"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3c0909c5234543ada2515c05dc08595b08d621ba919629e94427e8e03539c958"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c1fb0cda2abcc0ac62f64e2ea4b4e64c57dfd6b885e693095460c61bde7bb18e"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d142d2d6cf9b31c12aa4878d82ed3b2324226270b89b676ac62ccd7df52d08"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a547e21c5610b7e9093d870be50682a6a6cf180d6da0f42c47c306073bfdbbf6"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35e9a70a0f335371275cdcd08bc5b8051ac494dd58bff3bbfb421038220dc871"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dfa6115c6def37905344d56fb54c03afc49104e2ca473d5dedec0f6606913b4"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:313cfcd6af1a55a286a3c9a25f64af6d0e46cf60bc5798f1db152d97a216ff6f"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7bf2496fa563c046d05e4d232d7b7fd61346e2402052064b773e5c378bf6f73"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aa81873e2c8c5aa616ab8e017a481a96742fdf9313c40f14338ca7dbf50cb55f"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:68ffcf982715f5b5b7686bdd349ff75d422e8f22551000c24b30eaa1b7f7ae84"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6188de70e190847bb6db3dc3981cbadff87d27d6fe9b4f0e18726d55795cee9b"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1c962145c7473723df9722ba4c058de12eb5ebedcb4e27e7d902920aa3831ee8"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f61a9326f80ca59214d1cceb0a09bb2ece5b2563d4e0cd37bfd5515c28510674"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:183f857a53bcf4b1b42ef0f57ca553ab56bdd170e49d8091e96c51c3d69ca696"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:941c1cfdf4799d623cf3aa1d326a6b4fdb7a5799ee2687f3516738216d2262fb"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72a8d9564a717ee291f554eeb4bfeafe2309d5ec0aa6c475170bdab0f9ee8e88"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511d15193cbe013619dd05414c35a7dedf2088fcee93c6bbb7c77859765bd4e8"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1f9741b603a8d8fedb0ed5502c2bc0accbc51f43e2ad1337fe7259c2b77a5"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4019a9d473c708cf2f16415688ef0b4639e07abaa569d72f74745bbeffafa2c7"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:093d63b4b0f52d98ebae33b8c50900d3d67e0666094b1be7a12fffd7f65de74b"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2abe21d8ba64cded53a2a677e149ceb76dcf44284202d737178afe7ba540c1eb"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:4feb7511c29f8442cbbc28149a92093d32e815a28aa2c50d333826ad2a20fdf0"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a90a13408a7a856b87be8a9f008fff53c5080eea4e4180f6c2e546e4a972fb5d"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ac51b65e8dc76cf4949419c54c5528adb24fc721df722fd452e5fbc236f5c40"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59b2093224a18c6508d95cfdeba8db9cbfd6f3494e94793b58972933fcee4c6d"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f01a5d6444a3258b00dc07b6ea4733e26f8072b788bef750baa37b370266137"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6e2c12160c72aeda9d1283e612f68804621f448145a210f1bf1d79151c47090"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb28c1f569f8d33b2b5dcd05d0e6ef7005d8639c54c2f0be824f05aedf715255"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1766b5724c3f779317d5321664a343c07773c8c5fd1532e4039e6cc7d1a815be"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b6d9e5a2ed9c4988c8f9b28b3bc0e3e5b1aaa10c28d210a594ff3a8c02742daf"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:b5f7a446ddaf6ca0fad9a5535b56fbfc29998bf0e0b450d174bbec0d600e1d72"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:eed5ac260dd545fbc20da5f4f15e7efe36a55e0e7cf706e4ec005b491a9546a0"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:582462833ba7cee52e968b0341b85e392ae53d44c0f9af6a5927c80e539a8b67"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:69a607203441e07e9a8a529cff1d5b73f6a160f22db1097211e6212a68567d11"}, + {file = "rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0"}, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -1067,4 +1328,4 @@ test = ["websockets"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.14" -content-hash = "34026d3e2b94f93fc93d6dd357653ef1a1ff1d0521e5efb6200c77bdacf50085" +content-hash = "ccbc3053e60fae1ceaf480a5f286bd4656a9c2f27c9a5b1f1b15d7716c0d10af" diff --git a/pyproject.toml b/pyproject.toml index ca66b6b3..2bff91cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ prompt-toolkit = "^3.0.43" rich = "^13.7.1" platformdirs = "^4.2.0" pyyaml = "^6.0.2" -mcp = "1.8.0" +mcp = "1.11.0" ## POS dependencies urllib3 = ">= 1.25.3" diff --git a/src/pieces/_vendor/pieces_os_client/wrapper/websockets/base_websocket.py b/src/pieces/_vendor/pieces_os_client/wrapper/websockets/base_websocket.py index 743632e0..a6d36978 100644 --- a/src/pieces/_vendor/pieces_os_client/wrapper/websockets/base_websocket.py +++ b/src/pieces/_vendor/pieces_os_client/wrapper/websockets/base_websocket.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional,TYPE_CHECKING, List +from typing import Callable, Optional,TYPE_CHECKING, List, Self import websocket import threading from abc import ABC, abstractmethod @@ -149,7 +149,7 @@ def is_running(cls) -> bool: return False @classmethod - def get_instance(cls) -> Optional[type]: + def get_instance(cls) -> Optional[Self]: """ Get the singleton instance of the class. diff --git a/src/pieces/command_interface/open_command.py b/src/pieces/command_interface/open_command.py index 3304ba2e..6bb84f1a 100644 --- a/src/pieces/command_interface/open_command.py +++ b/src/pieces/command_interface/open_command.py @@ -54,6 +54,12 @@ def add_arguments(self, parser: argparse.ArgumentParser): action="store_true", help="Opens Pieces Settings", ) + parser.add_argument( + "--ltm", + dest="ltm", + action="store_true", + help="Opens Pieces LTM (Long-Term Memory)", + ) def execute(self, **kwargs) -> int: """Execute the open command.""" diff --git a/src/pieces/command_interface/simple_commands.py b/src/pieces/command_interface/simple_commands.py index 76169d10..64784886 100644 --- a/src/pieces/command_interface/simple_commands.py +++ b/src/pieces/command_interface/simple_commands.py @@ -1,4 +1,5 @@ import argparse +from urllib3.exceptions import MaxRetryError from pieces.base_command import BaseCommand from pieces.headless.models.base import CommandResult from pieces.headless.models.version import create_version_success @@ -205,3 +206,39 @@ def execute(self, **kwargs) -> CommandResult: return CommandResult( 0, create_version_success(__version__, Settings.pieces_os_version) ) + + +class RestartPiecesOSCommand(BaseCommand): + """Command to restart PiecesOS.""" + + def get_name(self) -> str: + return "restart" + + def get_help(self) -> str: + return "Restart PiecesOS" + + def get_description(self) -> str: + return "Restart the PiecesOS" + + def get_examples(self) -> list[str]: + return ["pieces restart"] + + def get_docs(self) -> str: + return URLs.CLI_RESTART_DOCS.value + + def add_arguments(self, parser: argparse.ArgumentParser): + pass + + def execute(self, **kwargs) -> int: + try: + Settings.pieces_client.os_api.os_restart() + except MaxRetryError: + pass + if Settings.pieces_client.is_pieces_running(15): + Settings.logger.print("[green]PiecesOS restarted successfully.") + return 0 + else: + Settings.logger.print( + "[red]Failed to restart PiecesOS. Please run `pieces open`." + ) + return 1 diff --git a/src/pieces/copilot/ltm.py b/src/pieces/copilot/ltm.py index 759bcab4..855801c3 100644 --- a/src/pieces/copilot/ltm.py +++ b/src/pieces/copilot/ltm.py @@ -115,7 +115,7 @@ def capture(application): application.exit(result=s if s.dimensions else None) -def check_ltm(docs=None) -> bool: +def check_ltm(docs=None, auto_enable=False) -> bool: # Update the local cache Settings.pieces_client.copilot.context.ltm.ltm_status = Settings.pieces_client.work_stream_pattern_engine_api.workstream_pattern_engine_processors_vision_status() if Settings.pieces_client.copilot.context.ltm.is_enabled: @@ -124,7 +124,7 @@ def check_ltm(docs=None) -> bool: if Settings.headless_mode: raise HeadlessLTMNotEnabledError() - if not Settings.logger.confirm( + if not auto_enable and not Settings.logger.confirm( "Pieces LTM must be running, do you want to enable it?", ): return False @@ -277,8 +277,8 @@ def _open_ltm(): Settings.show_error(f"Error in enabling the LTM: {e}") -def enable_ltm(): - if check_ltm(): +def enable_ltm(auto_enable: bool = False) -> bool: + if check_ltm(None, auto_enable): # window = add_qrcodes() # TODO: Clean at exist # if not window: # Settings.show_error( diff --git a/src/pieces/core/open_command.py b/src/pieces/core/open_command.py index 1e9780ee..a4236b2f 100644 --- a/src/pieces/core/open_command.py +++ b/src/pieces/core/open_command.py @@ -1,5 +1,6 @@ from pieces.urls import URLs from pieces.settings import Settings +from pieces.copilot.ltm import enable_ltm def open_command(**kwargs): from pieces._vendor.pieces_os_client.models.inactive_os_server_applet import InactiveOSServerApplet @@ -7,14 +8,19 @@ def open_command(**kwargs): copilot = kwargs.get("copilot", False) drive = kwargs.get("drive", False,) settings = kwargs.get("settings", False) + ltm = kwargs.get("ltm", False) # Let's try to Open POS health = Settings.pieces_client.open_pieces_os() - if (drive or copilot or settings) and not health: + if (drive or copilot or settings or ltm) and not health: Settings.logger.print("PiecesOS is not running") return + if ltm and enable_ltm(auto_enable=True): + Settings.logger.print("[green]LTM is enabled and running[/green]") + + if copilot: URLs.open_website( "localhost:" diff --git a/src/pieces/mcp/gateway.py b/src/pieces/mcp/gateway.py index 75c3547f..91de46fb 100644 --- a/src/pieces/mcp/gateway.py +++ b/src/pieces/mcp/gateway.py @@ -1,6 +1,17 @@ import asyncio +import hashlib +import signal +import threading +from typing import Tuple, Callable, Awaitable from pieces.mcp.utils import get_mcp_latest_url +from pieces.mcp.tools_cache import PIECES_MCP_TOOLS_CACHE from pieces.settings import Settings +from .._vendor.pieces_os_client.wrapper.version_compatibility import ( + UpdateEnum, + VersionChecker, +) +from .._vendor.pieces_os_client.wrapper.websockets.health_ws import HealthWS +from .._vendor.pieces_os_client.wrapper.websockets.ltm_vision_ws import LTMVisionWS from mcp.client.sse import sse_client from mcp import ClientSession from mcp.server import Server @@ -13,19 +24,278 @@ class PosMcpConnection: """Manages connection to the Pieces MCP server.""" - def __init__(self, upstream_url): - self.upstream_url = upstream_url + def __init__( + self, upstream_url: str, tools_changed_callback: Callable[[], Awaitable[None]] + ): + self.upstream_url = ( + upstream_url # Can be None if PiecesOS wasn't running at startup + ) self.session = None self.sse_client = None self.discovered_tools = [] self.connection_lock = asyncio.Lock() + self._pieces_os_running = None + self._ltm_enabled = None + self.result = None + self._previous_tools_hash = None + self._tools_changed_callback = tools_changed_callback + self._health_check_lock = threading.Lock() - async def connect(self): + def _try_get_upstream_url(self): + """Try to get the upstream URL if we don't have it yet.""" + if self.upstream_url is None: + if Settings.pieces_client.is_pieces_running(): + try: + self.upstream_url = get_mcp_latest_url() + return True + except: # noqa: E722 + pass + return False + return True + + async def _cleanup_stale_session(self): + """Clean up a stale session and its resources.""" + try: + if self.session: + try: + await self.session.__aexit__(None, None, None) + except Exception as e: + Settings.logger.debug(f"Error cleaning up stale session: {e}") + except Exception as e: + Settings.logger.debug(f"Error accessing stale session: {e}") + + try: + if self.sse_client: + try: + await self.sse_client.__aexit__(None, None, None) + except Exception as e: + Settings.logger.debug(f"Error cleaning up stale SSE client: {e}") + except Exception as e: + Settings.logger.debug(f"Error accessing stale SSE client: {e}") + + # Reset connection state + self.session = None + self.sse_client = None + self.discovered_tools = [] + + def _check_version_compatibility(self) -> Tuple[bool, str]: + """ + Check if the PiecesOS version is compatible with the MCP server. + + Returns: + Tuple[bool, str]: A tuple containing a boolean indicating compatibility, str: message if it is not compatible. + """ + if not self.result: + self.result = VersionChecker( + Settings.PIECES_OS_MIN_VERSION, + Settings.PIECES_OS_MAX_VERSION, + Settings.pieces_client.version, + ).version_check() + + if self.result.compatible: + return True, "" + + # These messages are sent to the llm to update the respective tool + if self.result.update == UpdateEnum.Plugin: + return ( + False, + "Please update the CLI version to be able to run the tool call, Run 'pieces manage update' to get the latest version. Then retry your request again after updating.", + ) + else: + return ( + False, + "Please update PiecesOS to a compatible version to be able to run the tool call. Run 'pieces update' to get the latest version. Then retry your request again after updating.", + ) + + def _check_pieces_os_status(self): + """Check if PiecesOS is running using health WebSocket""" + with self._health_check_lock: + # First check if already connected + if ( + HealthWS.is_running() + and hasattr(Settings.pieces_client, "is_pos_stream_running") + and Settings.pieces_client.is_pos_stream_running + ): + return True + + # Check if PiecesOS is available + if not Settings.pieces_client.is_pieces_running(2): + return False + + try: + health_ws = HealthWS.get_instance() + if health_ws: + health_ws.start() + + # Update LTM status cache + Settings.pieces_client.copilot.context.ltm.ltm_status = Settings.pieces_client.work_stream_pattern_engine_api.workstream_pattern_engine_processors_vision_status() + return True + except Exception as e: + Settings.logger.debug(f"Failed to start health WebSocket: {e}") + return False + + def _check_ltm_status(self): + """Check if LTM is enabled.""" + return Settings.pieces_client.copilot.context.ltm.is_enabled + + def _validate_system_status(self, tool_name: str) -> tuple[bool, str]: + """ + Perform 3-step validation before executing any command: + 1. Check health WebSocket + 2. Check compatibility + 3. Check LTM (for LTM tools) + + Returns: + tuple[bool, str]: (is_valid, error_message) + """ + # Step 1: Check health WebSocket / PiecesOS status + if not self._check_pieces_os_status(): + return False, ( + "PiecesOS is not running. To use this tool, please run:\n\n" + "`pieces open`\n\n" + "This will start PiecesOS, then you can retry your request." + ) + + # Step 2: Check version compatibility + is_compatible, compatibility_message = self._check_version_compatibility() + if not is_compatible: + return False, compatibility_message + + # Step 3: Check LTM status (only for LTM-related tools) + if tool_name in ["ask_pieces_ltm", "create_pieces_memory"]: + ltm_enabled = self._check_ltm_status() + if not ltm_enabled: + return False, ( + "PiecesOS is running but Long Term Memory (LTM) is not enabled. " + "To use this tool, please run:\n\n" + "`pieces open --ltm`\n\n" + "This will enable LTM, then you can retry your request." + ) + + # All checks passed + return True, "" + + def _get_error_message_for_tool(self, tool_name: str) -> str: + """Get appropriate error message based on the tool and system status.""" + # Use the 3-step validation system + is_valid, error_message = self._validate_system_status(tool_name) + + if not is_valid: + return error_message + tool_name = self._sanitize_tool_name(tool_name) + # If all validations pass but we still have an error, return generic message + + return ( + f"Unable to execute '{tool_name}' tool. Please ensure PiecesOS is running " + "and try again. If the problem persists, run:\n\n" + "`pieces restart`" + ) + + def _sanitize_tool_name(self, tool_name: str) -> str: + """Sanitize tool name for safe inclusion in messages.""" + import re + + # Remove control characters and limit length + sanitized = re.sub(r"[^\w\s\-_.]", "", tool_name) + return sanitized[:100] # Limit to reasonable length + + def _get_tools_hash(self, tools): + """Generate a hash of the tools list for change detection.""" + if not tools: + return None + + # Create a stable hash using SHA256 + hasher = hashlib.sha256() + + # Sort tools by name for consistency + sorted_tools = sorted(tools, key=lambda t: t.name) + + for tool in sorted_tools: + # Use truncated description to catch content changes while avoiding memory issues + description = tool.description or "" + truncated_desc = ( + description[:200] if len(description) > 200 else description + ) + tool_sig = f"{tool.name}:{truncated_desc}" + hasher.update(tool_sig.encode("utf-8")) + + return hasher.hexdigest() + + def _tools_have_changed(self, new_tools): + """Check if the tools have changed since last check.""" + new_hash = self._get_tools_hash(new_tools) + if self._previous_tools_hash is None: + # First time, consider as changed if we have tools + self._previous_tools_hash = new_hash + return bool(new_tools) + + if new_hash != self._previous_tools_hash: + Settings.logger.debug( + f"Tools changed: old hash {self._previous_tools_hash}, new hash {new_hash}" + ) + self._previous_tools_hash = new_hash + return True + return False + + async def update_tools(self, session, send_notification: bool = True): + """Fetch tools from the session and handle change detection.""" + try: + self.tools = await session.list_tools() + new_discovered_tools = [ + tool[1] for tool in self.tools if tool[0] == "tools" + ][0] + + # Check if tools have changed + tools_changed = self._tools_have_changed(new_discovered_tools) + + # Clean up old tool data if changed + if tools_changed and self.discovered_tools: + # Clear references to old tools to prevent memory buildup + self.discovered_tools.clear() + + self.discovered_tools = new_discovered_tools + + Settings.logger.info( + f"Discovered {len(self.discovered_tools)} tools from upstream server" + ) + + # If tools changed, call the callback + if send_notification and tools_changed: + try: + Settings.logger.info("Tools have changed - sending notification") + await self._tools_changed_callback() + except Exception as e: + Settings.logger.error(f"Error in tools changed callback: {e}") + + except Exception as e: + Settings.logger.error(f"Error fetching tools: {e}", exc_info=True) + raise + + async def connect(self, send_notification: bool = True): """Ensures a connection to the POS server exists and returns it.""" async with self.connection_lock: if self.session is not None: - Settings.logger.debug("Using existing upstream connection") - return self.session + # Validate the existing session is still alive + try: + await ( + self.session.send_ping() + ) # TODO: Uncomment when ping is implemented + Settings.logger.debug("Using existing upstream connection") + return self.session + except Exception as e: + Settings.logger.debug( + f"Existing connection is stale: {e}, creating new connection" + ) + # Clean up the stale connection + await self._cleanup_stale_session() + self.session = None + self.sse_client = None + + # Try to get upstream URL if we don't have it + if not self._try_get_upstream_url(): + raise ValueError( + "Cannot get MCP upstream URL - PiecesOS may not be running" + ) try: Settings.logger.info( @@ -36,17 +306,9 @@ async def connect(self): session = ClientSession(read_stream, write_stream) await session.__aenter__() - self.session = session - - self.tools = await session.list_tools() - self.discovered_tools = [ - tool[1] for tool in self.tools if tool[0] == "tools" - ][0] - Settings.logger.info( - f"Discovered {len(self.discovered_tools)} tools from upstream server" - ) - + await self.update_tools(session, send_notification) + await self.setup_notification_handler(session) return session except Exception as e: @@ -56,6 +318,23 @@ async def connect(self): ) raise + async def setup_notification_handler(self, session): + """Setup the notification handler for the session.""" + if not hasattr(self, "main_notification_handler"): + self.main_notification_handler = session._received_notification + + async def received_notification_handler( + notification: types.ServerNotification, + ): + """Handle received notifications from the SSE client.""" + Settings.logger.debug(f"Received notification: {notification.root}") + if isinstance(notification.root, types.ToolListChangedNotification): + await self.update_tools(session, send_notification=False) + await self._tools_changed_callback() + await self.main_notification_handler(notification) + + session._received_notification = received_notification_handler + async def cleanup(self): """Cleans up the upstream connection.""" async with self.connection_lock: @@ -66,19 +345,41 @@ async def cleanup(self): self.session = None self.sse_client = None - await session.__aexit__(None, None, None) + # Try to close the session first + if session: + try: + await session.__aexit__(None, None, None) + except Exception as e: + Settings.logger.debug(f"Error closing session: {e}") + + # Then close the SSE client if sse: - await sse.__aexit__(None, None, None) + try: + await sse.__aexit__(None, None, None) + except Exception as e: + Settings.logger.debug(f"Error closing SSE client: {e}") + Settings.logger.info("Closed upstream connection") except Exception as e: - Settings.logger.error( - f"Error closing upstream connection: {e}", exc_info=True - ) - sse = None - session = None + Settings.logger.debug(f"Error during connection cleanup: {e}") + finally: + self.session = None + self.sse_client = None + self.discovered_tools = [] async def call_tool(self, name, arguments): """Calls a tool on the POS MCP server.""" + Settings.logger.debug(f"Calling tool: {name}") + + # Perform 3-step validation before attempting to call tool + is_valid, error_message = self._validate_system_status(name) + if not is_valid: + Settings.logger.debug(f"Tool validation failed for {name}: {error_message}") + return types.CallToolResult( + content=[types.TextContent(type="text", text=error_message)] + ) + + # All validations passed, try to call the upstream tool try: Settings.logger.debug(f"Calling upstream tool: {name}") session = await self.connect() @@ -90,9 +391,11 @@ async def call_tool(self, name, arguments): except Exception as e: Settings.logger.error(f"Error calling POS MCP {name}: {e}", exc_info=True) - # @mark-at-pieces not sure if there is a better way to return an error + + # Return a helpful error message based on the tool and system status + error_message = self._get_error_message_for_tool(name) return types.CallToolResult( - content=[types.TextContent(type="text", text=str(e))] + content=[types.TextContent(type="text", text=error_message)] ) @@ -101,9 +404,31 @@ class MCPGateway: def __init__(self, server_name, upstream_url): self.server = Server(server_name) - self.upstream = PosMcpConnection(upstream_url) + self.upstream = PosMcpConnection( + upstream_url, self.send_tools_changed_notification + ) self.setup_handlers() + async def send_tools_changed_notification(self): + """Send a tools/list_changed notification to the client.""" + try: + ctx = self.server.request_context + await ctx.session.send_notification( + notification=types.ServerNotification( + root=types.ToolListChangedNotification( + method="notifications/tools/list_changed" + ) + ) + ) + Settings.logger.info("Sent tools/list_changed notification to client") + except LookupError: + Settings.logger.info("No active request context — can't send notification.") + except Exception as e: + Settings.logger.error(f"Failed to send tools changed notification: {e}") + Settings.logger.info( + "Tools have changed - clients will receive updated tools on next request" + ) + def setup_handlers(self): """Sets up the request handlers for the gateway server.""" Settings.logger.info("Setting up gateway request handlers") @@ -111,10 +436,28 @@ def setup_handlers(self): @self.server.list_tools() async def list_tools() -> list[types.Tool]: Settings.logger.debug("Received list_tools request") - Settings.logger.debug( - f"Discovered tools sent is {self.upstream.discovered_tools}" - ) - return self.upstream.discovered_tools + + if self.upstream._check_pieces_os_status(): + await self.upstream.connect(send_notification=False) + + Settings.logger.debug( + f"Successfully connected - returning {len(self.upstream.discovered_tools)} live tools" + ) + return self.upstream.discovered_tools + else: + # Only use cached/fallback tools when PiecesOS is not running + if self.upstream.discovered_tools: + Settings.logger.debug( + f"PiecesOS not running - returning cached tools: {len(self.upstream.discovered_tools)} tools" + ) + return self.upstream.discovered_tools + + Settings.logger.debug("PiecesOS not running - returning fallback tools") + # Use the hardcoded fallback tools + Settings.logger.debug( + f"Returning {len(PIECES_MCP_TOOLS_CACHE)} fallback tools" + ) + return PIECES_MCP_TOOLS_CACHE @self.server.call_tool() async def call_tool( @@ -131,7 +474,11 @@ async def run(self): """Runs the gateway server.""" try: Settings.logger.info("Starting MCP Gateway server") - await self.upstream.connect() + if self.upstream.upstream_url: + try: + await self.upstream.connect(send_notification=False) + except Exception as e: + Settings.logger.error(f"Failed to connect to upstream server {e}") Settings.logger.info(f"Starting stdio server for {self.server.name}") async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): @@ -140,32 +487,75 @@ async def run(self): write_stream, InitializationOptions( server_name=self.server.name, - server_version="0.1.0", # Do we need to use the cli-agent version here? @mark-at-pieces + server_version="0.2.0", capabilities=self.server.get_capabilities( - notification_options=NotificationOptions(), + notification_options=NotificationOptions( + tools_changed=True + ), experimental_capabilities={}, ), ), - raise_exceptions=True, ) + except KeyboardInterrupt: + Settings.logger.info("Gateway interrupted by user") except Exception as e: - Settings.logger.error(f"Error running gateway server: {e}", exc_info=True) + # Handle specific MCP-related errors more gracefully + if "BrokenResourceError" in str( + e + ) or "unhandled errors in a TaskGroup" in str(e): + Settings.logger.debug(f"Gateway server shutdown cleanly: {e}") + else: + Settings.logger.error( + f"Error running gateway server: {e}", exc_info=True + ) finally: # Ensure we clean up the connection when the gateway exits + # But do it in a way that doesn't interfere with stdio cleanup Settings.logger.info("Gateway shutting down, cleaning up connections") - await self.upstream.cleanup() + try: + await self.upstream.cleanup() + except Exception as e: + Settings.logger.debug(f"Error during cleanup: {e}") async def main(): - Settings.pieces_client.open_pieces_os() - Settings.startup() + # Just initialize settings without starting services + Settings.logger.info("Starting MCP Gateway") + + # Set up signal handlers for graceful shutdown + shutdown_event = asyncio.Event() + + def signal_handler(): + Settings.logger.info("Received shutdown signal") + shutdown_event.set() + + # Register signal handlers + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, lambda s, f: signal_handler()) + if hasattr(signal, "SIGINT"): + signal.signal(signal.SIGINT, lambda s, f: signal_handler()) + + ltm_vision = LTMVisionWS(Settings.pieces_client, lambda x: None) + health_ws = HealthWS( + Settings.pieces_client, lambda x: None, lambda ws: ltm_vision.start() + ) + + # Try to get the MCP URL, but continue even if it fails + upstream_url = None + if Settings.pieces_client.is_pieces_running(): + upstream_url = get_mcp_latest_url() + health_ws.start() + gateway = MCPGateway( server_name="pieces-stdio-mcp", - upstream_url=get_mcp_latest_url(), + upstream_url=upstream_url, ) - # Enable LTM - Settings.pieces_client.copilot.context.ltm.ltm_status = Settings.pieces_client.work_stream_pattern_engine_api.workstream_pattern_engine_processors_vision_status() - Settings.pieces_client.copilot.context.ltm.enable(True) - - await gateway.run() + try: + await gateway.run() + except KeyboardInterrupt: + Settings.logger.info("Gateway interrupted by user") + except Exception as e: + Settings.logger.error(f"Unexpected error in main: {e}", exc_info=True) + finally: + Settings.logger.info("MCP Gateway shutting down") diff --git a/src/pieces/mcp/tools_cache.py b/src/pieces/mcp/tools_cache.py new file mode 100644 index 00000000..7f78374d --- /dev/null +++ b/src/pieces/mcp/tools_cache.py @@ -0,0 +1,91 @@ +from mcp.types import Tool + +# Hardcoded fallback tools when PiecesOS isn't available +PIECES_MCP_TOOLS_CACHE = [ + Tool( + name="ask_pieces_ltm", + description="Ask Pieces a question to retrieve historical/contextual information from the user's environment.", + inputSchema={ + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The user's direct question for the Pieces LTM. Always include the exact user query if they request historical or contextual information.", + }, + "topics": { + "type": "array", + "items": {"type": "string"}, + "description": "An array of topical keywords extracted from the user's question, providing helpful context.", + }, + "open_files": { + "type": "array", + "items": {"type": "string"}, + "description": "List of currently open file paths or tabs within the IDE/workspace.", + }, + "application_sources": { + "type": "array", + "items": {"type": "string"}, + "description": "You will provide use with any application sources mentioned in the user query is applicable. IE if a user asks about what I was doing yesterday within Chrome, you should return chrome as one of the sources.If the user does NOT specifically ask a question about an application specific source then do NOT provide a source here.If the user asks about website or web application that could be found in either a browser or in a web application then please provide all possible sources. For instance, if I mention Notion, I could be referring the the browser or the Web application so include all browsers and the notion sources if it is included in the sources.Here is a set of the sources that you should return {Warp, Notes, \u200eWhatsApp, Mail, Claude, Obsidian, Problem Reporter, ChatGPT, Code, Cursor, kitty, Google Chrome}", + }, + "chat_llm": { + "type": "string", + "description": "This is the provided LLM that is being used to respond to the user. This is the user selected Model. for instance gpt-4o-mini.You will provide the LLM that will be used to use this information as context, Specifically the LLM that will respond directly to the user via chat.AGAIN This is the chat model that the user selected to converse with in a conversation.", + }, + "related_questions": { + "type": "array", + "items": {"type": "string"}, + "description": "This is an array of strings, that will supplement the given users question, and we will generate related questions to the original question, that will help what the user is trying to do/ the users true intent. Ensure that these questions are related and similar to what the user is asking.", + }, + "connected_client": { + "type": "string", + "description": "The name of the client that is connected to the Pieces API. for example: `Cursor`, `Claude`, `Perplexity`, `Goose`, `ChatGPT`.", + }, + }, + "required": ["question", "chat_llm"], + }, + annotations=None, + ), + Tool( + name="create_pieces_memory", + description='Use this tool to capture a detailed, never-forgotten memory in Pieces. Agents and humans alike—such as Cursor, Claude, Perplexity, Goose, and ChatGPT—can leverage these memories to preserve important context or breakthroughs that occur in a project. Think of these as "smart checkpoints" that document your journey and ensure valuable information is always accessible for future reference. Providing thorough file and folder paths helps systems or users verify the locations on the OS and open them directly from the workstream summary.', + inputSchema={ + "type": "object", + "properties": { + "summary_description": { + "type": "string", + "description": "A concise summary or title describing the memory (e.g., what the bug was or the primary outcome). Keep it short but descriptive (1-2 sentences).", + }, + "summary": { + "type": "string", + "description": "A detailed, **markdown-formatted** narrative of the entire story. Include any information that you, other agents (Cursor, Claude, Perplexity, Goose, ChatGPT), or future collaborators might want to retrieve later. Document major breakthroughs (like finally passing all unit tests or fixing a tricky bug), when a topic or goal changes significantly, when preparing a final commit or update to a change log, or when pivoting to a fundamentally different approach. Explain the background, the thought process, what worked and what did not, how and why decisions were made, and any relevant code snippets, errors, logs, or references. Remember: the goal is to capture as much context as possible so both humans and AI can benefit from it later.", + }, + "project": { + "type": "string", + "description": "The **absolute path** to the root of the project on the local machine. For example: `/Users/username/MyProject` or `C:\\Users\\username\\MyProject`.", + }, + "files": { + "type": "array", + "items": { + "type": "string", + "description": "An **absolute** file or folder path (e.g., `/Users/username/project/src/file.dart` or `C:\\Users\\username\\project\\src\\file.dart`). Providing multiple files or folders is encouraged to give a comprehensive view of all relevant resources. For example:/Users/jdoe/Dev/MyProject/src/controllers/user_controller.dart/Users/jdoe/Dev/MyProject/src/models/user_model.dart/Users/jdoe/Dev/MyProject/assets/images/The full file path is required as this file will not get associated unless it can be verified as existing at that location on the OS. This full path is also critical so the user can easily open the related files in their file system by having the entire exact file path available alongside the this related workstream summary/long-term memory.", + }, + "description": "A list of all relevant files or folders involved in this memory. Provide absolute paths. ", + }, + "externalLinks": { + "type": "array", + "items": { + "type": "string", + "description": "A URL that contributed to the final solution (e.g., GitHub repo link with specific branch/file, documentation pages, articles, or resources).", + }, + "description": "List any external references, including GitHub/GitLab/Bitbucket URLs (include branch details), documentation links, or helpful articles consulted.", + }, + "connected_client": { + "type": "string", + "description": "The name of the client that is connected to the Pieces API. for example: `Cursor`, `Claude`, `Perplexity`, `Goose`, `ChatGPT`.", + }, + }, + "required": ["summary_description", "summary"], + }, + annotations=None, + ), +] diff --git a/src/pieces/urls.py b/src/pieces/urls.py index 79c54b7e..fbd1cf0b 100644 --- a/src/pieces/urls.py +++ b/src/pieces/urls.py @@ -78,6 +78,7 @@ class URLs(Enum): CLI_OPEN_DOCS = "https://docs.pieces.app/products/cli/commands#open" CLI_HELP_DOCS = "https://docs.pieces.app/products/cli/troubleshooting" CLI_COMPLETION_DOCS = "" + CLI_RESTART_DOCS = "" def open(self): self.open_website(self.value) diff --git a/tests/links_test.py b/tests/links_test.py index a3abd8bd..9e31f190 100644 --- a/tests/links_test.py +++ b/tests/links_test.py @@ -3,6 +3,7 @@ from pieces.urls import URLs from typing import List, Tuple + def get_urls() -> List[Tuple[str, str]]: """Get all URLs from the URLs enum.""" urls = [] diff --git a/tests/mcps/__init__.py b/tests/mcps/__init__.py index 85832c16..f47f772c 100644 --- a/tests/mcps/__init__.py +++ b/tests/mcps/__init__.py @@ -1 +1,2 @@ -"""Test package for MCP-related unit tests.""" \ No newline at end of file +"""Test package for MCP-related unit tests.""" + diff --git a/tests/mcps/mcp_gateway/__init__.py b/tests/mcps/mcp_gateway/__init__.py new file mode 100644 index 00000000..86a79533 --- /dev/null +++ b/tests/mcps/mcp_gateway/__init__.py @@ -0,0 +1,15 @@ +from .utils import ( + TEST_SERVER_NAME, + mock_tools_changed_callback, + get_upstream_url, + ensure_pieces_setup, + mock_connection, +) + +__all__ = [ + "TEST_SERVER_NAME", + "mock_tools_changed_callback", + "get_upstream_url", + "ensure_pieces_setup", + "mock_connection", +] diff --git a/tests/mcps/mcp_gateway_e2e_test.py b/tests/mcps/mcp_gateway/test_e2e.py similarity index 99% rename from tests/mcps/mcp_gateway_e2e_test.py rename to tests/mcps/mcp_gateway/test_e2e.py index 2caf2e01..6496a106 100644 --- a/tests/mcps/mcp_gateway_e2e_test.py +++ b/tests/mcps/mcp_gateway/test_e2e.py @@ -1,6 +1,7 @@ """ End-to-end tests for the MCP Gateway using subprocess. -POS is required to run these tests. +PiecesOS is required to run these tests. +LTM must be running, and PiecesOS as well. """ import pytest diff --git a/tests/mcps/mcp_gateway_test.py b/tests/mcps/mcp_gateway/test_integration.py similarity index 91% rename from tests/mcps/mcp_gateway_test.py rename to tests/mcps/mcp_gateway/test_integration.py index 6715f1eb..4758258c 100644 --- a/tests/mcps/mcp_gateway_test.py +++ b/tests/mcps/mcp_gateway/test_integration.py @@ -1,14 +1,19 @@ """ -End-to-end tests for the MCP Gateway functionality. +Integration/E2E tests for the MCP Gateway functionality. These tests interact with a real Pieces OS instance and verify actual behavior. """ import urllib.request import pytest -import requests import mcp.types as types + +from .utils import ( + get_upstream_url, + ensure_pieces_setup, + mock_tools_changed_callback, + TEST_SERVER_NAME, +) from pieces.mcp.gateway import MCPGateway, PosMcpConnection -from pieces.mcp.utils import get_mcp_latest_url from pieces.settings import Settings # Constants @@ -70,7 +75,7 @@ async def test_gateway_connection_with_pos_running(ensure_pieces_setup): pytest.skip("MCP server is not accessible. Skipping test.") # Create the connection - connection = PosMcpConnection(upstream_url) + connection = PosMcpConnection(upstream_url, mock_tools_changed_callback) try: # Attempt to connect @@ -113,9 +118,14 @@ async def test_call_tool_with_pos_running(ensure_pieces_setup): pytest.skip("MCP server is not accessible. Skipping test.") # Create the connection - connection = PosMcpConnection(upstream_url) + connection = PosMcpConnection(upstream_url, mock_tools_changed_callback) try: + if hasattr(Settings.pieces_client, "version") and hasattr( + Settings.pieces_client.version, "_mock_name" + ): + Settings.pieces_client.version = "3.0.0" + # Connect to the server await connection.connect() diff --git a/tests/mcps/mcp_gateway/test_validation_advanced.py b/tests/mcps/mcp_gateway/test_validation_advanced.py new file mode 100644 index 00000000..b106cc44 --- /dev/null +++ b/tests/mcps/mcp_gateway/test_validation_advanced.py @@ -0,0 +1,460 @@ +""" +Advanced validation tests for MCP Gateway. +Tests complex scenarios: concurrency, performance, edge cases, error recovery. +""" + +import asyncio +import time +import pytest +import mcp.types as types +from unittest.mock import Mock, patch + +from .utils import ( + mock_tools_changed_callback, + mock_connection, + UpdateEnum, +) +from pieces.mcp.gateway import PosMcpConnection + + +class TestMCPGatewayValidationAdvanced: + """Advanced validation tests for complex scenarios and edge cases""" + + @pytest.mark.asyncio + async def test_concurrent_validation_calls(self, mock_connection): + """Test that concurrent validation calls don't cause race conditions""" + # Mock all components to return True + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + with patch.object(mock_connection, "_check_ltm_status", return_value=True): + # Run multiple validations concurrently + results = [] + for i in range(10): + result = mock_connection._validate_system_status(f"tool_{i}") + results.append(result) + + # All should succeed + assert all(result[0] for result in results) + assert all(result[1] == "" for result in results) + + @pytest.mark.asyncio + async def test_malformed_tool_names(self, mock_connection): + """Test validation with potentially malicious tool names""" + malicious_names = [ + "tool'; DROP TABLE users; --", + "tool\x00\x01\x02", + "tool\n\rmalicious\ncommand", + "tool" * 1000, # Very long name + "", + ] + + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=False + ): + for name in malicious_names: + is_valid, error_message = mock_connection._validate_system_status(name) + + assert is_valid is False + # Should not contain raw tool name in error + assert name not in error_message + # Should contain pieces open command + assert "`pieces open`" in error_message + + @pytest.mark.asyncio + async def test_connection_timeout_handling(self, mock_connection): + """Test handling of connection timeouts""" + # Mock validation success + with patch.object( + mock_connection, "_validate_system_status", return_value=(True, "") + ): + # Mock connection to timeout + with patch.object( + mock_connection, + "connect", + side_effect=asyncio.TimeoutError("Connection timed out"), + ): + result = await mock_connection.call_tool("test_tool", {}) + + assert isinstance(result, types.CallToolResult) + assert "pieces restart" in result.content[0].text + + @pytest.mark.asyncio + async def test_partial_failure_states(self, mock_connection): + """Test when some checks pass but others fail""" + # PiecesOS running but incompatible version + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + mock_result = Mock() + mock_result.compatible = False + mock_result.update = UpdateEnum.PiecesOS + mock_connection.result = mock_result + + is_valid, error_message = mock_connection._validate_system_status( + "test_tool" + ) + + assert is_valid is False + assert "Please update PiecesOS" in error_message + + # Reset for next test + mock_connection.result = None + + # PiecesOS running, compatible, but LTM disabled for LTM tool + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + with patch.object(mock_connection, "_check_ltm_status", return_value=False): + is_valid, error_message = mock_connection._validate_system_status( + "ask_pieces_ltm" + ) + + assert is_valid is False + assert "Long Term Memory (LTM) is not enabled" in error_message + + @pytest.mark.asyncio + async def test_concurrent_tool_calls(self, mock_connection): + """Test multiple simultaneous tool calls don't cause race conditions""" + call_count = 0 + + async def mock_call_tool_impl(tool_name, args): + nonlocal call_count + call_count += 1 + # Simulate some async work + await asyncio.sleep(0.01) + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Result for {tool_name}")] + ) + + with patch.object( + mock_connection, "_validate_system_status", return_value=(True, "") + ): + with patch.object(mock_connection, "connect"): + # Mock the actual tool execution + original_call_tool = mock_connection.call_tool + mock_connection.call_tool = mock_call_tool_impl + + try: + # Simulate concurrent calls + tasks = [ + mock_connection.call_tool(f"tool_{i}", {}) for i in range(10) + ] + results = await asyncio.gather(*tasks) + + # All should succeed without race conditions + assert len(results) == 10 + assert all(isinstance(r, types.CallToolResult) for r in results) + assert call_count == 10 + + # Verify each result is unique + result_texts = [r.content[0].text for r in results] + assert len(set(result_texts)) == 10 # All unique + + finally: + mock_connection.call_tool = original_call_tool + + @pytest.mark.asyncio + async def test_error_recovery_after_pos_restart(self, mock_connection): + """Test gateway recovers after PiecesOS restart""" + # Simulate PiecesOS down initially + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=False + ): + result1 = await mock_connection.call_tool("test_tool", {}) + assert isinstance(result1, types.CallToolResult) + assert "PiecesOS is not running" in result1.content[0].text + assert "`pieces open`" in result1.content[0].text + + # Reset any cached state + mock_connection.result = None + + # Simulate PiecesOS back up + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + with patch.object(mock_connection, "_check_ltm_status", return_value=True): + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + with patch.object(mock_connection, "connect"): + # Should work now - validation passes + is_valid, error_msg = mock_connection._validate_system_status( + "test_tool" + ) + assert is_valid is True + assert error_msg == "" + + @pytest.mark.asyncio + async def test_error_message_content_validation(self, mock_connection): + """Test that error messages provide helpful guidance to users""" + # Test PiecesOS not running scenario + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=False + ): + is_valid, error_message = mock_connection._validate_system_status( + "test_tool" + ) + + assert is_valid is False + assert "PiecesOS is not running" in error_message + assert "`pieces open`" in error_message + assert "pieces" in error_message + + # Test CLI version incompatible scenario + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + with patch.object( + mock_connection, + "_check_version_compatibility", + return_value=( + False, + "Please update the CLI version to be able to run the tool call, run 'pieces manage update' to get the latest version. Then retry your request again after updating.", + ), + ): + is_valid, error_message = mock_connection._validate_system_status( + "test_tool" + ) + + assert is_valid is False + assert "update the CLI version" in error_message + assert "'pieces manage update'" in error_message + assert "pieces" in error_message + + # Test PiecesOS version incompatible scenario + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + with patch.object( + mock_connection, + "_check_version_compatibility", + return_value=( + False, + "Please update PiecesOS to a compatible version to be able to run the tool call. run 'pieces update' to get the latest version. Then retry your request again after updating.", + ), + ): + is_valid, error_message = mock_connection._validate_system_status( + "test_tool" + ) + + assert is_valid is False + assert "update PiecesOS" in error_message + assert "'pieces update'" in error_message + assert "pieces" in error_message + + # Test LTM disabled scenario + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + with patch.object( + mock_connection, "_check_version_compatibility", return_value=(True, "") + ): + with patch.object( + mock_connection, "_check_ltm_status", return_value=False + ): + is_valid, error_message = mock_connection._validate_system_status( + "ask_pieces_ltm" + ) + + assert is_valid is False + assert "Long Term Memory (LTM) is not enabled" in error_message + assert "`pieces open --ltm`" in error_message + assert "pieces" in error_message + + @pytest.mark.asyncio + async def test_tools_hash_stability_and_memory_cleanup(self, mock_connection): + """Test the new stable hash implementation and memory cleanup""" + # Create mock tools + tool1 = Mock() + tool1.name = "tool1" + tool1.description = "Short description" + + tool2 = Mock() + tool2.name = "tool2" + tool2.description = "Very long description that exceeds 200 characters. " * 10 + + tool3 = Mock() + tool3.name = "tool3" + tool3.description = None + + tools = [tool1, tool2, tool3] + + # Test hash stability - same tools should produce same hash + hash1 = mock_connection._get_tools_hash(tools) + hash2 = mock_connection._get_tools_hash(tools) + assert hash1 == hash2, "Hash should be stable for same tools" + + # Test hash is deterministic regardless of order + shuffled_tools = [tool2, tool3, tool1] # Different order + hash3 = mock_connection._get_tools_hash(shuffled_tools) + assert hash1 == hash3, "Hash should be same regardless of tool order" + + # Test hash changes when content changes + tool1.description = "Different description" + hash4 = mock_connection._get_tools_hash(tools) + assert hash1 != hash4, "Hash should change when tool content changes" + + # Test memory cleanup during update_tools + mock_connection.discovered_tools = [Mock(), Mock()] # Simulate existing tools + + with patch.object(mock_connection, "_tools_have_changed", return_value=True): + # Mock session.list_tools() to return our test tools + mock_session = Mock() + + async def mock_list_tools(): + return [("tools", tools)] + + mock_session.list_tools = mock_list_tools + + await mock_connection.update_tools(mock_session, send_notification=False) + + # Verify tools were updated and old tools were cleared + assert mock_connection.discovered_tools == tools + # Note: The clearing happens before assignment, so we can't directly test it + # but we can verify the method completes without error + + @pytest.mark.asyncio + async def test_performance_validation_overhead(self, mock_connection): + """Test that validation doesn't add significant overhead""" + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + with patch.object(mock_connection, "_check_ltm_status", return_value=True): + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + # Warm up + for _ in range(10): + mock_connection._validate_system_status("test_tool") + + # Measure performance + start = time.time() + iterations = 100 + for _ in range(iterations): + is_valid, _ = mock_connection._validate_system_status("test_tool") + assert is_valid is True + elapsed = time.time() - start + + # Should complete 100 validations in under 0.5 seconds + # This is generous to account for CI environments + assert elapsed < 0.5, ( + f"Validation too slow: {elapsed:.3f}s for {iterations} calls" + ) + + avg_time = elapsed / iterations + assert avg_time < 0.005, ( + f"Average validation time too high: {avg_time:.6f}s" + ) + + @pytest.mark.asyncio + async def test_hash_edge_cases(self, mock_connection): + """Test hash function with edge cases""" + # Empty tools list + assert mock_connection._get_tools_hash([]) is None + assert mock_connection._get_tools_hash(None) is None + + # Tool with empty name + empty_name_tool = Mock() + empty_name_tool.name = "" + empty_name_tool.description = "Has description" + hash_empty_name = mock_connection._get_tools_hash([empty_name_tool]) + assert hash_empty_name is not None + + # Tool with special characters in name/description + special_tool = Mock() + special_tool.name = "tool-with_special.chars@2024" + special_tool.description = "Description with émojis 🔧 and símböls" + hash_special = mock_connection._get_tools_hash([special_tool]) + assert hash_special is not None + + # Very large description (tests truncation) + large_tool = Mock() + large_tool.name = "large_tool" + large_tool.description = "x" * 10000 # 10KB description + hash_large = mock_connection._get_tools_hash([large_tool]) + + # Same tool with truncated description should have same hash + truncated_tool = Mock() + truncated_tool.name = "large_tool" + truncated_tool.description = "x" * 200 # Exactly 200 chars + hash_truncated = mock_connection._get_tools_hash([truncated_tool]) + assert hash_large == hash_truncated, ( + "Large description should be truncated to 200 chars" + ) + + @pytest.mark.asyncio + async def test_concurrent_connection_and_cleanup(self, mock_connection): + """Test concurrent connection attempts and cleanup operations""" + # Track connection attempts + connection_count = 0 + cleanup_count = 0 + + async def mock_connect(): + nonlocal connection_count + connection_count += 1 + # Simulate connection work + await asyncio.sleep(0.01) + return Mock() # Mock session + + async def mock_cleanup(): + nonlocal cleanup_count + cleanup_count += 1 + # Simulate cleanup work + await asyncio.sleep(0.01) + + # Mock successful validation + with patch.object( + mock_connection, "_validate_system_status", return_value=(True, "") + ): + # Mock the connection and cleanup methods + original_connect = mock_connection.connect + original_cleanup = mock_connection.cleanup + + mock_connection.connect = mock_connect + mock_connection.cleanup = mock_cleanup + + try: + tasks = [] + + # Add connection tasks + for i in range(3): + tasks.append(mock_connection.connect()) + + # Add cleanup tasks + for i in range(3): + tasks.append(mock_connection.cleanup()) + + # Add tool call tasks that involve connection + for i in range(2): + tasks.append(mock_connection.call_tool(f"tool_{i}", {})) + + # Execute all concurrently + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Verify all operations completed + assert len(results) == 8 # 3 connects + 3 cleanups + 2 tool calls + + # Check for exceptions + exceptions = [r for r in results if isinstance(r, Exception)] + assert len(exceptions) == 0, f"Unexpected exceptions: {exceptions}" + + # Verify connection and cleanup methods were called expected number of times + # Note: tool calls might also trigger connections + assert connection_count >= 3, "Expected at least 3 connection attempts" + assert cleanup_count == 3, "Expected exactly 3 cleanup operations" + + finally: + # Restore original methods + mock_connection.connect = original_connect + mock_connection.cleanup = original_cleanup diff --git a/tests/mcps/mcp_gateway/test_validation_core.py b/tests/mcps/mcp_gateway/test_validation_core.py new file mode 100644 index 00000000..1f19c087 --- /dev/null +++ b/tests/mcps/mcp_gateway/test_validation_core.py @@ -0,0 +1,354 @@ +""" +Core validation tests for MCP Gateway. +Tests basic validation flows: system status, version compatibility, LTM checks. +""" + +import pytest +import mcp.types as types +from unittest.mock import Mock, patch + +from .utils import ( + mock_tools_changed_callback, + mock_connection, + UpdateEnum, +) +from pieces.mcp.gateway import PosMcpConnection + + +class TestMCPGatewayValidationCore: + """Core validation tests for basic system checks and validation flows""" + + @pytest.mark.asyncio + async def test_validate_system_status_pieces_os_not_running(self, mock_connection): + """Test validation when PiecesOS is not running""" + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=False + ): + is_valid, error_message = mock_connection._validate_system_status( + "ask_pieces_ltm" + ) + + assert is_valid is False + assert "PiecesOS is not running" in error_message + assert "`pieces open`" in error_message + + @pytest.mark.asyncio + async def test_validate_system_status_version_incompatible_plugin_update( + self, mock_connection + ): + """Test validation when CLI version needs updating""" + # Mock PiecesOS running + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + # Mock version compatibility check to return plugin update needed + mock_result = Mock() + mock_result.compatible = False + mock_result.update = UpdateEnum.Plugin + mock_connection.result = mock_result + + is_valid, error_message = mock_connection._validate_system_status( + "ask_pieces_ltm" + ) + + assert is_valid is False + assert "Please update the CLI version" in error_message + assert "pieces manage update" in error_message + assert "retry your request again after updating" in error_message + + @pytest.mark.asyncio + async def test_validate_system_status_version_incompatible_pos_update( + self, mock_connection + ): + """Test validation when PiecesOS version needs updating""" + # Mock PiecesOS running + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + # Mock version compatibility check to return POS update needed + mock_result = Mock() + mock_result.compatible = False + mock_result.update = UpdateEnum.PiecesOS # Or any value that's not Plugin + mock_connection.result = mock_result + + is_valid, error_message = mock_connection._validate_system_status( + "ask_pieces_ltm" + ) + + assert is_valid is False + assert "Please update PiecesOS" in error_message + assert "pieces update" in error_message + assert "retry your request again after updating" in error_message + + @pytest.mark.asyncio + async def test_validate_system_status_ltm_disabled(self, mock_connection): + """Test validation when LTM is disabled for LTM tools""" + # Mock PiecesOS running and version compatible + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + # Mock LTM disabled + with patch.object(mock_connection, "_check_ltm_status", return_value=False): + is_valid, error_message = mock_connection._validate_system_status( + "ask_pieces_ltm" + ) + + assert is_valid is False + assert "Long Term Memory (LTM) is not enabled" in error_message + assert "`pieces open --ltm`" in error_message + + @pytest.mark.asyncio + async def test_validate_system_status_ltm_disabled_create_memory_tool( + self, mock_connection + ): + """Test validation when LTM is disabled for create_pieces_memory tool""" + # Mock PiecesOS running and version compatible + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + # Mock LTM disabled + with patch.object(mock_connection, "_check_ltm_status", return_value=False): + is_valid, error_message = mock_connection._validate_system_status( + "create_pieces_memory" + ) + + assert is_valid is False + assert "Long Term Memory (LTM) is not enabled" in error_message + assert "`pieces open --ltm`" in error_message + + @pytest.mark.asyncio + async def test_validate_system_status_non_ltm_tool_success(self, mock_connection): + """Test validation success for non-LTM tools when LTM is disabled""" + # Mock PiecesOS running and version compatible + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + # Mock LTM disabled (shouldn't matter for non-LTM tools) + with patch.object(mock_connection, "_check_ltm_status", return_value=False): + is_valid, error_message = mock_connection._validate_system_status( + "some_other_tool" + ) + + assert is_valid is True + assert error_message == "" + + @pytest.mark.asyncio + async def test_validate_system_status_all_checks_pass(self, mock_connection): + """Test validation when all checks pass""" + # Mock PiecesOS running + with patch.object( + mock_connection, "_check_pieces_os_status", return_value=True + ): + # Mock version compatible + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + # Mock LTM enabled + with patch.object(mock_connection, "_check_ltm_status", return_value=True): + is_valid, error_message = mock_connection._validate_system_status( + "ask_pieces_ltm" + ) + + assert is_valid is True + assert error_message == "" + + @pytest.mark.asyncio + async def test_call_tool_with_validation_failure(self, mock_connection): + """Test call_tool returns error when validation fails""" + # Mock validation failure + error_msg = "Test validation error" + with patch.object( + mock_connection, "_validate_system_status", return_value=(False, error_msg) + ): + result = await mock_connection.call_tool("test_tool", {}) + + assert isinstance(result, types.CallToolResult) + assert len(result.content) == 1 + assert isinstance(result.content[0], types.TextContent) + assert result.content[0].text == error_msg + + @pytest.mark.asyncio + async def test_call_tool_with_connection_failure(self, mock_connection): + """Test call_tool handles connection failures gracefully""" + # Mock validation success + with patch.object( + mock_connection, "_validate_system_status", return_value=(True, "") + ): + # Mock connection failure + with patch.object( + mock_connection, "connect", side_effect=Exception("Connection failed") + ): + result = await mock_connection.call_tool("test_tool", {}) + + assert isinstance(result, types.CallToolResult) + assert len(result.content) == 1 + assert isinstance(result.content[0], types.TextContent) + assert "`pieces restart`" in result.content[0].text + + @pytest.mark.asyncio + async def test_get_error_message_for_tool_uses_validation(self, mock_connection): + """Test that _get_error_message_for_tool uses the validation system""" + # Mock validation failure + error_msg = "Validation failed" + with patch.object( + mock_connection, "_validate_system_status", return_value=(False, error_msg) + ): + result = mock_connection._get_error_message_for_tool("test_tool") + + assert result == error_msg + + @pytest.mark.asyncio + async def test_get_error_message_for_tool_validation_passes(self, mock_connection): + """Test _get_error_message_for_tool when validation passes but still has error""" + # Mock validation success + with patch.object( + mock_connection, "_validate_system_status", return_value=(True, "") + ): + result = mock_connection._get_error_message_for_tool("test_tool") + + assert "Unable to execute 'test_tool' tool" in result + assert "`pieces restart`" in result + + def test_check_version_compatibility_caches_result(self, mock_connection): + """Test that version compatibility check caches the result""" + # Mock the VersionChecker + with patch("pieces.mcp.gateway.VersionChecker") as mock_version_checker: + mock_result = Mock() + mock_result.compatible = True + mock_version_checker.return_value.version_check.return_value = mock_result + + # First call + is_compatible1, msg1 = mock_connection._check_version_compatibility() + # Second call + is_compatible2, msg2 = mock_connection._check_version_compatibility() + + # Should have cached the result + assert mock_connection.result == mock_result + assert is_compatible1 == is_compatible2 + assert is_compatible1 is True + assert msg1 == msg2 == "" + # VersionChecker should only be called once due to caching + mock_version_checker.assert_called_once() + + @patch("pieces.mcp.gateway.HealthWS") + def test_check_pieces_os_status_health_ws_running( + self, mock_health_ws, mock_connection + ): + """Test _check_pieces_os_status when health WS is already running""" + # Mock HealthWS.is_running() to return True + mock_health_ws.is_running.return_value = True + + # Mock Settings.pieces_client.is_pos_stream_running + with patch("pieces.mcp.gateway.Settings") as mock_settings: + mock_settings.pieces_client.is_pos_stream_running = True + + result = mock_connection._check_pieces_os_status() + + assert result is True + mock_health_ws.is_running.assert_called_once() + + @patch("pieces.mcp.gateway.HealthWS") + @patch("pieces.mcp.gateway.Settings") + def test_check_pieces_os_status_starts_health_ws( + self, mock_settings, mock_health_ws, mock_connection + ): + """Test _check_pieces_os_status starts health WS when PiecesOS is running""" + # Mock HealthWS.is_running() to return False initially + mock_health_ws.is_running.return_value = False + + # Mock pieces_client.is_pieces_running() to return True + mock_settings.pieces_client.is_pieces_running.return_value = True + + mock_health_ws_instance = Mock() + mock_health_ws.get_instance.return_value = mock_health_ws_instance + + # Mock the workstream API call + mock_settings.pieces_client.work_stream_pattern_engine_api.workstream_pattern_engine_processors_vision_status.return_value = Mock() + + result = mock_connection._check_pieces_os_status() + + assert result is True + mock_health_ws_instance.start.assert_called_once() + + @patch("pieces.mcp.gateway.Settings") + def test_check_ltm_status(self, mock_settings, mock_connection): + """Test _check_ltm_status returns LTM enabled status""" + mock_settings.pieces_client.copilot.context.ltm.is_enabled = True + + result = mock_connection._check_ltm_status() + + assert result is True + + @pytest.mark.asyncio + async def test_multiple_validation_calls_same_tool(self, mock_connection): + """Test that multiple validation calls for the same tool work correctly""" + # Mock all components + with ( + patch.object(mock_connection, "_check_pieces_os_status", return_value=True), + patch.object(mock_connection, "_check_ltm_status", return_value=True), + ): + mock_result = Mock() + mock_result.compatible = True + mock_connection.result = mock_result + + # Call validation multiple times + is_valid1, msg1 = mock_connection._validate_system_status("ask_pieces_ltm") + is_valid2, msg2 = mock_connection._validate_system_status("ask_pieces_ltm") + + assert is_valid1 == is_valid2 + assert is_valid1 is True + assert msg1 == msg2 == "" + + @pytest.mark.asyncio + async def test_try_get_upstream_url_success(self, mock_connection): + """Test _try_get_upstream_url when PiecesOS is running""" + mock_connection.upstream_url = None + + with ( + patch("pieces.mcp.gateway.Settings") as mock_settings, + patch( + "pieces.mcp.gateway.get_mcp_latest_url", return_value="http://test-url" + ), + ): + mock_settings.pieces_client.is_pieces_running.return_value = True + + result = mock_connection._try_get_upstream_url() + + assert result is True + assert mock_connection.upstream_url == "http://test-url" + + @pytest.mark.asyncio + async def test_try_get_upstream_url_failure(self, mock_connection): + """Test _try_get_upstream_url when PiecesOS is not running""" + mock_connection.upstream_url = None + + with patch("pieces.mcp.gateway.Settings") as mock_settings: + mock_settings.pieces_client.is_pieces_running.return_value = False + + result = mock_connection._try_get_upstream_url() + + assert result is False + assert mock_connection.upstream_url is None + + @pytest.mark.asyncio + async def test_try_get_upstream_url_already_set(self, mock_connection): + """Test _try_get_upstream_url when URL is already set""" + mock_connection.upstream_url = "http://existing-url" + + result = mock_connection._try_get_upstream_url() + + assert result is True + assert mock_connection.upstream_url == "http://existing-url" diff --git a/tests/mcps/mcp_gateway/utils.py b/tests/mcps/mcp_gateway/utils.py new file mode 100644 index 00000000..8cdd56ab --- /dev/null +++ b/tests/mcps/mcp_gateway/utils.py @@ -0,0 +1,246 @@ +""" +Utilities, fixtures, and helpers for MCP Gateway tests. + +This module provides shared components used across validation and integration tests: +- Constants and configuration +- Mock helpers and callbacks +- Test fixtures for dependency injection +- Utility functions for test setup +""" + +import urllib.request +import pytest +import requests +import mcp.types as types +from unittest.mock import Mock, patch +from pieces.mcp.gateway import MCPGateway, PosMcpConnection +from pieces.mcp.utils import get_mcp_latest_url +from pieces.settings import Settings +from pieces._vendor.pieces_os_client.wrapper.version_compatibility import ( + UpdateEnum, +) + +# ===== CONSTANTS ===== + +TEST_SERVER_NAME = "pieces-test-mcp" +"""Default server name used in MCP Gateway tests.""" + +DEFAULT_TEST_URL = "http://localhost:39300/model_context_protocol/2024-11-05/sse" +"""Fallback URL when Settings.startup() fails.""" + + +# ===== MOCK HELPERS ===== + + +async def mock_tools_changed_callback(): + """ + Mock callback for tools_changed_callback in tests. + + This is used as a placeholder callback when creating PosMcpConnection + instances for testing without triggering real tool change notifications. + """ + pass + + +def create_mock_tool(name: str, description: str = None) -> Mock: + """ + Create a mock Tool object for testing. + + Args: + name: The tool name + description: Optional tool description + + Returns: + Mock object configured as a Tool with name and description attributes + """ + tool = Mock() + tool.name = name + tool.description = description + return tool + + +def create_mock_tools_list(count: int = 3) -> list: + """ + Create a list of mock tools for testing. + + Args: + count: Number of mock tools to create + + Returns: + List of mock Tool objects + """ + return [ + create_mock_tool(f"tool_{i}", f"Description for tool {i}") for i in range(count) + ] + + +# ===== UTILITY FUNCTIONS ===== + + +def get_upstream_url(): + """ + Get the upstream URL for MCP server, handling potential errors. + + Attempts to get the real URL from Settings, falls back to hardcoded + URL if Settings initialization fails (common in mocked tests). + + Returns: + str: The upstream URL for MCP server connections + """ + try: + Settings.startup() + return get_mcp_latest_url() + except Exception: + # We are mocking the settings so this will raise an exception most of the time + # we can hardcode the url instead + return DEFAULT_TEST_URL + + +def is_pieces_os_accessible(url: str = None) -> bool: + """ + Check if PiecesOS MCP server is accessible. + + Args: + url: Optional URL to check, defaults to get_upstream_url() + + Returns: + bool: True if server is accessible, False otherwise + """ + if url is None: + url = get_upstream_url() + + if url is None: + return False + + try: + with urllib.request.urlopen(url, timeout=1) as response: + response.read(1) + return True + except Exception: + return False + + +# ===== PYTEST FIXTURES ===== + + +@pytest.fixture(scope="module") +def ensure_pieces_setup(): + """ + Fixture to ensure Pieces OS is installed and accessible for testing. + + This module-scoped fixture checks once per test session whether + PiecesOS is running and accessible. + + Returns: + bool: True if Pieces OS is running, False otherwise + """ + try: + Settings.startup() + return True + except (requests.RequestException, ConnectionError, SystemExit): + return False + + +@pytest.fixture +def mock_connection(): + """ + Create a fresh mock PosMcpConnection for testing. + + This function-scoped fixture provides a clean PosMcpConnection + instance for each test, with any cached results reset. + + Returns: + PosMcpConnection: Fresh connection instance for testing + """ + connection = PosMcpConnection("http://test-url", mock_tools_changed_callback) + # Reset any cached results to ensure clean state + connection.result = None + return connection + + +@pytest.fixture +def mock_gateway(): + """ + Create a mock MCPGateway for testing. + + Returns: + MCPGateway: Gateway instance configured for testing + """ + url = get_upstream_url() + return MCPGateway(server_name=TEST_SERVER_NAME, upstream_url=url) + + +@pytest.fixture +def sample_tools(): + """ + Provide a sample set of mock tools for testing. + + Returns: + list: List of mock Tool objects with various configurations + """ + return [ + create_mock_tool( + "ask_pieces_ltm", "Ask questions using Pieces Long Term Memory" + ), + create_mock_tool("create_pieces_memory", "Create a new memory in Pieces"), + create_mock_tool("search_pieces", "Search through Pieces assets"), + ] + + +# ===== CONTEXT MANAGERS ===== + + +class MockPiecesOSContext: + """Context manager for mocking PiecesOS state in tests.""" + + def __init__( + self, running: bool = True, ltm_enabled: bool = True, compatible: bool = True + ): + """ + Initialize mock context. + + Args: + running: Whether PiecesOS should appear to be running + ltm_enabled: Whether LTM should appear enabled + compatible: Whether version should appear compatible + """ + self.running = running + self.ltm_enabled = ltm_enabled + self.compatible = compatible + self.patches = [] + + def __enter__(self): + """Enter the context and apply mocks.""" + # This could be expanded to provide common mocking patterns + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit the context and clean up mocks.""" + for patch_obj in self.patches: + patch_obj.stop() + + +# ===== EXPORTS ===== + +__all__ = [ + # Constants + "TEST_SERVER_NAME", + "DEFAULT_TEST_URL", + # Mock helpers + "mock_tools_changed_callback", + "create_mock_tool", + "create_mock_tools_list", + # Utilities + "get_upstream_url", + "is_pieces_os_accessible", + # Fixtures + "ensure_pieces_setup", + "mock_connection", + "mock_gateway", + "sample_tools", + # Context managers + "MockPiecesOSContext", + # Re-exports for convenience + "UpdateEnum", + "types", +] diff --git a/tests/mcps/mcp_handler_test.py b/tests/mcps/mcp_handler_test.py index dd47d9a4..2c82f632 100644 --- a/tests/mcps/mcp_handler_test.py +++ b/tests/mcps/mcp_handler_test.py @@ -69,7 +69,9 @@ def test_handle_mcp_server_status(self): "mcp": {"servers": {"Pieces": {"url": "pieces_url", "type": "sse"}}} } with patch("builtins.open", mock_open(read_data=json.dumps(mock_config))): - with patch.object(self.integration, "search", return_value=(True, {"type": "sse"})): + with patch.object( + self.integration, "search", return_value=(True, {"type": "sse"}) + ): status = self.integration.is_set_up() self.assertTrue(status) diff --git a/tests/mcps/utils.py b/tests/mcps/utils.py index 832845a3..ee518c03 100644 --- a/tests/mcps/utils.py +++ b/tests/mcps/utils.py @@ -235,4 +235,3 @@ def create_mock_client(self) -> MockPiecesClient: def get_mcp_properties(self) -> MCPProperties: """Return a default :class:`MCPProperties` instance.""" return default_mcp_properties() -