Skip to content

Commit e1d72c6

Browse files
committed
feat: add MCP tools caching to list_mcp_tools
Adds optional in-process result caching for list_mcp_tools() to avoid redundant MCP session round-trips in agentic loops where the tool list rarely changes. - New CacheOptions(ttl, max_size) dataclass accepted by list_mcp_tools(cache=...) - Results cached per filter + auth-type combination with monotonic TTL (default 600 s) - cache.evict() for caller-triggered invalidation - LRU eviction when max_size entries exceeded (default 32) - 19 new unit tests covering hit/miss, TTL expiry, LRU eviction, evict() - user-guide.md updated with usage examples and CacheOptions API reference Closes #178
1 parent 3ec44e8 commit e1d72c6

11 files changed

Lines changed: 499 additions & 95 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.44.0"
3+
version = "0.45.0"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"

src/sap_cloud_sdk/agentgateway/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454

5555
from sap_cloud_sdk.agentgateway._models import (
5656
AuthResult,
57+
CacheOptions,
5758
MCPTool,
5859
MCPToolFilter,
5960
Agent,
@@ -78,6 +79,7 @@
7879
"ClientConfig",
7980
# Data models
8081
"AuthResult",
82+
"CacheOptions",
8183
"MCPTool",
8284
"MCPToolFilter",
8385
"Agent",

src/sap_cloud_sdk/agentgateway/_models.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
"""Data models for Agent Gateway MCP tools."""
22

3+
from __future__ import annotations
4+
35
from dataclasses import dataclass, field
4-
from typing import Any
6+
from typing import TYPE_CHECKING, Any
7+
8+
from sap_cloud_sdk.agentgateway.config import (
9+
DEFAULT_MAX_MCP_TOOLS_CACHE_SIZE,
10+
DEFAULT_MCP_TOOLS_CACHE_TTL_SECONDS,
11+
)
12+
13+
if TYPE_CHECKING:
14+
from sap_cloud_sdk.agentgateway._tools_cache import MCPToolsCache
515

616

717
@dataclass
@@ -185,3 +195,50 @@ class MCPToolFilter:
185195

186196
names: list[str] = field(default_factory=list)
187197
ord_ids: list[str] = field(default_factory=list)
198+
199+
200+
class CacheOptions:
201+
"""Options for caching the result of list_mcp_tools.
202+
203+
Pass an instance to list_mcp_tools(cache=...) to enable result caching.
204+
The same instance can be reused across calls — cache state is stored on
205+
it. Call evict() to force a fresh fetch on the next call.
206+
207+
Args:
208+
ttl: Cache lifetime in seconds. Defaults to 600 s.
209+
max_size: Maximum number of distinct cached entries (keyed by filter
210+
combo + auth type). Oldest entry is evicted when the limit is
211+
exceeded. Defaults to 32.
212+
213+
Example:
214+
```python
215+
from sap_cloud_sdk.agentgateway import CacheOptions
216+
217+
cache = CacheOptions(ttl=300)
218+
tools = await agw_client.list_mcp_tools(cache=cache)
219+
220+
# Later — force a fresh fetch (e.g. after a tool was added):
221+
cache.evict()
222+
tools = await agw_client.list_mcp_tools(cache=cache)
223+
```
224+
225+
Note:
226+
Cache is in-process only. It is not shared across client instances,
227+
processes, or Kubernetes pods. Two concurrent calls that both miss
228+
the cache will both fetch independently — the last writer wins, no
229+
data corruption occurs.
230+
"""
231+
232+
def __init__(
233+
self,
234+
ttl: float = DEFAULT_MCP_TOOLS_CACHE_TTL_SECONDS,
235+
max_size: int = DEFAULT_MAX_MCP_TOOLS_CACHE_SIZE,
236+
) -> None:
237+
self.ttl = ttl
238+
self.max_size = max_size
239+
self._cache: MCPToolsCache | None = None
240+
241+
def evict(self) -> None:
242+
"""Clear all cached tool list entries. Forces a fresh fetch on the next call."""
243+
if self._cache is not None:
244+
self._cache.evict()
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Result cache for MCP tool lists.
2+
3+
Caches list[MCPTool] per (filter, auth-type) key to avoid redundant MCP
4+
session round-trips during agentic loops. Bounded by max_size with LRU
5+
eviction; each entry has a monotonic TTL.
6+
7+
Thread safety:
8+
CPython GIL makes individual OrderedDict operations atomic, but compound
9+
check-then-set is not. Two concurrent coroutines for the same key may both
10+
miss and both fetch; the race produces redundant tool-list requests, not
11+
data corruption. This matches the accepted behaviour in _token_cache.py.
12+
"""
13+
14+
import logging
15+
import time
16+
from collections import OrderedDict
17+
from dataclasses import dataclass
18+
19+
from sap_cloud_sdk.agentgateway._models import CacheOptions, MCPTool, MCPToolFilter
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
@dataclass
25+
class _CachedToolList:
26+
tools: list[MCPTool]
27+
expires_at: float # time.monotonic() value
28+
29+
def is_valid(self) -> bool:
30+
return time.monotonic() < self.expires_at
31+
32+
33+
def _make_cache_key(filter: MCPToolFilter | None, user_scoped: bool) -> str:
34+
"""Build a stable string key from filter options and auth type."""
35+
ord_ids = "|".join(sorted(filter.ord_ids)) if filter and filter.ord_ids else ""
36+
names = "|".join(sorted(filter.names)) if filter and filter.names else ""
37+
auth = "user" if user_scoped else "system"
38+
return f"{auth}:ord={ord_ids}:names={names}"
39+
40+
41+
class MCPToolsCache:
42+
"""TTL + LRU cache for MCP tool list results.
43+
44+
Keyed by (filter combo, auth type). Entries expire after `options.ttl`
45+
seconds. When the number of entries exceeds `options.max_size`, the
46+
least-recently-used entry is evicted.
47+
48+
Callers hold a reference to their CacheOptions instance and call
49+
evict() to invalidate all entries.
50+
"""
51+
52+
def __init__(self) -> None:
53+
self._entries: OrderedDict[str, _CachedToolList] = OrderedDict()
54+
55+
def get(
56+
self,
57+
filter: MCPToolFilter | None,
58+
user_scoped: bool,
59+
) -> list[MCPTool] | None:
60+
"""Return cached tools for the given filter/auth combo, or None if miss/expired."""
61+
key = _make_cache_key(filter, user_scoped)
62+
entry = self._entries.get(key)
63+
if entry and entry.is_valid():
64+
self._entries.move_to_end(key)
65+
return entry.tools
66+
if entry:
67+
del self._entries[key]
68+
return None
69+
70+
def set(
71+
self,
72+
tools: list[MCPTool],
73+
filter: MCPToolFilter | None,
74+
user_scoped: bool,
75+
options: CacheOptions,
76+
) -> None:
77+
"""Store tools under the given filter/auth key, evicting LRU if at capacity."""
78+
key = _make_cache_key(filter, user_scoped)
79+
expires_at = time.monotonic() + options.ttl
80+
self._entries[key] = _CachedToolList(tools=tools, expires_at=expires_at)
81+
self._entries.move_to_end(key)
82+
while len(self._entries) > options.max_size:
83+
evicted, _ = self._entries.popitem(last=False)
84+
logger.debug("MCP tools cache full — evicted key '%s'", evicted)
85+
86+
def evict(self) -> None:
87+
"""Clear all cached entries. Forces a fresh fetch on the next call."""
88+
self._entries.clear()
89+
logger.debug("MCP tools cache evicted")

src/sap_cloud_sdk/agentgateway/agw_client.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@
3434
Agent,
3535
AgentCardFilter,
3636
AuthResult,
37+
CacheOptions,
3738
MCPTool,
3839
MCPToolFilter,
3940
)
4041
from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache
42+
from sap_cloud_sdk.agentgateway._tools_cache import MCPToolsCache
4143
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError
4244
from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics
4345

@@ -358,6 +360,7 @@ async def list_mcp_tools(
358360
self,
359361
user_token: str | Callable[[], str] | None = None,
360362
filter: MCPToolFilter | None = None,
363+
cache: CacheOptions | None = None,
361364
) -> list[MCPTool]:
362365
"""List all MCP tools from MCP servers.
363366
@@ -378,6 +381,11 @@ async def list_mcp_tools(
378381
If provided, uses user-scoped auth instead of system auth.
379382
filter: Optional filter to narrow results by tool name or ORD ID.
380383
If None or empty, all tools are included.
384+
cache: Optional caching options. When provided, tool lists are cached
385+
in-process for ``cache.ttl`` seconds (default 600 s). Distinct filter
386+
and auth-type combinations are cached independently, up to
387+
``cache.max_size`` entries (LRU eviction). Call ``cache.evict()`` to
388+
clear all entries and force a fresh fetch on the next call.
381389
382390
Returns:
383391
List of MCPTool objects from all MCP servers.
@@ -402,9 +410,32 @@ async def list_mcp_tools(
402410
ord_ids=["sap.s4:apiAccess:salesOrder:v1"],
403411
)
404412
)
413+
414+
# With caching — avoids redundant MCP round-trips:
415+
from sap_cloud_sdk.agentgateway import CacheOptions
416+
cache = CacheOptions(ttl=300)
417+
tools = await agw_client.list_mcp_tools(cache=cache)
418+
419+
# Force a fresh fetch (e.g. after a tool was added on the server):
420+
cache.evict()
421+
tools = await agw_client.list_mcp_tools(cache=cache)
405422
```
406423
"""
407424
try:
425+
user_scoped = bool(user_token)
426+
427+
if cache is not None:
428+
if cache._cache is None:
429+
cache._cache = MCPToolsCache()
430+
tools_cache: MCPToolsCache | None = cache._cache
431+
cache_opts: CacheOptions | None = cache
432+
cached = tools_cache.get(filter, user_scoped)
433+
if cached is not None:
434+
return cached
435+
else:
436+
tools_cache = None
437+
cache_opts = None
438+
408439
if user_token:
409440
auth = await self.get_user_auth(user_token)
410441
else:
@@ -417,36 +448,45 @@ async def list_mcp_tools(
417448
"Customer agent credentials detected at '%s'", credentials_path
418449
)
419450
credentials = load_customer_credentials(credentials_path)
420-
return await get_mcp_tools_customer(
451+
tools = await get_mcp_tools_customer(
421452
credentials,
422453
auth.access_token,
423454
self._config.timeout,
424455
filter=filter,
425456
)
457+
if tools_cache is not None and cache_opts is not None:
458+
tools_cache.set(tools, filter, user_scoped, cache_opts)
459+
return tools
426460

427461
# Check for transparent mode
428462
if detect_transparent_credentials():
429463
logger.info(_LOG_TRANSPARENT_MODE)
430464
credentials = load_customer_credentials_from_env()
431-
return await get_mcp_tools_customer(
465+
tools = await get_mcp_tools_customer(
432466
credentials,
433467
auth.access_token,
434468
self._config.timeout,
435469
filter=filter,
436470
)
471+
if tools_cache is not None and cache_opts is not None:
472+
tools_cache.set(tools, filter, user_scoped, cache_opts)
473+
return tools
437474

438475
# LoB flow - requires tenant_subdomain
439476
tenant = self._resolve_tenant_subdomain()
440477
if user_token:
441478
auth = await self.get_user_auth(user_token)
442479
else:
443480
auth = await self.get_system_auth()
444-
return await get_mcp_tools_lob(
481+
tools = await get_mcp_tools_lob(
445482
tenant,
446483
auth.access_token,
447484
self._config.timeout,
448485
filter=filter,
449486
)
487+
if tools_cache is not None and cache_opts is not None:
488+
tools_cache.set(tools, filter, user_scoped, cache_opts)
489+
return tools
450490

451491
except AgentGatewaySDKError:
452492
raise

src/sap_cloud_sdk/agentgateway/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
DEFAULT_TOKEN_EXPIRY_BUFFER_SECONDS = 30.0
88
DEFAULT_MAX_SYSTEM_TOKEN_CACHE_SIZE = 32
99
DEFAULT_MAX_USER_TOKEN_CACHE_SIZE = 256
10+
DEFAULT_MCP_TOOLS_CACHE_TTL_SECONDS = 600.0
11+
DEFAULT_MAX_MCP_TOOLS_CACHE_SIZE = 32
1012

1113

1214
@dataclass

src/sap_cloud_sdk/agentgateway/user-guide.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,36 @@ agents = await agw_client.list_agent_cards(
9595
)
9696
```
9797

98+
### Caching Tool Lists
99+
100+
In agentic loops, `list_mcp_tools()` can be called repeatedly. By default every call opens fresh MCP sessions — expensive for a tool list that rarely changes. Pass a `CacheOptions` instance to cache results in-process.
101+
102+
```python
103+
from sap_cloud_sdk.agentgateway import CacheOptions, create_client
104+
105+
agw_client = create_client(tenant_subdomain="my-tenant")
106+
cache = CacheOptions(ttl=300) # cache for 5 minutes
107+
108+
# First call fetches from network and stores in cache
109+
tools = await agw_client.list_mcp_tools(cache=cache)
110+
111+
# Subsequent calls within TTL return immediately — no network round-trip
112+
tools = await agw_client.list_mcp_tools(cache=cache)
113+
114+
# Force a fresh fetch (e.g. after a tool was added on the server):
115+
cache.evict()
116+
tools = await agw_client.list_mcp_tools(cache=cache)
117+
```
118+
119+
The cache is scoped to the `CacheOptions` instance — different instances don't share state. Distinct filter and auth-type combinations are cached as independent entries, up to `max_size` entries total (LRU eviction when the limit is hit).
120+
121+
```python
122+
# Custom TTL and size cap
123+
cache = CacheOptions(ttl=600, max_size=10)
124+
```
125+
126+
The cache is **in-process only** — not shared across client instances, processes, or Kubernetes pods.
127+
98128
### LangChain Integration
99129

100130
Convert MCP tools to LangChain `StructuredTool` objects for use with LangChain agents:
@@ -221,6 +251,7 @@ class AgentGatewayClient:
221251
self,
222252
user_token: str | Callable[[], str] | None = None,
223253
filter: MCPToolFilter | None = None,
254+
cache: CacheOptions | None = None,
224255
) -> list[MCPTool]
225256

