Skip to content

Commit faacebd

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 2f40acd commit faacebd

11 files changed

Lines changed: 446 additions & 20 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.48.2"
3+
version = "0.48.3"
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,8 +1,18 @@
11
"""Data models for Agent Gateway MCP tools."""
22

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

717

818
@dataclass
@@ -213,3 +223,50 @@ class MCPToolFilter:
213223

214224
names: list[str] = field(default_factory=list)
215225
ord_ids: list[str] = field(default_factory=list)
226+
227+
228+
class CacheOptions:
229+
"""Options for caching the result of list_mcp_tools.
230+
231+
Pass an instance to list_mcp_tools(cache=...) to enable result caching.
232+
The same instance can be reused across calls — cache state is stored on
233+
it. Call evict() to force a fresh fetch on the next call.
234+
235+
Args:
236+
ttl: Cache lifetime in seconds. Defaults to 600 s.
237+
max_size: Maximum number of distinct cached entries (keyed by filter
238+
combo + auth type). Oldest entry is evicted when the limit is
239+
exceeded. Defaults to 32.
240+
241+
Example:
242+
```python
243+
from sap_cloud_sdk.agentgateway import CacheOptions
244+
245+
cache = CacheOptions(ttl=300)
246+
tools = await agw_client.list_mcp_tools(cache=cache)
247+
248+
# Later — force a fresh fetch (e.g. after a tool was added):
249+
cache.evict()
250+
tools = await agw_client.list_mcp_tools(cache=cache)
251+
```
252+
253+
Note:
254+
Cache is in-process only. It is not shared across client instances,
255+
processes, or Kubernetes pods. Two concurrent calls that both miss
256+
the cache will both fetch independently — the last writer wins, no
257+
data corruption occurs.
258+
"""
259+
260+
def __init__(
261+
self,
262+
ttl: float = DEFAULT_MCP_TOOLS_CACHE_TTL_SECONDS,
263+
max_size: int = DEFAULT_MAX_MCP_TOOLS_CACHE_SIZE,
264+
) -> None:
265+
self.ttl = ttl
266+
self.max_size = max_size
267+
self._cache: MCPToolsCache | None = None
268+
269+
def evict(self) -> None:
270+
"""Clear all cached tool list entries. Forces a fresh fetch on the next call."""
271+
if self._cache is not None:
272+
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,11 +34,13 @@
3434
Agent,
3535
AgentCardFilter,
3636
AuthResult,
37+
CacheOptions,
3738
MCPTool,
3839
MCPToolFilter,
3940
)
4041
from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain
4142
from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache
43+
from sap_cloud_sdk.agentgateway._tools_cache import MCPToolsCache
4244
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError
4345
from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics
4446

