← All projects

MCP Tool Server for Internal Business Tools

Build a Model Context Protocol server over HTTP with Spring Boot, speaking JSON-RPC 2.0 directly, with schema-validated tool calls, OAuth scopes per tool, rate limits, an audit log and error results the model can recover from.

AI AgentsAdvanced14-20 hours#ai-agents#mcp#tool-use#security#restJava 21Spring Boot 3Spring Security (JWT resource server)Jacksonnetworknt json-schema-validatorJdbcClientPostgreSQLTestcontainers

Revise first: Exception HandlingJava SecurityREST API Design, Validation & ControllerAdviceSpring Security & JWTLogging, Monitoring, Tracing, Prometheus & Grafana

Agents become useful when they can touch your systems: look up a customer, list invoices, issue a refund. The Model Context Protocol (MCP) is the open standard for exposing such tools so any compliant agent or client can discover and call them. In this project you implement the server side yourself, as a small Spring Boot controller speaking JSON-RPC 2.0, so the mechanics are visible. Then you do the part tutorials skip: the model is an untrusted caller. Arguments are validated, every tool needs a scope, mutations are idempotent and capped, calls are rate limited and audited, and failures come back as results the model can read and correct.

Target behaviour

MCP messages are JSON-RPC 2.0 objects. A client first sends initialize, then a notifications/initialized notification (no id, so no reply body), then tools/list and tools/call.

--> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"demo","version":"1"}}}
<-- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"acme-tools","version":"0.1.0"}}}
--> {"jsonrpc":"2.0","method":"notifications/initialized"}
<-- 202 Accepted
--> {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"issue_refund","arguments":{"customerId":"C-100234","amountCents":99999999,"reason":"x","idempotencyKey":"r-1"}}}
<-- {"jsonrpc":"2.0","id":2,"result":{"isError":true,"content":[{"type":"text","text":"Invalid arguments: $.amountCents: must have a maximum value of 50000. Fix the arguments and call again."}]}}

Two kinds of failure, deliberately different. A protocol problem (bad JSON, unknown method, or unknown tool, code -32602) is a JSON-RPC error. A tool that ran, or refused to run, is a normal result with isError: true, because that text is fed back to the model, which can fix its arguments and retry.

Architecture

 Agent / MCP client ──POST /mcp (Bearer JWT)──▶ Spring Security: verify JWT
  (Claude, IDE, your bridge)                              ▼
                                        McpController: parse JSON-RPC, route method
                                                          │ tools/call
            ┌───────────────┬─────────────────┬────────────────┴──┬────────────┐
            ▼               ▼                 ▼                   ▼            ▼
        tool exists?   scope granted?   rate limit ok?    args match schema?  execute
            └──── any failure ──▶ isError result (model-readable) + audit row ◀──┘

Every gate runs before the tool body, in a fixed order, and every outcome, denials included, is audited.

Transports: with stdio the client launches your server as a subprocess and exchanges newline-delimited JSON on stdin and stdout, so stdout carries protocol only. With Streamable HTTP the client POSTs each message to one endpoint and the server answers with JSON or upgrades to an SSE stream. This guide implements the JSON-only subset. Internal business tools belong on HTTP, where authentication and audit are the point.

The core of it

Dependencies: Spring Web, Security, OAuth2 resource server, JDBC, Postgres, Actuator, plus com.networknt:json-schema-validator (1.5.x) for argument validation.

Each tool is a small contract: a name, a JSON Schema for its arguments, the scope it needs, and whether it mutates. Descriptions are prompts, so write them for a model.

public interface McpTool {
    String name();
    String description();
    JsonSchema schema();
    String requiredScope();
    boolean readOnly();
    String call(JsonNode args, String subject) throws ToolException;
}

ToolException is a checked exception whose message is written for the model; RpcException is a runtime exception carrying a JSON-RPC code.

A mutating tool. The schema is the first line of defence; the body re-checks business rules and uses bind parameters only. The idempotency key (unique column on a refund table) makes a model retry safe.