226257
async def call_mcp_tool(
@@ -271,16 +302,31 @@ Both fields default to empty lists. `agent_names` is applied after fetching; `or
271302
from sap_cloud_sdk.agentgateway import MCPToolFilter
272303

273304
MCPToolFilter(
274-
names=[], # tool names to include (matched against MCPTool.name); empty = no filter
305+
names=[], # tool names to include (matched against MCPTool.name); empty = no filter
275306
ord_ids=[], # ORD IDs to include (extracted from fragment URL for LoB, or matched
276-
# against IntegrationDependency.ord_id for customer agents); empty = no filter
307+
# against IntegrationDependency.ord_id for customer agents); empty = no filter
277308
)
278309
```
279310

280311
Both fields default to empty lists. `names` is applied after fetching; `ord_ids` is applied before fetching, skipping non-matching fragments.
281312

282313
> Both filter classes use AND semantics: if both fields are set, a result must match all of them to be included.
283314

315+
### CacheOptions
316+
317+
```python
318+
from sap_cloud_sdk.agentgateway import CacheOptions
319+
320+
CacheOptions(
321+
ttl=600.0, # cache lifetime in seconds; default 600
322+
max_size=32, # max distinct cached entries (LRU eviction); default 32
323+
)
324+
```
325+
326+
- `ttl`: How long a cached tool list is considered valid. After expiry the next call fetches fresh from the network.
327+
- `max_size`: Cap on how many distinct entries (filter + auth-type combinations) are held in memory. When exceeded, the least-recently-used entry is evicted.
328+
- `.evict()`: Clears all entries immediately, forcing a fresh fetch on the next call.
329+
284330
### Data Models
285331

286332
```python

src/sap_cloud_sdk/core/telemetry/user-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ from litellm import completion
295295

296296
logger = logging.getLogger(__name__)
297297

298+
298299
async def handle_request(query: str, user_id: str):
299300
set_tenant_id("bh7sjh...")
300301

0 commit comments

Comments
 (0)