Skip to content

Write a server from the template

A server is a Spring Boot application with the starter (mcp-platform-starter). You write the tools, the resource policies and the calls to your system. The starter verifies the gateway’s token, hands you the credential, checks policy on every resource a tool names, audits the call and keeps unsafe errors from leaving. Stack: Java 25, Spring Boot 4.1.0, starter 4.0.0, contracts 7.0.0.

Start from Build and register your first server: it creates the project this page changes.

Module Holds
mcp-core Domain types and ports
mcp-application Use cases
mcp-integration The client of your system, the resolver of resources, the version probe
mcp-tools The MCP tools and their errors
mcp-app Bootstrap, application.yml, policies, the single jar, end-to-end tests

Replace the sample record domain in the first three modules with the resources and API calls of your system. The template’s stub backend (StubBackend in mcp-app) lets the tests run without Docker or a real system.

Register tools with a ToolCallbackProvider bean. A bean with @Tool methods alone is not enough.

@Bean
ToolCallbackProvider notesTools(NotesTool tool) {
return SchemaLimitedToolCallbacks.of(tool);
}
@Tool(name = "get_note", description = "Returns one note by its id: id, project, title and status.")
@ToolRisk(RiskLevel.READ_ONLY)
@ToolTitle("Get note")
public NoteView getNote(
@ToolParam(description = "Note id as the system issues it")
@ArgumentLimits(maxLength = 16, resourceType = "Notes::Note")
@ResourceRef(type = "Notes::Note") String noteId) {
Note note = ResourceContext.required().fetched("Notes::Note", noteId, Note.class).orElseThrow();
return NoteView.of(note);
}

Every tool needs:

Item Rule
@ToolTitle Non-empty, at most 100 characters
@ToolRisk READ_ONLY, UPDATE or DESTRUCTIVE. Without it, or with a name that contradicts it (delete_* as READ_ONLY), the server does not start
@ResourceRef On every argument that names a resource, with the resource type
@ArgumentLimits A bound on every level: maxLength for text, minimum and maximum for numbers, maxItems for lists. resourceType takes the id pattern from resource-types.json
Closed schema additionalProperties: false. SchemaLimitedToolCallbacks writes it. A Map, a union or a list of lists cannot be bounded and stop startup
No confirmation_code argument The gateway adds it to tools/list and removes it before the call

Outside the dev profile, a tool whose schema has an open level stops startup. In dev you get the warning event=tool_schema_open.

@ToolRisk Meaning Cedar action Group the user needs Gateway behavior
READ_ONLY Changes nothing read mcp-<system>-read Runs after approval
UPDATE Changes data update mcp-<system>-write Runs after approval; never retried
DESTRUCTIVE Deletes or cannot be undone delete (or explicit admin) mcp-<system>-destructive Asks the user for a one-time code first

The server does not ask for confirmation; it only states the risk truthfully. A name containing delete, drop, remove, purge, truncate, destroy, wipe or erase does not pass as READ_ONLY or UPDATE. To narrow a tool to an extra group, use @ToolRisk(value = RiskLevel.READ_ONLY, additionalGroup = "mcp-<system>-ops").

An UPDATE call can be delivered twice, so prefer a target state (set_assignee) over an increment, create entries with a key you can check, and send an expected version where the API takes one. When the system answers upstream_unavailable, say in the message that the operation may have been performed.

@ResourceRef makes the starter resolve each id with your ResourceResolver, using the caller’s credential, and evaluate the policy before the method runs. A resource that is missing, hidden or out of scope never reaches your code; the client gets not_found_or_denied for all three.

@Component
class NoteResolver implements ResourceResolver<Note> {
public String resourceType() { return "Notes::Note"; }
public Optional<Note> fetch(String id) {
return notes.find(id); // empty for 404 and 403; throw for a system failure
}
public ResolvedResource describe(Note note) {
List<String> scopes = List.of(Scopes.id("Notes::Project", note.projectKey()));
ResolvedResource project = ResolvedResource.of("Notes::Project", note.projectKey(),
Map.of("key", note.projectKey(), "category", note.projectCategory())).withScopes(scopes);
return ResolvedResource.of("Notes::Note", note.id(), Map.of("key", note.id()))
.withParent(project).withScopes(scopes);
}
}

The id, the parent and the classification come from the system, never from an argument. A resource without a required classification is not built and the call ends with internal_error. A tool that returns lists of resources has the elements checked after the call, and disallowed ones are removed.

Text from the system (a title, a comment, a description) goes into a result as UntrustedText, so clients treat it as data. The result must be a record; the starter publishes an outputSchema from it, and a component that can be empty needs @Nullable.

Policies live in mcp-app/src/main/resources/mcp/policies/ and go into the jar and the manifest. The starter refuses to start without them.

File Content
schema.cedarschema One namespace, the server’s own (Notes): an entity type for every resource, each with scopes: Set<ResourceScope>, and the actions read, update, delete, admin
resource-types.json One declaration per entity type: attributes, parent and parentAttribute, idPattern, optional scopePattern, earlyScopeCheck, classification
*.cedar The access rules, each with an @id unique in the server

A policy that names Action::"call" or Tool is refused: that decision belongs to the gateway. Write access rules only. The platform generates the rule for restricted data and the one for the service account, so a server cannot leave them out.

A read rule and an update rule from the template:

@id("notes-read")
permit (
principal in UserGroup::"mcp-notes-read",
action == Notes::Action::"read",
resource
)
when { resource is Notes::Project || resource is Notes::Note };
@id("notes-update-in-scope")
permit (
principal in UserGroup::"mcp-notes-write",
action == Notes::Action::"update",
resource is Notes::Note
)
when { principal in resource.scopes };

