npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cap-mcp-guard

v0.14.0

Published

A CDS plugin — the trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking/pseudonymization, and OpenTelemetry-native observability for MCP-enabled applications.

Readme

cap-mcp-guard

A CAP (CDS) plugin — auto-discovered via cds-plugin.js the moment it's a dependency of your project, zero manual wiring required.

CAP MCP Guard is the trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking, and OpenTelemetry-native observability for MCP-enabled applications.

Today, organizations have only two options when exposing SAP CAP business data to AI agents: grant unrestricted access or deny access completely. CAP MCP Guard introduces a third option — controlled, observable, and policy-driven access, without requiring developers to hand-write authorization, masking, and audit logic for every entity.

We give CAP developers a standardized, reusable way to enforce AI-agent security, observability, and policy — without hand-writing that logic for every entity. Convention over implementation, in the same spirit as CAP itself.

Why

  • Field exposure. When AI agents connect to CAP entities over MCP, sensitive fields (CreditCardNo, Salary, national ID numbers, ...) are visible to the agent unless someone filters them by hand, entity by entity.
  • No visibility. Which agent accessed which entity, when, how many rows, and how long it took isn't logged anywhere standard. There's no answer when someone asks for an audit trail.
  • Excessive trust. Even a "read-only" agent can technically reach every action/function a service exposes through the MCP layer, unless something enforces otherwise. Relying on the agent to "behave" isn't a control.
  • Enterprise distrust. Companies want AI agents connected to business data, but security teams block it because nobody can prove what the agent will actually do. CAP MCP Guard is the third option between "wide open" and "no access at all."

How it works