@Component
class IssueRefundTool implements McpTool {
    private static final String SCHEMA = """
        {"type":"object","additionalProperties":false,
         "required":["customerId","amountCents","reason","idempotencyKey"],
         "properties":{"customerId":{"type":"string","pattern":"^C-[0-9]{6}$"},
          "amountCents":{"type":"integer","minimum":1,"maximum":50000},
          "reason":{"type":"string","maxLength":200},
          "idempotencyKey":{"type":"string","minLength":8,"maxLength":64}}}""";
    private final JdbcClient jdbc;
    private final JsonSchema schema = Schemas.parse(SCHEMA);
    IssueRefundTool(JdbcClient jdbc) { this.jdbc = jdbc; }
    public String name() { return "issue_refund"; }
    public String description() { return "Refund up to 500.00. Mutating. Reuse the idempotencyKey when retrying."; }
    public JsonSchema schema() { return schema; }
    public String requiredScope() { return "refunds:write"; }
    public boolean readOnly() { return false; }
}
public String call(JsonNode a, String subject) throws ToolException {
    String customer = a.get("customerId").asText();
    if (jdbc.sql("SELECT count(*) FROM customer WHERE id = :id")
            .param("id", customer).query(Integer.class).single() == 0)
        throw new ToolException("No customer " + customer + ". Look the customer up first.");
    int rows = jdbc.sql("""
            INSERT INTO refund(customer_id, amount_cents, reason, idempotency_key, requested_by)
            VALUES (:c, :a, :r, :k, :u) ON CONFLICT (idempotency_key) DO NOTHING""")
        .param("c", customer).param("a", a.get("amountCents").asInt())
        .param("r", a.get("reason").asText()).param("k", a.get("idempotencyKey").asText())
        .param("u", subject).update();
    return rows == 1 ? "Refund recorded." : "Already processed (same idempotencyKey); no new refund.";
}

Validation: a small Schemas helper builds schemas with JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(jsonNode) and errors(schema, args) joins the getMessage() of each ValidationMessage from schema.validate(args) (an empty set means valid).

Auth is stock Spring Security: a SecurityFilterChain that requires authentication on everything except health and calls .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())), with spring.security.oauth2.resourceserver.jwt.issuer-uri set. The JWT scope claim becomes SCOPE_* authorities, so per-tool authorization is a scope check in the controller, and tools/list only advertises tools the caller may use.

The per-subject token bucket (2 per second, burst 10; make them properties). compute makes refill-and-take atomic per key.

final class RateLimiter {
    private record Bucket(double tokens, long at) {}
    private final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>();
    private final double perSecond = 2, burst = 10;
    boolean tryAcquire(String key) {
        long now = System.nanoTime(); boolean[] ok = {false};
        buckets.compute(key, (k, b) -> {
            double t = b == null ? burst : Math.min(burst, b.tokens() + (now - b.at()) / 1e9 * perSecond);
            if (t >= 1) { ok[0] = true; t -= 1; }
            return new Bucket(t, now);
        });
        return ok[0];
    }
}

The audit record stores who, which tool, a SHA-256 hash of the arguments (they may hold personal data), the outcome and duration. Emit it as one structured log line from a dedicated logger (or an append-only table) and increment a Micrometer counter mcp_tool_calls_total{tool,outcome}, so denials and rate limits show up on a dashboard.

The controller. Notifications have no id and get an empty 202. Parsing happens by hand so malformed JSON becomes a JSON-RPC -32700, not a Spring 400 page.

@PostMapping(value = "/mcp", consumes = "application/json", produces = "application/json")
ResponseEntity<JsonNode> handle(@RequestBody String raw, Authentication auth) {
    JsonNode req;
    try { req = om.readTree(raw); }
    catch (JsonProcessingException e) { return ResponseEntity.ok(error(null, -32700, "Parse error")); }
    JsonNode id = req.get("id");
    if (id == null) return ResponseEntity.accepted().build();
    try {
        JsonNode result = switch (req.path("method").asText()) {
            case "initialize" -> initResult();
            case "tools/list" -> listResult(auth);
            case "tools/call" -> callResult(req.path("params"), auth);
            default -> throw new RpcException(-32601, "Method not found");
        };
        return ResponseEntity.ok(envelope(id).set("result", result));
    } catch (RpcException e) { return ResponseEntity.ok(error(id, e.code, e.getMessage())); }
}
private ObjectNode envelope(JsonNode id) {
    ObjectNode n = om.createObjectNode().put("jsonrpc", "2.0");
    n.set("id", id == null ? NullNode.instance : id);   // copies numeric or string ids unchanged
    return n;
}
private ObjectNode error(JsonNode id, int code, String msg) {
    ObjectNode n = envelope(id); n.putObject("error").put("code", code).put("message", msg); return n;
}

envelope(id).set("result", result) returns a generic JsonNode, which is what the handler returns. The gate sequence lives in callResult:

private record Gate(String outcome, String message) {}
private Gate check(McpTool t, JsonNode args, Authentication auth) {
    if (!granted(auth, t.requiredScope())) return new Gate("denied", "Not permitted to use " + t.name() + ".");
    if (!limiter.tryAcquire(auth.getName())) return new Gate("rate_limited", "Rate limit exceeded. Wait a few seconds.");
    String bad = Schemas.errors(t.schema(), args);
    return bad.isEmpty() ? null : new Gate("invalid", "Invalid arguments: " + bad + ". Fix the arguments and call again.");
}

