Balancing context size and callable tool metadata with CAP's MCP adapter
Some notes on both sides of the per_action_tool setting of CAP's MCP adapter plugin.
CAP supports the Model Context Protocol, and treats it beautifully as "just another protocol". The support comes in the form of the @cap-js/mcp plugin, which has these feature flags:
{
"cds": {
"mcp": {
"per_action_tool": false,
"prefix": false,
"format": "cql"
}
}
}
In this post I want to explore the per_action_tool setting.
Sample service
To illustrate, let's use the classic
capire/bookshop sample project which,
amongst other resources, has a service defined in srv/cat-service.cds like
this:
using { sap.capire.bookshop as my } from '../db/schema';
service CatalogService @(path:'browse') {
/** For displaying lists of Books */
@readonly entity ListOfBooks as projection on Books {
*, currency.symbol as currency,
}
excluding { descr };
/** For display in details pages */
@readonly entity Books as projection on my.Books {
*, // all fields with the following denormalizations:
author.name as author,
genre.name as genre,
} excluding { createdBy, modifiedBy };
@requires: 'authenticated-user'
action submitOrder ( book: Books:ID, quantity: Integer );
}
// Serve via OData, HCQL and REST
annotate CatalogService with @odata @hcql @rest;
The service has:
- entities
ListOfBooksandBooks - an unbound action
submitOrder(for which authentication is needed)
We'll begin by adding the MCP adapter plugin to the project like this:
npm add @cap-js/mcp
Extending the service definition
Extending the definition slightly will help us dig in and understand the different
approaches that we can control with per_action_tool.
To keep things simple, let's just add content to the existing srv/cat-service.cds.
First, let's add a doc
comment
to the submitOrder action, so it looks like this:
@requires: 'authenticated-user'
/** Place an order for a given book, specifying quantity required */
action submitOrder ( book: Books:ID, quantity: Integer );
Now let's define another action in the service, by adding this at the end of the file:
extend service CatalogService with {
/** Return the total value of all stock */
action stockValue ();
}
Finally, let's actually add the @mcp annotation, on a further line at the
end of the file:
annotate CatalogService with @mcp;
A running CAP server
We'll be running a CAP server, started with DEBUG=mcp cds watch, throughout
the explorations here; the log output that we need to know about looks like
this:
[mcp] - Adapter initialized { service: 'CatalogService' }
[cds] - serving CatalogService {
at: [
'/odata/v4/browse',
'/rest/browse',
'/hcql/browse',
'/mcp/browse'
],
decl: 'srv/cat-service.cds:3',
impl: 'srv/cat-service.js'
}
[mcp] - registering MCP services: [ 'CatalogService' ]
[cds] - server listening on { url: 'http://localhost:4004' }
This tells us that the MCP adapter is accessible at http://localhost:4004/mcp/browse.
Inspector
To assist exploration, let's use the MCP inspector tool, which can be operated in GUI, TUI or CLI mode. We'll use the CLI mode here to keep things simple.
The basic inspector CLI call to connect to and interact with the MCP server provided by the MCP adapter looks like this:
npx @modelcontextprotocol/inspector \
--cli
--header 'Authorization: Basic YWxpY2U6Cg==' \
--transport http \
--server-url http://localhost:4004/mcp/browse
plus some actual method, with the --method option.
We're sending an auth header - the encoded value is
alice:(there's no password) asaliceis one of the users available by default in the mocked authentication strategy which is in effect by default when running in development mode - see Local-first dev with CAP Node.js - mocking auth. This is to address theauthenticated-userpseudo-role requirement for thesubmitOrderaction later.
Tools list
By default, the MCP adapter offers up to three tools per service annotated with
@mcp, in response to the
tools/list
request.
We can see this with the inspector, using the basic CLI call plus --method tools/list:
npx @modelcontextprotocol/inspector \
--cli
--header 'Authorization: Basic YWxpY2U6Cg==' \
--transport http \
--server-url http://localhost:4004/mcp/browse \
--method tools/list \
> tools.json
This returns quite a bit of output, in JSON, so we're redirecting that to a
tools.json file, where we can use jq to explore it separately.
Let's first reduce the JSON response to simply see what the tool names are and how they're described:
jq '[.tools[]|{(.name):.description}]|add' tools.json
Echoing Perl's TMTOWTDI tradition, one could also achieve the same result with the jq expression
.tools|map({(.name):.description})|add.
This produces:
{
"describe": "Describe the data model of CatalogService service. Returns an overview of all entities and actions with descriptions. Specify 'entity' to get element details, or 'action' to get parameter details.",
"query": "Query any entity in CatalogService service.Ensure to first use the `describe` tool to discover an entity's available fields.",
"call": "Call an unbound action or function in CatalogService service. Use describe to discover available actions and their parameters."
}
The describe and query tools are fairly standard and nearly always supplied
(except when the caller has no access). The call tool, returned when there
are actions or functions available, is the focus of this post and of the
per_action_tool setting.
The generic call tool
Let's dig in to the details supplied with the call tool. With:
jq '.tools[]|select(.name=="call")' tools.json
we see this output:
{
"name": "call",
"description": "Call an unbound action or function in CatalogService service. Use describe to discover available actions and their parameters.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"submitOrder",
"stockValue"
],
"description": "The action or function to call"
},
"parameters": {
"description": "Parameters for the action. Use describe to see available parameters.",
"type": "object",
"properties": {},
"additionalProperties": {}
}
},
"required": [
"action"
],
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": false
}
}
If we stare at this for a moment, we see that it's rather generic.
Moreover, the description includes instructions that require the use of the
describe tool first, before working out which (if any) call can be made. The
descriptions we have in our doc comments ("Place an order for a given book,
specifying quantity required" and "Return the total value of all stock") aren't
even surfaced here.
All we have is a simple enumerated list of action names in inputSchema.properties.enum.
Per-action tools
Let's now contrast that "describe" + "query" + single "call" tool set, with what we get
if we set the MCP adapter plugin's per_action_tool feature flag to true. We can do
that in many ways; let's keep things simple and add a mcp.per_action_tool property
to the .cdsrc.yaml file that already exists in the project, like this:
cds:
mcp:
per_action_tool: true
requires:
"[production]":
auth: mocked # as a sample app run with mocked auth also in production
When we restart the CAP server, and make a tools/list request again,
we get the same response detail for the describe and query
tools, but the detail for the actions available looks completely different.
Let's examine the JSON output again. First, with
.tools|map({(.name):.description})|add we can see the tools and their
descriptions:
{
"describe": "Describe the data model of CatalogService service. Returns an overview of all entities and actions with descriptions. Specify 'entity' to get element details, or 'action' to get parameter details.",
"query": "Query any entity in CatalogService service.Ensure to first use the `describe` tool to discover an entity's available fields.",
"submitOrder": "Call action submitOrder in CatalogService",
"stockValue": "Return the total value of all stock"
}
This time, instead of the generic call tool, we get individual tools,
complete with unique descriptions (the descriptions from the doc comments).
Also, note that the simple call tool info earlier didn't have any parameter
detail for either of the actual actions; instead, there was an instruction to
"use describe to see available parameters.".
But now, for each callable action, all the parameter details are available. Let's
look at the details for the submitOrder action, with .tools[2]:
{
"name": "submitOrder",
"description": "Call action submitOrder in CatalogService",
"inputSchema": {
"type": "object",
"properties": {
"book": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"quantity": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"$schema": "https://json-schema.org/draft/2020-12/schema"
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": false
}
}
The included inputSchema describes the two properties required (book and quantity).
So in this case, no pre-action-call discovery step is needed, no extra describe step need be executed just to find out what these actions are, whether they might be useful and how to call them.
Comparing the options
Which option is better? per_action_tool: true or per_action_tool: false?
Well, that depends of course. There's a balance to be had.
Returning a single generic call tool, merely flagging that there's a facility
to make calls to specific actions and functions, with a simple list of them,
makes for quite a concise response. This in turn means that the size of the
context where this is stored remains relatively small. But the downside is that
there are going to be extra roundtrips required to accomplish anything
meaningful, as a describe call is necessary to find out more.
Describing separate call-type tools means that the context is potentially
going to get much larger, if there are many actions and functions available.
However the upside is that they're likely immediately comprehensible and
usable.
The MCP adapter plugin team has taken the decision to default to "safe" and
simple, to avoid exploding the context size unexpectedly, which makes sense. In
other words, the default is per_action_tool: false.
Only you can make the decision to change the value for that feature flag, based
on your service definitions, and other circumstances. Certainly if you only
have a small number of actions, then it's worth considering turning on the
per_action_tool flag. But if you have many, you may wish not to.
But now at least you know what the flag does, why, and how it affects things.
Further info
This topic is also covered in Part 3 of CAP in the age of AI.
- ← Previous
Knowledge and netiquette in today's AI era