principal in resource.scopes is true when one of the user’s groups is mapped to a scope of the resource. The administrator makes that mapping in the portal; the server never derives a group name. The scope mapping, the allowed AI clients and emergency overrides are not configured in the server: the gateway pushes them.

A changed policy or declaration changes policiesHash in the manifest and puts the deployed server in pending_approval until an administrator approves it. Until then the server refuses calls with policies_not_approved or policies_differ (both are upstream_unavailable to the client).

The template’s application.yml is the production configuration. These values differ per environment and come from deploy/server.env:

Variable Description
MCP_GATEWAY_JWKS_URI Key set of the gateway: https://<platform>/.well-known/mcp-jwks.json
MCP_CONNECT_URL Portal page where a user connects an account: https://<platform>/connections?system={system}
MCP_NOTES_URL API address of your system, https outside the dev profile
MCP_ACCEPTED_CREDENTIAL_TYPES user-secret by default. One of user-oauth, user-secret, token-exchange, or service-account alone

The first accepted type becomes context.credentialType in every Cedar request. Never mix service-account with user types: the server does not start. The type must be one the platform’s broker returns for the system, see Systems.

The server’s TLS certificate is generated once into MCP_STATE_DIR and reused, so keep that directory on a volume. The gateway pins its fingerprint, so a new certificate needs a new approval. Set MCP_TLS_CERT and MCP_TLS_KEY, or MCP_TLS_KEYSTORE and MCP_TLS_STORE_PASSWORD, for a certificate from a CA the gateway trusts.

Tests need neither Docker nor a platform. ./mvnw -B verify runs them.

Policies are tested in plain JUnit with PolicyDecisions from mcp-platform-test:

PolicyDecisions policies = PolicyDecisions.fromResources()
.scopeMapping(Map.of("mcp-notes-proj-ABC", List.of("Notes::Project/ABC")));
policies.assertAllowed(PolicyDecisions.caller("mcp-notes-write", "mcp-notes-proj-ABC"), "update", note);
policies.assertDenied(PolicyDecisions.caller("mcp-notes-write"), "update", note);

For each policy write an allowed and a denied case: a scope that covers the resource and one that does not, restricted data, the service account, a client the gateway does not allow, an emergency override. For tools, TestGateway signs internal JWTs with the contracts’ test keys, pushes policy data and calls the server in the dev profile (@ActiveProfiles("dev")). Every new tool also needs a negative test.

To call tools from an MCP client without a platform, start LocalGateway. After ./mvnw verify:

Terminal window
java -jar ~/.m2/repository/pl/aiwcorpo/mcp/mcp-platform-test/<platform version>/mcp-platform-test-<platform version>-local-gateway.jar \
--server mcp-notes --jwks-port 18445 --policies-dir mcp-app/src/main/resources/mcp/policies \
--scope-mapping .run/scope-mapping.json --env-file local-gateway.env &
SPRING_PROFILES_ACTIVE=dev java -jar mcp-app/target/mcp-notes.jar

<platform version> is mcp-platform.version in pom.xml. Connect the client to http://127.0.0.1:18500/mcp with Authorization: Bearer <your-token>; the token is sent to your system as the credential. The local user has the groups mcp-notes-read and mcp-notes-write. There is no login, no tool approval and no confirmation code. A call reaches the system with every right of the token you send.

deploy/ holds compose.yaml, server.env and a systemd unit. Build and start:

Terminal window
./mvnw -B verify
cd deploy
docker compose build
docker compose up -d

The jar is mcp-app/target/mcp-notes.jar. Several instances behind one address need the same Redis (MCP_REDIS_HOST, so a token is used once across all instances), the same MCP_PAGING_CURSOR_KEY (openssl rand -base64 32) and the same certificate. For a host without Docker, deploy/systemd/ has a unit and an environment file.

Common problems:

Symptom Cause Fix
Server stops at start: cannot fetch the key set The host cannot resolve <platform> or does not trust its certificate Check DNS; add the company CA to a trust store in deploy/certs/ and set JAVA_TOOL_OPTIONS in server.env
Portal: tls_handshake_failed or manifest_unreachable Port 8443 is closed between the platform and the server Open it to the platform host
Server status certificate_changed The state volume was recreated and a new certificate was generated Approve the new fingerprint
Calls fail with policies_differ A new or changed policy waits for approval Ask an administrator to approve the policies
Calls fail with upstream_unavailable and SSLHandshakeException in the log The JVM trust store lacks the CA of your system Import the CA and recreate the container

The generated server has .github/workflows/ci.yml and release.yml. A tag vX.Y.Z that matches the project version starts the release: it runs the tests, builds the manifest and an SBOM, pushes the image to ghcr.io/<owner>/mcp-server-notes and attaches the manifest and SBOM to the GitHub Release. The jobs that push and publish need approval in the GitHub environment release.

  1. Set the version without -SNAPSHOT in the poms, in deploy/compose.yaml (the default of MCP_NOTES_VERSION) and in CHANGELOG.md.

  2. Run ./mvnw -B verify.

  3. Commit, tag and push only that tag:

    Terminal window
    git tag v1.0.0
    git push origin v1.0.0
  4. Approve the jobs in the release environment.

The operator pulls the new image and runs docker compose up -d. The state volume keeps the certificate, so the connection stays approved. A version with other tools or policies waits in the portal until an administrator approves it, see Servers.