Skip to content

Commit c0d1cc9

Browse files
hf-kkleinclaude
andauthored
feat: add Choice provider for system selection (#592)
* feat: add Choice provider for system selection and integration tests - Add fastmcp[apps] Choice provider so the LLM presents clickable system options to the user before calling sap_login - Server instructions dynamically list available system keys and guide the LLM to use the choose tool - Update README getting-started section: dict keys are arbitrary, connection_name field matches SAP Logon entry - Add unit tests for connection_name resolution (both backends) - Add MCP protocol integration tests via FastMCP Client Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: recompile requirements.txt with fastmcp[apps] and fix formatting requirements.txt was missing prefab-ui (needed by fastmcp.apps.choice), causing CI import failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: bump fastmcp minimum to 3.2.0 (fastmcp.apps added in 3.2.0) fastmcp.apps.choice.Choice doesn't exist in 3.1.1 — the apps subpackage was introduced in 3.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: narrow exception catch to satisfy pylint broad-exception-caught Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rename connection_name→system_key on sap_login, expose configured systems without passwords (#594) Disambiguate the two meanings of "connection_name": - sap_login parameter renamed to system_key (the systems.json dict key) - configured_systems response uses sap_logon_entry (the SAP Logon pad description) Also: sap_list_connections now returns configured_systems from systems.json (key, sap_logon_entry, host, client, language — no passwords/users). Unknown system_key returns an error instead of silently falling back to default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d29f100 commit c0d1cc9

13 files changed

Lines changed: 547 additions & 58 deletions

README.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,28 @@ On **Windows**, open Windows Explorer and paste this into the address bar:
9393

9494
Create the folder if it doesn't exist, then create a file called `systems.json` inside it. On **macOS/Linux**, the path is `~/.config/sap-mcp/systems.json`.
9595

96-
**System key = SAP Logon description, NOT the SID.** The key in `"systems"` (e.g. `"HF S/4"`) must match the **description** shown in SAP Logon — the bold text in the connection list. It is _not_ the 3-character System ID (SID) like `HFQ` or `DEV`.
96+
There are two distinct identifiers per system — don't mix them up:
9797

98-
| What you see in SAP Logon | Use as system key | NOT this (SID) |
99-
| ------------------------- | ------------------------- | -------------- |
100-
| **HF S/4** | `"HF S/4"` | ~~`"HFQ"`~~ |
101-
| **DEV - ERP Development** | `"DEV - ERP Development"` | ~~`"DEV"`~~ |
98+
| Concept | Example | Where it's used |
99+
| --- | --- | --- |
100+
| **System key** (dictionary key) | `"dev"`, `"qa"` | `sap_login(system_key="qa")` — selects which system to log into |
101+
| **SAP Logon entry** (`connection_name` field) | `"HF S/4"`, `"DEV - ERP Development"` | Must match the **bold description** in the SAP Logon pad exactly |
102102

103-
If the key doesn't match exactly, you'll get _"SAP Logon connection entry not found"_.
103+
The SAP Logon entry is _not_ the 3-character System ID (SID):
104+
105+
| What you see in SAP Logon | `connection_name` value | NOT this (SID) |
106+
| ------------------------- | --------------------------- | -------------- |
107+
| **HF S/4** | `"HF S/4"` | ~~`"HFQ"`~~ |
108+
| **DEV - ERP Development** | `"DEV - ERP Development"` | ~~`"DEV"`~~ |
109+
110+
If the `connection_name` doesn't match exactly, you'll get _"SAP Logon connection entry not found"_.
104111

105112
```json
106113
{
107-
"default_system": "HF S/4",
114+
"default_system": "dev",
108115
"systems": {
109-
"HF S/4": {
116+
"dev": {
117+
"connection_name": "HF S/4",
110118
"host": "https://your-sap-system:44300",
111119
"client": "100",
112120
"user": "your_username",
@@ -157,10 +165,10 @@ Multi-system support is built into `systems.json` — add multiple systems and t
157165

158166
**How it works:**
159167

160-
1. `sap_list_connections` reads your SAP Logon entries (from `SAPUILandscape.xml`) to show which systems are available.
161-
2. `sap_login(connection_name="qa")` logs into a specific system using credentials from `systems.json`.
168+
1. `sap_list_connections` returns both configured systems (from `systems.json`) and SAP Logon entries (from `SAPUILandscape.xml`).
169+
2. `sap_login(system_key="qa")` logs into a specific system using credentials from `systems.json`.
162170

163-
**Configuration:** Add multiple systems to your `systems.json`. The dictionary key (e.g. `"dev"`, `"qa"`) is used for config lookup. The `connection_name` field must match the SAP Logon entry description:
171+
**Configuration:** Add multiple systems to your `systems.json`. The **dictionary key** (e.g. `"dev"`, `"qa"`) is the `system_key` you pass to `sap_login`. The `connection_name` field must match the SAP Logon entry description exactly:
164172

165173
```json
166174
{
@@ -186,7 +194,7 @@ Multi-system support is built into `systems.json` — add multiple systems and t
186194
}
187195
```
188196

189-
When `sap_login(connection_name="qa")` is called, it looks up the system in `systems.json` and uses the `connection_name` field to open the SAP Logon entry. If not found, it falls back to `default_system`.
197+
When `sap_login(system_key="qa")` is called, it looks up `"qa"` in `systems.json`, reads the credentials, and uses the `connection_name` field (`"QA System"`) to open the matching SAP Logon entry. If the system key is not found, an error is returned listing the available keys.
190198

191199
No Chrome, no browser setup required.
192200

@@ -732,7 +740,7 @@ docker logs sap-mcp-cdp-proxy-1
732740

733741
- Check `SAP_URL` is correct and accessible from your browser
734742
- If using auto-login, verify credentials are configured in `systems.json` (see [Configuration Reference](#configuration-reference))
735-
- **Desktop backend:** Make sure the `connection_name` field in `systems.json` matches the SAP Logon **description** exactly (not the SID). Open SAP Logon and compare.
743+
- **Desktop backend:** Make sure the `connection_name` field in `systems.json` matches the SAP Logon pad **description** exactly (the bold text, not the SID). Open SAP Logon and compare.
736744
- Try logging in manually first to verify credentials
737745

738746
### Transaction input field (OK-Code field) not visible

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ classifiers = [
1919
"Programming Language :: Python :: 3.14",
2020
]
2121
dependencies = [
22-
"fastmcp>=2.14.0,<4.0.0", # Standalone FastMCP v2.14+, replaces mcp[cli]
22+
"fastmcp[apps]>=3.2.0,<4.0.0", # FastMCP v3.2+ required for Choice provider (fastmcp.apps)
2323
"httpx>=0.27.0", # HTTP client for GitHub API (feedback issue handler)
2424
"playwright>=1.40.0",
2525
"pydantic>=2.0.0",

requirements.txt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ cryptography==46.0.6
4343
# pyjwt
4444
# secretstorage
4545
cyclopts==4.8.0
46-
# via fastmcp
46+
# via
47+
# fastmcp
48+
# prefab-ui
4749
dnspython==2.8.0
4850
# via email-validator
4951
docstring-parser==0.17.0
@@ -54,7 +56,7 @@ email-validator==2.3.0
5456
# via pydantic
5557
exceptiongroup==1.3.1
5658
# via fastmcp
57-
fastmcp==3.1.1
59+
fastmcp[apps]==3.2.0
5860
# via sapwebguimcp (pyproject.toml)
5961
greenlet==3.3.2
6062
# via playwright
@@ -120,6 +122,8 @@ platformdirs==4.9.4
120122
# via fastmcp
121123
playwright==1.58.0
122124
# via sapwebguimcp (pyproject.toml)
125+
prefab-ui==0.18.1
126+
# via fastmcp
123127
py-key-value-aio[filetree,keyring,memory]==0.4.4
124128
# via fastmcp
125129
pycparser==3.0
@@ -129,6 +133,7 @@ pydantic[email]==2.12.5
129133
# fastmcp
130134
# mcp
131135
# openapi-pydantic
136+
# prefab-ui
132137
# pydantic-settings
133138
# sap-mcp-config
134139
# sapsucker
@@ -170,6 +175,7 @@ rich==14.3.3
170175
# via
171176
# cyclopts
172177
# fastmcp
178+
# prefab-ui
173179
# rich-rst
174180
rich-rst==1.3.2
175181
# via cyclopts

src/sapwebguimcp/models/config.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,13 +253,14 @@ def credentials_for(self, system_key: str) -> tuple[str, str]:
253253
"""Return (user, password) for a system key.
254254
255255
Looks up ``system_key`` in the shared SAP config
256-
(``~/.config/sap-mcp/systems.json``). Falls back to the default
257-
system when the key is not found.
256+
(``~/.config/sap-mcp/systems.json``). Raises ``KeyError`` when the
257+
key is not found.
258258
"""
259259
sap_cfg = get_sap_config()
260260
system = sap_cfg.systems.get(system_key)
261261
if system is None:
262-
system = sap_cfg.get_default()
262+
available = list(sap_cfg.systems.keys())
263+
raise KeyError(f"System key {system_key!r} not found in systems.json. " f"Available: {available}")
263264
return system.user, system.password.get_secret_value()
264265

265266
def validate_for_browser(self) -> list[str]:

src/sapwebguimcp/server.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111

1212
import httpx
1313
from fastmcp import FastMCP
14+
from fastmcp.apps.choice import Choice
1415
from fastmcp.server.middleware.logging import LoggingMiddleware
1516

1617
from sapwebguimcp.backend.manager import close_backend
1718
from sapwebguimcp.logging_config import configure_logging
1819
from sapwebguimcp.middleware import ToolCallLoggingMiddleware
19-
from sapwebguimcp.models.config import get_settings
20+
from sapwebguimcp.models.config import get_sap_config, get_settings
2021
from sapwebguimcp.prompts import register_prompts
2122
from sapwebguimcp.resources import register_feedback_resources, register_intent_resources
2223
from sapwebguimcp.tools import (
@@ -178,14 +179,14 @@ async def app_lifespan(_server: FastMCP) -> AsyncIterator[None]:
178179
179180
CONNECTION:
180181
The server connects using the default_system from ~/.config/sap-mcp/systems.json.
181-
Each system entry has a connection_name field that identifies the SAP Logon entry.
182-
Override via the connection_name parameter in sap_login() (pass the system key from the config).
182+
Each system entry has a sap_logon_entry field (the bold description text in the SAP Logon pad).
183+
Override via the system_key parameter in sap_login() (pass the dictionary key from systems.json).
183184
184185
IF CONNECTION FAILS:
185186
Ask the user to verify:
186187
1. "Is SAP GUI running?" (SAP Logon must be open)
187188
2. "Is scripting enabled?" (both server-side RZ11 and client-side SAP GUI Options)
188-
3. "Does the connection_name in systems.json match the SAP Logon entry description?"
189+
3. "Does the sap_logon_entry in systems.json match the SAP Logon pad description exactly?"
189190
190191
COMMON ERROR CAUSES:
191192
- RPC_E_DISCONNECTED: SAP GUI closed or session timed out — call sap_login() again
@@ -209,7 +210,27 @@ async def app_lifespan(_server: FastMCP) -> AsyncIterator[None]:
209210
- VKey codes: 0=Enter, 3=F3/Back, 8=F8/Execute, 11=F11/Save, 12=F12/Cancel
210211
"""
211212

212-
SERVER_INSTRUCTIONS = _DESKTOP_INSTRUCTIONS if _settings.backend_type == "desktop" else _WEBGUI_INSTRUCTIONS
213+
214+
def _build_instructions() -> str:
215+
base = _DESKTOP_INSTRUCTIONS if _settings.backend_type == "desktop" else _WEBGUI_INSTRUCTIONS
216+
try:
217+
sap_cfg = get_sap_config()
218+
keys = list(sap_cfg.systems.keys())
219+
default = sap_cfg.default_system
220+
systems_info = (
221+
f"\nAVAILABLE SYSTEMS (from systems.json):\n"
222+
f"Default: {default!r}\n"
223+
f"All system keys: {keys}\n"
224+
f"When multiple systems are configured, use the choose tool to let the user "
225+
f"pick a system before calling sap_login(). "
226+
f"Pass the selected key as system_key to sap_login().\n"
227+
)
228+
return base + systems_info
229+
except (FileNotFoundError, ValueError): # config not found or invalid — don't crash
230+
return base
231+
232+
233+
SERVER_INSTRUCTIONS = _build_instructions()
213234

214235
# Create the FastMCP server instance with strict input validation
215236
_SERVER_NAME = "sap-desktop-mcp" if _settings.backend_type == "desktop" else "sap-webgui-mcp"
@@ -223,6 +244,9 @@ async def app_lifespan(_server: FastMCP) -> AsyncIterator[None]:
223244
# Add logging middleware for tool call sequence analysis
224245
mcp.add_middleware(ToolCallLoggingMiddleware())
225246

247+
# Add Choice provider so the LLM can present system selection UI to the user
248+
mcp.add_provider(Choice())
249+
226250
# Add FastMCP built-in logging with payload visibility
227251
mcp.add_middleware(LoggingMiddleware(include_payloads=True, max_payload_length=1000))
228252

src/sapwebguimcp/tools/sap_list_connections_impl.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sapwebguimcp.backend.desktop._landscape import _find_landscape_path, _parse_landscape_xml
88
from sapwebguimcp.backend.manager import get_backend
99
from sapwebguimcp.models.base import ToolResult
10+
from sapwebguimcp.models.config import get_sap_config
1011

1112
__all__ = ["sap_list_connections_impl", "_parse_landscape_xml"]
1213

@@ -15,18 +16,52 @@ class ConnectionListResult(ToolResult):
1516
"""Result from sap_list_connections tool."""
1617

1718
connections: list[dict[str, Any]] = Field(default_factory=list)
19+
configured_systems: list[dict[str, str]] = Field(default_factory=list)
20+
21+
22+
def _get_configured_systems() -> list[dict[str, str]]:
23+
"""Return systems from systems.json without sensitive fields."""
24+
try:
25+
sap_cfg = get_sap_config()
26+
except (FileNotFoundError, ValueError):
27+
return []
28+
result = []
29+
for key, system in sap_cfg.systems.items():
30+
result.append(
31+
{
32+
"key": key,
33+
"sap_logon_entry": system.connection_name,
34+
"host": system.host,
35+
"client": system.client,
36+
"language": system.language,
37+
}
38+
)
39+
return result
1840

1941

2042
async def sap_list_connections_impl() -> ConnectionListResult:
2143
"""List available SAP Logon connections from the landscape file or backend."""
44+
configured_systems = _get_configured_systems()
2245
try:
2346
backend = await get_backend(tool_name="sap_list_connections")
2447
connections = await backend.list_connections()
25-
return ConnectionListResult(success=True, connections=connections)
26-
except Exception as e: # pylint: disable=broad-exception-caught
48+
return ConnectionListResult(
49+
success=True,
50+
connections=connections,
51+
configured_systems=configured_systems,
52+
)
53+
except Exception: # pylint: disable=broad-exception-caught
2754
# Fall back to reading the landscape file directly
2855
path = _find_landscape_path()
2956
if path is None:
30-
return ConnectionListResult.failure(f"Could not find SAPUILandscape.xml: {e}")
57+
return ConnectionListResult(
58+
success=True,
59+
connections=[],
60+
configured_systems=configured_systems,
61+
)
3162
connections = _parse_landscape_xml(path.read_text(encoding="utf-8"))
32-
return ConnectionListResult(success=True, connections=connections)
63+
return ConnectionListResult(
64+
success=True,
65+
connections=connections,
66+
configured_systems=configured_systems,
67+
)

src/sapwebguimcp/tools/sap_login_impl.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
async def sap_login_impl(
1313
url: Optional[str] = None,
1414
client: Optional[str] = None,
15-
connection_name: Optional[str] = None,
15+
system_key: Optional[str] = None,
1616
session_id: Optional[str] = None,
1717
) -> LoginResult:
1818
"""
@@ -21,7 +21,7 @@ async def sap_login_impl(
2121
Args:
2222
url: SAP Web GUI URL (WebGUI only). If not provided, derives from shared config or SAP_URL.
2323
client: SAP client/mandant (3-digit string). If not provided, uses shared config.
24-
connection_name: System key from systems.json. Overrides default_system.
24+
system_key: Dictionary key from systems.json (e.g. "dev-100"). Overrides default_system.
2525
session_id: Session ID for multi-session support.
2626
2727
Returns:
@@ -30,14 +30,22 @@ async def sap_login_impl(
3030
settings = get_settings()
3131
sap_cfg = get_sap_config()
3232

33-
effective_key = connection_name or sap_cfg.default_system
34-
system = sap_cfg.systems.get(effective_key) or sap_cfg.get_default()
33+
effective_key = system_key or sap_cfg.default_system
34+
system = sap_cfg.systems.get(effective_key)
35+
if system is None:
36+
if system_key is not None:
37+
return LoginResult.failure(
38+
f"System key {system_key!r} not found in systems.json. "
39+
f"Available system keys: {list(sap_cfg.systems.keys())}"
40+
)
41+
system = sap_cfg.get_default()
42+
3543
user, password = system.user, system.password.get_secret_value()
3644
effective_client = client or system.client
3745
effective_url = url or settings.sap_url or (system.host + "/sap/bc/gui/sap/its/webgui")
3846
language = system.language
3947

40-
# URL check only applies to WebGUI -- Desktop uses connection_name instead
48+
# URL check only applies to WebGUI -- Desktop uses the SAP Logon entry instead
4149
if settings.backend_type == "webgui" and not effective_url:
4250
return LoginResult.failure(
4351
"No SAP URL provided. Either pass a URL parameter or set SAP_URL, "

src/sapwebguimcp/tools/sap_tools.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -244,13 +244,14 @@ async def sap_keepalive_stop() -> KeepaliveResult:
244244
"On WebGUI: requires Chrome with --remote-debugging-port=9222 and VPN (if internal SAP). "
245245
"On Desktop: requires SAP GUI for Windows with scripting enabled. "
246246
"Credentials are read from ~/.config/sap-mcp/systems.json. "
247-
"Use client or connection_name (system key from systems.json) to override the default system."
247+
"Use system_key (dictionary key from systems.json) to select a non-default system. "
248+
"Call sap_list_connections first to see available system keys."
248249
)
249250
)
250251
async def sap_login(
251252
url: Optional[str] = None,
252253
client: Optional[str] = None,
253-
connection_name: Optional[str] = None,
254+
system_key: Optional[str] = None,
254255
ctx: Context | None = None,
255256
) -> LoginResult:
256257
"""
@@ -263,24 +264,27 @@ async def sap_login(
263264
Args:
264265
url: SAP Web GUI URL (WebGUI only). If not provided, derived from system host.
265266
client: SAP client/mandant (3-digit string, e.g. '200'). Overrides config value.
266-
connection_name: System key from systems.json. The system's connection_name
267-
field identifies the SAP Logon entry.
267+
system_key: Dictionary key from systems.json (e.g. "dev", "qa").
268+
Use sap_list_connections to see available keys. Defaults to default_system.
268269
269270
Returns:
270271
LoginResult indicating login success or what action is needed.
271272
"""
272273
session_id = getattr(ctx, "session_id", None) if ctx else None
273-
return await sap_login_impl(url=url, client=client, connection_name=connection_name, session_id=session_id)
274+
return await sap_login_impl(url=url, client=client, system_key=system_key, session_id=session_id)
274275

275276
@mcp.tool(
276277
description=(
277-
"List available SAP systems from SAP Logon (SAPUILandscape.xml). "
278-
"Returns connection names that can be passed to sap_login as connection_name. "
279-
"Desktop backend only."
278+
"List available SAP systems. Returns two sections: "
279+
"'configured_systems' from systems.json (pass the 'key' field as "
280+
"system_key to sap_login), and 'connections' from SAP Logon "
281+
"(SAPUILandscape.xml, Desktop only). "
282+
"IMPORTANT: pass a configured_systems 'key' as system_key to sap_login, "
283+
"NOT a SAP Logon entry name."
280284
)
281285
)
282286
async def sap_list_connections() -> ConnectionListResult:
283-
"""List available SAP Logon connections."""
287+
"""List configured systems and SAP Logon entries."""
284288
return await sap_list_connections_impl()
285289

286290
@mcp.tool(

0 commit comments

Comments
 (0)