private JsonNode callResult(JsonNode p, Authentication auth) {
    McpTool t = tools.get(p.path("name").asText());
    if (t == null) throw new RpcException(-32602, "Unknown tool: " + p.path("name").asText());
    JsonNode args = p.has("arguments") ? p.get("arguments") : om.createObjectNode();
    String who = auth.getName();
    long t0 = System.nanoTime();
    Gate g = check(t, args, auth);
    if (g != null) return done(who, t, args, g.outcome(), t0, toolError(g.message()));
    try { return done(who, t, args, "ok", t0, toolText(t.call(args, who))); }
    catch (ToolException e) { return done(who, t, args, "tool_error", t0, toolError(e.getMessage())); }
    catch (RuntimeException e) {
        log.error("tool failed", e);
        return done(who, t, args, "failure", t0, toolError("Internal error. Do not retry this call."));
    }
}

toolText and toolError build {"content":[{"type":"text","text":...}],"isError":false|true}; granted checks for the authority SCOPE_ plus the scope; done audits and returns its last argument. initResult returns the shape shown above and listResult maps each tool the caller may use to name, description and inputSchema. Note the internal error message is generic: stack traces and SQL fragments help attackers, not models.

Arguments are untrusted at three levels: the schema rejects bad shape and range, the tool body re-checks invariants a schema cannot know (the customer exists), and every query uses bind parameters so a reason of '; DROP TABLE is just text. Results are untrusted too: customer free text may say "ignore your instructions", so keep authority (scopes, caps) on the server where text cannot argue with it.

Using it from an agent

The Anthropic Messages API (POST https://api.anthropic.com/v1/messages, headers x-api-key and anthropic-version: 2023-06-01) takes tools as {name, description, input_schema}. MCP calls the schema field inputSchema, so a bridge (a few lines of Jackson: copy each listed tool, putting inputSchema under input_schema) renames one key and relays each tool_use block as a tools/call, returning {"type":"tool_result","tool_use_id":...,"content":text,"is_error":bool} built from the MCP result.

The loop: send messages with tools; while stop_reason is tool_use, execute every tool_use block in content, append the assistant turn, then one user turn holding all matching tool_result blocks, and call again. Use model claude-sonnet-5-5 from a property such as agent.model. Production alternative: the official MCP Java SDK, or the Spring AI MCP server starter, handle transports, sessions and capability negotiation for you; check their current documentation for exact class names. Build this by hand once to understand what they do, then prefer them.

Testing

Run the real app with a real database, and use Spring Security's jwt() request post-processor to mint caller identities without an identity provider.

@SpringBootTest @AutoConfigureMockMvc @Testcontainers
class McpServerIT {
    @Container @ServiceConnection static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
    @Autowired MockMvc mvc;

    @Test
    void overCapRefundIsModelReadableError() throws Exception {
        mvc.perform(post("/mcp").contentType("application/json")
                .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_refunds:write")))
                .content("""
                    {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"issue_refund",
                     "arguments":{"customerId":"C-100234","amountCents":99999999,"reason":"x","idempotencyKey":"key-0001"}}}"""))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.result.isError").value(true))
            .andExpect(jsonPath("$.result.content[0].text").value(containsString("amountCents")));
    }
}

Bugs you will hit

  • Replying to notifications. A message with no id is a notification; answering it with a JSON body confuses strict clients. Return 202 with an empty body and never invent an id.
  • Using JSON-RPC errors for tool failures. If bad arguments produce error instead of result.isError, many clients show the model nothing useful and the agent gives up. Use error for protocol faults only.
  • Writing logs to stdout under stdio transport. One stray System.out.println or a console logger corrupts the message stream. Log to stderr or a file, and keep stdout for protocol frames.
  • Trusting the schema as the only check. pattern proves a customer id looks right, not that it exists or belongs to the caller. Re-check ownership and limits in the tool body and enforce caps server-side, even when the schema also states them.
  • Leaking identity and secrets in audits and errors. Logging raw arguments stores personal data forever, and echoing exception text exposes internals. Log a hash plus the subject, return generic internal errors, and validate the Origin header on browser-reachable endpoints to blunt DNS rebinding.

Interview angle

  • Why is an MCP tool call different from a normal REST call? The caller is a language model acting on untrusted input, possibly steered by prompt injection from data it read. So you assume malformed and hostile arguments, authorize per tool rather than per session, and design errors as instructions the model can act on.
  • Where do you put authorization, in the schema, the tool or the model prompt? On the server, in code, keyed to the token's scopes. Prompts and descriptions are advisory and can be overridden by injected text; a missing scope cannot be argued with. Hide tools the caller lacks from tools/list as well.
  • How do you make mutating tools safe with an agent that retries? Require an idempotency key stored under a unique constraint, cap amounts server-side, mark the tool as mutating in the audit log, and consider a two-step propose and confirm flow for high-value actions.
  • stdio or Streamable HTTP? stdio suits local single-user tools launched by the client, with the process boundary as isolation. Streamable HTTP suits shared internal services: standard OAuth, load balancing, central rate limits and audit, at the cost of running a service.

Milestones

0 of 7 milestones

Done when

Stretch goals