Przejdź do głównej zawartości

Napisz serwer z szablonu

Serwer to aplikacja Spring Boot ze starterem (mcp-platform-starter). Piszesz narzędzia, polityki zasobów i wywołania swojego systemu. Starter weryfikuje token bramki, podaje ci poświadczenie, sprawdza politykę dla każdego zasobu, który narzędzie nazywa, zapisuje audyt wywołania i pilnuje, żeby niebezpieczne błędy nie wychodziły na zewnątrz. Stos: Java 25, Spring Boot 4.1.0, starter 4.0.0, kontrakty 7.0.0.

Zacznij od Zbuduj i zarejestruj pierwszy serwer: tam powstaje projekt, który zmieniasz na tej stronie.

Moduł Zawiera
mcp-core Typy domenowe i porty
mcp-application Przypadki użycia
mcp-integration Klient twojego systemu, resolver zasobów, sonda wersji
mcp-tools Narzędzia MCP i ich błędy
mcp-app Start aplikacji, application.yml, polityki, jedyny jar, testy end-to-end

Zastąp przykładową domenę rekordów w pierwszych trzech modułach zasobami i wywołaniami API swojego systemu. Atrapa backendu z szablonu (StubBackend w mcp-app) pozwala uruchamiać testy bez Dockera i prawdziwego systemu.

Narzędzia rejestruje się beanem ToolCallbackProvider. Sam bean z metodami @Tool nie wystarczy.

@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);
}

Każde narzędzie potrzebuje:

Element Reguła
@ToolTitle Niepusty, do 100 znaków
@ToolRisk READ_ONLY, UPDATE lub DESTRUCTIVE. Bez niego albo z nazwą sprzeczną z nim (delete_* jako READ_ONLY) serwer się nie uruchomi
@ResourceRef Na każdym argumencie, który nazywa zasób, z typem zasobu
@ArgumentLimits Ograniczenie na każdym poziomie: maxLength dla tekstu, minimum i maximum dla liczb, maxItems dla list. resourceType bierze wzorzec identyfikatora z resource-types.json
Zamknięty schemat additionalProperties: false. Pisze go SchemaLimitedToolCallbacks. Map, unii i listy list nie da się ograniczyć, zatrzymują start
Brak argumentu confirmation_code Bramka dodaje go do tools/list i usuwa przed wywołaniem

Poza profilem dev narzędzie z otwartym poziomem schematu zatrzymuje start. W dev dostajesz ostrzeżenie event=tool_schema_open.

@ToolRisk Znaczenie Akcja Cedar Grupa potrzebna użytkownikowi Zachowanie bramki
READ_ONLY Niczego nie zmienia read mcp-<system>-read Działa po zatwierdzeniu
UPDATE Zmienia dane update mcp-<system>-write Działa po zatwierdzeniu; nigdy nie jest ponawiane
DESTRUCTIVE Usuwa lub nie da się cofnąć delete (lub jawnie admin) mcp-<system>-destructive Najpierw prosi użytkownika o kod jednorazowy

Serwer nie prosi o potwierdzenie; tylko prawdziwie deklaruje ryzyko. Nazwa zawierająca delete, drop, remove, purge, truncate, destroy, wipe lub erase nie przejdzie jako READ_ONLY ani UPDATE. Żeby zawęzić narzędzie do dodatkowej grupy, użyj @ToolRisk(value = RiskLevel.READ_ONLY, additionalGroup = "mcp-<system>-ops").

Wywołanie UPDATE może dotrzeć dwa razy, więc wybieraj stan docelowy (set_assignee) zamiast przyrostu, twórz wpisy z kluczem, który możesz sprawdzić, i wysyłaj oczekiwaną wersję, gdy API ją przyjmuje. Gdy system odpowie upstream_unavailable, napisz w komunikacie, że operacja mogła zostać wykonana.

@ResourceRef sprawia, że starter rozwiązuje każdy identyfikator twoim ResourceResolver, z poświadczeniem wywołującego, i ocenia politykę, zanim metoda się wykona. Zasób brakujący, ukryty lub spoza zakresu nigdy nie dociera do twojego kodu; klient dostaje not_found_or_denied we wszystkich trzech przypadkach.