@@ -375,6 +377,7 @@ async def list_mcp_tools(
375377
self,
376378
user_token: str | Callable[[], str] | None = None,
377379
filter: MCPToolFilter | None = None,
380+
cache: CacheOptions | None = None,
378381
) -> list[MCPTool]:
379382
"""List all MCP tools from MCP servers.
380383
@@ -395,6 +398,11 @@ async def list_mcp_tools(
395398
If provided, uses user-scoped auth instead of system auth.
396399
filter: Optional filter to narrow results by tool name or ORD ID.
397400
If None or empty, all tools are included.
401+
cache: Optional caching options. When provided, tool lists are cached
402+
in-process for ``cache.ttl`` seconds (default 600 s). Distinct filter
403+
and auth-type combinations are cached independently, up to
404+
``cache.max_size`` entries (LRU eviction). Call ``cache.evict()`` to
405+
clear all entries and force a fresh fetch on the next call.
398406
399407
Returns:
400408
List of MCPTool objects from all MCP servers.
@@ -419,9 +427,32 @@ async def list_mcp_tools(
419427
ord_ids=["sap.s4:apiAccess:salesOrder:v1"],
420428
)
421429
)
430+
431+
# With caching — avoids redundant MCP round-trips:
432+
from sap_cloud_sdk.agentgateway import CacheOptions
433+
cache = CacheOptions(ttl=300)
434+
tools = await agw_client.list_mcp_tools(cache=cache)
435+
436+
# Force a fresh fetch (e.g. after a tool was added on the server):
437+
cache.evict()
438+
tools = await agw_client.list_mcp_tools(cache=cache)
422439
```
423440
"""
424441
try:
442+
user_scoped = bool(user_token)
443+
444+
if cache is not None:
445+
if cache._cache is None:
446+
cache._cache = MCPToolsCache()
447+
tools_cache: MCPToolsCache | None = cache._cache
448+
cache_opts: CacheOptions | None = cache
449+
cached = tools_cache.get(filter, user_scoped)
450+
if cached is not None:
451+
return cached
452+
else:
453+
tools_cache = None
454+
cache_opts = None
455+
425456
if user_token:
426457
auth = await self.get_user_auth(user_token)
427458
else:
@@ -434,32 +465,41 @@ async def list_mcp_tools(
434465
"Customer agent credentials detected at '%s'", credentials_path
435466
)
436467
credentials = load_customer_credentials(credentials_path)
437-
return await get_mcp_tools_customer(
468+
tools = await get_mcp_tools_customer(
438469
credentials,
439470
auth.access_token,
440471
self._config.timeout,
441472
filter=filter,
442473
)
474+
if tools_cache is not None and cache_opts is not None:
475+
tools_cache.set(tools, filter, user_scoped, cache_opts)
476+
return tools
443477

444478
# Check for transparent mode
445479
if detect_transparent_credentials():
446480
logger.info(_LOG_TRANSPARENT_MODE)
447481
credentials = load_customer_credentials_from_env()
448-
return await get_mcp_tools_customer(
482+
tools = await get_mcp_tools_customer(
449483
credentials,
450484
auth.access_token,
451485
self._config.timeout,
452486
filter=filter,
453487
)
488+
if tools_cache is not None and cache_opts is not None:
489+
tools_cache.set(tools, filter, user_scoped, cache_opts)
490+
return tools
454491

455492
# LoB flow - requires tenant_subdomain
456493
tenant = self._resolve_tenant_subdomain()
457-
return await get_mcp_tools_lob(
494+
tools = await get_mcp_tools_lob(
458495
tenant,
459496
auth.access_token,
460497
self._config.timeout,
461498
filter=filter,
462499
)
500+
if tools_cache is not None and cache_opts is not None:
501+
tools_cache.set(tools, filter, user_scoped, cache_opts)
502+
return tools
463503

464504
except AgentGatewaySDKError:
465505
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: 46 additions & 0 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(
@@ -281,6 +312,21 @@ Both fields default to empty lists. `names` is applied after fetching; `ord_ids`
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/aicore/user-guide.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ before the cached OAuth token expires — so agents never see a 401 at all.
7777
```python
7878
from sap_cloud_sdk.aicore import set_aicore_config, watch_aicore_config
7979

80-
set_aicore_config() # load credentials at startup
80+
set_aicore_config() # load credentials at startup
8181
watch_aicore_config() # proactive reload on secret rotation
8282
```
8383

@@ -104,7 +104,7 @@ directly, bypassing the SDK's reactive handler. Two options:
104104
from sap_cloud_sdk.aicore import set_aicore_config, watch_aicore_config
105105

106106
set_aicore_config()
107-
watch_aicore_config() # ADD THIS — no other changes needed
107+
watch_aicore_config() # ADD THIS — no other changes needed
108108
```
109109

110110
**Option B — also add reactive reload for ChatLiteLLM:**
@@ -117,7 +117,7 @@ from sap_cloud_sdk.aicore import (
117117
)
118118

119119
set_aicore_config()
120-
patch_litellm_for_credential_rotation() # patches litellm.completion globally
120+
patch_litellm_for_credential_rotation() # patches litellm.completion globally
121121
watch_aicore_config()
122122
```
123123

0 commit comments

Comments
 (0)