AI Request → Intercept → Evaluate Policy → Mask → Execute → Audit → Trace
Claude / Joule / Copilot / Custom Agent
              │
        Any MCP Runtime
   (gavdilabs/cap-mcp-plugin, a custom runtime, a future official SAP
    solution — it doesn't matter which)
              │
    ┌─────────────────────────┐
    │      cap-mcp-guard      │
    │                         │
    │  lib/core/              │
    │   ├─ interceptor.js     │ → attaches to CAP's srv.before/srv.after hooks
    │   └─ context.js         │ → builds an OTel gen_ai.*-shaped, framework-
    │                         │   agnostic request context
    │                         │
    │  lib/policy/            │ → knows nothing about CAP. Plain JS: Context in,
    │   ├─ config.js          │   Decision out.
    │   ├─ evaluator.js       │
    │   ├─ masking.js         │
    │   └─ pseudonym.js       │
    │                         │
    │  lib/audit/             │
    │   └─ log.js             │ → Context + Decision → structured JSON log line
    │                         │
    │  lib/otel/              │
    │   └─ exporter.js        │ → Context + Decision → a real OTel span
    │                         │
    │  lib/adapters/          │
    │   ├─ cap.js             │ → the ONE place that knows @sap/cds.
    │   │                     │   cds-plugin.js calls this.
    │   └─ annotations.js     │ → reads @mcp.policy off the compiled CDS model
    └─────────────────────────┘
              │
         CAP Service

lib/policy/ and lib/core/context.js never import @sap/cds — they only ever see a plain Context object and a plain PolicyDefinition object, regardless of where either one came from. That's what lets policy come from package.json and from @mcp.policy CDS annotations (see Configure) without the engine knowing the difference, and what lets a third source be added later without touching it either.

Install

npm install --save cap-mcp-guard

This is a CDS plugin, not a library you wire up by hand: CAP auto-discovers cds-plugin.js the moment the package is a dependency of your project — no manual require, no server bootstrap changes. Annotate your model, add a "cap-mcp-guard" key to your project's package.json, and both are picked up the next time your CAP server starts.

Configure

There are two layers, and the split between them is fixed:

| Setting | .cds model (@mcp.policy) | package.json | | --- | :---: | :---: | | per-entity mask, pseudonymizewhich fields are sensitive | yes | yes | | per-entity maxRows, allowTools — per-entity limits | yes | yes | | mode, services, users, audit — how the guard behaves overall | — | only here |

Classify data in the model; switch the guard on in package.json. A field is sensitive because of what it is, not because of which environment you're in — so that belongs in the .cds file, on the field itself. Whether the guard enforces or only observes, which services and users it applies to, where the audit log goes: that's deployment configuration, and it lives in package.json.

Declaring policy in the schema

Annotate the fields directly, where nobody can add a new sensitive column without seeing the rule sitting next to its neighbours:

entity Employees {
  key ID     : Integer;
      name   : String;
      salary : Decimal(10, 2) @mcp.policy.mask;
      iban   : String         @mcp.policy.pseudonymize: 'iban';
}

@mcp.policy.maxRows: 100
entity Orders as projection on my.Orders;
  • @mcp.policy.mask on a field — replaced with '***MASKED***' in agent-facing responses. Add an object to keep part of the value visible instead: @mcp.policy.mask: {type: 'partial', keepLeft: 4, keepRight: 4} (see Partial masking).
  • @mcp.policy.pseudonymize: 'iban' — replaced with a fake but deterministic value; the string names the generator ('opaque' or 'iban', see Masking vs. pseudonymizing) and is required here. Use an object for custom: @mcp.policy.pseudonymize: {type: 'custom', value: '[email protected]'}.
  • @mcp.policy.maxRows / @mcp.policy.allowTools — entity-level.

Annotations are read off the compiled model once services are served, so entity names always match — there's no name to keep in sync by hand, which is the main practical advantage over configuring the same fields in package.json (see the naming caveat below).

They propagate the way CDS annotations always do. Annotate a field on a db/ entity and every service projecting that entity inherits the rule — including your human-facing UI service, which will then see masked data too. That's what "services"/"users" scoping is for: classify once in the model, then scope where the guard applies. (Annotating only the AI-facing projection entity works too, at the cost of moving the rule away from the field again.)

Switching the guard on in package.json

Annotations classify; they can't turn enforcement on by themselves. The "cap-mcp-guard" key is what activates the guard and scopes it:

{
  "cap-mcp-guard": {
    "mode": "enforce",
    "services": ["AgentCatalogService"]
  }
}
  • No "cap-mcp-guard" key at all? The guard runs in pass-through mode (a console.warn tells you so) rather than crashing your server — no matter how much you annotated.
  • A config that exists but fails to parse does fail loudly — a broken config shouldn't fail silently.
  • mode is "observe" (compute and log what would happen, touch nothing) or "enforce" (actually mask, truncate, and reject). Start with observe, read the audit log, then flip.

You can also declare per-entity rules here instead of — or on top of — the annotations, which is the right call when a limit is environment-specific (maxRows of 500 in dev, 50 in prod) rather than a property of the data:

{
  "cap-mcp-guard": {
    "mode": "enforce",
    "entities": {
      "AgentCatalogService.Orders": {
        "mask": ["creditCard", "salary"],
        "maxRows": 100,
        "allowTools": ["ReadOrders"]
      },
      "AgentCatalogService.Customers": {
        "mask": ["email", "phone"]
      }
    }
  }
}
  • Entity keys are service-qualified. They're matched exactly against the name the request resolves to — AgentCatalogService.Orders, not the bare Orders. A key matching no served entity is inert rather than an error (that entity simply has no policy), so a typo or a renamed service silently means no enforcement. This is the trap annotations avoid.
  • Entities configured in neither layer are fully accessible — this is opt-in by design; you don't have to cover every entity up front.

How the two layers merge

Annotation-derived and package.json-derived config for the same entity are merged, per entity:

  • mask — union of both sides' fields.
  • mask strategies (partial / email) — union by field; the package.json rule wins, so a deployment can change how a field is masked without editing the model.
  • pseudonymize — union by field; the package.json entry wins if the same field is configured on both sides.
  • maxRows / allowTools — the package.json value wins when both are present.
  • A field listed under mask from one source and pseudonymize from the other fails loudly at startup, exactly like configuring both in the same package.json entity.

What a masked value looks like, per field type

A property has exactly one type — the one the service publishes in $metadata — so the replacement has to fit it. '***MASKED***' is a string, and writing it into an Edm.Decimal or Edm.Date property produces a response that contradicts its own metadata: Fiori Elements renders an empty cell (which reads as no value, not withheld), and a generated client fails to parse.

So the replacement is chosen from the field's compiled type:

| Field type | Masked to | | --- | --- | | String, LargeString | '***MASKED***' | | everything else — numbers, dates, booleans, UUID, … | null |

Both are type-valid, and either way the record of which fields were withheld travels in the Decision, so it reaches the audit log and the OTel span even when the payload just shows null. (UUID is in the second group on purpose: it's a string in JS but surfaces as Edm.Guid, and the placeholder isn't a GUID.)

There are two ways to get the placeholder visible on a non-string field, and which one fits depends on whether the agent has a projection of its own.

It does — model the field as text there, and it genuinely becomes a string field:

@readonly entity AgentEmployees as projection on my.Employees {
  ID, name, role,
  cast(salary as String(20)) as salary,   // now masked to '***MASKED***'
  iban
};

The UI's own projection keeps salary as Decimal and keeps reading real numbers — one field, one type, per projection, and no metadata is contradicted.

It doesn't — one entity serves both audiences and identity decides who sees what ("users" scoping). Then there is no second projection to cast in, and the type-safe default is working against you: Fiori always receives real Decimals here, so the masked copy only ever reaches something reading JSON. Turn it off (see below):

{ "cap-mcp-guard": { "mode": "enforce", "users": ["mcp-agent"], "maskTypeSafe": false } }

Partial masking: keeping part of the value visible

A full mask replaces the whole value. Sometimes that's more than you need — a human reading the agent's answer recognises their own account from the last four digits, and a support workflow that can't show anything stops being useful. Name a strategy on the mask instead of taking the default:

entity Employees {
  key ID     : Integer;
      salary : Decimal(10, 2) @mcp.policy.mask;                                          // ***MASKED***
      iban   : String @mcp.policy.mask: {type: 'partial', keepLeft: 4, keepRight: 4};   // TR33******************1326
      email  : String @mcp.policy.mask: {type: 'email'};                                // a****@firma.com
      phone  : String @mcp.policy.mask: {type: 'partial', keepRight: 4, char: '#'};      // #######4567
}

The same in package.json, where a mask entry is either a bare field name (full mask, as before) or an object naming the strategy:

{
  "cap-mcp-guard": {
    "entities": {
      "AgentCatalogService.Employees": {
        "mask": [
          "salary",
          { "field": "iban", "type": "partial", "keepLeft": 4, "keepRight": 4 },
          { "field": "email", "type": "email" }
        ]
      }
    }
  }
}

| type | Keeps | Options | | --- | --- | --- | | full (default) | nothing — the placeholder replaces the value | — | | partial | keepLeft characters from the start, keepRight from the end | keepLeft, keepRight, char | | email | the first character of the local part, and the whole domain | char |

char is the character drawn in place of the hidden ones ('*' by default) — a single character, and only meaningful for the two strategies that draw with it. To change the full-mask placeholder, use the top-level "maskValue" instead.

partial discloses real characters. That is the trade, not a side effect. TR33...1326 hands the agent eight real characters of the IBAN, and an agent that can read many rows can use them to tell rows apart, group them, and match them against anything else it knows. Use it where partial recognition is worth more than concealment; for a field that's simply secret, mask it fully, and if what you actually want is distinguishable but fake, that's pseudonymize.

Two safety behaviours worth knowing, both of which fall back to the full replacement rather than to something readable:

  • A value no longer than keepLeft + keepRight is starred out entirely, so a short value can never come back fully visible just because the bounds happened to cover it.
  • A value the strategy can't process — a null, or an email rule on something with no @ — becomes the placeholder. A malformed value is exactly when you least want to publish a prefix of it.

Both strategies produce a string, so they only apply to string fields. On a Decimal or a Date the rule can't apply at all and the type-safe full mask stands in — with "lint": true set, that mismatch is reported at startup rather than discovered in a payload:

error: AgentEmployees.salary: mask type "partial" produces a string, but the field is cds.Decimal,
       so the rule cannot apply. Mask it fully, or model the field as text on the agent-facing projection.

If you're coming from a table-driven masking config (an ABAP *_MASK_CFG table, say), the mapping is direct: TABNAME/FIELDNAME become the entity key and the field, MASK_TYPE becomes type, and KEEP_LEFT/KEEP_RIGHT/MASK_CHAR keep their names in camelCase. The difference is where it lives — a rule row is data you can change without a deploy, an annotation is a rule that travels with the field it protects and can't drift away from it.

Masking vs. pseudonymizing

Plain mask replaces every real value with the same fixed string, '***MASKED***' — which means an AI agent can no longer tell two different customers' masked fields apart at all (no grouping, no counting distinct values, no relational reasoning). pseudonymize replaces a real value with a fake but deterministic one instead: the same real value always produces the same fake value, and different real values produce different fake values — so the agent keeps that relational structure without ever seeing the real data.

entity Customers {
  key ID         : Integer;
      creditCard : String @mcp.policy.mask;                    // → '***MASKED***'
      email      : String @mcp.policy.pseudonymize: 'opaque';  // → 'email-7f3a9c21e1b4'
      iban       : String @mcp.policy.pseudonymize: 'iban';    // → a checksum-valid fake IBAN
}

The same, in package.json:

{
  "cap-mcp-guard": {
    "entities": {
      "AgentCatalogService.Customers": {
        "mask": ["creditCard"],
        "pseudonymize": ["email", { "field": "iban", "type": "iban" }]
      }
    }
  }
}

"opaque" is the generic generator — a deterministic token like email-7f3a9c21e1b4, carrying no information about the real value. It's the default everywhere a type isn't named: a bare field name or a { "field" } object in package.json, and a bare @mcp.policy.pseudonymize (no type) in .cds.

Two typed generators exist alongside it, both format-preserving so the fake value passes wherever the real one would:

  • "iban" keeps the real country code and total length and computes a real ISO 7064 MOD 97-10 check digit pair, so the fake IBAN passes standard IBAN checksum validation. Not a real account, but nothing downstream sees it as malformed.
  • "uuid" produces a syntactically valid v4 UUID, for fields declared UUID (see below).

A field can't be listed in both mask and pseudonymize on the same entity (config fails to load if it is). More typed generators (e.g. a Luhn-valid fake credit card number) can be added later without changing this config shape — anything without a dedicated generator just falls back to "opaque".

Sometimes you don't want a derived fake value at all — just one fixed, human-chosen replacement, the same for every row (e.g. a shared support alias instead of each customer's real email). Use type: "custom" with a required "value":