@Component
class NoteResolver implements ResourceResolver<Note> {
public String resourceType() { return "Notes::Note"; }
public Optional<Note> fetch(String id) {
return notes.find(id); // pusty dla 404 i 403; wyjątek przy awarii systemu
}
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);
}
}

Identyfikator, rodzic i klasyfikacja pochodzą z systemu, nigdy z argumentu. Zasób bez wymaganej klasyfikacji nie jest budowany, a wywołanie kończy się internal_error. W narzędziu zwracającym listy zasobów elementy są sprawdzane po wywołaniu, a niedozwolone usuwane.

Tekst z systemu (tytuł, komentarz, opis) trafia do wyniku jako UntrustedText, żeby klienty traktowały go jako dane. Wynik musi być rekordem; starter publikuje z niego outputSchema, a składowa, która może być pusta, wymaga @Nullable.

Polityki leżą w mcp-app/src/main/resources/mcp/policies/ i trafiają do jara oraz manifestu. Starter odmawia startu bez nich.

Plik Zawartość
schema.cedarschema Jedna przestrzeń nazw, własna serwera (Notes): typ encji dla każdego zasobu, każdy z scopes: Set<ResourceScope>, oraz akcje read, update, delete, admin
resource-types.json Jedna deklaracja na typ encji: attributes, parent i parentAttribute, idPattern, opcjonalnie scopePattern, earlyScopeCheck, classification
*.cedar Reguły dostępu, każda z @id unikalnym w serwerze

Polityka nazywająca Action::"call" lub Tool jest odrzucana: ta decyzja należy do bramki. Pisz tylko reguły dostępu. Platforma sama generuje regułę dla danych restricted i regułę dla konta serwisowego, więc serwer nie może ich pominąć.

Reguła odczytu i reguła zmiany z szablonu:

@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 jest prawdziwe, gdy jedna z grup użytkownika jest zmapowana na zakres zasobu. Mapowanie robi administrator w portalu; serwer nigdy nie wyprowadza nazwy grupy. Mapowanie zakresów, dozwolonych klientów AI i nadzwyczajne wyjątki nie są konfiguracją serwera: wysyła je bramka.

Zmieniona polityka lub deklaracja zmienia policiesHash w manifeście i przenosi wdrożony serwer w stan pending_approval, dopóki administrator jej nie zatwierdzi. Do tego czasu serwer odrzuca wywołania z policies_not_approved lub policies_differ (dla klienta oba to upstream_unavailable).

application.yml szablonu to konfiguracja produkcyjna. Te wartości różnią się między środowiskami i pochodzą z deploy/server.env:

Zmienna Opis
MCP_GATEWAY_JWKS_URI Zestaw kluczy bramki: https://<platform>/.well-known/mcp-jwks.json
MCP_CONNECT_URL Strona portalu, na której użytkownik łączy konto: https://<platform>/connections?system={system}
MCP_NOTES_URL Adres API twojego systemu, https poza profilem dev
MCP_ACCEPTED_CREDENTIAL_TYPES Domyślnie user-secret. Jeden z user-oauth, user-secret, token-exchange albo samo service-account

Pierwszy akceptowany typ staje się context.credentialType w każdym żądaniu Cedar. Nigdy nie mieszaj service-account z typami użytkownika: serwer się nie uruchomi. Typ musi być jednym z tych, które broker platformy zwraca dla systemu, zobacz Systemy.

Certyfikat TLS serwera jest generowany raz do MCP_STATE_DIR i używany ponownie, więc trzymaj ten katalog na wolumenie. Bramka przypina jego odcisk, więc nowy certyfikat wymaga nowego zatwierdzenia. Ustaw MCP_TLS_CERT i MCP_TLS_KEY albo MCP_TLS_KEYSTORE i MCP_TLS_STORE_PASSWORD, by użyć certyfikatu z urzędu, któremu bramka ufa.

Testy nie potrzebują Dockera ani platformy. Uruchamia je ./mvnw -B verify.

