MCP server (curated /mcp/* surface)
Operator runbook for the anonymous Model Context Protocol surface at /mcp/*. The CAP adapter (@cap-js/mcp@1.1.1) exposes selected read-only services as MCP tools; visitors' MCP clients connect through the approuter with no XSUAA round-trip.
Adapter: @cap-js/mcp@1.1.1 — cds10-compatible; registers itself under cds.protocols.mcp so no protocols block is needed in project package.json. Approuter route: /mcp/* → srv-api, authenticationType: none, csrfProtection: false. The parallel /mcp-auth/* prefix is reserved for Phase 2 — do not repurpose. Services participating: SearchService, HomepageService, KnowledgeGraphService — each declares @protocol: ['odata', ..., 'mcp']. Never use the @mcp single-protocol shortcut alone: it replaces the default OData mount (same trap as [[cap-graphql-shortcut-replaces-odata]]).
TL;DR
| You want to | Do this | Turnaround |
|---|---|---|
| Ship a new curated tool | Add a CDS function under one of the three services, redeploy MTA | ~15 min |
| Retire one tool (temporary) | Comment out the CDS function + its handler, redeploy | ~15 min |
| Retire one service's whole MCP surface | Remove 'mcp' from that service's @protocol list, redeploy | ~15 min |
| Full MCP shutdown (ultima ratio) | npm uninstall @cap-js/mcp, redeploy | ~15 min |
| Roll back the last change | cf rollback tutorials-srv | ~1 min |
Deploy
The adapter is a normal CAP protocol plugin — it boots with the CAP process and has no separate lifecycle. There is no MCP-specific deploy step. Use the standard MTA workflow:
npm run build:all
cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -fOn boot you'll see the plugin register under cds.protocols.mcp and each participating service serve at /mcp/<serviceRoot>. Endpoints are /mcp/search, /mcp/homepage, /mcp/graph.
Disable one curated tool
To pull a single tool off the surface without touching anything else:
- Comment out the CDS function declaration in the service
.cdsfile. - Comment out the paired handler registration in the corresponding
srv/*-service.js(or delete the whole handler — MCP will simply stop advertising the tool once the CDS function is gone). npm run build:all && cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f.
Clients that previously used the tool will get an "unknown tool" error on tools/call and the tool will not appear in tools/list. The other tools on the same service keep working. This is a no-op for well-behaved MCP clients — they refresh tools/list on reconnect.
Disable one whole service's MCP surface
To take one service out of MCP entirely while keeping OData and any other protocols live:
Edit the service definition and drop 'mcp' from the @protocol list. Example, for KnowledgeGraphService:
- @protocol: ['odata', 'hcql', 'mcp']
+ @protocol: ['odata', 'hcql']
service KnowledgeGraphService @(path: '/knowledge-graph') { ... }Redeploy. The service continues to serve OData at its existing path; /mcp/graph returns 404. The other two services (SearchService, HomepageService) are unaffected.
Do NOT rewrite this as @mcp or @odata alone — those are single-protocol shortcuts that replace the default OData mount and will 404 all OData clients too. Always keep @protocol: as an explicit list.
Full MCP shutdown (ultima ratio)
Prefer per-service disable above. If you must pull the whole adapter (adapter bug, CVE, protocol-level abuse), do this:
npm uninstall @cap-js/mcp
npm run build:all
cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -fThe adapter is gone from cds.protocols on next boot; every /mcp/* request returns 404 at the CAP layer. The approuter still routes the prefix but there's nothing behind it. To restore: npm install @cap-js/mcp@1.1.1 and redeploy.
Rollback
There is no MCP-specific state — no manifest, no session store, no outbox rows. Standard app rollback recovers the entire surface:
cf rollback tutorials-srvIf the change touched only .cds / .js files and not db/, this is safe to run at any time. If a schema change rode along, use the normal deploy-rollback procedure (see mta-deployment.md).
Config knobs
All flags live under cds.mcp.* in package.json (project-level cds block). Set at the project root; adapter reads them on boot.
| Flag | Default | Effect |
|---|---|---|
per_action_tool | true in this project | Each CDS function surfaces as its own named MCP tool (e.g. search_semanticSearch). Flip to false to revert to a single generic call_action tool that takes an action name + parameters map. Curated tool ergonomics on the client side are much better with true. |
toon_format | true (adapter default) | Query results serialize as TOON (a compact tabular text format friendlier to LLM token budgets). Set false to force JSON when a specific client can't parse TOON. |
prefix | unset | Optional string prefixed to every tool name. Useful when a single MCP client attaches to multiple CAP services and needs to disambiguate tool names. Not currently set in this project — service-per-endpoint gives natural namespacing. |
autowire | true | The adapter's own auto-registration of participating services (anything with mcp in @protocol). Turning this off requires manual wiring per service and is not currently needed. |
To change a flag, edit package.json under cds.mcp, redeploy. There is no runtime toggle.
Rate limiting
/mcp/* inherits the approuter's anonymous-IP throttle — the same bucket that guards /tutorials/* and /homepage/* for unauthenticated visitors. If MCP traffic patterns diverge (e.g. one client hammers tools/list in tight loops), tune independently in approuter/xs-app.json by adding a dedicated route with its own rateLimit block for /mcp/*. There is no per-tool rate limit inside CAP — the adapter treats every call as a normal CDS function invocation.
Common failures
tools/list returns empty
Two causes, both diagnosable from a redeploy:
- The service you expected is missing
'mcp'from@protocol. Check the.cdsfile — the list must literally include'mcp', not@mcp. cds.env.mcp?.autowire === false(project-level or env override). Restorecds.mcp.autowire: true(or delete the key —trueis the default).
Boot logs show serving <Service> { at: '/mcp/<root>' } for each MCP-enabled service. Missing that line = the service is not participating.
initialize returns 401
/mcp/* is anonymous. If a client sees 401, the request is not hitting the anonymous route:
- Verify the URL is
/mcp/search,/mcp/homepage, or/mcp/graph. Anything else (including/mcp-auth/*) is not routed tosrv-apias anonymous —/mcp-auth/*is reserved for Phase 2 and currently returns 404 or 401 depending on approuter config. - Confirm the client isn't hitting an authenticated CAP service at a different path and appending
/mcpto it. Only the three curated services participate.
Connection resets after the first request
The MCP transport in this adapter is stateless per-request (sessionIdGenerator: undefined, the adapter default). Some MCP clients assume a persistent session and reset when a subsequent request is treated as a fresh handshake. Confirm the client is configured for stateless transport, or wrap it in a helper that reissues initialize per call.
Client sees JSON when it expected SSE (or vice versa)
The adapter picks the response encoding from the client's Accept header. If a client omits Accept: text/event-stream, it gets a single JSON payload. Set the header explicitly on the client side; no server-side change is needed.
Metrics
No MCP-specific counters are exported yet. The general srv-error-rate alert on 5xx from tutorials-srv covers adapter-level failures — if the plugin throws on tools/list, that surfaces as a 500 and rolls into the standard alert. Per-tool call counts / latency histograms are a future enhancement (tracked as a nice-to-have; no issue open).
Phase 2 preparation
The approuter reserves /mcp-auth/* for the authenticated MCP surface (Phase 2 — MCP calls that require an XSUAA bearer, e.g. tools that read a user's tutorial progress). Do not squat on this prefix for anything else. When Phase 2 lands, the plan is to mount an OAuth-protected sibling of the current adapter under /mcp-auth/* while keeping the anonymous /mcp/* surface unchanged.
Phase 2 operations
Minting a fixture PAT for smoke tests
Smoke tests can verify PAT-authenticated routes by setting the MCP_SMOKE_PAT env var before running npm run test:smoke. To mint a fixture token:
- Sign in to
<env-base>/admin-ui/#patsas a user withTutorials MCP Usersrole collection. - Click New token, name it
smoke-fixture, scopesread, TTL 365 days. - Copy the displayed
pat_...value — shown once only. - Store it in the env's BTP Credential Store as secret name
mcp-smoke-pat(or setMCP_SMOKE_PATfor local runs).
For emergency rotation without the admin UI (not recommended), call the endpoint directly:
curl -X POST https://<approuter-url>/pats/mintPAT \
-H "Authorization: Bearer <xsuaa-jwt>" \
-H "Content-Type: application/json" \
-d '{ "name": "smoke-fixture", "scopes": ["read"], "ttlDays": 365 }'The PAT mint UI is tracked as follow-up issue #1132. Until it ships, minting goes through the API endpoint above or via the
/admin-ui/#patsadmin page.
Flipping the feature flags
Three Phase 2 feature flags control the MCP surface. All default to true (enabled).
# Disable the authenticated MCP surface entirely
cf set-env tutorials-srv MCP_AUTH_ENABLED false && cf restart tutorials-srv
# Disable PAT minting (existing PATs continue to work)
cf set-env tutorials-srv MCP_PAT_MINT_ENABLED false && cf restart tutorials-srv
# Disable the step-HTML slicer (get_tutorial_step returns 404 for all slugs)
cf set-env tutorials-srv KG_STEP_SLICER_ENABLED false && cf restart tutorials-srvTo restore a flag to default, cf unset-env tutorials-srv <NAME> && cf restart tutorials-srv (unset = default true).
Granting Tutorials MCP Users role collection
The Tutorials MCP Users BTP role collection grants Tutorial.MCP scope — required for OAuth-authenticated access to /mcp-auth/*. Assign it per-user:
# Single user
btp assign security/role-collection "Tutorials MCP Users" \
--to-user <email> \
--subaccount <subaccount-id>For batch assignment (e.g. all IAS users in a team), use the bulk-assign script:
node scripts/btp-role-collection-sync.js \
--collection "Tutorials MCP Users" \
--users emails.txt \
--subaccount <subaccount-id>Alternatively, assign the role collection via the BTP cockpit: Security → Role Collections → Tutorials MCP Users → Users → Add.
Reading the MCP metrics
Phase 2 emits custom metrics.counter() events via the project's in-memory metrics producer (srv/lib/metrics.js) — not Prometheus. Counter names are dot-separated with labels embedded directly in the name string (e.g. mcp.pat.auth[outcome=hit]); there is no _total suffix and no per-label Prometheus dimension. Every 5 minutes srv/jobs/metrics-rollup-job.js snapshots and drains these counters into HANA MetricSnapshots rows and structured cds.log('jobs/metrics-rollup') lines. See observability.md.
Query them from any of the observability surfaces:
- Admin UI —
/admin-ui/#metrics(live snapshot cards). - CAP function —
GET /admin/getMetricsSnapshot()(XSUAA Admin scope) returns{ counters, gauges, histograms }; each MCP counter appears as a key incounterswith its full label-embedded name. - Express route —
GET /admin/metrics/live(Admin scope) — same shape, for on-callcurlvia an authenticated session. - CF logs — one
cds.log('jobs/metrics-rollup')info line per counter per 5-minute boundary.
Raw counter names, exactly as emitted by metrics.counter() (grep the counters map from a snapshot for these prefixes):
mcp.pat.auth[outcome=hit|miss|revoked|expired]— PAT middleware auth outcomes (srv/lib/mcp-pat-middleware.js). Anything other thanhitis a rejected 401.mcp.pat.mint/mcp.pat.revoke— PAT lifecycle actions (srv/lib/mcp-pat-actions.js).mcp.slice[outcome=hit|miss|error]— step-HTML slicer cache/extraction outcomes (srv/lib/tutorial-step-slicer.js).mcp.tool[service=DeveloperService,tool=<name>,tokenSource=<pat|anon>,outcome=ok|error]— authenticated DeveloperService tool calls (srv/lib/mcp-developer-tools.js).mcp.tool[service=HomepageService,tool=<name>,tokenSource=<pat|anon>,outcome=ok|error]— authenticated HomepageService tool calls (srv/lib/mcp-homepage-tools.js).
To compute a rate, take two MetricSnapshots rows (5 minutes apart) and subtract the counter values — the rollup zeroes counters on each drain, so a single snapshot already holds the delta for its 5-minute window. Example ratios:
- PAT auth failure rate —
sum(mcp.pat.auth[outcome!=hit]) / sum(mcp.pat.auth[*])over a window. - Step-slicer cache hit rate —
mcp.slice[outcome=hit] / (mcp.slice[outcome=hit] + mcp.slice[outcome=miss]). - Tool call error rate per service —
mcp.tool[...,outcome=error] / mcp.tool[...,outcome=ok|error], grouped by theservice=label embedded in the name.
Reading the audit trail for authenticated tool calls
The TutorialProgressReset audit event (emitted by reset_tutorial_progress) now carries a tokenSource field. Filter for MCP-originating resets:
SELECT * FROM "COM_SAP_DEVELOPERS_IMS_AUDITLOG"
WHERE "EVENTSOURCETYPE" = 'TutorialProgressReset'
AND "TOKENSOURCE" IS NOT NULL
ORDER BY "CREATEDAT" DESC;tokenSource = 'pat' = PAT caller; tokenSource = null = JWT/OAuth browser caller. tokenSource is visible in the existing observability surface — see docs/developers/architecture/observability.md.
Migration note for the sap-devs CLI / MCP owner
The sap-devs CLI and its bundled MCP server are a downstream consumer of this surface. When they wire up to the hosted endpoints, hand the owner the following so no assumptions leak from Phase 1:
Endpoint map
| Consumer shape | URL | Auth |
|---|---|---|
| Anonymous curated tools (Phase 1, unchanged) | <base>/mcp/search, /mcp/homepage, /mcp/graph | none |
| Authenticated / personalized tools (browser agent) | <base>/mcp-auth/api | OAuth 2.1 + PKCE via XSUAA; requires the Tutorial.MCP scope |
| Authenticated / personalized tools (headless CLI, CI) | <base>/mcp-pat/api | Authorization: Bearer pat_… |
<base> on Dev is https://tutorial-system-dev-tutorials-approuter.cfapps.eu10-005.hana.ondemand.com. (Note this is the actual CF route, not the vanity developers-dev.* host — confirm the current route with cf routes before hardcoding.)
What the owner must do
- Prefer PAT for the CLI. A CLI is a headless agent — route personalized calls through
/mcp-pat/apiwith a PAT minted at/admin-ui/#pats. Reserve/mcp-auth/api(interactive OAuth) for GUI clients like Claude Desktop. - Auto-discovery. OAuth clients read
<base>/.well-known/oauth-authorization-serverand<base>/.well-known/oauth-protected-resourceto self-configure. These are served statically by the approuter — if a client 404s on them, the approuter route order regressed (the specific static routes must precede the broad^/.well-known/(.*)$ORD route; guarded bytest/unit/approuter-mcp-route.test.js). - Scope grant. Interactive OAuth users need the
Tutorials MCP Usersrole collection (grantsTutorial.MCP) — see GrantingTutorials MCP Usersrole collection above. PAT callers do not need the XSUAA scope; the PAT's ownscopesarray (read/write) governs access. - Do not depend on
@sap/-internal MCP behavior. The adapter is the public@cap-js/mcp; tool names and the/mcp*namespaces are stable across Phase 2→3 (Phase 3 only adds tools). Client configs will not need to change on the Phase 3 rollout. - Rate limits & failure modes. Both authenticated paths converge on the same CAP handlers and rate limits as the anonymous surface — see Rate limiting and Common failures. An expired/revoked PAT returns 401.
File any downstream integration issues against this repo referencing #1105, and cross-link the sap-devs-side tracking issue here once it exists.
Phase 3 flags & operations
Phase 3 introduces four additional feature flags. All default to true (enabled). Toggle with cf set-env tutorials-srv <FLAG> false && cf restart tutorials-srv; restore with cf unset-env tutorials-srv <FLAG> && cf restart tutorials-srv.
| Flag | Default | Effect when false |
|---|---|---|
MCP_PHASE3_ENABLED | true | Compose router is not mounted; @cap-js/mcp serves all services in tools-only mode; /mcp-admin/* returns 503 |
MCP_RESOURCES_ENABLED | true | Resources capability is not advertised; resources/list and resources/read return "method not found" |
MCP_PROMPTS_ENABLED | true | Prompts capability is not advertised; prompts/list and prompts/get return "method not found" |
MCP_ADMIN_TOOLS_ENABLED | true | /mcp-admin/* returns 503 even when MCP_PHASE3_ENABLED is true |
/mcp-admin/* access control
The approuter enforces XSUAA on /mcp-admin/* (no anonymous access). Inside the compose router, AdminService carries @requires: 'Admin' at the service level, so that scope is ANDed with every per-tool scope before any admin tool is dispatched. Each admin tool additionally carries a per-action @requires annotation for finer gating. An admin caller therefore needs all three: the approuter's route scope, the service-level Admin scope, and the per-tool scope.
Effective scope requirement per tool:
| Tool | Approuter (route) | Service level | Per-tool |
|---|---|---|---|
merge_concepts | Tutorial.MCP | Admin | KnowledgeGraph.Admin |
promote_community_to_mission | Tutorial.MCP | Admin | SuperAdmin |
publish_content | Tutorial.MCP | Admin | SuperAdmin |
trigger_rebuild | Tutorial.MCP | Admin | Tutorial.Author |
A caller with
Tutorial.MCP+Tutorial.Authorbut withoutAdminwill receive a 403 from the service layer. TheAdminscope must be granted in addition to any per-tool scope.
Tutorial.MCP— required for all/mcp-admin/*access (enforced by approuter).Admin— required byAdminServiceat the service level (ANDed with per-tool scope).KnowledgeGraph.Admin—merge_concepts.SuperAdmin—promote_community_to_mission,publish_content.Tutorial.Author—trigger_rebuild.
publish_content also requires CONTENT_API_KEY to be configured at runtime — the tool returns 503 if the env var is absent. Prefer trigger_rebuild (GitHub workflow dispatch) for all routine content updates.
Phase 3 metrics
Three new counters are emitted by the compose layer and follow the same metrics.counter() convention as Phase 2 — see Reading the MCP metrics above for how to query them.
| Counter | What it measures | Alert threshold |
|---|---|---|
mcp_resource_read_total{scheme, outcome} | resources/read calls by URI scheme (tutorial, mission, concept) and outcome (ok, error, not_found) | — |
mcp_prompt_get_total{name} | prompts/get calls by prompt name | — |
mcp_compose_fallback_total | Times the deep-import seam (@cap-js/mcp/lib/tools) failed on boot and Phase 3 fell back to tools-only | Alert if sustained non-zero — indicates the adapter was updated and the seam broke; pin the adapter version or disable MCP_PHASE3_ENABLED until fixed |
The mcp_compose_fallback_total counter is the canary for the adapter deep-import seam. A single non-zero value after a deploy warrants investigation; sustained non-zero values across multiple deploys mean the compose layer is silently degraded.
References
- Adapter:
@cap-js/mcpon npm - Related memory: [[cap-graphql-shortcut-replaces-odata]] — why
@protocol:must be a list, not a shortcut - Related runbook: mta-deployment.md — standard deploy path