entity Customers {
  key ID    : Integer;
      email : String @mcp.policy.pseudonymize: {type: 'custom', value: '[email protected]'};
}
{
  "cap-mcp-guard": {
    "entities": {
      "AgentCatalogService.Customers": {
        "pseudonymize": [{ "field": "email", "type": "custom", "value": "[email protected]" }]
      }
    }
  }
}

Unlike "opaque"/"iban", "custom" doesn't derive anything from the real value or the pseudonym secret — every row gets the exact same literal, so it doesn't need CAP_MCP_GUARD_PSEUDONYM_SECRET at all (a config that mixes a "custom" entry with a non-"custom" one on the same or another entity still needs the secret, for the other entry). Because every real value maps to the same output, "custom" gives up the relational structure (telling two customers apart) that "opaque"/"iban" preserve — it's closer in effect to mask, just with your own replacement string instead of the fixed '***MASKED***'.

Keeping one value recognizable across differently-named fields

"opaque" derives its token from the field name plus the value, so the same real value under two differently-named fields comes out as two different pseudonyms. That's the right default — unrelated fields shouldn't become correlatable just because they happen to hold equal values. But it's wrong for the case where one logical value is simply spelled differently across a large model: syd in one entity, soyad in another, lastName in a third. The agent sees three unrelated tokens and can no longer tell it's one person.

