Skip to content

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 toDo thisTurnaround
Ship a new curated toolAdd 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 surfaceRemove '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 changecf 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:

bash
npm run build:all
cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f

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:

  1. Comment out the CDS function declaration in the service .cds file.
  2. 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).
  3. 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:

diff
- @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:

bash
npm uninstall @cap-js/mcp
npm run build:all
cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f

The 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:

bash
cf rollback tutorials-srv

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.

FlagDefaultEffect
per_action_tooltrue in this projectEach 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_formattrue (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.
prefixunsetOptional 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.
autowiretrueThe 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:

  1. The service you expected is missing 'mcp' from @protocol. Check the .cds file — the list must literally include 'mcp', not @mcp.
  2. cds.env.mcp?.autowire === false (project-level or env override). Restore cds.mcp.autowire: true (or delete the key — true is 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 to srv-api as 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 /mcp to 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:

  1. Sign in to <env-base>/admin-ui/#pats as a user with Tutorials MCP Users role collection.
  2. Click New token, name it smoke-fixture, scopes read, TTL 365 days.
  3. Copy the displayed pat_... value — shown once only.
  4. Store it in the env's BTP Credential Store as secret name mcp-smoke-pat (or set MCP_SMOKE_PAT for local runs).

For emergency rotation without the admin UI (not recommended), call the endpoint directly:

bash
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/#pats admin page.

Flipping the feature flags

Three Phase 2 feature flags control the MCP surface. All default to true (enabled).

bash
# 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-srv

To 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:

bash
# 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:

bash
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 functionGET /admin/getMetricsSnapshot() (XSUAA Admin scope) returns { counters, gauges, histograms }; each MCP counter appears as a key in counters with its full label-embedded name.
  • Express routeGET /admin/metrics/live (Admin scope) — same shape, for on-call curl via 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 than hit is 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 ratesum(mcp.pat.auth[outcome!=hit]) / sum(mcp.pat.auth[*]) over a window.
  • Step-slicer cache hit ratemcp.slice[outcome=hit] / (mcp.slice[outcome=hit] + mcp.slice[outcome=miss]).
  • Tool call error rate per servicemcp.tool[...,outcome=error] / mcp.tool[...,outcome=ok|error], grouped by the service= 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:

sql
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 shapeURLAuth
Anonymous curated tools (Phase 1, unchanged)<base>/mcp/search, /mcp/homepage, /mcp/graphnone
Authenticated / personalized tools (browser agent)<base>/mcp-auth/apiOAuth 2.1 + PKCE via XSUAA; requires the Tutorial.MCP scope
Authenticated / personalized tools (headless CLI, CI)<base>/mcp-pat/apiAuthorization: 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

  1. Prefer PAT for the CLI. A CLI is a headless agent — route personalized calls through /mcp-pat/api with a PAT minted at /admin-ui/#pats. Reserve /mcp-auth/api (interactive OAuth) for GUI clients like Claude Desktop.
  2. Auto-discovery. OAuth clients read <base>/.well-known/oauth-authorization-server and <base>/.well-known/oauth-protected-resource to 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 by test/unit/approuter-mcp-route.test.js).
  3. Scope grant. Interactive OAuth users need the Tutorials MCP Users role collection (grants Tutorial.MCP) — see Granting Tutorials MCP Users role collection above. PAT callers do not need the XSUAA scope; the PAT's own scopes array (read / write) governs access.
  4. 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.
  5. 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.

FlagDefaultEffect when false
MCP_PHASE3_ENABLEDtrueCompose router is not mounted; @cap-js/mcp serves all services in tools-only mode; /mcp-admin/* returns 503
MCP_RESOURCES_ENABLEDtrueResources capability is not advertised; resources/list and resources/read return "method not found"
MCP_PROMPTS_ENABLEDtruePrompts capability is not advertised; prompts/list and prompts/get return "method not found"
MCP_ADMIN_TOOLS_ENABLEDtrue/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:

ToolApprouter (route)Service levelPer-tool
merge_conceptsTutorial.MCPAdminKnowledgeGraph.Admin
promote_community_to_missionTutorial.MCPAdminSuperAdmin
publish_contentTutorial.MCPAdminSuperAdmin
trigger_rebuildTutorial.MCPAdminTutorial.Author

A caller with Tutorial.MCP + Tutorial.Author but without Admin will receive a 403 from the service layer. The Admin scope must be granted in addition to any per-tool scope.

  • Tutorial.MCP — required for all /mcp-admin/* access (enforced by approuter).
  • Admin — required by AdminService at the service level (ANDed with per-tool scope).
  • KnowledgeGraph.Adminmerge_concepts.
  • SuperAdminpromote_community_to_mission, publish_content.
  • Tutorial.Authortrigger_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.

CounterWhat it measuresAlert 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_totalTimes the deep-import seam (@cap-js/mcp/lib/tools) failed on boot and Phase 3 fell back to tools-onlyAlert 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/mcp on 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