Spring, Kotlin를 활용하여 만든 WebMVC MCP Server에 인증 추가하기

WebMVC MCP Server에 Spring Secutiry를 적용하여 인증을 추가해 본다.

앞에서는 WebMVC 전송 방식의 MCP Server를 만들었다면, 여기서는 서버에 인증 기능을 추가헤 보도록 하겠다.

WebMVC MCP Server에 인증 추가

이번에도 기존 만들었던 WebMVC 방식의 MCP Server 코드를 그대로 사용하도록 하겠다.

기존 코드 복사

기존에 작성한 코드가 있다면 디렉토리 통채로 복사하여 사용하도록 하겠다.

작성한 코드가 없다면, GitHub를 통채로 복사해서 사용해도 된다.

코드를 복사하고, 디렉토리도 다음과 같이 변경한다.

% mv spring-ai-mcp-server-webmvc spring-ai-mcp-server-auth

빌드 스크립트

복사한 프로젝트의 settings.gradle.kts 파일에세 root 프로젝트 이름도 spring-ai-mcp-server-auth 로 변경한다.

settings.gradle.kts

rootProject.name = "spring-ai-mcp-server-auth"

의존성 라이브러리에 Spring Security(spring-boot-starter-security), MCP Server Secutiry(mcp-server-security) 라이브러리를 추가한다.

/build.gradle.kts

dependencies {
	...
	implementation("org.springframework.ai:spring-ai-starter-mcp-server")
  implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc") // webmvc 추가
  implementation("org.springframework.boot:spring-boot-starter-security") // auth 추가
  implementation("org.springaicommunity:mcp-server-security:0.0.3") // auth 추가
  ...
}

어플리케이션 설정 파일

src/main/resources/application.yml에 다음와 같이 변경한다.

spring:
  main:
#    web-application-type: none
    # NOTE: You must disable the banner and the console logging to allow the STDIO transport to work !!!
    banner-mode: off
  ai:
    mcp:
      server:
        name: my-weather-server
        version: 0.0.1
        protocol: STATELESS # webmvc 추가

logging:
#  pattern:
#    console:
  file:
    name: ./log/spring-ai-starter-mcp-server-auth.log
  level:
    org.springframework.security: TRACE

로그 파일명(spring-ai-starter-mcp-server-auth.log)이 변경 되었고,
Spring Security 로그 레벨만 추가 되었을 뿐 다른건 수정 된 것이 없다.

logging:
  level:
    org.springframework.security: TRACE

MCP 서버 보안 설정 객체 생성

Spring Security 설정에 MCP Api Key를 등록한다.

/src/main/kotlin/com/devkuma/ai/mcp/server/SecurityConfig.kt

package com.devkuma.ai.mcp.server

import org.springaicommunity.mcp.security.server.apikey.ApiKeyEntityRepository
import org.springaicommunity.mcp.security.server.apikey.memory.ApiKeyEntityImpl
import org.springaicommunity.mcp.security.server.apikey.memory.InMemoryApiKeyEntityRepository
import org.springaicommunity.mcp.security.server.config.McpApiKeyConfigurer.mcpServerApiKey
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain =
        http.authorizeHttpRequests { authorizeHttpRequestsCustomizer ->
            authorizeHttpRequestsCustomizer.anyRequest().authenticated()
        }.with(
            mcpServerApiKey(),
            { apiKey ->
                apiKey.apiKeyRepository(apiKeyRepository())
            }
        ).build()

    private fun apiKeyRepository(): ApiKeyEntityRepository<ApiKeyEntityImpl> {
        val apiKey = ApiKeyEntityImpl.builder()
            .name("test api key")
            .id("api01")
            .secret("mycustomapikey")
            .build()

        return InMemoryApiKeyEntityRepository(listOf(apiKey))
    }
}
  • 인증으로 위한 ID는 api01으로 하고 Secret(비밀키)는 mycustomapikey로 설정한 것을 확인할 수 있다.
  • ID, Secret는 InMemoryApiKeyEntityRepository 통하여, 메모리에 담기도록 하였다.

여기서는 임시로 어플리케이션 기동시에 메모리에 담기도록 저장고를 만들었지만, 실제에서는 다른 DB나, API 등 다른 방식으로 하기를 권한다.

테스트용 클라이언트 생성

서버 코드는 완성이 되었고, 이제 클라이언트를 전송을 시도해 보겠다.

기존에 작성하였던 /test/kotlin/com/devkuma/ai/mcp/client/HttpClient.kt 클라이언트 파일에 인증을 추가한다.

package com.devkuma.ai.mcp.client

import io.modelcontextprotocol.client.McpClient
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest

import java.net.http.HttpRequest

fun main() {
    val request = HttpRequest.newBuilder()
        .header("Content-Type", "application/json")
        .header("X-API-key", "api01.mycustomapikey")

    val transport = HttpClientStreamableHttpTransport.builder("http://localhost:8080")
        .requestBuilder(request)
        .build()

    val client = McpClient.sync(transport)
        .build()

    client.initialize()

    client.ping()

    // List and demonstrate tools
    val toolsList = client.listTools()
    println("Available Tools = $toolsList")

    val alertResult = client.callTool(CallToolRequest("get_weather", mapOf("city" to "seoul")))
    println("get_weather Response = $alertResult")

    client.closeGracefully()
}

Output:

... 생략 ...

Available Tools = ListToolsResult[tools=[Tool[name=get_weather, title=null, description=Return the weather of a given city., inputSchema=JsonSchema[type=object, properties={city={type=string, description=The city for which to get the weather}}, required=[city], additionalProperties=false, defs=null, definitions=null], outputSchema=null, annotations=null, meta=null]], nextCursor=null, meta=null]
get_weather Response = CallToolResult[content=[TextContent[annotations=null, text="The weather in seoul is good.", meta=null]], isError=false, structuredContent=null, meta=null]

... 생략 ...
  • 헤더키는 X-API-key으로 설정하고 헤더 값은api01.mycustomapikey로 설정한 것을 확인할 수 있다.
  • 설정된 요청 헤더는 transportrequestBuilder로 설정이 되었다.
  • 실행을 해보면 이전에 했던 결과와 동일하게, 사용 가능한 Tool 목록이 표시되고, get_weather 도구를 호출하여 응답을 받은 값을 표시되는 것을 확인할 수 있다.

참고

위에 예제 코드는 GitHub에서 확인해 볼 수 있다.