"group" names an explicit pseudonym namespace, replacing the field name in the derivation. Fields sharing a group produce identical pseudonyms for identical values, however they're named:

type Soyad : String(100) @mcp.policy.pseudonymize: { type: 'opaque', group: 'surname' };

entity Employees { syd      : Soyad; }
entity Customers { soyad    : Soyad; }
entity Vendors   { lastName : Soyad; }

Annotating the type rather than each element is what makes this scale: every field of that type inherits the policy no matter what it's called, and a field added later can't be forgotten. CDS propagates element annotations from a named type to every element using it, and on into service projections.

The same in package.json:

{
  "cap-mcp-guard": {
    "entities": {
      "AgentService.Employees": { "pseudonymize": [{ "field": "syd", "group": "surname" }] },
      "AgentService.Customers": { "pseudonymize": [{ "field": "soyad", "group": "surname" }] }
    }
  }
}

The group name replaces the field name in the token too (surname-7f3a9c21e1b4), so the output doesn't disclose which field it came from. Different groups still separate equal values, and rotating CAP_MCP_GUARD_PSEUDONYM_SECRET still invalidates everything as before. "iban" is already field-name-independent by construction, so a group is optional there and only affects its fallback for malformed values; "custom" derives nothing at all, so combining it with "group" is rejected at config load rather than silently ignored.

Linking records: use a canonical id, not a name

A shared surname is not a shared person — two people can both be Yilmaz. If the agent needs to follow one person across entities, pseudonymize the id everyone already joins on, not the name:

type ProtectedPersonId : UUID @mcp.policy.pseudonymize: { type: 'uuid', group: 'person-id' };

entity Employees { personId   : ProtectedPersonId; }
entity Managers  { employeeId : ProtectedPersonId; }

Note type: 'uuid' rather than opaque. opaque produces a token like person-id-0787abd9060d, and a field declared UUID surfaces as Edm.Guid — handing that token back makes the response contradict its own metadata and anything that parses GUIDs rejects it. uuid produces a deterministic, syntactically valid v4 UUID instead, the same way iban produces a checksum-valid IBAN: 0787abd9-060d-4204-b5e9-2a34a462023d, identical for the same real id and group, different for a different one, and not the real value.

Keeping groups honest: an allowlist and a startup lint

A group is a namespace shared by name, so a typo in one is silent and expensive: person-surename in one entity and person-surname in another produce two namespaces that will never match, and nothing at runtime says so. Declare the groups you actually use and a stray one becomes a startup error:

{
  "cap-mcp-guard": {
    "mode": "enforce",
    "pseudonymGroups": ["person-id", "person-surname", "iban"],
    "lint": { "strict": true }
  }
}
entities.Customers.pseudonymize.surname: group "person-surename" is not in "pseudonymGroups"
("person-id", "person-surname", "iban")

pseudonymGroups is optional — omit it and groups stay free-form, which is fine for a small model. Once declared it is enforced, since that is what an allowlist is for.

lint prints what the policy will actually do, once, at startup, and checks the parts a machine can decide:

[cap-mcp-guard] pseudonym groups:
[cap-mcp-guard]   person-id
[cap-mcp-guard]     - CatalogService.AgentEmployees.personId
[cap-mcp-guard]     - CatalogService.AgentManagers.employeeId
[cap-mcp-guard]   person-surname
[cap-mcp-guard]     - CatalogService.AgentEmployees.syd
[cap-mcp-guard] warning: CatalogService.AgentEmployees.salary: masking a cds.Decimal field yields
[cap-mcp-guard]          null rather than '***MASKED***', since the placeholder is a string.