Polityki testuje się zwykłym JUnit przez PolicyDecisions z 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);

Dla każdej polityki napisz przypadek dozwolony i odmowny: zakres obejmujący zasób i taki, który go nie obejmuje, dane restricted, konto serwisowe, klient niedozwolony przez bramkę, nadzwyczajny wyjątek. Dla narzędzi TestGateway podpisuje wewnętrzne JWT kluczami testowymi kontraktów, wysyła dane polityk i wywołuje serwer w profilu dev (@ActiveProfiles("dev")). Każde nowe narzędzie potrzebuje też testu negatywnego.

Żeby wywołać narzędzia z klienta MCP bez platformy, uruchom LocalGateway. Po ./mvnw verify:

Okno terminala
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> to mcp-platform.version z pom.xml. Podłącz klienta do http://127.0.0.1:18500/mcp z Authorization: Bearer <twoj-token>; token jest wysyłany do twojego systemu jako poświadczenie. Lokalny użytkownik ma grupy mcp-notes-read i mcp-notes-write. Nie ma logowania, zatwierdzania narzędzi ani kodu potwierdzenia. Wywołanie dociera do systemu z pełnymi uprawnieniami wysłanego tokenu.

Katalog deploy/ zawiera compose.yaml, server.env i jednostkę systemd. Zbuduj i uruchom:

Okno terminala
./mvnw -B verify
cd deploy
docker compose build
docker compose up -d

Jar to mcp-app/target/mcp-notes.jar. Kilka instancji za jednym adresem potrzebuje tego samego Redisa (MCP_REDIS_HOST, żeby token był użyty raz na wszystkich instancjach), tego samego MCP_PAGING_CURSOR_KEY (openssl rand -base64 32) i tego samego certyfikatu. Dla hosta bez Dockera deploy/systemd/ ma jednostkę i plik środowiska.

Typowe problemy:

Objaw Przyczyna Rozwiązanie
Serwer zatrzymuje się przy starcie: nie można pobrać zestawu kluczy Host nie rozwiązuje <platform> albo nie ufa jego certyfikatowi Sprawdź DNS; dodaj urząd firmowy do magazynu zaufania w deploy/certs/ i ustaw JAVA_TOOL_OPTIONS w server.env
Portal: tls_handshake_failed lub manifest_unreachable Port 8443 jest zamknięty między platformą a serwerem Otwórz go dla hosta platformy
Status serwera certificate_changed Wolumen stanu odtworzono i wygenerowano nowy certyfikat Zatwierdź nowy odcisk
Wywołania kończą się policies_differ Nowa lub zmieniona polityka czeka na zatwierdzenie Poproś administratora o zatwierdzenie polityk
Wywołania kończą się upstream_unavailable, a w logu jest SSLHandshakeException W magazynie zaufania JVM brakuje urzędu twojego systemu Zaimportuj urząd i odtwórz kontener

Wygenerowany serwer ma .github/workflows/ci.yml i release.yml. Tag vX.Y.Z zgodny z wersją projektu uruchamia wydanie: testy, budowę manifestu i SBOM, wypchnięcie obrazu do ghcr.io/<owner>/mcp-server-notes oraz dołączenie manifestu i SBOM do GitHub Release. Zadania wypychające i publikujące wymagają zatwierdzenia w środowisku GitHub release.

  1. Ustaw wersję bez -SNAPSHOT w plikach pom, w deploy/compose.yaml (domyślna wartość MCP_NOTES_VERSION) i w CHANGELOG.md.

  2. Uruchom ./mvnw -B verify.

  3. Zatwierdź zmiany, otaguj i wypchnij tylko ten tag:

    Okno terminala
    git tag v1.0.0
    git push origin v1.0.0
  4. Zatwierdź zadania w środowisku release.

Operator pobiera nowy obraz i uruchamia docker compose up -d. Wolumen stanu zachowuje certyfikat, więc połączenie pozostaje zatwierdzone. Wersja z innymi narzędziami lub politykami czeka w portalu na zatwierdzenie administratora, zobacz Serwery.