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.
Project layout
Section titled “Project layout”| 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.
Write a tool
Section titled “Write a tool”Register tools with a ToolCallbackProvider bean. A bean with @Tool methods alone is not enough.
@BeanToolCallbackProvider 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.
Risk classes
Section titled “Risk classes”@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.
Resources and the resolver
Section titled “Resources and the resolver”@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.
@Componentclass 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.
Declare resources and write policies
Section titled “Declare resources and write policies”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).
Configure the server
Section titled “Configure the server”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.
Test it
Section titled “Test it”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:
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.
Build the image and run it
Section titled “Build the image and run it”deploy/ holds compose.yaml, server.env and a systemd unit. Build and start:
./mvnw -B verifycd deploydocker compose builddocker compose up -dThe 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 |
Release
Section titled “Release”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.
-
Set the version without
-SNAPSHOTin the poms, indeploy/compose.yaml(the default ofMCP_NOTES_VERSION) and inCHANGELOG.md. -
Run
./mvnw -B verify. -
Commit, tag and push only that tag:
Terminal window git tag v1.0.0git push origin v1.0.0 -
Approve the jobs in the
releaseenvironment.
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.