The group map is the point: a mistyped or duplicated group shows up as a group of one. Errors are generator/field-type mismatches — a uuid pseudonym on a string field, a string pseudonym on a UUID or numeric field — the class of defect that looks fine in the guard's own audit log and only surfaces at whatever consumes the payload. lint: true reports; lint: { strict: true } refuses to start:

Error: [cap-mcp-guard] policy lint failed with 1 error(s) and lint.strict is set:
  - CatalogService.ProbeView.pid: pseudonymize type "opaque" produces a string, but the field is
    cds.UUID. Use type "uuid" for a UUID field.

What it deliberately does not check is whether a field is bound to the right group — a linter can't know that Customers.surname and Employees.syd are the same concept, only that they claim to be. That part is what the group map is for reading.

Getting the placeholder onto a non-string field

The type-safe default assumes the masked payload reaches a typed consumer. That is true when the UI and the agent read the same entity through different projections — but not when they read the same entity and only the agent's copy is masked, as with "users" scoping. There Fiori always receives real Decimals, the masked copy only ever reaches something reading JSON, and a null it can't distinguish from an empty column is strictly less informative than a placeholder.

"maskTypeSafe": false says so:

{
  "cap-mcp-guard": {
    "mode": "enforce",
    "users": ["mcp-agent"],
    "maskTypeSafe": false,
    "maskValue": "***MASKED***"
  }
}
{ "ID": 1, "name": "***MASKED***", "salary": "***MASKED***", "hiredOn": "***MASKED***" }

maskValue sets the text (default '***MASKED***') and applies either way. With maskTypeSafe off, a non-string field's response contradicts its own $metadata — which is exactly the trade being made, so the startup lint says which of the two is in force rather than staying silent:

[cap-mcp-guard] warning: CatalogService.Employees.salary: "maskTypeSafe" is off, so this
                         cds.Decimal field is masked to the string "***MASKED***" and the response
                         contradicts its own $metadata. Fine for a JSON-reading agent, not for a
                         typed client such as Fiori.

Use it when one entity serves both audiences and identity decides who sees what. When the agent has a projection of its own, cast(salary as String(20)) as salary gets you the same placeholder with no metadata contradiction, because there the field genuinely is text.

Requires a secret. Set the CAP_MCP_GUARD_PSEUDONYM_SECRET environment variable (or pass pseudonymSecret directly to registerCapMcpGuard) — every pseudonym is derived from it via HMAC, so without it a fake value can't be reproduced or tied back to a real one. Never commit this value. If any entity configures pseudonymize and no secret is set, the server fails to start rather than silently producing unprotected data. Rotating the secret invalidates every previously-issued pseudonym (the same real value will map to a new fake one from then on) — this is expected, not a bug.

Separating the agent from the UI: three shapes

When the same tables serve both a human UI and an agent, masking everything is wrong — the UI would see placeholders too. Something has to tell the two apart, and there are exactly three things that can: which service the request arrived at, which entity it named, or who sent it. Each is a subsection below; this is how to pick.

| | Own service | Own entity | Identity | | --- | --- | --- | --- | | Config | "services": [...] | none | "users": [...] | | Extra CDS objects | one service | one entity per guarded table | none | | Needs a trustworthy IdP | no | no | yes | | Placeholder on a numeric field | via cast | via cast | via "maskTypeSafe": false | | UI requests reach the guard | no | yes, and pass through | yes, and pass through | | UI requests in the audit log | no | yes | yes | | Agent can reach the unmasked copy | only by calling a URL it wasn't given | same | by presenting another identity |

Pick identity only when you cannot add a projection — you don't own the model, or the agent is contractually bound to an existing entity. It is the smallest change and the weakest boundary: with CAP's mocked auth an agent simply sends another user's name, so it is worth having only behind XSUAA, IAS, Entra, Okta or a gateway that terminates authentication.

Otherwise put the boundary in the URL, where there is nothing to impersonate. Choose own entity by default and own service when UI traffic is heavy enough that you don't want it passing through the guard at all (it is the only shape where the interceptor never attaches to the UI's service, so those requests cost nothing and produce no audit lines).

Whichever you pick, the field-level decisions are the same and are worth making in this order:

  1. Leave it out of what the agent reads, if it has no business with it. An absent field can't be selected, filtered, sorted, aggregated or navigated to — strictly stronger than any policy here.
  2. mask it if the agent should know the field exists but never its value.
  3. pseudonymize it if the agent has to reason across rows — count distinct values, spot duplicates, follow one person through the model — without seeing the real thing.

Scoping by entity, without a second service

Give the agent its own entity inside the service the UI already uses. The guard keys its policy by entity name, so one carries a policy and the other simply has no entry — which is why this shape needs no "services" and no "users" at all:

service CatalogService {
  // With two projections of one db entity in a service, CDS can't guess which an association
  // should point at. Naming the UI's keeps navigation resolving to the unmasked pair.
  @cds.redirection.target
  entity Employees as projection on my.Employees;

  @readonly entity AgentEmployees as projection on my.Employees {
    ID, name, role,
    cast(salary as String(20)) as salary,
    iban
  };
}

annotate CatalogService.AgentEmployees with {
  salary @mcp.policy.mask;
  iban   @mcp.policy.pseudonymize: 'iban';
};
{ "cap-mcp-guard": { "mode": "enforce" } }

Three things make this shape hold, and skipping any of them quietly undoes it:

  • Annotate the agent's projection, never the db entity. A @mcp.policy annotation on my.Employees propagates to every projection of it, the UI's included.
  • List fields instead of *. Under a wildcard, a sensitive column added to my.Employees tomorrow appears in the agent's view immediately and unmasked, since the policy names fields and a new one isn't among them. Listing makes the agent's surface an allowlist.
  • Leave the associations out. Included, they redirect to the entity marked @cds.redirection.target — the unmasked one — and AgentEmployees?$expand=department hands over real values straight past the policy.

examples/bookshop ships this shape and the next one side by side, both covered by integration tests.

Scoping the guard to specific services

If the same entities are served both to a human-facing UI and to an AI/MCP agent, masking everything is usually wrong — the UI would see masked fields too. Add a "services" array to scope the guard to only the CAP services named there; every other served service is left completely untouched (no masking, no audit, no interceptor at all):

{
  "cap-mcp-guard": {
    "mode": "enforce",
    "services": ["AgentCatalogService"],
    "entities": {
      "AgentCatalogService.Customers": { "mask": ["IBAN"] }
    }
  }
}

The recommended pattern: expose the AI/MCP-facing traffic through its own CDS service (a projection over the same entities your UI's service already serves), point "services" at that one, and leave your UI's service out of the list entirely — it keeps seeing real, unmasked data. Omitting "services" keeps the default: every served service is guarded, as before.

Scoping the guard to the agent's own URL

The strongest discriminator available without touching the model is neither the caller's name nor a second projection: it is which door the request arrived at. A runtime serving an agent — an MCP endpoint, say — reaches the same CAP service the UI does, but through its own URL, and unlike an identity, a path is not something the caller asserts about itself.

{ "cap-mcp-guard": { "mode": "enforce", "paths": ["/mcp"], "maskTypeSafe": false } }

Prefix match, root-anchored. The policy applies to requests whose inbound HTTP path starts with one of these and to no others. It keeps working when the runtime queries the service internally rather than over HTTP — the guard reads the path of the outermost request, so a tool call handled at /mcp that then selects from the same entity the UI reads still reports /mcp.

Every gate still runs for an in-scope request: a $filter/$orderby/$apply over a protected field is still refused, $expand is still masked at every level, writes to protected fields are still rejected. Path scoping decides whether the policy applies, not how much of it.

On its own this is worse than nothing, and the guard says so at startup. Scoping the policy to /mcp says nothing about the service's own URL, where the same rows are still served — now with no policy applying at all. The agent asks /odata/v4/... instead and gets real values:

[cap-mcp-guard] warning: policy is scoped to path "/mcp", but CatalogService.Employees carries no
  @requires/@restrict naming a real role — the same rows are readable unmasked through the service's
  own URL by anyone who can reach it, because the policy does not apply there.

Closing the other doors is authorization, not masking, so it belongs to CAP's own @requires / @restrict — on a real role, not authenticated-user, which an agent that authenticated perfectly well already satisfies. The two halves are one design: masking decides what happens at the door you point it at, authorization decides who reaches the others. Neither is any use alone.

One wrinkle worth knowing: @requires is declared on a service or an entity and knows nothing about paths, so it cannot say "this role for OData but not for /mcp" — and put on the service it locks out the agent-serving runtime too, which queries that same service. Where the split has to be per-path it goes in front of CAP: an ingress rule, or a middleware registered after CAP's own auth (before it, req.user isn't populated yet and every role check fails open). The guard reads the model only and can see neither, so once one is in place, say so and the warning stops:

{ "cap-mcp-guard": { "mode": "enforce", "paths": ["/mcp"], "otherSurfacesGated": true } }

A request the guard cannot place is masked, not passed. paths scoping needs an inbound path to compare against, and some requests carry none — an internal call with no HTTP context, a runtime that queries the service without preserving the outer request. Treating that as "not matching" would return real values, and return them silently. So the ambiguous case leans the other way: masked data reaching something that wanted real data is a failure somebody sees and comes asking about, while real data reaching something that should have been masked is invisible. Between a loud wrong answer and a quiet one, take the loud one.

If a project's internal jobs legitimately run without an HTTP context and need the real values, opt back out:

{ "cap-mcp-guard": { "mode": "enforce", "paths": ["/mcp"], "whenPathUnknown": "pass" } }

What no scoping can reach. The guard hooks CAP's service layer, so it sees whatever goes through a service — which is how every MCP runtime that builds its tools from entities reaches the data. Code that queries the database directly (cds.db.run) bypasses services by construction, and no policy here applies to it. That is not a setting to change; it is where this layer ends. To check a runtime you haven't used before, log req.http?.req?.originalUrl from a srv.before('*') handler and call one tool: the path tells you whether paths can scope it, and no log line at all tells you the runtime is bypassing the service layer entirely.

"paths", "services" and "users" compose as AND — set several and a request must satisfy all of them for the policy to apply.

Scoping the guard to specific users

If splitting into a second CDS service isn't worth it, scope the guard by identity instead, using CAP's own authenticated user (req.user.id, not a client-supplied header — so it can't be spoofed the way a raw HTTP header could). Add a "users" array: masking (and allowTools/maxRows enforcement) only applies to requests whose authenticated user is in that list — everyone else's request is fully passed through, exactly as if the entity had no policy at all:

{
  "cap-mcp-guard": {
    "mode": "enforce",
    "users": ["mcp-agent-technical-user"],
    "entities": {
      "AgentCatalogService.Customers": { "mask": ["IBAN"] }
    }
  }
}

Authenticate your MCP runtime as that technical user (via XSUAA/IAS, a service key bound to the CAP app) so its requests carry that identity; your UI's human users authenticate normally and are never in the list, so they always see real data through the same service and the same endpoint.

This is only as trustworthy as whatever established that identity, and the guard now says so at startup. With "users" set and CAP running a development auth strategy (mocked, basic, dummy), you get:

[cap-mcp-guard] warning: "users" scopes this policy by caller identity ("mcp-agent"), but the
  configured auth strategy is "mocked", which does not verify who the caller is — a request
  presenting any other name reads every masked field in the clear, and nothing at runtime reports
  that it did.

That is not a theoretical caveat. CAP's mocked user list is seeded with "*": true, so a request authenticating as a name nobody configured is accepted as that name:

$ curl -u mcp-agent:mcp-secret     .../Employees   # in the "users" list
  nationalId: 123******01     salary: ***MASKED***
$ curl -u whatever-i-typed:       .../Employees   # not configured anywhere
  nationalId: 12345678901     salary: 85000.00

The warning prints whether or not "lint" is enabled — every other finding is about a payload being shaped wrongly, this one is about the policy silently not applying at all, so someone who never asked for the lint is exactly who needs telling. "lint": { "strict": true } upgrades it to a refusal to start, which is how a production pipeline makes sure it was acted on rather than scrolled past. A strategy the guard doesn't recognize (your own impl) is left alone — that was a deliberate choice by someone who knew what they were doing.

Because both audiences read the same entity here, a field has one type for both of them and there is no agent-side projection to cast in. That is what "maskTypeSafe": false is for — see Getting the placeholder onto a non-string field. It is safe in exactly this shape: Fiori never receives a masked payload, so the only consumer of the placeholder is something reading JSON.

"services" and "users" compose — set both if you want a dedicated AI-facing service and identity verification within it.

What you get, per request

  • Masking — in enforce mode, fields listed under mask are replaced with '***MASKED***' on string fields and null on every other type (see above), and fields under pseudonymize with a deterministic fake value, in the real response. In observe mode nothing is touched; the guard only computes what would happen. Applied at every level of $expand, not just the first: each nested entity is evaluated on its own, so identity scoping applies there too and an association path back to an already-masked entity doesn't hand over raw values on the second hop.
  • Query-side refusal — masking rewrites the response, but $filter, $orderby, $groupby and aggregations run against the real column, so the result set itself discloses what the payload hides: which rows come back, in what order, what they sum to — and repeated range filters recover an exact value by bisection. In enforce mode a request that computes over a masked or pseudonymized field is rejected with 403 before it runs. Plainly selecting such a field stays allowed, since that is what masking is for. In observe mode the refusal is reported, not applied.
  • Write refusal — a caller that only ever reads a field masked must not be able to write it, or it replaces the real value with the placeholder (or with a pseudonym plausible enough that nobody notices). In enforce mode a CREATE/UPDATE/UPSERT whose payload carries a protected field is rejected with 403. @readonly on the agent-facing projection is still the better first line; this is what catches the case where it was forgotten.
  • Tool/row enforcement — in enforce mode, a request naming an operation outside allowTools is rejected with a 403 before it runs. maxRows is pushed onto the query as a LIMIT before it executes — so the database stops reading rows that were always going to be discarded — and the response is truncated to the same bound. A client asking for fewer rows keeps its own smaller limit. In observe mode both are only computed and reported, never applied.
  • Audit log — every request produces a structured JSON line (Context + Decision), to stdout and/or a file you choose.
  • OpenTelemetry spans — every request also becomes a real span via @opentelemetry/api. If your app already has an OTel SDK configured (any OTLP-compatible backend — Grafana, Jaeger, Datadog, SAP Cloud Logging), the guard's spans just show up there, correctly linked into the caller's trace via W3C Trace Context (traceparent/tracestate) when present — no extra mapping needed, because the context schema was built against OTel's GenAI semantic conventions (gen_ai.*) from the start.

Audit and OTel run independently of each other and can each be disabled per-call (audit: false, otel: false) if you're wiring registerCapMcpGuard yourself instead of relying on auto-discovery.

Known limits

The guard rewrites responses and refuses requests. It is not an authorization layer, and a few things follow from that — worth knowing before you rely on it.

The un-guarded path stays open. Scoping with "services" doesn't stop an agent from calling a service that isn't in the list; it stops the guard from touching that service. Same for an agent-facing entity: AgentEmployees being masked says nothing about Employees next to it. Close the human-facing surface with CAP's own @requires / @restrict, or don't tell the agent about it (an entity that isn't in the MCP runtime's tool list is one it can't call).

Refusal is not the same as concealment. A 403 on $filter=salary gt 100000 tells the caller that salary is protected — and $metadata lists the field either way. If a field's existence is sensitive, leave it out of the agent's projection (excluding { salary }) rather than mask it. An absent field can't be selected, filtered, sorted, aggregated or navigated to, which makes excluding strictly stronger than any policy here. Mask what the agent must know about but shouldn't read; exclude everything else.

Nested query options aren't checked. A $filter inside an $expand belongs to the nested entity and isn't matched against that entity's policy, only the top level's. The nested rows still come back masked; what leaks is which of them come back. @Capabilities.ExpandRestrictions or cutting the association on the agent's projection closes it.

Masked columns are still read. The refusal above stops the query from computing over a protected field, but a plain $select of it still fetches the real value from the database before the response is rewritten. That's a cost, not a disclosure — the value never leaves the process — but on a wide table it is a real one.

Row count is bounded, not the work. maxRows caps rows. It doesn't cap columns, joins, or the cost of a $filter over an unprotected but unindexed field.

partial discloses part of the value, by design. keepLeft/keepRight publish real characters — that's what makes the value recognisable and it is also what an agent can accumulate across rows. The kept characters are chosen by you, not bounded by the guard.

Uniqueness leaks through pseudonyms, by design. Two rows sharing a real value share a pseudonym; that is the whole point, and it means an agent can count distinct values and spot duplicates. If even that is too much, use mask — and if the shape matters (a valid IBAN tells you the country), use type: "custom" or exclude the field.

Prefer endpoint scoping over identity scoping. "users" is only as trustworthy as the app's authentication: with CAP's mocked auth an agent can simply present another user's name — and it need not even be a name you configured, since CAP seeds its mock user list with "*": true. The guard warns about exactly this combination at startup (see Scoping the guard to specific users), but a warning is not a control. Splitting the agent's surface into its own service or its own entity puts the boundary in the URL, where there is nothing to impersonate. Note also what identity scoping does not narrow: the agent still reaches every entity the service serves, masked — an agent-facing projection reaches only what it lists.

Try it

A full working example lives in examples/bookshop — SAP's own CAP getting-started sample, with both agent-facing shapes wired up and package.json containing nothing but {"mode": "enforce"}:

  • A service of its ownsrv/agent-service.cds adds AgentService at /odata/v4/agent, projecting the same entities CatalogService serves to the UI.
  • An entity of its own, in the UI's servicesrv/agent-entity.cds adds CatalogService.AgentBooks at /odata/v4/browse/AgentBooks, no second service involved.

Both annotate the agent-facing projection, never the db entity — that's what leaves the UI's own entities policy-free, and why neither needs "services" or "users" scoping.

cd examples/bookshop
npm install
npm test    # annotation/enforce/observe/identity/audit/OTel integration tests against a real CAP service
npm start   # boots a real server at localhost:4004

With the server up, read the same row three ways:

curl 'http://localhost:4004/odata/v4/agent/Books(201)?$select=price'        # "***MASKED***"  (own service)
curl 'http://localhost:4004/odata/v4/browse/AgentBooks(201)?$select=price'  # "***MASKED***"  (own entity)
curl 'http://localhost:4004/odata/v4/browse/Books(201)?$select=price'       # "11.11"         (the UI's entity)

One table, one row, no copies — masked for the agent, untouched for the UI. Point your MCP runtime at either agent URL; the UI keeps using /odata/v4/browse/Books unchanged.

Coming soon (not in v1)

  • Approval workflows (human-in-the-loop for sensitive operations)
  • Rate limiting and a dashboard UI

Development

npm test   # unit tests for lib/core, lib/policy, lib/audit, lib/otel, lib/adapters

License

MIT