diff --git a/pyproject.toml b/pyproject.toml index f2b0e6a..cfd83c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Topic :: Office/Business :: Financial", ] dependencies = [ - "mcp>=1.0.0,<2.0.0", # 2.0 移除了 Server 的 list_tools/call_tool decorator API(issue #16) + "mcp>=2.0.0", "httpx>=0.27", "pydantic>=2.0", ] diff --git a/src/finmind_mcp/knowledge.py b/src/finmind_mcp/knowledge.py index 30d7d3d..f373c29 100644 --- a/src/finmind_mcp/knowledge.py +++ b/src/finmind_mcp/knowledge.py @@ -58,7 +58,7 @@ def resource_definitions() -> list[Resource]: uri=f"finmind://{suffix}", name=suffix, description=f"FinMind knowledge pack: {filename}", - mimeType="text/markdown", + mime_type="text/markdown", ) ) return resources diff --git a/src/finmind_mcp/server.py b/src/finmind_mcp/server.py index 6d1a086..22491e1 100644 --- a/src/finmind_mcp/server.py +++ b/src/finmind_mcp/server.py @@ -4,6 +4,10 @@ shared knowledge pack. Designed to be launched by an MCP host such as Claude Desktop, Claude Code, Cursor, Windsurf, or Gemini CLI. +Handlers are passed to the `Server` constructor: mcp 2.0 removed the 1.x +`@app.list_tools()` decorators in favour of `on_*` keyword arguments, and +handlers now take `(ctx, params)` and return full result models. + Run via: finmind-mcp or: @@ -15,36 +19,61 @@ import asyncio import logging +import mcp.types as types from mcp.server import Server from mcp.server.stdio import stdio_server -from mcp.types import Resource, TextContent, Tool from . import knowledge, tools logger = logging.getLogger(__name__) -app: Server = Server("finmind") - - -@app.list_tools() -async def list_tools() -> list[Tool]: - return tools.tool_definitions() - - -@app.call_tool() -async def call_tool(name: str, arguments: dict | None) -> list[TextContent]: - result = await tools.dispatch(name, arguments or {}) - return [TextContent(type="text", text=result)] - - -@app.list_resources() -async def list_resources() -> list[Resource]: - return knowledge.resource_definitions() -@app.read_resource() -async def read_resource(uri) -> str: - # `uri` is a pydantic AnyUrl in current MCP SDK; convert to string. - return knowledge.read(str(uri)) +async def on_list_tools(ctx, params) -> types.ListToolsResult: + return types.ListToolsResult(tools=tools.tool_definitions()) + + +async def on_call_tool(ctx, params) -> types.CallToolResult: + # mcp 1.x turned a raising handler into `isError`; mcp 2.0 lets the + # exception become a JSON-RPC protocol error, which hosts report as a + # server failure instead of showing the model something it can act on. + try: + result = await tools.dispatch(params.name, params.arguments or {}) + except Exception as exc: # noqa: BLE001 — surfaced to the model, not swallowed + logger.exception("tool %s failed", getattr(params, "name", "?")) + return types.CallToolResult( + content=[types.TextContent(type="text", text=str(exc))], + is_error=True, + ) + return types.CallToolResult( + content=[types.TextContent(type="text", text=result)] + ) + + +async def on_list_resources(ctx, params) -> types.ListResourcesResult: + return types.ListResourcesResult(resources=knowledge.resource_definitions()) + + +async def on_read_resource(ctx, params) -> types.ReadResourceResult: + # `params.uri` is a pydantic AnyUrl; knowledge.read wants a plain string. + uri = str(params.uri) + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=uri, + mime_type="text/markdown", + text=knowledge.read(uri), + ) + ] + ) + + +app: Server = Server( + "finmind", + on_list_tools=on_list_tools, + on_call_tool=on_call_tool, + on_list_resources=on_list_resources, + on_read_resource=on_read_resource, +) async def _run() -> None: diff --git a/src/finmind_mcp/tools.py b/src/finmind_mcp/tools.py index 83b2785..bc4bdce 100644 --- a/src/finmind_mcp/tools.py +++ b/src/finmind_mcp/tools.py @@ -53,7 +53,7 @@ def tool_definitions() -> list[Tool]: " start_date(YYYY-MM-DD),回傳 markdown 表格。超過 500 列會截斷" "並標註總列數。" ), - inputSchema={ + input_schema={ "type": "object", "properties": { "dataset": { @@ -82,7 +82,7 @@ def tool_definitions() -> list[Tool]: "列出 FinMind 支援的所有 dataset(讀取內建知識庫,不需連線)。" "依分類回傳 dataset 名稱、會員層級與說明的 markdown 條列。" ), - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ), Tool( name="get_stock_info", @@ -90,7 +90,7 @@ def tool_definitions() -> list[Tool]: "查詢台股代號 / 中文名 / 產業別總覽(呼叫 TaiwanStockInfo)。" "可選 stock_id 指定單一標的;未提供時回傳全市場清單(會截斷)。" ), - inputSchema={ + input_schema={ "type": "object", "properties": { "stock_id": { @@ -107,7 +107,7 @@ def tool_definitions() -> list[Tool]: "此 dataset 走專屬 endpoint 不在 /api/v4/data 通用路徑:必填股票代號 data_id" "與單一日期 date(非區間)。需要 Sponsor 等級。" ), - inputSchema={ + input_schema={ "type": "object", "properties": { "data_id": { diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..ec23014 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,105 @@ +"""Tests for the MCP protocol handlers in server.py. + +Since the mcp 2.0 migration the handlers are plain async functions registered +on the `Server` constructor, so they can be called directly — no subprocess, +no transport. `smoke.py` and `regression/runner.py` still cover the real +stdio handshake end to end. +""" + +import mcp.types as types +import pytest + +from finmind_mcp import server, tools + + +class _Params: + """Stand-in for the pydantic params models the runner passes in. + + The handlers only read attributes off params, so a namespace is enough. + """ + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +async def test_list_tools_returns_the_four_tools(): + result = await server.on_list_tools(None, None) + + assert isinstance(result, types.ListToolsResult) + assert [t.name for t in result.tools] == [ + "query_dataset", + "list_datasets", + "get_stock_info", + "query_trading_daily_report", + ] + + +async def test_call_tool_wraps_dispatch_output_as_text_content(monkeypatch): + async def fake_dispatch(name, arguments): + return f"dispatched {name} {arguments}" + + monkeypatch.setattr(tools, "dispatch", fake_dispatch) + + result = await server.on_call_tool( + None, _Params(name="query_dataset", arguments={"dataset": "TaiwanStockPrice"}) + ) + + assert isinstance(result, types.CallToolResult) + assert not result.is_error + assert result.content[0].text == ( + "dispatched query_dataset {'dataset': 'TaiwanStockPrice'}" + ) + + +async def test_call_tool_handles_omitted_arguments(monkeypatch): + seen = {} + + async def fake_dispatch(name, arguments): + seen["arguments"] = arguments + return "ok" + + monkeypatch.setattr(tools, "dispatch", fake_dispatch) + + await server.on_call_tool(None, _Params(name="list_datasets", arguments=None)) + + assert seen["arguments"] == {} + + +async def test_call_tool_reports_errors_as_tool_results_not_protocol_errors(): + """A raising dispatch must come back as isError, not as an exception. + + mcp 1.x wrapped call_tool handlers and turned exceptions into + `CallToolResult(isError=True)`; mcp 2.0 lets them escape and become + JSON-RPC protocol errors, which hosts surface as a server failure rather + than feeding back to the model. server.py restores the old behaviour, and + this test is what keeps it restored. + """ + result = await server.on_call_tool(None, _Params(name="no_such_tool", arguments={})) + + assert isinstance(result, types.CallToolResult) + assert result.is_error + assert "no_such_tool" in result.content[0].text + + +async def test_list_resources_exposes_the_knowledge_pack(): + result = await server.on_list_resources(None, None) + + assert isinstance(result, types.ListResourcesResult) + assert result.resources, "expected at least one bundled knowledge resource" + assert all(str(r.uri).startswith("finmind://") for r in result.resources) + + +async def test_read_resource_returns_markdown_contents(): + uri = str((await server.on_list_resources(None, None)).resources[0].uri) + + result = await server.on_read_resource(None, _Params(uri=uri)) + + assert isinstance(result, types.ReadResourceResult) + contents = result.contents[0] + assert contents.mime_type == "text/markdown" + assert contents.text.strip() + + +async def test_read_resource_rejects_unknown_uri(): + with pytest.raises(ValueError): + await server.on_read_resource(None, _Params(uri="finmind://nope")) diff --git a/tests/test_tools.py b/tests/test_tools.py index 8ecd2df..e07b6dc 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -63,8 +63,8 @@ def test_tool_definitions_returns_four_tools(): ] for d in defs: assert d.description - assert d.inputSchema is not None - assert d.inputSchema.get("type") == "object" + assert d.input_schema is not None + assert d.input_schema.get("type") == "object" @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 823ecc4..0a4231d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "annotated-types" @@ -246,7 +250,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", specifier = ">=1.0.0,<2.0.0" }, + { name = "mcp", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, @@ -276,6 +280,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -292,21 +309,28 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -347,15 +371,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -365,9 +389,34 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] [[package]] @@ -528,20 +577,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -600,15 +635,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.28" @@ -868,6 +894,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"