MCP
The Brightspot AI plugin can expose CMS content over the Model Context Protocol (MCP), letting external AI clients—IDE assistants, chat apps, custom agents—search, read, create, update, and delete content through a tool-calling interface. It can also do the reverse: connect to an external MCP server and use its tools from a Brightspot AI chat request (see Consuming external MCP servers).
The MCP server is a separate module that runs inside the Brightspot web application. It is implemented as an ApiEndpoint, so it inherits Brightspot's existing API client, credential, and permission model. The same @ToolMethod-annotated tools used by the Esca Agent (Ask Esca, Create with Esca) are reused as MCP tools.
How it works
- The
mcpmodule exposes MCP throughCustomApiEndpoint(display name Custom API) paired withMcpApiHandler(display name MCP). The endpoint owns paths and the authenticator chain; the handler owns the protocol. - A servlet filter (
McpApiEndpointFilter) starts a stateless MCP server on application init. It discovers every concreteToolGroupon the classpath, resolves each@ToolMethod-annotated method into a tool, and registers it with the server. - The server advertises tools only—prompts and resources are disabled. Each tool name is namespaced as
<GroupName>_<methodName>(for example,Content_search). - The endpoint authenticates the request and then delegates to the MCP handler, which dispatches the tool call. The first authenticator that resolves a user sets it as the current
ToolUser. - Tool return values become the MCP
structuredContentpayload. Scalars and collections are wrapped as{"result": ...}. Exceptions are returned as an error result with the stack trace as text content.
Installation
Add the mcp artifact alongside the core AI plugin:
- Maven
- Gradle
- Gradle (Kotlin DSL)
<!-- Requires Brightspot 4.8 or later. -->
<dependency>
<groupId>com.brightspot.ai</groupId>
<artifactId>mcp</artifactId>
<version>3.2.0</version>
</dependency>
// Requires Brightspot 4.8 or later.
implementation 'com.brightspot.ai:mcp:3.2.0'
// Requires Brightspot 4.8 or later.
implementation("com.brightspot.ai:mcp:3.2.0")
This pulls in the upstream MCP SDK (io.modelcontextprotocol.sdk:mcp-core plus the Jackson JSON transport at runtime) and registers the MCP endpoint and built-in Content tool group.
Setting up the endpoint
The MCP server is exposed through a Brightspot API endpoint, configured in the CMS like any other endpoint.
To create an MCP endpoint:
- From the left navigation, under Admin, click APIs.
- Create a new API endpoint and select Custom API as the type.
- In the Paths field, add one or more URL paths to serve MCP traffic at (for example,
/mcp). - Add one or more Authenticators (typically OAuth and/or API Key). Endpoints with no authenticators reject every request with
401 Unauthorized. - From the Handler list, select MCP.
- Save.
The endpoint is now reachable at the configured paths on your application host. Point MCP clients at the full URL, including scheme and host.
Authentication
CustomApiEndpoint runs the configurable ApiAuthenticator chain. Two built-in authenticators ship with the plugin:
- OAuth (
OAuthApiAuthenticator) —Authorization: Bearer <JWT>. The JWT is issued by Brightspot's OAuth 2.1 authorization server (see OAuth). Itssubclaim names the end user; tool calls run with that user's CMS permissions. - API Key (
ApiKeyApiAuthenticator) —Authorization: Bearer <opaque-token>orX-API-Key: <token>. The client is resolved as a BrightspotApiClientand tool calls run as the client's configured service user (see Access control).
When more than one is configured the chain runs in order; the first authenticator that matches the request wins.
When the Authorization header carries a perimeter HTTP Basic credential (for example, a lower-environment access gate that occupies the standard Authorization slot), both authenticators read the bearer token from an X-Auth-Token request header instead. The fallback only applies when Authorization uses the Basic scheme; clients in environments without the perimeter gate should continue to send Authorization: Bearer <token>.
The standard OAuth WWW-Authenticate: Bearer resource_metadata=... challenge is not extended to advertise this fallback, so OAuth autodiscovery does not work behind a Basic gate. Clients in these environments must be configured out-of-band to send their token via X-Auth-Token (alongside the Basic credential they already have for the gate).
Invalid credentials produce 401 Unauthorized with each configured authenticator's challenge as a WWW-Authenticate header — for OAuth that's the RFC 9728 Bearer resource_metadata=... pointer to the protected-resource metadata document. Valid credentials that lack endpoint or permission access produce 403 Forbidden.
For external MCP clients (IDE assistants, hosted chat apps), configure the OAuth authenticator — clients discover the authorization server from the challenge, register dynamically, and issue per-user tokens with no manual credential sharing. The API-key path is useful for in-house service-to-service calls where a shared credential is acceptable.
OAuth
OAuth support is provided by the cms-oauth-server module that ships with Brightspot CMS. Add it to your application alongside the mcp dependency to enable the OAuth authenticator and discovery endpoints:
- Maven
- Gradle
- Gradle (Kotlin DSL)
<!-- Requires Brightspot 4.8 or later. -->
<dependency>
<groupId>com.psddev</groupId>
<artifactId>cms-oauth-server</artifactId>
<version>3.2.0</version>
</dependency>
// Requires Brightspot 4.8 or later.
implementation 'com.psddev:cms-oauth-server:3.2.0'
// Requires Brightspot 4.8 or later.
implementation("com.psddev:cms-oauth-server:3.2.0")
It is an OAuth 2.1 authorization server supporting the authorization-code flow with PKCE (S256), the client-credentials flow, refresh tokens with rotation and replay detection, dynamic client registration (RFC 7591), token revocation (RFC 7009), resource indicators (RFC 8707), and automatic signing-key rotation.
Discovery endpoints (RFC 8414, RFC 9728):
| Path | Purpose |
|---|---|
/.well-known/oauth-authorization-server | Authorization server metadata. |
/.well-known/oauth-protected-resource/<path> | Protected resource metadata for the MCP endpoint at <path>. |
/.well-known/jwks.json | JSON Web Key Set for verifying issued JWTs. |
OAuth endpoints:
| Path | Purpose |
|---|---|
/_oauth/authorize | Authorization endpoint; renders the consent screen. |
/_oauth/token | Token endpoint; handles all supported grant types. |
/_oauth/register | Dynamic client registration. |
/_oauth/revoke | Refresh-token revocation. |
A typical MCP client flow:
- The client makes an unauthenticated request to the MCP endpoint and receives
401with theresource_metadatachallenge. - The client fetches the metadata documents to discover the authorization server.
- The client registers itself at
/_oauth/register(or uses a pre-provisionedApiClient). - The client starts the authorization-code flow at
/_oauth/authorizewithcode_challenge(S256) andresource=<MCP endpoint URL>. - The CMS-authenticated user approves the consent screen (or reuses a remembered consent).
- The client exchanges the code at
/_oauth/tokenand calls the MCP endpoint with the resulting JWT.
The OAuthPlugin record configures the issuer URL, code/token/key TTLs, and the key rotation interval. When the issuer is unset, the subsystem derives it from the current request's scheme and host — fine for development, but set it explicitly behind reverse proxies or for tokens minted outside a web request.
Toggle on the Disable DCR setting on the same record to restrict access to operator-provisioned clients only. When disabled, /_oauth/register returns 404 and the registration_endpoint field is omitted from the authorization-server metadata, signaling to clients that dynamic registration is unavailable.
To pre-provision an MCP client without OAuth:
- From the left navigation, under Admin, click APIs.
- Create or open an API Client.
- Generate a token for the client and grant it access to the MCP endpoint.
- Set the Service User field on the client (see Access control) — this is the tool user the client acts as.
- Share the token with the MCP client out of band.
Access control
Every ApiClient carries a Service User field (via the ApiClientExtra modification). It's the tool user the client acts as for non-OAuth authentication — API key, opaque bearer, or the OAuth client_credentials grant. Tool calls run with that user's site and content type permissions, the same model used by the in-CMS tool UI.
Configure access by creating a dedicated service ToolUser (or reusing an existing one) with exactly the role, sites, and content types the MCP client should see, then point the API client at it. Built-in and custom ToolGroup implementations that go through ToolRequest automatically pick up these restrictions—no extra wiring is required.
Each API client carries its own service user, so different manually-provisioned clients can run with different CMS permissions and audit trails. OAuth-issued tokens resolve their user from the JWT's sub claim instead and don't consult the service user.
Treat MCP credentials like any other API token. A client inherits everything its service user can do; create a narrowly scoped user rather than reusing a privileged administrator.
Every built-in content tool group additionally enforces the same area permissions (area/admin/adminUsers, etc.) the CMS UI gates Managed types behind—for example ToolUser. A caller with broad type access still cannot read or write a Managed record over MCP unless it also holds the area/... permission governing that type's edit page.
Built-in tools
The mcp module ships the Content tool group. Three more first-party groups—Translation, Experimentation, and Theme—ship as separate optional artifacts and register automatically once added to the classpath, the same way a custom tool group would.
Content
ContentToolGroup provides CRUD and search over Brightspot content. All tools are namespaced Content_*:
| Tool | Hints | Purpose |
|---|---|---|
Content_searchType | read-only, idempotent | Find names of CMS content types the user can read, by partial match. |
Content_searchSite | read-only, idempotent | List the sites you can access, each with its id, name, and primary URL. |
Content_getType | read-only, idempotent | Get the JSON Schema for a content type. |
Content_getFieldValues | read-only, idempotent | List allowed values for a field with a value generator. |
Content_searchTextually | read-only, idempotent | Keyword search across content. |
Content_searchSemantically | read-only, idempotent | Embedding-based search across content. |
Content_get | read-only, idempotent | Fetch a single content item by ID. |
Content_generateIds | read-only | Generate fresh UUIDs for new content. |
Content_create | write | Create new content of a given type through the publish pipeline, optionally as a draft. |
Content_update | write, destructive, idempotent | Update existing content by ID; republish, save changes as a draft, or publish a draft. |
Content_transitionWorkflow | write | Advance content to the next state of the workflow governing its type. |
Content_delete | write, destructive | Delete content (or a single draft) by ID. |
Content_searchType returns only CMS content types the caller's user has permission to read, so an agent never discovers a type it cannot query—a missing type then reads as "no access" rather than "no data." Embedded and other non-content Record types—the $ref targets in a Content_getType schema—are not listed here; resolve them by exact name through Content_getType, which is not permission-filtered.
The group also publishes a usage guide to the MCP server's instructions payload so that clients can route tool calls correctly without trial and error. See ContentToolGroup#getInstructions for the current text.
Content_searchSemantically requires a configured text embedding generator (see Configuration). The other tools work without one.
Translation
- Maven
- Gradle
- Gradle (Kotlin DSL)
<!-- Requires Brightspot 4.8 or later. -->
<dependency>
<groupId>com.brightspot.ai</groupId>
<artifactId>mcp-translation</artifactId>
<version>3.2.0</version>
</dependency>
// Requires Brightspot 4.8 or later.
implementation 'com.brightspot.ai:mcp-translation:3.2.0'
// Requires Brightspot 4.8 or later.
implementation("com.brightspot.ai:mcp-translation:3.2.0")
TranslationToolGroup translates content through Brightspot's core translation feature, namespaced Translation_*. It supports two paths: an agent path, where the caller produces the translated text itself and writes it back, and a provider path, where a configured external service (for example AWS, DeepL, or Google) performs the translation.
| Tool | Hints | Purpose |
|---|---|---|
Translation_listServices | read-only, idempotent | List configured external translation providers. |
Translation_listLocales | read-only, idempotent | List locales available for translation. |
Translation_inspectContent | read-only, idempotent | Get a content item's source locale, translatable fields, and existing localized variations. |
Translation_getTranslatableText | read-only, idempotent | Get a content item's source text to translate, keyed by field path. |
Translation_status | read-only, idempotent | Get translation logs for a content item. |
Translation_translate | write, destructive | Translate content into one or more locales using a configured provider (the provider path). |
Translation_applyTranslation | write, destructive | Write agent-produced translations back as a new localized variation (the agent path). |
When no provider is configured, Translation_translate refuses to run rather than copying untranslated text—use Translation_getTranslatableText and Translation_applyTranslation instead. Each tool requires the same translation/translate or translation/action/view-log permission as the equivalent CMS translation UI action.
Experimentation
- Maven
- Gradle
- Gradle (Kotlin DSL)
<!-- Requires Brightspot 4.8 or later. -->
<dependency>
<groupId>com.brightspot.ai</groupId>
<artifactId>mcp-experimentation</artifactId>
<version>3.2.0</version>
</dependency>
// Requires Brightspot 4.8 or later.
implementation 'com.brightspot.ai:mcp-experimentation:3.2.0'
// Requires Brightspot 4.8 or later.
implementation("com.brightspot.ai:mcp-experimentation:3.2.0")
ExperimentationToolGroup creates and manages Brightspot experiments (A/B tests) on content, namespaced Experimentation_*. It is service-agnostic: it touches only the experimentation core, never a specific provider module.
| Tool | Hints | Purpose |
|---|---|---|
Experimentation_listServices | read-only, idempotent | List configured experiment service providers for a site. |
Experimentation_listExperimentTypes | read-only, idempotent | List creatable experiment types. |
Experimentation_listSupportedContentTypes | read-only, idempotent | List content types that can have experiments run on them. |
Experimentation_list | read-only, idempotent | List experiments, optionally filtered by target content or status. |
Experimentation_get | read-only, idempotent | Get an experiment by ID, including its variations. |
Experimentation_listVariations | read-only, idempotent | List the variation records that exist for a content item. |
Experimentation_createVariation | write | Create a variation arm for a target content item. |
Experimentation_create | write | Create an experiment of a given type running on a target content item. |
Experimentation_update | write | Update an experiment's fields. |
Experimentation_setStatus | write | Set an experiment's status (RUNNING, PAUSED, PLANNED, COMPLETED). |
Experimentation_end | write, destructive | Complete an experiment, optionally promoting a winner and archiving variations. |
Experimentation_promoteVariation | write, destructive | Promote a variation to the content's live primary variation immediately. |
Experimentation_delete | write, destructive | Notify the provider and move an experiment to trash. |
Experiments and variations are content, so type-specific and provider-specific fields are set through a fields map whose shape the caller learns from Content_getType—the group does not duplicate that schema. The group's own getInstructions text walks through the typical create flow in detail.
Theme
- Maven
- Gradle
- Gradle (Kotlin DSL)
<!-- Requires Brightspot 4.8 or later. -->
<dependency>
<groupId>com.brightspot.ai</groupId>
<artifactId>mcp-theme</artifactId>
<version>3.2.0</version>
</dependency>
// Requires Brightspot 4.8 or later.
implementation 'com.brightspot.ai:mcp-theme:3.2.0'
// Requires Brightspot 4.8 or later.
implementation("com.brightspot.ai:mcp-theme:3.2.0")
ThemeToolGroup inspects the active CMS theme and the style-scoped theme fields it defines per content type, namespaced Theme_*. It is read-only—all writes go through Content_update in the Content tool group, using the field names these tools resolve.
| Tool | Hints | Purpose |
|---|---|---|
Theme_getCurrent | read-only, idempotent | Get the active theme's summary and global (theme-wide) fields. |
Theme_describeType | read-only, idempotent | Get the styles and fields the active theme defines for a content type. |
For an MCP client, the active theme is either set explicitly on the client (via the MCP cluster's Theme field on the API client) or, when the client has exactly one allowed site, that site's configured theme.
Choosing a site
Several content tools accept an optional site parameter, and what it does depends on the tool: the read tools treat it as a filter, Content_create treats it as the site the new content belongs to, and Content_update treats it as the site to operate within. The remaining tools—including Content_transitionWorkflow—have no site parameter at all.
Call Content_searchSite to list the sites the caller can reach. Each entry carries an id, a name, and a primaryUrl, which is null when the site has no URL configured. Site IDs are deployment-specific, so read them from this tool rather than assuming them.
Reads and deletes
Content_get, Content_searchTextually, Content_searchSemantically, and Content_delete span every site the caller can access when site is omitted. Supplying one narrows results to what that site reaches: the content it owns, content shared into it, and content marked global. Content with no owning site—taxonomy such as tags and authors—is included either way, so it stays readable from any site. A caller holding the site/global permission reads across every site unscoped, and a caller who can reach none of the sites that exist reads nothing rather than everything.
A deployment that defines no sites at all is the one exception, and it is not the same case: with no sites there is no site filter to apply—the CMS applies none either—so reads are unscoped and writes create global content, whatever site permissions the caller holds. Site permissions start governing reads and writes as soon as the deployment has a site to govern.
Content_delete belongs with the read tools because it resolves its target through the same scope: site decides what the call can find, not where anything is written.
Creates
Content_create has to resolve one site, because it stamps the new content's owner. It uses the first of these that answers: the site named in site; the site an in-CMS agent's session is operating in; the service user's saved current site, when the caller still has access to it; global content, when the caller holds site/global; the only accessible site, when exactly one is accessible; and global content again, when the deployment defines no sites at all. On the MCP path there is no session site, so the service user's saved current site is the first rule that can apply.
When several sites are accessible and none of those rules answers, the call fails with an ambiguous-site error naming Content_searchSite. Call that tool, then pass the chosen id as site and retry. A caller who can reach none of the sites that exist is refused outright.
Updates
Omit site on Content_update. That is the normal case: the content already belongs to a site, and updating it does not move it. Supplying one both authorizes the write against that site and narrows the lookup to it, so content owned elsewhere reports as not found.
Other tool groups
The Experimentation and Translation tool groups, shipped by the mcp-experimentation and mcp-translation modules, apply these same rules to their own site parameters. Four of their tools resolve one site the way Content_create does, because each either creates a record or reads that site's settings to decide what it writes: Experimentation_create, Experimentation_createVariation, Translation_translate, and Translation_applyTranslation. On a multi-site deployment, a caller who can reach two or more sites and has no saved current site has to pass site to those four, or the call fails with the same ambiguous-site error naming Content_searchSite.
Their remaining tools follow the read rule instead. site is a filter on Experimentation_list and Experimentation_listVariations and on the tools that change an existing experiment, and it selects whose configuration to read on Experimentation_listServices, Experimentation_listSupportedContentTypes, and Translation_listLocales.
Service user permissions
Grant each API client's service user an explicit role. Permission checks for a user with no role do not fail closed—they fall back to the brightspot/missingRolePermissions setting, which is broad by default: outside production a role-less user passes every check, and the built-in production default grants everything under the root permission. A role-less service user therefore reaches every site and every content type. A request that resolves no user at all is the opposite: reads fall closed to a scope that matches nothing—on a deployment that has sites to scope to—and writes are always refused, because there is no one to attribute them to.
Moving content between sites
There is no way to move existing content between sites through these tools. The owning site is managed by Brightspot, so a write that sets it is refused and no parameter replaces it: site on Content_create chooses the owner for new content, and on Content_update it only selects the site the call operates in. This is deliberate—an arbitrary owner write would place content in a site the caller may hold no permission for. Results report the current owner as _site.
The cross-site sharing controls behind the Sites widget are not managed, so sharing content into other sites is still possible through a write. Only reassigning the owner is not.
Those sharing controls are not permission-checked: a write that sets cms.site.consumers or cms.site.blacklist publishes content into, or hides it from, any site whose ID it names, whether or not the caller has access to that site. Nothing advertises the fields, because all four cms.site.* fields are hidden and never appear in a generated schema, but a caller that knows a site ID can write them.
Publishing, drafts, and workflow
Content_create and Content_update persist content through Brightspot's publish pipeline (Content.Static#publish), so AI-driven writes carry the same publish metadata, revision history, and publish-time triggers as writes made in the CMS UI. Writes are attributed to the acting user — in the MCP server, the API client's configured service user — so publish metadata and Version History record who made the change; the tools refuse to write when no user resolves. Both tools accept an optional action parameter:
PUBLISH— the content goes live immediately. This is the effective default forContent_create; forContent_updatethe default keeps the content's current stage (published content is republished, a draft stays a draft).DRAFT— the content is saved as an unpublished draft. For currently published content,Content_updatecreates a separateDraftrecord holding only the changes and leaves the live content untouched.
Results include a _status field (PUBLISHED or DRAFT). Draft results add a _message explaining what happened and, when the changes live in a separate Draft record, a _draftId — the ID to pass to subsequent Content_update calls to keep editing (or publish) that draft.
When the content's type is governed by a CMS workflow for the site, content is saved as a draft in the workflow's initial state, and the result's _workflow field reports the workflow name, current state, and available transitions. The caller advances it from there with Content_transitionWorkflow, or a CMS user advances it from the publishing widget. One exception matches the CMS's publish override: an explicit PUBLISH request from a caller whose service user holds the type's Publish permission bypasses the workflow and publishes directly. A PUBLISH request without that permission is redirected to a draft and the result's _message explains why.
Drafts and workflow-governed content are excluded from search results, but remain reachable by ID through Content_get, Content_update, Content_transitionWorkflow, and Content_delete. Deleting a draft's _draftId removes only the draft; the published content is untouched.
Advancing a workflow
When a workflow governs a content type, a result's _workflow block reports the workflow's name, its currentState, and the transitions available from that state. Only transitions the caller may actually perform are listed, and the key is omitted when there are none—each transition is gated on the type/<typeId>/<transitionName> permission, the same permission the CMS's own workflow actions check.
Pass one of those names to Content_transitionWorkflow to move the content along, optionally with a comment recorded in the workflow log. The workflow's final transition publishes the content, so its result reports _status as PUBLISHED; every other transition leaves the content a draft.
Passing a draft's _draftId transitions that draft's own workflow state, the way the CMS's publishing widget tracks a revision. The live content is left alone and the draft keeps the changes it holds. Only the final transition merges the revision into the content, publishes it, and deletes the draft.
Live content cannot enter a named workflow state. A transition on already-published content into a named state is refused with a message pointing at Content_update(id, ..., action=DRAFT): stage the change as a draft first, then transition the _draftId that call returns. That is the only transition refused, mirroring the CMS's own guard, so a workflow whose single transition runs from the initial state straight to publication still works on live content and republishes it.
Content with no owning site is not governed by a site-scoped workflow. The governing workflow resolves from the content's owner and never from the caller's site, so a workflow configured for specific sites does not govern owner-less content, which publishes normally. A workflow configured with no sites at all still governs it.
Three details of the workflow audit log differ from a transition performed in the CMS. A log written through Content_transitionWorkflow carries no site id, and it is never marked a draft workflow item, which the CMS's bulk workflow view keys off—the CMS sets both only while serving a web request. The log is also saved before the content is published, so a publish that then fails leaves an audit entry for a transition that did not land. All three are the CMS's own behavior.
URLs
Content written through these tools receives a permalink the same way the CMS generates one, for any type that implements Directory.Item. Results report the canonical path as _permalink and the owning site's paths as _permalinks, each entry carrying its path, its type (PERMALINK, ALIAS, REDIRECT, or REDIRECT_TEMPORARY), and its site. Paths generated for other sites are not reported.
Brightspot generates the URL while the content is still new, a draft, or an unpublished workflow item, replacing the paths it generated for the owning site before—so renaming a draft moves its URL instead of stacking a second one. Paths an editor added by hand, and paths generated for other sites, are kept. Once the content is live its URLs are frozen, so renaming it later does not move the published page. Content that is live but has no URL at all still receives one, which repairs content published before this behavior existed.
The freeze is decided from the saved record, not from the kind of write. Publishing a draft revision of a live page therefore does not move the page's URL, even though that write reaches the tools as an edit to unpublished content.
To set a URL yourself, pass a path—not a full URL—as permalink, for example /news/my-story. On Content_update the value replaces the content's current URL and leaves the old path behind as a permanent redirect, so inbound links keep working. That deliberately overrides the freeze: the freeze governs the URL Brightspot regenerates from a rename, while an explicit permalink is direct caller intent. Passing a path already in use by other content is refused with an error naming the parameter.
permalink is a one-way switch. Supplying it moves the content to manual URLs, and that mode persists: Brightspot never generates a URL for that content again, so later renames leave the URL alone and every subsequent change has to pass permalink again.
permalink is refused, rather than ignored, for a type that does not implement Directory.Item. Paths are unique across every content type, so reserving one against content that can never serve it would block the type that can.
When a URL change is staged in a separate draft, that draft's result reports the new path as _permalink while the live page still serves the old one until the draft is published. A _permalink read from a draft result is not necessarily the URL currently in service.
Field value shapes
Content_getType describes each field as the shape Brightspot actually accepts on a write and actually returns on a
read, so a value taken from Content_get is valid input to Content_update without translation. A test walks every
field type, writes a real value, reads it back, and fails if the published shape and the read disagree, so the two
cannot drift apart unnoticed.
The schema describes; it does not enforce. The tools do not validate fields against it, so keywords like
required and additionalProperties are guidance for a client that validates before sending, not server-side
constraints—a reference missing _type, or carrying an extra key, is accepted and the extra ignored. Nor is a field's
declared target type enforced consistently: writing a reference of the wrong type may be stored as-is, or may be
rejected. A rejected value does not fail the call. It is recorded under dari.trash.<field>, which then appears in
every later read of that content, so a write that reports success can still have dropped a field. Check the field you
wrote is present in the response rather than trusting the status.
Most types are unremarkable. These are the ones where the shape is not the obvious guess:
| Field type | Shape |
|---|---|
| Reference to other content | An object, {"_ref": "<uuid>", "_type": "<type name>"}—not a bare id. Copy both keys from a read. |
| Date, instant | An integer of epoch milliseconds, not a formatted date string. |
| Local/offset/zoned date and time | An ISO-8601 string. No format is declared, because Brightspot's accepted and emitted forms are not the RFC 3339 productions date-time and time name. |
| Rich reference text | An array of HTML segments and reference objects, not a single string. |
| Map/area | An object whose circles key is required; see the caveat below. |
| Metric | Not published. A metric value never appears in a read and a write discards it, so no schema describes one. |
Two asymmetries are worth knowing before an agent compares its input against a read:
Time values are normalized on read. Sending 12:30:00 reads back as 12:30, and 2026-08-14T12:30:00Z reads back as
2026-08-14T12:30Z—the value round-trips, but not byte-identically, so an equality check against what was sent will
disagree. A zoned value keeps its bracketed zone id.
A map/area field holding only a shape outline and no circles reads back with circles set to null, and that value is
not accepted on a write—an absent or null circles discards the whole field, where an empty list is fine. So this is
the one field whose read output is not valid write input; the rejected value is recorded, so it is recoverable rather
than silent. Send circles as [] when there are no circles.
Reference fields previously published as a bare uuid string. A client that caches a Content_getType response and
composes reference values from it should fetch it again; the object form is what reads have always returned.
Fields managed by Brightspot
Some fields present on every content type are managed by Brightspot and cannot be written through these tools. Each has a sanctioned route, or none at all:
| Field | Route |
|---|---|
cms.directory.* (paths, path types, paths mode) | The permalink parameter on Content_create and Content_update. |
cms.site.owner | The site parameter on Content_create, for new content only. |
cms.workflow.* (current state, current log) | Content_transitionWorkflow. |
cms.content.draft | The action parameter. |
cms.content.publishUser, cms.content.createUser, cms.content.createDate, cms.content.overlaid | None. Publish stamps them, and writing them would falsify the audit trail. |
cms.content.scheduled, cms.content.scheduleDate | None. They are bookkeeping for a schedule no tool creates, so writing them would only make the CMS show content as scheduled that nothing will publish. |
The derived keys a result reports—_permalink, _permalinks, and _site—follow the same rule. Brightspot discards them on a write, so setting one would otherwise be ignored without an error: a caller who sets _permalink to move a URL would get a successful response and an unmoved page. They are refused instead, and the error names permalink and site as it does for the fields they are read from. The keys that report what a write did—_status, _message, _draftId, and _workflow—are exempt, because their value is only known once the write has run.
Callers routinely echo a whole Content_get result back into an update, and that result carries every cms.* key and every derived key, so a managed field whose value has not changed is dropped silently. Only an attempt to actually change one is an error, and the error names the parameter or tool that does the job.
"Changed" is measured against the content's current values, not against what the caller last read, so a value the CMS has moved on its own since that read counts as a change. cms.directory.* is the case that arises in practice: renaming content regenerates its URL, so a map cached before the rename and re-sent afterwards carries a stale path. The error also names the remedy that applies here—remove those keys from fields and retry—since no parameter can set them. One error names every offending key, not just the first, which matters because a rename moves the three cms.directory.* keys at once, along with the two derived URL keys read from them: removing the keys it names converges in a single retry. Re-reading with Content_get before each update avoids it entirely.
cms.content.trashed and cms.content.publishDate stay writable: archiving and back-dating are editorial capabilities with no other route through these tools. cms.content.updateDate and cms.content.updateUser stay writable too, because every publish stamps them unconditionally—claiming them would close nothing while turning an echoed read into an error.
Tracking AI-generated content
Content and fields written through Content_create, Content_update, or a writing tool from Translation or Experimentation—whether the caller is an external MCP client or the Esca Agent (Ask Esca, Create with Esca)—are flagged as AI-generated. The flag is recorded per field, so an editor can tell which fields on an asset came from a tool call and which were written by hand.
This is the same tracking Create with Esca uses for its own writes—see Finding AI-generated content for the Contains AI? search filter and the AI Fields search result column.
Adding custom tools
Any concrete subclass of ToolGroup on the classpath is picked up automatically. Tools defined this way are available both to the Esca Agent and to MCP clients.
1@NullMarked2public class WeatherToolGroup extends ToolGroup {34@Override5public String getName() {6return "Weather";7}89@Override10public @Nullable String getInstructions() {11return "Use Weather_* tools for current conditions and forecasts.";12}1314@ToolMethod(15title = "Get the current temperature for a city",16readOnly = true,17destructive = false,18idempotent = false,19description = "Returns the current temperature in Celsius for the given city.")20public double currentTemperature(21@ToolParam(description = "City name, e.g. `San Francisco`.") String city) {2223return WeatherApi.lookup(city).temperatureCelsius();24}25}
Notes:
- The group's
getName()becomes the tool name prefix (Weather_currentTemperaturehere). - Per-method behavioral hints (
readOnly,destructive,idempotent) on@ToolMethodare forwarded to MCP clients, which may use them to auto-approve calls or prompt for confirmation. - Parameter schemas are derived from
@ToolParamannotations and the method signature. For non-standard schemas, supply aSchemaSuppliervia@ToolParam. - Return values are serialized as structured content. Scalars and collections are auto-wrapped as
{"result": ...}; return aMap<String, Object>for richer payloads. - Throwing from a tool method produces an MCP error result; the stack trace is included as text content.
Handling a global modification's fields
A modification applied to every content type—permalinks and site ownership are the built-in examples—is handled through GlobalFieldHandler. Implement it to control how your modification's fields appear in generated schemas, what derived values they add to a tool result, and what happens immediately before a publish. Implementations are discovered on the classpath, so a downstream module can contribute one without the root project depending on it.
1@NullMarked2public class ReviewFieldHandler implements GlobalFieldHandler {34@Override5public Set<String> claimedFields() {6return Set.of("example.review.approvedBy");7}89@Override10public Access access() {11return Access.READ_ONLY;12}1314@Override15public void contributeResult(State state, @Nullable Site site, Map<String, Object> result) {16ToolUser approvedBy = state.as(ReviewData.class).getApprovedBy();1718if (approvedBy != null) {19result.put("_approvedBy", approvedBy.getName());20}21}22}
Notes:
claimedFieldsholds internal field names. An entry ending in a period matches by prefix (cms.workflow.); any other entry must match exactly. A claimed field cannot be written through the tools, whatever its access mode.- Claim as narrowly as possible. A claimed field becomes unwritable, so claiming a whole prefix can remove an editorial capability that has no other route through the tools. Claim the exact fields that represent an escalation and leave the rest alone.
- Two handlers must not claim the same field. The access mode for a field is taken from the first handler that matches it, in nondeterministic classpath order, so an overlap makes the result a coin flip. A build test fails on any overlap between the handlers in this project.
accessgoverns the generated schema only.READ_ONLYkeeps the field visible and marks itreadOnly: true;INTERNAL, the default, omits it. Neither is observable for a@ToolUi.Hiddenfield, because schema generation drops hidden fields before it consults a handler. All three built-in claims overcms.directory.*,cms.site.owner, andcms.workflow.*are hidden fields, so what they mean reaches a caller only throughcontributeResult.contributeResultruns for every read and write result regardless of which handler the caller cares about, so it must resolve leniently: contribute nothing when a value is absent, and never throw. A failure is logged and that handler's contribution skipped, so one misbehaving handler cannot break every content read.beforePublishis the opposite. A failure propagates, aborting the write and skipping any handler that would have run after it, because a half-applied write is worse than a refused one.- The
siteparameter of both callbacks is nullable. Content with no owning site genuinely has no site to resolve, so an implementation must handle null rather than assume one. - The
WriteStagepassed tobeforePublishdescribes where the write started—CREATE,UPDATE_PRELIVE, orUPDATE_LIVE—not where it lands. Readstate.as(Content.ObjectModification.class).isDraft()to tell a routine draft revision from the write that makes content live; every caller applies its own state changes first, so that value is authoritative. beforePublishruns on writes that publish the content record itself. Saving a separateDraftrecord—the write behind a_draftId—does not run it, and neither doesContent_delete.
Consuming external MCP servers
The mcp-client module is the reverse direction of everything above: instead of Brightspot hosting an MCP server, application code connects to an external MCP server and exposes its tools to an AI chat request.
- Maven
- Gradle
- Gradle (Kotlin DSL)
<!-- Requires Brightspot 4.8 or later. -->
<dependency>
<groupId>com.brightspot.ai</groupId>
<artifactId>mcp-client</artifactId>
<version>3.2.0</version>
</dependency>
// Requires Brightspot 4.8 or later.
implementation 'com.brightspot.ai:mcp-client:3.2.0'
// Requires Brightspot 4.8 or later.
implementation("com.brightspot.ai:mcp-client:3.2.0")
Open a connection with McpServerConnection, then add its tools to a ChatRequest:
1try (McpServerConnection github = McpServerConnection.streamableHttp("https://example.com/mcp")2.name("github")3.header("Authorization", "Bearer " + token)4.connect()) {56ChatRequest request = ChatRequest.builder()7.addTools(github.getTools())8.build();9}
A connection wraps a single MCP session: open it in a try-with-resources block around one run, and do not share it across concurrent runs. The name(...) passed to the builder namespaces the remote tool names as mcp__<name>__<tool> so several servers can be attached to one request without collisions.
Three transports are available: streamableHttp and sse connect to a remote server over HTTP and are permitted by default; stdio launches a local child process and is disabled by default because it is arbitrary code execution on the host. Enable it (or restrict the HTTP transports) with the brightspot/ai/mcp/allowedTransports setting, a comma-separated list of STREAMABLE_HTTP, SSE, STDIO, or HTTP as shorthand for both HTTP transports:
1brightspot/ai/mcp/allowedTransports=HTTP,STDIO
Calling a stdio() factory method when STDIO is not enabled throws McpTransportNotAllowedException.
Enabling the STDIO transport lets application code launch arbitrary local processes. Only enable it on deployments that need to connect to local MCP servers, and only when the code constructing the connection is trusted.
For deeper internals—Tool, RunnableTool, ToolGroup discovery, and the agentic chat loop that consumes the same tools—see Architecture overview.