ariba-mcp
README.md
# Metadata-Driven SAP Ariba MCP Server (PoC)
A proof-of-concept MCP server that exposes selected SAP Ariba OpenAPI operations as MCP tools.
Which operations become tools is **configuration, not code**: point it at a downloaded OpenAPI
file, list the `operationId`s you want in `config/tools.yaml`, restart. No per-operation Python.
---
## 1. What this PoC does
```
Client app
→ Entra access token (who is calling this MCP server)
→ MCP server (validates the token, builds the request from OpenAPI)
→ Ariba OAuth token + API key (how this server authenticates to Ariba)
→ SAP Ariba API
```
Two independent identities are involved, and it matters that you keep them apart:
- **Microsoft Entra ID authenticates the caller to the MCP server.** Every MCP request must
carry a valid Entra *access token* for this API, holding the required delegated scope.
- **Ariba OAuth authenticates the MCP application to Ariba.** The server holds one set of
Ariba client credentials and one API key, and uses them for every call.
- **There is no named-user propagation in this PoC.** Ariba sees the same technical identity no
matter who called the tool. Ariba has no idea which Entra user triggered a request; the Entra
identity appears only in this server's logs.
The server:
1. Serves MCP over Streamable HTTP.
2. Validates Entra bearer tokens (signature, issuer, audience, tenant, lifetime, scope).
3. Loads one local OpenAPI 3.0/3.1 JSON or YAML file at startup.
4. Exposes only the operations you explicitly enable.
5. Generates each tool's MCP input schema from the OpenAPI operation.
6. Validates tool arguments against that schema.
7. Acquires and caches an Ariba OAuth token.
8. Calls Ariba with the OAuth token and the API key.
9. Returns a normalized structured response with a correlation ID.
## 2. Architecture
```
Authorization: Bearer <entra-access-token>
MCP client ─────────────────────────────────────────────▶ POST /mcp
│
┌───────────────────────────────────────────────────────────────▼───────────┐
│ Starlette app (built by the MCP SDK) │
│ │
│ GET /health ──────────────────────────────▶ {"status":"ok"} (no auth) │
│ │
│ /mcp ─▶ BearerAuthBackend ─▶ RequireAuthMiddleware ─▶ MCP session │
│ │ │ │ │
│ │ 401 invalid token │ 403 missing scope │ │
│ ▼ ▼ ▼ │
│ EntraTokenVerifier on_list_tools │
│ (OpenID config + JWKS, on_call_tool │
│ both cached) │ │
└──────────────────────────────────────────────────────────────┼───────────┘
│
built once at startup ▼
OpenAPI file ─▶ OpenApiLoader ─▶ RefResolver ─▶ SchemaConverter ─▶ ToolRegistry
tools.yaml ─────────────────────────────────────────────────────▶ │
▼
ToolExecutor
(validate ▸ bind ▸ call ▸ normalize)
│
AribaClient ◀──────────┘
│ Authorization: Bearer <ariba-token>
│ apiKey: <ariba-api-key>
▼
SAP Ariba API
```
## 3. Project structure
| Path | Purpose |
| --- | --- |
| `app/main.py` | Startup wiring: builds the registry once, mounts `/mcp` and `/health`, runs uvicorn. |
| `app/config.py` | Typed environment settings; exits with a readable message when configuration is invalid. |
| `app/errors.py` | The `AppError` model and error codes behind the normalized error envelope. |
| `app/logging_config.py` | Key-value logging helpers; only ever receives non-secret fields. |
| `app/auth/entra.py` | Validates Entra access tokens and extracts the caller context. |
| `app/auth/ariba_oauth.py` | Acquires, caches and refreshes the Ariba application OAuth token. |
| `app/openapi/loader.py` | Loads the OpenAPI file and indexes operations by `operationId`. |
| `app/openapi/models.py` | Immutable snapshots of an operation, its parameters and request body. |
| `app/openapi/resolver.py` | Resolves local `#/...` references and refuses external ones. |
| `app/openapi/schema_converter.py` | Turns an operation into an MCP input schema plus argument bindings. |
| `app/tools/registry.py` | Reads `config/tools.yaml` and validates it against the OpenAPI document. |
| `app/tools/executor.py` | The one execution path shared by every tool. |
| `app/ariba/client.py` | The long-lived HTTP client; owns all security-sensitive headers. |
| `config/tools.yaml` | Which operations are exposed, under what names, with which parameters. |
| `specs/ariba-api.example.yaml` | Mock spec so the PoC runs without the real SAP file. |
## 4. Prerequisites
- Python 3.12
- A Microsoft Entra tenant where you can register applications
- An app registration that **exposes** this MCP API (the "API app registration")
- An app registration for the **client** that will call the MCP server
- An SAP Ariba Developer Portal application, with access approved for the API you want
- The downloaded OpenAPI JSON/YAML for that API
- Ariba OAuth client ID and secret, and the application (API) key
## 5. Microsoft Entra setup
1. **Register the MCP API.** Entra admin centre → App registrations → New registration. Note its
*Application (client) ID* — this is `ENTRA_API_CLIENT_ID`, and the *Directory (tenant) ID* —
this is `ENTRA_TENANT_ID`.
2. **Expose the API.** On the API registration → *Expose an API* → set the Application ID URI
(`api://<client-id>` is the default).
3. **Create the delegated scope** `ariba.access` (Add a scope → admin/user consent text →
enabled).
4. **Register the client application** that will call the MCP server.
5. On the client → *API permissions* → *My APIs* → select the MCP API → **Delegated permissions**
→ tick `ariba.access`.
6. **Grant admin consent** if your tenant requires it.
7. **Configure a redirect URI** on the client that matches its type (for a desktop/CLI test client,
`http://localhost` as a *Mobile and desktop* platform).
8. **Obtain an access token** for the scope `api://<ENTRA_API_CLIENT_ID>/ariba.access` (see §10).
The server expects an **access token, not an ID token**, and checks these claims:
| Claim | Expected |
| --- | --- |
| `aud` | `ENTRA_API_CLIENT_ID`, or `api://<ENTRA_API_CLIENT_ID>` — both are accepted |
| `iss` | The `issuer` published by your tenant's OpenID configuration |
| `tid` | Exactly `ENTRA_TENANT_ID` |
| `oid` | Required; `tid` + `oid` is the audit identity |
| `scp` | Space-separated; must contain `ENTRA_REQUIRED_SCOPE` as a whole entry |
| `exp` / `nbf` | Must be currently valid (60s leeway by default) |
A token with no `scp` claim is rejected outright, which is what keeps an ID token from being
accepted even if its audience happens to match.
## 6. SAP Ariba setup
1. Create or reuse an application in the [SAP Ariba Developer Portal](https://developer.ariba.com).
2. Request access to the API you need and wait for approval.
3. Copy the **application key** — this is `ARIBA_API_KEY`.
4. Generate the **OAuth credentials** — `ARIBA_CLIENT_ID` and `ARIBA_CLIENT_SECRET`.
5. Note the **token URL** (`ARIBA_TOKEN_URL`) and the **API base URL** (`ARIBA_BASE_URL`) for
your region and environment.
6. Download the OpenAPI JSON/YAML for that API.
7. Put the file under `specs/`.
8. Set `ARIBA_OPENAPI_FILE` to its path.
The exact token parameters and the API-key header name differ between Ariba APIs and between
application configurations. Check your API's documentation and adjust `ARIBA_API_KEY_HEADER`
(commonly `apiKey`) and `ARIBA_TOKEN_AUTH_STYLE`. See §15 for what this PoC assumes.
## 7. Local setup
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # Windows: copy .env.example .env
```
Then edit `.env`:
- `ENTRA_TENANT_ID` / `ENTRA_API_CLIENT_ID` from §5.
- `ARIBA_*` values from §6.
- Leave `ARIBA_OPENAPI_FILE` as the example spec for a first run.
- Set `MCP_ALLOWED_HOSTS` if you bind to anything other than localhost (see §9).
`.env` is in `.gitignore`. Do not commit it.
## 8. Configure tools
`config/tools.yaml` maps `operation_id` (an `operationId` from the OpenAPI document) to an MCP
tool:
```yaml
tools:
- operation_id: searchCatalogItems # must exist in the OpenAPI file
tool_name: search_catalog_items # the MCP tool name; must be unique
enabled: true # only enabled operations are exposed
read_only: true # advertised as an MCP read-only hint
description: >
Search SAP Ariba catalog items using approved structured search
parameters. Use this when a requester is looking for products.
allowed_parameters: # only these parameters are exposed
- query
- supplierId
- commodityCode
- limit
defaults: # applied when the caller omits the argument
limit: 10
limits: # may only tighten the OpenAPI schema
limit:
maximum: 25
```
Rules:
- Only operations with `enabled: true` become tools.
- Every `operation_id` must exist in the OpenAPI document — **including disabled ones**, so a
typo fails at startup rather than the day somebody enables it.
- `allowed_parameters` is an allowlist. Anything not listed is invisible to callers. Path
parameters are always exposed, since the URL cannot be built without them.
- `defaults` and `limits` must refer to parameters that are actually exposed.
- `limits` can only make a constraint stricter. Configuring `maximum: 500` against a spec that
says `maximum: 50` leaves 50 in place.
- `description` overrides the OpenAPI description. If both are absent, startup fails.
- `aliases` renames an awkward parameter (`{"$top": "top"}`) while still sending the original name.
- **Restart the server after any change** — the configuration is read once at startup.
Optional key not shown above: `aliases`.
## 9. Run the server
```bash
python -m app.main
```
Expected routes:
| Route | Auth | Purpose |
| --- | --- | --- |
| `GET /health` | none | Liveness. Returns `{"status": "ok"}`. Never contacts Entra or Ariba. |
| `POST /mcp` | Bearer | MCP Streamable HTTP requests. |
| `GET /mcp` | Bearer | MCP Streamable HTTP server-to-client stream. |
| `GET /.well-known/oauth-protected-resource/mcp` | none | Resource metadata added by the SDK. |
On a successful start you will see:
```
INFO ariba_mcp event=server_started tools=search_catalog_items,get_catalog_item openapi_file=specs/ariba-api.example.yaml
INFO Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
```
**A note on `MCP_ALLOWED_HOSTS`.** The MCP SDK's DNS-rebinding protection is enabled
automatically only when the server binds to localhost. If you bind to `0.0.0.0` (the default, and
what the container does) that protection is off unless you list the hostnames clients use:
```
MCP_ALLOWED_HOSTS=ariba-mcp.example.com,localhost:*
```
Requests whose `Host` header is not on the list are then rejected with HTTP 421.
## 10. Obtain a test Entra token
Use your registered **client** application with MSAL and an interactive sign-in. Save this as
`get_token.py` outside the repository, or run it in a scratch directory:
```python
# pip install msal
import msal
TENANT_ID = "<your-tenant-id>"
CLIENT_ID = "<your-CLIENT-app-client-id>" # the client, not the API
SCOPE = ["api://<your-API-app-client-id>/ariba.access"]
app = msal.PublicClientApplication(
CLIENT_ID, authority=f"https://login.microsoftonline.com/{TENANT_ID}"
)
result = app.acquire_token_interactive(scopes=SCOPE)
print(result["access_token"])
```
Keep the token out of your shell history — assign it in a way your shell does not record
(a leading space works in bash/zsh with `HISTCONTROL=ignorespace`), or have your MCP client
fetch it directly. Paste it into [jwt.ms](https://jwt.ms) to confirm `aud`, `tid` and `scp`
match what §5 describes before you blame the server.
## 11. Test the MCP server
This is an MCP endpoint, not a REST API — talk to it with an MCP client that supports Streamable
HTTP and a bearer token, not with plain `curl` calls to tool URLs (there are none). Configure your
client with:
- **Endpoint:** `http://localhost:8000/mcp`
- **Header:** `Authorization: Bearer <entra-access-token>`
With the official Python SDK:
```python
import asyncio, httpx2
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
TOKEN = "<entra-access-token>"
async def main():
async with httpx2.AsyncClient(headers={"Authorization": f"Bearer {TOKEN}"}) as http:
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http)
async with Client(transport, raise_exceptions=True) as client:
tools = await client.list_tools()
for tool in tools.tools:
print(tool.name, tool.input_schema)
result = await client.call_tool(
"search_catalog_items", {"query": "laptop", "limit": 5}
)
print(result.structured_content)
asyncio.run(main())
```
`tools/list` returns the enabled tools with their OpenAPI-derived schemas:
```json
{
"name": "search_catalog_items",
"description": "Search SAP Ariba catalog items using approved structured search parameters. ...",
"inputSchema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "default": 10, "minimum": 1, "maximum": 25},
"query": {"type": "string", "minLength": 2, "description": "Free-text search term."},
"supplierId": {"type": "string"},
"commodityCode": {"type": "string"}
},
"additionalProperties": false,
"required": ["query"]
}
}
```
A successful `tools/call` returns:
```json
{
"data": {"items": [{"itemId": "I1", "description": "Laptop"}]},
"pagination": {"next_cursor": null, "has_more": false},
"metadata": {
"tool": "search_catalog_items",
"operation_id": "searchCatalogItems",
"http_status": 200,
"correlation_id": "3f2a..."
},
"warnings": []
}
```
`pagination` appears only when the backend actually reported pagination. A failure returns
`is_error: true` with:
```json
{
"error": {
"code": "ARIBA_API_ERROR",
"message": "SAP Ariba rejected the request.",
"retryable": false,
"correlation_id": "3f2a...",
"details": {"http_status": 400}
}
}
```
You can still check liveness and auth with `curl`:
```bash
curl -i http://localhost:8000/health # 200 {"status":"ok"}
curl -i -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' # 401, no token
```
## 12. Run tests
```bash
pytest
pytest -q
```
Tests never contact Microsoft or SAP: Entra discovery/JWKS and every Ariba call are mocked with
`respx`, and test tokens are signed with an RSA key generated in-process.
## 13. Docker
```bash
docker build -t ariba-mcp-poc .
docker run --rm -p 8000:8000 --env-file .env ariba-mcp-poc
```
Configuration arrives through environment variables at run time. The image contains no secrets
and no `.env` (both are excluded by `.dockerignore`), and runs as a non-root user. For a single
override, add `-e ARIBA_VERIFY_SSL=false` and so on. To use a spec that is not baked into the
image, mount it: `-v "$PWD/specs:/app/specs:ro"`.
## 14. Replace the example OpenAPI file
1. Copy your downloaded file into `specs/`, e.g. `specs/ariba-catalog.yaml`.
2. Set `ARIBA_OPENAPI_FILE=specs/ariba-catalog.yaml` in `.env`.
3. Inspect the available operation IDs:
```bash
python -c "from pathlib import Path; from app.openapi.loader import load_openapi_document; \
print('\n'.join(load_openapi_document(Path('specs/ariba-catalog.yaml')).operation_ids()))"
```
4. Add the operations you want to `config/tools.yaml` with `enabled: true`.
5. Start the server.
6. Resolve any `OPENAPI_SCHEMA_UNSUPPORTED` errors. Each one names the operation, the location
and the construct, e.g. *"Operation 'createRequisitionDraft' uses the unsupported construct
'oneOf' at request body."* Either pick a different operation, or narrow the exposed parameters
with `allowed_parameters` so the unsupported part is not reachable.
Adding a compatible operation this way exposes a new MCP tool with **no Python changes**.
### Unsupported OpenAPI constructs
These fail at startup for an *enabled* tool rather than being silently widened:
- `oneOf`, `anyOf`, `not`, `discriminator`
- Recursive schemas (a `$ref` that reaches itself)
- Request bodies without an `application/json` content type
- `allOf` that composes anything other than objects
- Empty/untyped schemas (`{}`)
- Cookie parameters, and HTTP methods other than GET/POST/PUT/PATCH
- Types outside string, integer, number, boolean, array, object, null
`nullable: true` (OpenAPI 3.0) is normalized to a JSON Schema type union `["string", "null"]`,
which matches how OpenAPI 3.1 expresses it.
Objects that declare `properties` get `additionalProperties: false` unless the spec says
otherwise, so unknown fields are rejected before they reach Ariba. A free-form object (no declared
properties) stays open.
## 15. Assumptions about the Ariba OAuth request
Verified against SAP's documented shape but **not** against a live Ariba tenant:
- `POST` to `ARIBA_TOKEN_URL` with `grant_type=client_credentials`, form-encoded.
- Credentials as an HTTP Basic header, `Authorization: Basic base64(client_id:client_secret)`.
Set `ARIBA_TOKEN_AUTH_STYLE=body` to send `client_id`/`client_secret` as form fields instead.
- `expires_in` is honoured when present; otherwise a 10-minute lifetime is assumed. The token is
refreshed `ARIBA_TOKEN_REFRESH_MARGIN_SECONDS` (default 60) before it expires.
- **Refresh tokens are not used.** A client-credentials token is cheap to re-acquire, so the
provider simply requests a new one. If your Ariba application requires a refresh-token flow,
`AribaTokenProvider._build_token_request` is the single method to change.
- On a backend 401 the cached token is dropped and the request is retried **once**. A second 401
is returned as an error. Write requests are never retried after a 5xx.
## 16. Troubleshooting
| Symptom | Cause and fix |
| --- | --- |
| `401` with `error="invalid_token"` | No `Authorization: Bearer` header, or the token failed validation. Check the server log for `event=entra_token_rejected`, which states the reason. |
| `401`, token looks fine | Wrong audience. `aud` must be `ENTRA_API_CLIENT_ID` or `api://<that-id>`. Requesting a token for Microsoft Graph instead of your API is the usual cause. |
| `401`, "Token was issued by a different tenant" | `tid` ≠ `ENTRA_TENANT_ID`. |
| `401`, "no 'scp' claim" | You sent an ID token, not an access token. Request the `api://.../ariba.access` scope. |
| `403 insufficient_scope` | Valid token, but `scp` lacks `ariba.access`. Add the delegated permission and grant consent. Matching is exact — `ariba.accessible` does not count. |
| `421 Misdirected Request` | The `Host` header is not in `MCP_ALLOWED_HOSTS`. See §9. |
| `ARIBA_AUTHENTICATION_FAILED` | Ariba rejected the client credentials. Verify `ARIBA_CLIENT_ID`/`ARIBA_CLIENT_SECRET` and `ARIBA_TOKEN_URL`; try `ARIBA_TOKEN_AUTH_STYLE=body`. |
| `ARIBA_API_ERROR` with 401/403 after a token was obtained | Usually the API key: check `ARIBA_API_KEY` and that `ARIBA_API_KEY_HEADER` matches your API's documented header name. |
| `ARIBA_API_ERROR` with 404 on every call | `ARIBA_BASE_URL` is wrong, or already includes a path the OpenAPI templates repeat. |
| `OPENAPI_OPERATION_NOT_FOUND` at startup | An `operation_id` in `tools.yaml` is not in the spec. Use the command in §14 to list valid IDs. |
| `OPENAPI_SCHEMA_UNSUPPORTED` at startup | See §14. The message names the operation, location and construct. |
| SSL / certificate errors | A TLS-intercepting proxy. Point `SSL_CERT_FILE` at your CA bundle. `ARIBA_VERIFY_SSL=false` exists for local debugging only — never use it against production. |
| `TIMEOUT` errors | Raise `ARIBA_HTTP_TIMEOUT_SECONDS`, or check network reachability to `ARIBA_BASE_URL`. The error is marked `retryable: true`. |
Every tool call logs one line with the correlation ID that also appears in the response
`metadata`, so you can match a client-side failure to a server-side log:
```
event=tool_call correlation_id=3f2a... tenant_id=... object_id=... tool=search_catalog_items \
operation_id=searchCatalogItems method=GET path=/catalog/items backend_status=200 \
duration_ms=142 status=ok
```
Note that `path` is the OpenAPI *template*, not the substituted URL, so caller-supplied
identifiers stay out of the logs.
## 17. Security limitations
This is a proof of concept, not a production service:
- **A single technical Ariba identity** performs every backend call.
- **No named-user propagation.** Ariba cannot attribute an action to the Entra user who caused it.
- **No production RBAC.** Any caller holding `ariba.access` can invoke every enabled tool; there
is no per-tool authorization.
- **In-memory token cache**, so **one server instance is assumed**. Multiple replicas each hold
their own Ariba token.
- **No persistent audit store.** Audit information exists only in the process log.
- **Write tools should stay disabled** unless you are deliberately testing them.
`createRequisitionDraft` ships as `enabled: false` for this reason.
- Secrets come from environment variables, with no secret-manager integration and no rotation.
## 18. Future enhancements
- Entra-to-Ariba identity resolution and named-user requester/preparer enforcement
- App roles and tool-level RBAC
- Dynamic metadata refresh instead of a restart
- Azure Key Vault for secrets, and a distributed token cache for multiple replicas
- Persistent audit storage
- API-specific response normalization
- Approval/confirmation controls in front of write operations
---
## Note on the MCP SDK
The original brief called for `FastMCP`. Built against **`mcp==2.0.0`**, this uses the SDK's
low-level `Server` instead, for one blocking reason:
`mcp.server.fastmcp` no longer exists in 2.0 (it was renamed `MCPServer`), and neither that class
nor 1.x's `FastMCP` can advertise a hand-built input schema — both derive a tool's `inputSchema`
from its Python function signature via `func_metadata`, with no override. That is incompatible
with the core requirement that schemas come from OpenAPI.
`mcp.server.lowlevel.Server` takes `on_list_tools` / `on_call_tool` callables and passes each
`types.Tool(input_schema=...)` through untouched, and its `streamable_http_app(...)` supplies the
Streamable HTTP transport, the bearer-auth middleware and the unauthenticated `/health` route in
one supported call. Everything else in the brief is unchanged.
Two consequences worth knowing:
- The low-level server does **not** validate `tools/call` arguments against the advertised schema,
so `ToolExecutor` validates them with `jsonschema` before anything is sent to Ariba.
- The SDK depends on `httpx2`. This project's own Ariba client uses `httpx` 0.28 (so tests can
mock it with `respx`); both libraries coexist in one environment.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues