Skip to content

MCP Server Architecture ​

How the Model Context Protocol surface is structured across CAP services, approuter routes, and the shared step-HTML slicer. For the design rationale, see the spec at docs/superpowers/specs/2026-07-08-mcp-server-phase2-design.md.

Deployment topology β€” two MTAs ​

The MCP surface spans two separately-deployed MTAs (see operations/mta-deployment.md):

  • tutorials-ims (main MTA, .deploy/mta.yaml) β€” the tutorials-srv CAP runtime + the approuter. Serves the anonymous /mcp/*, PAT /mcp-pat/*, and admin /mcp-admin/* surfaces, and owns the shared services (HANA, XSUAA, destination, credstore, …).
  • tutorials-mcp (MCP MTA, root mta-mcp.yaml) β€” a single tutorials-srv-mcp Node.js app serving only the authenticated /mcp-auth/* surface. It adopts the main MTA's services as existing-service (it creates none of its own) and authenticates inbound requests with a hybrid IAS (public PKCE) + XSUAA scheme.

The approuter (in the main MTA) fronts both: /mcp-auth/* is proxied to the external tutorials-srv-mcp app via the srv-mcp-api destination (${srv-mcp-url}); every other /mcp* route goes to the local srv-api.

Four-route stack ​

Internet / MCP client
        |
  Approuter (xs-app.json)
        |
   β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  Route          Auth                   Backend app                  β”‚
   β”‚  /mcp/*         none                    tutorials-srv  (srv-api)     β”‚
   β”‚  /mcp-pat/*     Bearer PAT (in CAP)     tutorials-srv  (srv-api)     β”‚
   β”‚  /mcp-admin/*   XSUAA (at approuter)    tutorials-srv  (srv-api)     β”‚
   β”‚  /mcp-auth/*    IAS PKCE / XSUAA        tutorials-srv-mcp (srv-mcp-  β”‚
   β”‚                 (in the MCP app)         api β†’ separate MTA)         β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        |                                              |
  CAP runtime (tutorials-srv)                   MCP app (tutorials-srv-mcp)
        |                                              |
   β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  @cap-js/mcp adapter                β”‚    β”‚  srv-mcp/server.js rewrites    β”‚
   β”‚  Registers each @protocol:          β”‚    β”‚  /mcp-auth/* β†’ /mcp/* onto its β”‚
   β”‚  ['odata','mcp'] service at         β”‚    β”‚  own DeveloperService handlers β”‚
   β”‚  /mcp/<serviceRoot> (anonymous);    β”‚    β”‚  (the 7 authenticated MCP      β”‚
   β”‚  /mcp-pat/<root> via PAT middleware;β”‚    β”‚  read/write tools). Hybrid     β”‚
   β”‚  /mcp-admin/* via the compose layer β”‚    β”‚  IAS+XSUAA inbound auth.        β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Anonymous route (/mcp/*): No XSUAA round-trip. The approuter's authenticationType: none route forwards directly to srv-api (the main tutorials-srv). Participating services: SearchService, HomepageService, KnowledgeGraphService.

Authenticated route (/mcp-auth/*): Served by the separate tutorials-srv-mcp app (MCP MTA), proxied through the approuter's srv-mcp-api destination with authenticationType: none at the approuter β€” the bearer is validated inside the MCP app, not at the approuter. Inbound auth is hybrid: an IAS public-PKCE client (primary, for mcp-remote clients) with an XSUAA bearer fallback. srv-mcp/server.js rewrites /mcp-auth/* β†’ /mcp/* onto its own DeveloperService handlers (the 7 authenticated read/write tools). HomepageService authenticated tools are also reachable here.

PAT route (/mcp-pat/*): The approuter uses authenticationType: none (the PAT is not a XSUAA token β€” the approuter cannot validate it). The CAP middleware srv/lib/mcp-pat-middleware.js intercepts every request before CAP's routing, validates the Authorization: Bearer pat_... header against the PersonalAccessTokens HANA table (SHA-256 comparison, TTL check), and synthesises a req.user object with the matched user's SAP ID and a tokenSource: 'pat' marker.

Adapter package ​

@cap-js/mcp@1.1.1 β€” the CAP MCP protocol adapter. Registers itself under cds.protocols.mcp on boot. No separate install or configuration needed beyond adding 'mcp' to a service's @protocol list. Peer-dep: @sap/cds ^10.

Key behavior:

  • cds.mcp.per_action_tool: true β†’ every CDS action/function surfaces as its own named MCP tool.
  • cds.mcp.toon_format: true β†’ query results serialize as TOON (compact tabular text, LLM-friendly).
  • Tools annotated @requires: 'authenticated-user' are hidden from tools/list for unauthenticated requests and return 401 when called unauthenticated. This is adapter-level filtering in lib/auth.js::checkActionAccess().

Full adapter reference: https://cap.cloud.sap/docs/guides/protocols/mcp.

req.user resolution ​

All authenticated tools ultimately resolve the caller to a database row. Note the two paths run in different processes: /mcp-auth/* resolves inside the separate tutorials-srv-mcp app (MCP MTA), while /mcp-pat/* resolves inside the main tutorials-srv runtime.

/mcp-auth/*  β†’  tutorials-srv-mcp  β†’  IAS PKCE (primary) or XSUAA (fallback) bearer
   (MCP MTA)                           ↓
                                    tiered resolveUser(req.user) + pinResolvedUser (#2552)
                                       req.user.id = SAP universal ID (from the token sub)
                                       ↓
                                    DB row from Users WHERE sapId = req.user.id

/mcp-pat/*   β†’  tutorials-srv      β†’  mcp-pat-middleware β†’ req.user.id = sapId from PAT
   (main MTA)                          req.user.tokenSource = 'pat'
                                       req.user.roles = ['pat-read'] + ['pat-write'] (if scopes include 'write')
                                       ↓
                                    resolveDbUser(req.user)
                                       ↓
                                    DB row from Users WHERE sapId = req.user.id

If user resolution returns null (user not in the DB, stale OAuth clientId, or unmigrated user), the handler emits a WARN log [mcp-dev] resolveDbUser miss and rejects with 401. This surfaces stale-token issues without a silent zero-row response β€” see [[silent-user-resolution-hides-token-bugs]] in MEMORY.md.

Shared step-HTML slicer ​

srv/lib/tutorial-step-slicer.js is the single implementation for extracting a step's HTML from the HANA content BLOB. Four consumers:

  1. DeveloperService.get_tutorial_step (authenticated MCP, /mcp-auth/*) β€” runs in the separate tutorials-srv-mcp app, which consumes the slicer logic via the shared mcp.bundle.mjs bundle (scripts/bundle-shared.cjs), not a second copy of the source
  2. SearchService.get_tutorial_step (anonymous MCP, /mcp/search)
  3. srv/lib/code-check-step-loader.js (Joule checkStepCode)
  4. srv/lib/chat-context.js server-side fallback

The slicer is disabled when KG_STEP_SLICER_ENABLED=false. It returns null on any error (fail-open). Content is cached via the shared caching service (cds-caching plugin, #1180 β€” TTL 30 min, eviction owned by the store, cross-instance coherent in prod) keyed by slice:<slug>::<activeManifestVersion> and invalidated on content.published CDS events via a per-slug tag (deleteByTag). The cached value is a serializable step-entries array (the live Map is rebuilt on read, since a serializing store cannot round-trip a Map).

The slicer uses raw db.run() for BLOB retrieval to avoid the CAP/HANA LOB locator expiry bug that occurs when BLOBs are mixed with non-BLOB columns in a CDS QL query. See the gotcha in CLAUDE.md (Never SELECT a HANA BLOB alongside metadata).

.well-known discovery ​

Discovery documents are served as runtime approuter middleware (insertMiddleware.first in approuter/server.js), NOT static files β€” values derive at request time from the bound XSUAA VCAP credentials and the request host, so they are correct in every environment with no build-time substitution.

  • approuter/lib/well-known-oauth.js β€” /.well-known/oauth-authorization-server (RFC 8414), its alias /.well-known/openid-configuration, and /.well-known/oauth-protected-resource (RFC 9728). The openid-configuration alias returns the OAuth Authorization Server Metadata body (RFC 8414), intentionally omitting OIDC-only fields like jwks_uri; MCP OAuth-fallback clients consume only the OAuth endpoints. authorization_servers/issuer advertise the approuter itself (self-as-AS): clients run RFC 8414 discovery against our host, which returns a valid 200 document. The authorize/token endpoints inside still point at XSUAA. (This reverses the original "Option A" that pointed issuer at the XSUAA URL β€” XSUAA does not implement RFC 8414 and 302β†’loginβ†’200-HTML on that path, which breaks MCP SDK discovery with a ZodError and no 404 fallback.) scopes_supported advertises the fully-qualified <xsappname>.Tutorial.MCP (bare Tutorial.MCP is rejected by XSUAA with invalid_scope).
  • approuter/lib/well-known-mcp-manifest.js β€” /.well-known/mcp.json, a non-standard courtesy manifest listing the MCP mounts. Not part of the MCP spec.
  • approuter/lib/security-txt.js β€” /.well-known/security.txt (RFC 9116).
  • approuter/lib/mcp-auth-challenge.js β€” a 401 on /mcp-auth/* and /mcp-admin/* without a bearer carries WWW-Authenticate: Bearer resource_metadata="…", the MCP-preferred discovery trigger.

Edge note: on developers.sap.com, Akamai 403s /.well-known/* at the edge except security.txt. The edge must forward these paths to origin β€” see operations/akamai-well-known-forward.md.

Phase 3 β€” the compose layer ​

@cap-js/mcp@1.1.1 is tools-only: it has no API for registering MCP resources or prompts. Phase 3 adds srv/lib/mcp-compose-router.js to bridge this gap without forking the adapter.

How it works ​

On each incoming request the compose router builds a per-request McpServer instance using the MCP TypeScript SDK. It then:

  1. Calls the adapter's exported tool-registration functions (@cap-js/mcp/lib/tools) to re-register all curated tools from the underlying @cap-js/mcp-managed services.
  2. Adds registerResource callbacks for the three URI schemes (tutorial://, mission://, concept://).
  3. Adds registerPrompt callbacks for the four prompt templates.

The resulting McpServer advertises merged capabilities {tools, resources, prompts} on initialize, so clients see a single endpoint with all three capability types.

Mounting ​

The compose router is registered in cds.on('bootstrap', ...) β€” before CAP's own cds.protocols.mcp adapter fires β€” so it wins the Express first-match race for /mcp/graph and /mcp-admin/*. The CAP adapter continues to serve /mcp/search and /mcp/homepage directly; /mcp/graph and /mcp-admin/* are owned by the compose layer.

/mcp/search        β†’ @cap-js/mcp adapter   (SearchService, tools only)
/mcp/homepage      β†’ @cap-js/mcp adapter   (HomepageService, tools only)
/mcp/graph         β†’ mcp-compose-router.js (KnowledgeGraphService + resources + prompts)
/mcp-admin/*       β†’ mcp-compose-router.js (admin tools, XSUAA-gated)

Fragile seam and fallback ​

Deep-importing @cap-js/mcp/lib/tools is a private-API seam. If a future adapter update moves or renames those exports, the compose layer will fail to register tools on boot. Two guards protect against this:

  • MCP_PHASE3_ENABLED flag (default true) β€” when false, the compose router is never mounted; @cap-js/mcp serves all three services in tools-only mode and /mcp-admin/* returns 503.
  • Fail-open fallback β€” if require('@cap-js/mcp/lib/tools') throws on boot, the compose router logs a WARN and falls back to tools-only mode for /mcp/graph (same as Phase 2). The mcp_compose_fallback_total metric increments; a sustained non-zero value means the seam broke and manual intervention is needed (pin or patch the adapter, or disable Phase 3).

See the Operations runbook for flag knobs and alert guidance.

References ​