Tools
MCP-Server absichern: OAuth mit Spring AI
Ein MCP-Server ohne Auth ist eine offene Tür. So sichert ihr ihn mit Spring AI 2.0 und OAuth 2.1 ab, mit Scopes und lauffähigem Beispielcode auf GitHub.
Im Artikel zu MCP- und Connector-Sicherheit steht das Warum: Ein Agent mit privaten Daten, fremden Inhalten und einem Kanal nach außen ist ein Angriffsziel, und Least Privilege ist die wichtigste Gegenmaßnahme. Hier steht das Wie. Wir bauen einen MCP-Server, der ohne gültiges OAuth-Token gar nichts herausgibt und pro Werkzeug prüft, ob der Aufrufer den passenden Scope hat.
Der komplette Code liegt als lauffähiges Repo auf GitHub: kiberblick-mcp-oauth-demo. Zwei mvn-Befehle, kein Docker, keine Datenbank. Wer nur die Grundlagen von MCP mit Spring AI sucht, fängt bei MCP-Server mit Spring AI bauen an.
Zwei getrennte Rollen
OAuth trennt zwei Aufgaben, und das Beispiel bildet sie als zwei eigenständige Spring-Boot-Apps ab, so wie es in Produktion auch gehört:
- Authorization Server (Port 9000): gibt Tokens aus und kennt die Scopes.
- Resource Server (der MCP-Server, Port 8090): prüft das Token und setzt die Scopes durch.
Diese Trennung ist nicht nur formale Sauberkeit. Sie ist genau die Grenze, an der Least Privilege durchgesetzt wird: Der MCP-Server vertraut keinem Aufrufer, sondern nur einem Token, das eine andere, dafür zuständige Instanz ausgestellt hat.
Der Authorization Server: Scopes am Client
Der Authorization Server basiert auf Spring Authorization Server. Das Herzstück ist ein registrierter Client mit dem client_credentials-Grant und genau zwei Scopes, mcp.read und mcp.write:
RegisteredClient demoClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("mcp-demo-client")
.clientSecret("{noop}mcp-demo-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("mcp.read")
.scope("mcp.write")
.build();
Mehr braucht es nicht. Sobald ein RegisteredClientRepository-Bean da ist, konfiguriert Spring Boot den Token-Endpoint, das JWK-Set und die OpenID-Metadaten von selbst. Die vollständige Klasse steht in RegisteredClientConfig.java.
Der MCP-Server als Resource Server
Auf der anderen Seite steht der MCP-Server. Ihn zum OAuth-Resource-Server zu machen, ist überraschend wenig Arbeit, weil das Community-Modul mcp-server-security-spring-boot die Absicherung aus einer einzigen Property ableitet. In der application.yml genügt der Verweis auf den Issuer:
spring:
ai:
mcp:
server:
name: kiberblick-notes-mcp
protocol: STREAMABLE
type: SYNC
streamable-http:
mcp-endpoint: /mcp
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9000
Sobald issuer-uri gesetzt ist, baut der Starter die SecurityFilterChain, die jeden Zugriff auf /mcp gegen ein gültiges JWT prüft. Ohne Token gibt es 401, bevor der MCP-Server die Anfrage überhaupt anschaut. Die zugehörige Config-Klasse schaltet nur noch die Method-Security ein, damit die Scope-Prüfung an den einzelnen Tools greift:
@Configuration
@EnableMethodSecurity
public class McpServerSecurityConfig {
}
Scope-Gating an den Tools
Jetzt der eigentliche Least-Privilege-Teil. Der Notiz-Service bietet drei Tools an, und jedes verlangt per @PreAuthorize den passenden Scope. Lesen braucht mcp.read, Schreiben braucht mcp.write:
@PreAuthorize("hasAuthority('SCOPE_mcp.read')")
@McpTool(name = "list-notes", description = "Alle Notizen auflisten")
public List<Note> listNotes() {
return repository.findAll();
}
@PreAuthorize("hasAuthority('SCOPE_mcp.write')")
@McpTool(name = "create-note", description = "Eine neue Notiz anlegen")
public Note createNote(
@McpToolParam(description = "Titel der Notiz") String title,
@McpToolParam(description = "Inhalt der Notiz") String content) {
return repository.save(title, content);
}
Zwei Details, die beim Umstieg auf Spring AI 2.0 auffallen. Erstens liegen die MCP-Annotationen jetzt im Package org.springframework.ai.mcp.annotation (in Spring AI 1.1 kamen sie noch aus dem Community-Package). Zweitens setzt Spring das Scope-Präfix SCOPE_ vor jeden Scope aus dem Token, deshalb heißt die Authority SCOPE_mcp.write und nicht nur mcp.write. Die ganze Klasse steht in NoteService.java.
Ausprobieren: ein Token, das nicht schreiben darf
Der beste Test ist der, der fehlschlägt. Hol dir ein Token mit nur mcp.read und versuch damit zu schreiben:
TOKEN=$(curl -s -u mcp-demo-client:mcp-demo-secret \
-d 'grant_type=client_credentials&scope=mcp.read' \
http://localhost:9000/oauth2/token | grep -o '"access_token":"[^"]*"')
Ein create-note-Aufruf mit diesem Token wird abgelehnt, ein list-notes läuft. Ein Token mit mcp.write darf beides. Genau das ist Least Privilege in einer Zeile Konfiguration pro Tool.
Ein Punkt, der einen beim ersten Mal stolpern lässt: MCP verpackt den Autorisierungsfehler nicht als HTTP 403, sondern als regulären JSON-RPC-Fehler mit isError: true in einem HTTP-200-Body ("Access Denied"). Der Schutz greift, aber wer sein Monitoring nur auf HTTP-Statuscodes baut, sieht die Ablehnung nicht. Für die Beobachtung von Agenten-Zugriffen heißt das: den JSON-RPC-Body auswerten, nicht nur den Statuscode.
Was hier Demo ist und was nicht
Damit das Beispiel mit zwei Befehlen läuft, sind ein paar Dinge bewusst vereinfacht. Client-Secret und Signaturschlüssel sind Klartext-Demowerte, die Notizen liegen im Speicher, und beide Apps laufen lokal. In Produktion gehören die Secrets in einen Secret-Store, der Authorization Server ist ein gehärteter Identity Provider (oder ein Dienst wie Keycloak), und die Token-Gültigkeit ist kurz. Was sich nicht ändert, ist das Muster: ein Token pro Aufruf, ein Scope pro Werkzeug, und ein Resource Server, der beidem nicht traut, bis es geprüft ist.
Und die wichtigste Grenze bleibt bestehen: OAuth schützt davor, dass ein nicht berechtigter Aufrufer ein Werkzeug nutzt. Es schützt nicht davor, dass ein berechtigter Agent per Prompt Injection dazu gebracht wird, seine legitimen Rechte falsch einzusetzen. Scopes verkleinern den Radius, sie schließen ihn nicht.
Fazit
Einen MCP-Server abzusichern ist mit Spring AI 2.0 kein Großprojekt: ein Authorization Server mit klaren Scopes, ein Resource Server, der aus einer Property entsteht, und eine @PreAuthorize-Zeile pro Tool. Der Aufwand steht in keinem Verhältnis zum Unterschied zwischen einem offenen und einem geschlossenen Werkzeugkasten. Das komplette Beispiel zum Klonen und Durchspielen liegt im Repo.