Repository: matthewhand/mcp-openapi-proxy

Issue:
Path normalisation
The path normalisation does not seem to work correctly. 

Lets say we have a path call `/api/agent/service/list` and a path called `/api/agent/announcement/list`, the result in Claude is that there are two (2, or even more) tools called `post_list`. And even if the tools filter is e.g. set to `/api/agent/service`, the call to `post_list` calls the (alphanumerically) first one, which is `/api/agent/announcement/list` in this example.

I would expect the resulting tools to be (uniquely) called `post_api_agent_service_list` and `post_api_agent_announcement_list`.

-- Cheers, Nils

Hints from issue comments:
@nilei: ![Image](https://github.com/user-attachments/assets/d3a53e7c-9d1f-43fe-95a7-6c59ad8cb8f5)@matthewhand: PR is merged does it fix this issue?
@nilei: @matthewhand I explained some issues with the new features here: https://github.com/matthewhand/mcp-openapi-proxy/pull/7#issuecomment-2778262904

-- Cheers, Nils 
@nilei: @matthewhand  But it seems, that I am getting also new errors. Can you test with my OpenAI file please. 

-- Cheers, Nils 

[otrs_restapi_openapi.json](https://github.com/user-attachments/files/19636714/otrs_restapi_openapi.json)@nilei: ```
2025-04-07T17:33:54.602Z [OTRSRESTAPI] [info] Message from client: {"method":"tools/list","params":{},"jsonrpc":"2.0","id":1}
[DEBUG] 2025-04-07 19:33:54,603 - Handling list_tools request - start
[DEBUG] 2025-04-07 19:33:54,603 - Tools list length: 281
[ERROR] 2025-04-07 19:33:54,604 - MCP run crashed: unhandled errors in a TaskGroup (1 sub-exception)
  + Exception Group Traceback (most recent call last):
  |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp_openapi_proxy/server_lowlevel.py", line 415, in start_server
  |     await mcp.run(
  |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/server/lowlevel/server.py", line 483, in run
  |     async with AsyncExitStack() as stack:
  |   File "/usr/local/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 754, in __aexit__
  |     raise exc_details[1]
  |   File "/usr/local/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 737, in __aexit__
  |     cb_suppress = await cb(*exc_details)
  |                   ^^^^^^^^^^^^^^^^^^^^^^
  |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/shared/session.py", line 210, in __aexit__
  |     return await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 772, in __aexit__
  |     raise BaseExceptionGroup(
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/server/lowlevel/server.py", line 489, in run
    |     async with anyio.create_task_group() as tg:
    |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 772, in __aexit__
    |     raise BaseExceptionGroup(
    | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | Traceback (most recent call last):
      |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/server/lowlevel/server.py", line 517, in _handle_message
      |     await self._handle_request(
      |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/server/lowlevel/server.py", line 563, in _handle_request
      |     await message.respond(response)
      |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/shared/session.py", line 122, in respond
      |     await self._session._send_response(  # type: ignore[reportPrivateUsage]
      |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/shared/session.py", line 292, in _send_response
      |     result=response.model_dump(
      |            ^^^^^^^^^^^^^^^^^^^
      | AttributeError: 'types.SimpleNamespace' object has no attribute 'model_dump'
      +------------------------------------
2025-04-07T17:33:54.944Z [OTRSRESTAPI] [info] Message from client: {"method":"resources/list","params":{},"jsonrpc":"2.0","id":2}
[CRITICAL] 2025-04-07 19:33:54,946 - Failed to start MCP server: unhandled errors in a TaskGroup (1 sub-exception)
  + Exception Group Traceback (most recent call last):
  |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp_openapi_proxy/server_lowlevel.py", line 464, in run_server
  |     asyncio.run(start_server())
  |   File "/usr/local/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/asyncio/runners.py", line 194, in run
  |     return runner.run(main)
  |            ^^^^^^^^^^^^^^^^
  |   File "/usr/local/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/asyncio/runners.py", line 118, in run
  |     return self._loop.run_until_complete(task)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/usr/local/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/asyncio/base_events.py", line 685, in run_until_complete
  |     return future.result()
  |            ^^^^^^^^^^^^^^^
  |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp_openapi_proxy/server_lowlevel.py", line 407, in start_server
  |     async with stdio_server() as (read_stream, write_stream):
  |   File "/usr/local/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 231, in __aexit__
  |     await self.gen.athrow(value)
  |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/server/stdio.py", line 83, in stdio_server
  |     async with anyio.create_task_group() as tg:
  |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 772, in __aexit__
  |     raise BaseExceptionGroup(
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/mcp/server/stdio.py", line 69, in stdin_reader
    |     await read_stream_writer.send(message)
    |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/anyio/streams/memory.py", line 242, in send
    |     self.send_nowait(item)
    |   File "/Users/nils/.cache/uv/archive-v0/JYTZBCtoCSVdcyxQ1dMRV/lib/python3.12/site-packages/anyio/streams/memory.py", line 213, in send_nowait
    |     raise BrokenResourceError
    | anyio.BrokenResourceError
    +------------------------------------
2025-04-07T17:33:54.959Z [OTRSRESTAPI] [info] Message from client: {"method":"resources/list","params":{},"jsonrpc":"2.0","id":3}
2025-04-07T17:33:54.959Z [OTRSRESTAPI] [info] Message from client: {"method":"tools/list","params":{},"jsonrpc":"2.0","id":4}
2025-04-07T17:33:55.111Z [OTRSRESTAPI] [info] Server transport closed
2025-04-07T17:33:55.111Z [OTRSRESTAPI] [info] Client transport closed
2025-04-07T17:33:55.111Z [OTRSRESTAPI] [info] Server transport closed unexpectedly, this is likely due to the process exiting early. If you are developing this MCP server you can add output to stderr (i.e. `console.error('...')` in JavaScript, `print('...', file=sys.stderr)` in python) and it will appear in this log.
2025-04-07T17:33:55.111Z [OTRSRESTAPI] [error] Server disconnected. For troubleshooting guidance, please visit our [debugging documentation](https://modelcontextprotocol.io/docs/tools/debugging) {"context":"connection"}
2025-04-07T17:33:55.111Z [OTRSRESTAPI] [info] Client transport closed

```@matthewhand: Latest commit fixes for me. Please let me know if it fixes for you.@nilei: It now seems, that the `TOOL_NAME_MAX_LENGTH` option is not respected anymore.

![Image](https://github.com/user-attachments/assets/e9b3c117-ece6-4932-a6c2-ba19da750346)

![Image](https://github.com/user-attachments/assets/ca751b41-faa8-47c8-8663-d3bc00529b0c)

The config that I am using:

```
{
    "mcpServers": {
        "OTRSRESTAPI": {
            "command": "/opt/homebrew/bin/uvx",
            "args": [
              "mcp-openapi-proxy"
            ],
            "env": {
                "DEBUG": "true",
                "OPENAPI_SPEC_URL": "file:///Users/nils/Downloads/otrs_restapi_openapi.json",
                "SERVER_URL_OVERRIDE": "https://demo-otrs8.managed-otrs.com",
                "TOOL_NAME_MAX_LENGTH": "60",
                "API_KEY": "1234"
            }
        }
    }
}
```

-- Cheers, Nils
@nilei: @matthewhand Do you have any suggestions for this to work? Due to the long tool names, I am relying on the max. name length setting. 

-- Cheers, Nils
@matthewhand: I made some minor tweaks to the normalization function.  Let me know if this resolves this issue.
@nilei: @matthewhand Fixed for me. Big thanks!

Solution patch:
diff --git a/README.md b/README.md
index 9ca4156..95e61bb 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,7 @@ Refer to the **Examples** section below for practical configurations tailored to
 - `DEBUG`: (Optional) Enables verbose debug logging when set to "true", "1", or "yes".
 - `EXTRA_HEADERS`: (Optional) Additional HTTP headers in "Header: Value" format (one per line) to attach to outgoing API requests.
 - `SERVER_URL_OVERRIDE`: (Optional) Overrides the base URL from the OpenAPI specification when set, useful for custom deployments.
+- `TOOL_NAME_MAX_LENGTH`: (Optional) Truncates tool names to a max length.
 - **Additional Variable:** `OPENAPI_SPEC_URL_<hash>` – a variant for unique per-test configurations (falls back to `OPENAPI_SPEC_URL`).
 
 ## Examples
diff --git a/mcp_openapi_proxy/__init__.py b/mcp_openapi_proxy/__init__.py
index 6ec36f8..bce8eb0 100644
--- a/mcp_openapi_proxy/__init__.py
+++ b/mcp_openapi_proxy/__init__.py
@@ -6,6 +6,7 @@ FastMCP Server (static tools) based on OPENAPI_SIMPLE_MODE env var.
 """
 
 import os
+import sys
 from dotenv import load_dotenv
 from mcp_openapi_proxy.utils import setup_logging
 
diff --git a/mcp_openapi_proxy/utils.py b/mcp_openapi_proxy/utils.py
index 41b4b42..e739be4 100644
--- a/mcp_openapi_proxy/utils.py
+++ b/mcp_openapi_proxy/utils.py
@@ -3,12 +3,12 @@ Utility functions for mcp-openapi-proxy.
 """
 
 import os
+import re
 import sys
 import json
 import logging
 import requests
 import yaml
-import jmespath
 from typing import Dict, Optional, Tuple
 from mcp import types
 
@@ -27,22 +27,40 @@ def setup_logging(debug: bool = False) -> logging.Logger:
     logger.debug("Logging initialized, all output to stderr")
     return logger
 
-def normalize_tool_name(raw_name: str) -> str:
+def normalize_tool_name(raw_name: str, max_length: int = None) -> str:
     """Convert an HTTP method and path into a normalized tool name."""
+
+    max_length = max_length or os.getenv("TOOL_NAME_MAX_LENGTH", None)
+
     try:
         method, path = raw_name.split(" ", 1)
-        method = method.lower()
-        # Take only the last meaningful part, skip prefixes like /api/v*
-        path_parts = [part for part in path.split("/") if part and not part.startswith("{")]
-        if not path_parts:
-            return "unknown_tool"
-        last_part = path_parts[-1].lower()  # Force lowercase
-        name = f"{method}_{last_part}"
-        if "{" in path:
-            name += "_id"
-        return name if name else "unknown_tool"
-    except ValueError:
-        logger.debug(f"Failed to normalize tool name: {raw_name}")
+
+        # remove common uninformative url prefixes
+        path = re.sub(r"/(api|rest|public)/?", "/", path)
+
+        url_template_pattern = re.compile(r"\{([^}]+)\}")
+        normalized_parts = []
+        for part in path.split("/"):
+            if url_template_pattern.search(part):
+                # Replace path parameters with "by_param" format
+                params = url_template_pattern.findall(part)
+                base = url_template_pattern.sub("", part)
+                part = f"{base}_by_{'_'.join(params)}"
+
+            # Clean up part and add to list
+            part = part.replace(".", "_").replace("-", "_")
+            normalized_parts.append(part)
+
+        # Combine and clean final result
+        tool_name = f"{method.lower()}_{'_'.join(normalized_parts)}"
+        # Remove repeated underscores
+        tool_name = re.sub(r"_+", "_", tool_name)
+
+        if max_length:
+            tool_name = tool_name[:max_length]
+
+        return tool_name
+    except Exception:
         return "unknown_tool"
 
 def is_tool_whitelist_set() -> bool:


Test patch:
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index af11543..652aa9f 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -2,13 +2,21 @@
 Unit tests for utility functions in mcp-openapi-proxy.
 """
 
-import os
-import pytest
 from mcp_openapi_proxy.utils import normalize_tool_name, detect_response_type, build_base_url, handle_auth, strip_parameters
 
 def test_normalize_tool_name():
-    assert normalize_tool_name("GET /api/v2/users") == "get_users"
-    assert normalize_tool_name("POST /users/{id}") == "post_users_id"
+    assert normalize_tool_name("GET /api/v2/users") == "get_v2_users"
+    assert normalize_tool_name("POST /users/{id}") == "post_users_by_id"
+    assert normalize_tool_name("GET /api/agent/service/list") == "get_agent_service_list"
+    assert normalize_tool_name("GET /api/agent/announcement/list") == "get_agent_announcement_list"
+    assert normalize_tool_name("GET /section/resources/{param1}.{param2}") == "get_section_resources_by_param1_param2"
+    assert normalize_tool_name("GET /resource/{param1}/{param2}-{param3}") == "get_resource_by_param1_by_param2_param3"
+    assert normalize_tool_name("GET /{param1}/resources") == "get_by_param1_resources"
+    assert normalize_tool_name("GET /resources/{param1}-{param2}.{param3}") == "get_resources_by_param1_param2_param3"
+    assert normalize_tool_name("GET /users/{id1}/{id2}") == "get_users_by_id1_by_id2"
+    assert normalize_tool_name("GET /users/user_{id}") == "get_users_user_by_id"
+    assert normalize_tool_name("GET /search+filter/results") == "get_search+filter_results"
+    assert normalize_tool_name("GET /user_profiles/active") == "get_user_profiles_active"
     assert normalize_tool_name("INVALID") == "unknown_tool"
 
 def test_detect_response_type_json():