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 the authenticated surface, routed to the separate tutorials-srv-mcp app (MCP MTA) via the srv-mcp-api destination โ see Phase 2 โ the authenticated surface. 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 (anonymous/PAT/admin) or cf rollback tutorials-srv-mcp (/mcp-auth/*) | ~1 min |
Deploy โ
The anonymous /mcp/*, PAT /mcp-pat/*, and admin /mcp-admin/* surfaces run inside the main tutorials-srv CAP runtime. The adapter is a normal CAP protocol plugin โ it boots with that process and has no separate lifecycle. There is no MCP-specific deploy step for those surfaces; use the standard main-MTA workflow:
npm run build:all
cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -fThe authenticated
/mcp-auth/*surface is a SEPARATE MTA. As of the auth-split (#2569/#2579/#2581) the OAuth MCP backend is thetutorials-srv-mcpapp in its owntutorials-mcpMTA (descriptormta-mcp.yaml), nottutorials-srv. It deploys by hand:bashmbt build -f mta-mcp.yaml -t mta_archives cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f # or deploy/mcp-prod.mtaext
npm run deploy/scripts/deploy-mta.cjsdeploy ONLY the main MTA โ there is no orchestrated MCP-MTA path today, so the main-MTA deploy above does NOT ship/mcp-auth/*changes. See mta-deployment.md ยง MCP MTA (tutorials-mcp).
On 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 surface โ but note the two backends roll back independently:
cf rollback tutorials-srv # anonymous /mcp/*, PAT /mcp-pat/*, admin /mcp-admin/*
cf rollback tutorials-srv-mcp # authenticated /mcp-auth/* (separate MCP MTA)If 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 is not routed tosrv-apias anonymous โ notably/mcp-auth/*is the authenticated surface (separatetutorials-srv-mcpapp) and requires an IAS/XSUAA bearer, so an anonymousinitializethere is expected to 401. - 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 โ the authenticated surface (shipped) โ
/mcp-auth/* is the authenticated MCP surface โ MCP calls that require a bearer, e.g. tools that read or write a user's tutorial progress. It is live, and since the auth-split (#2569/#2579/#2581) it is served by the separate tutorials-srv-mcp app (the tutorials-mcp MTA), not the main tutorials-srv adapter. Inbound auth is hybrid IAS (public PKCE, for mcp-remote clients) with an XSUAA bearer fallback. The anonymous /mcp/* surface stays on tutorials-srv unchanged. See architecture/mcp-server.md for the full topology.
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).
Flag target depends on the surface.
MCP_AUTH_ENABLEDgates/mcp-auth/*, which now runs in the separatetutorials-srv-mcpapp (read insrv-mcp/server.js) โ set it on that app.MCP_PAT_MINT_ENABLEDandKG_STEP_SLICER_ENABLEDgate/mcp-pat/*and the anonymous step-slicer on the maintutorials-srv.
# Disable the authenticated MCP surface entirely (separate MCP MTA app)
cf set-env tutorials-srv-mcp MCP_AUTH_ENABLED false && cf restart tutorials-srv-mcp
# Disable PAT minting (existing PATs continue to work) โ main srv
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) โ main srv
cf set-env tutorials-srv KG_STEP_SLICER_ENABLED false && cf restart tutorials-srvTo restore a flag to default, cf unset-env <app> <NAME> && cf restart <app> (unset = default true) โ using the same app the flag was set on above.
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 (separate tutorials-srv-mcp app) | OAuth 2.1 + PKCE via IAS (primary) with XSUAA fallback; requires the Tutorial.MCP scope on the XSUAA path |
| 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 by runtime approuter middleware (insertMiddleware.firstinapprouter/server.js, see architecture/mcp-server.md ยง.well-knowndiscovery) โ values derive at request time from the bound XSUAA/IAS VCAP credentials and the request host. If a client 404s on them, the approuter middleware order regressed (the discovery middleware must run before 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, incl. ยง MCP MTA (tutorials-mcp)
- MCP MTA descriptors:
mta-mcp.yaml(root),deploy/mcp-dev.mtaext,deploy/mcp-prod.mtaext - Architecture: architecture/mcp-server.md โ two-MTA topology, route-to-backend map