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.

Three-route stack ​

Internet / MCP client
        |
  Approuter (xs-app.json)
        |
   β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  Route             Auth              Description               β”‚
   β”‚  /mcp/*            none              Anonymous read-only tools  β”‚
   β”‚  /mcp-auth/*       XSUAA OAuth JWT   Authenticated tools        β”‚
   β”‚  /mcp-pat/*        Bearer PAT        Headless / CI agents       β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        |
  CAP runtime (tutorials-srv)
        |
   β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  @cap-js/mcp adapter                                           β”‚
   β”‚  Registers each @protocol:['odata','mcp'] service at          β”‚
   β”‚  /mcp/<serviceRoot> (anonymous) and                           β”‚
   β”‚  /mcp-auth/<serviceRoot> / /mcp-pat/<serviceRoot>              β”‚
   β”‚  (via the approuter prefix routing above)                     β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

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

Authenticated route (/mcp-auth/*): The approuter enforces a valid XSUAA bearer. The CAP runtime receives req.user pre-populated by the XSUAA middleware. Participating services: DeveloperService, HomepageService (authenticated tools).

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 call resolveDbUser(req.user) to look up the user's database row. Three paths converge on this function:

/mcp-auth/*  β†’  XSUAA middleware  β†’  req.user.id = SAP universal ID (from JWT sub)
                                       req.user.tokenSource = undefined (JWT)
                                       ↓
                                    resolveDbUser(req.user)
                                       ↓
                                    DB row from Users WHERE sapId = req.user.id

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

If resolveDbUser 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/*)
  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 ​